@solidrt/3d 0.0.46 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -11,10 +11,11 @@ blendMode and pointer events like any element. Design rationale:
11
11
 
12
12
  - Two layers. The imperative core is Solid-free: `createScene`,
13
13
  `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
14
- `setVisible` - plain objects with dirty flags, batched to a microtask,
15
- one `setDrawParams` (the uModel matrix) per changed mesh and ONE
16
- `setTargetParams` (the shared uViewProj) per camera change, however many
17
- meshes. The component
14
+ `lookAt`, `getRotation`, `setVisible` - plain objects with dirty flags, batched to a
15
+ microtask,
16
+ one `setDrawParams` (uModel, plus uNormal for materials declaring it)
17
+ per changed mesh and ONE `setTargetParams` (the shared uViewProj +
18
+ uCamPos) per camera change, however many meshes. The component
18
19
  face (`Scene`/`Group`/`Mesh`/`PerspectiveCamera`) syncs props into that
19
20
  core over context and renders nothing itself.
20
21
  - Rendering is the runtime's. The target is `render: "auto"`: it
@@ -23,9 +24,23 @@ blendMode and pointer events like any element. Design rationale:
23
24
  own `onFrame` writing a signal (declarative) or `setTransform` on a
24
25
  `ref`-grabbed node (the frame-rate escape hatch - signals carry
25
26
  structure, per-frame motion goes straight to the scene).
26
- - One vertex layout everywhere: `aPos` vec3 + `aNormal` vec3 + `aUV` vec2,
27
- uint16-indexed. Geometry GPU buffers are lazy, shared, and app-lifetime
28
- (owner-scoped free would break sharing); `disposeGeometry` frees them.
27
+ - Two named vertex layouts (`Geometry.layout`, absent = "standard"):
28
+ "standard" is `aPos` vec3 + `aNormal` vec3 + `aUV` vec2 - what every
29
+ generator emits - and "colored" appends `aColor` vec4, the per-vertex
30
+ data channel (a tint, baked AO, any four scalars; standard name, your
31
+ contents). Derive colored geometry with `withColors(geometry, fill)` -
32
+ fill is a flat 4-per-vertex array or a per-vertex callback receiving
33
+ `(index, pos, normal, uv)`. Geometry and material layouts must match
34
+ (layout is stride); a mismatched pair throws at add(). The whole layout
35
+ ships whether a material reads every attribute or not (inactive
36
+ attributes only keep the stride), so colored vertices cost 12 floats
37
+ regardless - keep data-light passes (a wireframe reading only aPos) on
38
+ standard geometry.
39
+ Indices are uint16 or uint32 - the `Geometry.indices` array type picks
40
+ the draw's index format, so hand-built geometry past 64k vertices just
41
+ uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
42
+ lazy, shared, and app-lifetime (owner-scoped free would break sharing);
43
+ `disposeGeometry` frees them.
29
44
  - Materials dedupe hard: one program + one pipeline per material CLASS
30
45
  (unlit color, unlit map), `depth: true` + `cull: "back"`; an instance is
31
46
  just per-entry uniforms (`uColor`) and bindings (`uMap`).
@@ -34,41 +49,144 @@ blendMode and pointer events like any element. Design rationale:
34
49
 
35
50
  | Component | Props |
36
51
  | --- | --- |
37
- | `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)` |
38
- | `Group` | `position?`, `rotation?` (Euler radians, x-y-z order), `scale?` (number = uniform), `visible?`, `ref?(node)` |
39
- | `Mesh` | `geometry`, `material`, transforms as Group, `ref?(mesh)` |
52
+ | `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)`, `output?(texture)` |
53
+ | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, `ref?(node)` |
54
+ | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `ref?(mesh)` |
40
55
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
41
56
 
57
+ Output composition: without `output`, `Scene` emits a minimal
58
+ `<texture width height>` leaf and nothing else is forwarded - anything
59
+ more goes through `output(texture)`, which renders in place of that leaf:
60
+ a `<d-texture>`, a leaf with blendMode/fit/pointer/layout props, or a
61
+ post-effect chain (`createShaderTarget` sampling the id with a
62
+ covering-triangle pass; created in the callback it disposes with the
63
+ Scene). Return null for no leaf at all and compose `scene.texture`
64
+ elsewhere. Called once, untracked, inside the scene context. Scene
65
+ `width`/`height` are target pixels and the leaf's own width/height are
66
+ layout, so render and display size separate - render at 2x and display
67
+ smaller for supersampling.
68
+
42
69
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
43
70
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
44
- zoomSpeed? })` - drag-to-rotate, wheel-to-zoom, optional auto-orbit. Spread
71
+ zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
72
+ - drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
73
+ auto-orbit. Input runs on core's `createTransform` recognizer, so drag and
74
+ pinch arbitrate in the app-wide gesture arena (a viewport inside a scroller
75
+ does not double-handle) and rotation starts after the recognizer's slop;
76
+ `zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
77
+ scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
78
+ `viewport()` supplies `{ height, fov }` for the pixel-to-world mapping -
79
+ without it, it rotates like one finger; `clampTarget(target)` bounds where
80
+ a pan may put the pivot. Zoom aims
81
+ at the target unless `zoomAnchor(x, y, {eye, target})` maps the pinch focal
82
+ / wheel cursor to a world point (ground hit, target-depth plane, ...) - then
83
+ that point stays pinned under the pointer and the target slides toward it;
84
+ only the app can build that mapping, since fov, aspect and element placement
85
+ are app state. Pair it with `rotateAnchor({eye, target})`: called at gesture
86
+ start, its point is projected onto the view axis and re-seats the pivot
87
+ without moving the picture, so a drag after an anchored zoom orbits what the
88
+ camera looks at, not wherever the zoom left the target. Spread
45
89
  `orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
46
90
  from your onFrame (no frame loop of its own), and use its return - true
47
- when the pose changed - to gate per-frame dependents like a `uCamPos`
48
- write. `orbiting()` is reactive (HUD-safe); the pose is plain state via
91
+ when the pose changed - to gate per-frame dependents like reprojecting
92
+ HUD overlays. `orbiting()` is reactive (HUD-safe); the pose is plain state via
49
93
  `pose()`/`set()` (also the debug-command shape). It drives position and
50
94
  target only; fov/near/far stay on scene.setCamera. In a component tree,
51
95
  reach the scene via `<Scene ref>` or useScene().
52
96
 
53
- Geometry: `box(w?, h?, d?)`, `plane(w?, h?)` (XY, faces +z - rotate
54
- `[-Math.PI/2, 0, 0]` for a floor), `sphere(radius?, wSeg?, hSeg?)`,
55
- `torusKnot(radius?, tube?, tubularSeg?, radialSeg?, p?, q?)` (standing
56
- y-up, unlike Three's z-up).
97
+ Overlay projection: `scene.project(point)` maps a world point to scene
98
+ pixels (top-left origin, y down - the output texture's own space; `w` is
99
+ clip-space w, the camera-forward distance) and returns null for a point
100
+ at or behind the camera plane. It reflects a pending `setCamera`
101
+ immediately, so set-then-project in one tick is exact. `scene.viewProj(out?)`
102
+ copies the view-projection matrix for batch work. Never rebuild the
103
+ camera matrices by hand for a HUD.
104
+
105
+ Geometry: `box(w?, h?, d?)`; `plane(w?, h?)`, `circle(radius?, seg?)` and
106
+ `ring(inner?, outer?, seg?)` (XY, facing +z - rotate `[-Math.PI/2, 0, 0]`
107
+ for a floor); `sphere(radius?, wSeg?, hSeg?)`;
108
+ `cylinder(rTop?, rBottom?, height?, radialSeg?)` (y axis, capped; unequal
109
+ radii taper it) and `cone(radius?, height?, radialSeg?)`;
110
+ `torus(radius?, tube?, radialSeg?, tubularSeg?)` (lying flat, hole on the
111
+ y axis) and `torusKnot(radius?, tube?, tubularSeg?, radialSeg?, p?, q?)`
112
+ (standing y-up) - both oriented for the y-up world, unlike Three's z-up.
113
+ `withColors(geometry, fill, label?)` derives a "colored"-layout copy of
114
+ any standard-layout geometry (generator or hand-built), adding the
115
+ `aColor` vec4 channel; the source is untouched.
116
+ `fillColors(vertices, fill, first?, count?)` is the in-place primitive
117
+ under it: writes the aColor slots of a colored-layout interleave you
118
+ already own (a merging builder's packed buffer), reading pos/normal/uv
119
+ from the buffer itself - so a packer that bakes transforms while writing
120
+ hands the baker world-space vertices. `fill` indexes relative to
121
+ `first`. It trusts the buffer's layout (no tag to check); withColors is
122
+ the checked path.
123
+
124
+ Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
125
+ closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
126
+ share an averaged normal - `fillet(points, radius, segs?)` and
127
+ `roundRect(w?, h?, radius?, segs?)` emit those (arc corners smooth).
128
+ Winding is normalized, so either authoring direction works.
129
+ `extrude(profile, depth?, bevel?, bevelSegs?)` sweeps along z, centered,
130
+ with a quarter-round bevel at both rims; `lathe(profile, segs?, angle?,
131
+ start?)` revolves a CLOSED (x = radius, y = height) profile about the y
132
+ axis - watertight by construction, flat caps on partial sweeps;
133
+ `sweep(profile, path)` runs the profile along an open 3D polyline with
134
+ MITRED joints (each cross-section sits on its bend's bisector plane, so
135
+ bends never gape or overlap) and flat caps at both ends. The path
136
+ mirrors the profile convention: bare `[x, y, z]` points crease (a strap
137
+ folding over an edge), `{ p, smooth }` points shade continuous (tag a
138
+ sampled curve's points); the profile's y starts as close to world up as
139
+ the first segment allows, then parallel-transports without spinning.
140
+ Closed loops are NOT supported yet - overlap the ends by a segment to
141
+ fake one. `tube(path, radius?, radialSegs?)` is the round-profile
142
+ shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
143
+ per-segment frames (tangents, cross-section axes, arc lengths) for
144
+ custom work along a path. `shape(profile)` fills one flat (facing +z,
145
+ like circle); `triangulate(points)` is the ear-clipping core (fan
146
+ fallback, never drops a cap), exported for custom flat work. These pick
147
+ uint16/uint32 indices by vertex count automatically.
148
+
57
149
  Materials:
58
150
 
59
151
  - `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
60
152
  internally.
61
153
  - `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
62
154
  depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
63
- custom-look escape hatch. The vertex stage MUST declare and use BOTH
64
- `uniform mat4 uModel` (the mesh's world matrix, per entry) and
65
- `uniform mat4 uViewProj` (the camera, shared target-level params) -
66
- transform with `uViewProj * uModel * vec4(aPos, 1.0)`; attributes come
67
- from the shared layout by name; sources without
68
- `#version` get the standard pipeline preamble. App-driven uniforms
69
- beyond uModel/uViewProj: seed via `params`, then write per mesh with
155
+ custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
156
+ MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
157
+ per entry) and `uniform mat4 uViewProj` (the camera, shared
158
+ target-level params) - transform with
159
+ `uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
160
+ at shaderMaterial() creation. The rest is opt-in by declare-and-use:
161
+ `uniform vec3 uCamPos` (the camera's world position, shared and written
162
+ with uViewProj - the specular/fresnel view vector is
163
+ `normalize(uCamPos - worldPos)`) and `uniform mat4 uNormal` (the world
164
+ inverse-transpose, written beside uModel for this material's meshes;
165
+ take `mat3(uNormal)` - correct under non-uniform scale, where
166
+ mat3(uModel) bends normals off the surface). Attributes come from the
167
+ geometry's layout by name; a vertex stage reading `in vec4 aColor` opts
168
+ the material into the "colored" layout, and its meshes then need
169
+ `withColors()` geometry. Sources without `#version` get the standard
170
+ pipeline preamble. App-driven uniforms beyond the standard set: seed
171
+ via `params`, then write per mesh with
70
172
  `setMeshParams(mesh, { name: value })` (validated names; values persist
71
- across entry rebuilds; frame-rate-safe like setTransform).
173
+ across entry rebuilds; frame-rate-safe like setTransform) or declaratively
174
+ with the `Mesh` `params` prop (same merge semantics - a key that
175
+ disappears from the object keeps its old value; for per-frame values
176
+ prefer `ref` + setMeshParams from onFrame, the setTransform split).
177
+
178
+ Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
179
+ into shaderMaterial sources with plain template literals - `LIT_VERTEX`
180
+ (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
181
+ varyings, normals via mat3(uNormal)), `LIT_VERTEX_COLORED` (the same
182
+ plus the colored layout's aColor forwarded raw as vColor - using it opts
183
+ the material into that layout) and the pure functions `HEMISPHERE`
184
+ (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
185
+ `BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
186
+ (`fresnel(n, v, power)`). Lights, colors and exponents are arguments, so
187
+ nothing is pinned but the function names; future lit material classes
188
+ compose from these same constants - customizing never means leaving the
189
+ system.
72
190
 
73
191
  ## Traps
74
192
 
@@ -83,17 +201,89 @@ Materials:
83
201
  - Alpha does not blend in v1: pipelines are opaque (`blend: "none"`), a
84
202
  translucent color overwrites. Transparency waits on blend factors +
85
203
  sorting (research note, staging step 4).
86
- - Rotation is Euler radians applied x, then y, then z. No quaternions in
87
- v1.
88
- - Transforms have ONE write path: `setTransform`/`setVisible` (or the
89
- props that call them). Mutating `node.position` directly does not sync.
90
- - A camera change is ONE `setTargetParams` write (uViewProj is target
91
- state), independent of mesh count - never reintroduce per-mesh camera
92
- writes. Scene scale honestly: hundreds to a
204
+ - Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
205
+ always unit). There is exactly one rotation field: no `node.rotation`
206
+ shadowing it, because a second field is a second thing to go stale (an
207
+ aimed node whose Euler triple still reads as the old pose is the bug
208
+ this model deletes). Euler triples are a boundary format only -
209
+ `setTransform({ rotation })` and the `rotation` prop convert in,
210
+ `getRotation(node, out?)` converts out.
211
+ - Euler triples are XYZ order (x applied first: `R = Rx * Ry * Rz`),
212
+ Three's `Euler` default, so a triple copied from a Three scene means the
213
+ same thing here. This CHANGED 2026-08-11: the old `compose()` built
214
+ `Rz * Ry * Rx` (Three's `'ZYX'`) while its comment claimed XYZ. Every
215
+ rotation triple then in the repo, examples, demos and projects was
216
+ single-axis, which is order-independent, so the fix moved no pixels -
217
+ verified, not assumed. There is ONE order and no order argument: a
218
+ per-call order is how one triple ends up meaning two things.
219
+ - `getRotation` cannot recover the triple that was written, only a triple
220
+ meaning the same rotation (and at the poles it pins z to 0 and folds the
221
+ roll into x). It is for reading and debugging; anything composing or
222
+ interpolating rotations works with the quaternion.
223
+ - `eulerFromQuat` extracts y with `atan2(m02, cos(y))`, NOT Three's
224
+ `asin(m02)`: asin's derivative blows up at the poles, turning 1e-16 of
225
+ matrix error into 1e-8 of angle. Same reason its pole branch starts at
226
+ `cos(y) < 1e-7` rather than Three's `|m02| > 0.9999999` (which is
227
+ `cos(y) ~ 4.5e-4` - three orders early, and inside that band Three
228
+ silently discards real roll). Do not "restore parity" here.
229
+ - Aim with `lookAt(node, target, up?)`, never by extracting angles by
230
+ hand. Three's `Object3D.lookAt` semantics deliberately: `target` and
231
+ `up` are WORLD space (ancestor transforms are undone, and the ancestor
232
+ chain is refreshed on the spot rather than waiting for the sync), and
233
+ local +z ends up pointing at the target. To aim along a DIRECTION, add
234
+ it to `worldPosition(node)` - the same conversion Three asks for.
235
+ +z is the library's own sweep axis, so `extrude`/`sweep`/`tube` output
236
+ needs no correction. For a y-axis solid (`cylinder`, `cone`) use
237
+ `quatFromTo(q, [0, 1, 0], dir)` instead of correcting lookAt's +z.
238
+ Divergences from Three, both deliberate: `up` is an argument, NOT a
239
+ per-node field (Three's `object.up` is hidden state that costs a vector
240
+ on every node), and degenerate frames pick a stable perpendicular
241
+ instead of Three's epsilon nudge of the eye.
242
+ There is no `setTransform(node, { matrix })`, and lookAt is a MUTATOR,
243
+ not a rotation-returning function.
244
+ - `quatFromTo` is Three's `setFromUnitVectors`, renamed after Unity's
245
+ `FromToRotation` / glam's `from_rotation_arc`: the Three name states a
246
+ precondition instead of the operation, and ours has no such
247
+ precondition (it normalizes). Check Unity/glam/Godot too before copying
248
+ a Three name that reads as an artifact of its class layout.
249
+ - The composition set: `quatFromAxisAngle` (radians; normalizes the axis -
250
+ Three/Unity/glam all require a unit axis and silently corrupt
251
+ otherwise), `quatMultiply` (same order contract as the mat4 `multiply`:
252
+ `a * b`, b applies first; does NOT renormalize - the unit product only
253
+ drifts under long accumulation, and setTransform renormalizes on
254
+ write), `quatSlerp` (shortest path across the double cover, constant
255
+ angular velocity, unit output; the damped follow is
256
+ `quatSlerp(q, q, target, 1 - Math.exp(-k * dt))`). All aim/verb usage
257
+ live in `examples/aim.tsx`.
258
+ - `setTransform` NORMALIZES an incoming quaternion, and passing `rotation`
259
+ and `quaternion` in one call throws. A non-unit quaternion scales
260
+ geometry by `|q|^2` through `compose()` - Three leaves that trap open
261
+ and documents it; we close it at the one write path instead of paying
262
+ for a check in every compose.
263
+ - `lookAt` is exact for rotation and uniform scale up the chain. A
264
+ non-uniformly scaled ancestor shears the frame, so the aim is
265
+ approximate - Three has the identical limitation (both read the parent's
266
+ upper 3x3 as if it were a rotation), and the fix is not to special-case
267
+ it here but to not shear parents of things you aim.
268
+ - The package root's `lookAt` is the scene verb; `@solidrt/3d/math` keeps
269
+ its own `lookAt` (the camera view matrix) on the SUBPATH ONLY, the same
270
+ collision rule the Vec3 helpers follow - and the same Object3D/Matrix4
271
+ split Three makes under one name. Do not re-export math's from the root.
272
+ - Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
273
+ the props that call them). Mutating `node.position` directly does not
274
+ sync. Components have no `lookAt` prop - aim through a `ref`.
275
+ - A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
276
+ target state), independent of mesh count - never reintroduce per-mesh
277
+ camera writes (uEye-style per-mesh params are exactly the O(scene) cost
278
+ the shared channel removed). Scene scale honestly: hundreds to a
93
279
  few thousand objects, bounded by the interpreter, not the GPU.
94
280
  - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
95
281
  list END. Irrelevant while everything is opaque + depth-tested; revisit
96
282
  when transparency lands.
283
+ - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
284
+ to the axis at x = 0) - it is a solid of revolution, NOT Three's open
285
+ polyline shell. An "open" outline must be closed by the author;
286
+ otherwise the shape is simply wrong, there is no open-profile mode.
97
287
  - `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
98
288
  context).
99
289
  - A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
@@ -101,14 +291,16 @@ Materials:
101
291
  content-keyed caches are the anti-pattern the GPU layer avoids). Create
102
292
  one per look at app scope, share across meshes, `dispose()` when done
103
293
  for good.
104
- - A shaderMaterial vertex stage without `uniform mat4 uModel` (declared
105
- AND used) throws at mesh attach - the scene seeds uModel on every entry
106
- and the engine rejects unknown uniform names. One without `uViewProj`
107
- also throws at attach when it is the scene's ONLY material class: after
108
- the first camera sync, _attach re-issues the shared uViewProj (same
109
- value, one write) so the coverage error lands at add() instead of
110
- inside a later camera-sync microtask. Only the very first attach (no
111
- camera sync yet) reports it asynchronously, from the sync that attach
112
- schedules. With other declaring materials present it silently ignores
113
- the camera instead (partial coverage is legal). Declare and use both,
114
- always.
294
+ - The standard-set contract is checked TEXTUALLY at shaderMaterial()
295
+ creation (uModel and uViewProj must appear in the vertex source) and
296
+ strictly at add() for the per-entry names: a uModel or uNormal that is
297
+ declared but never USED compiles out, and the scene's entry seed then
298
+ throws at attach (the engine rejects unknown entry uniform names). The
299
+ shared names have no such backstop - a declared-but-unused uViewProj or
300
+ uCamPos is skipped silently (shared params tolerate zero coverage), so
301
+ the symptom is an untransformed or unlit render, not an error. Use what
302
+ you declare.
303
+ - The layout scan is textual the same way: any `aColor` token in the
304
+ vertex source - a comment counts - selects the "colored" layout, and
305
+ the material then rejects standard geometry at add(). Do not mention
306
+ aColor you do not read.
package/README.md CHANGED
@@ -4,6 +4,8 @@ A retained 3D scene graph for SolidRT: meshes, materials and a camera,
4
4
  declared as Solid components, rendered by the runtime into an ordinary
5
5
  texture in your UI tree.
6
6
 
7
+ _@solidrt/3d is experimental: expect more API churn here than in the rest of SolidRT._
8
+
7
9
  ```tsx
8
10
  import { createSignal, onFrame, render } from "@solidrt/core"
9
11
  import { box, Mesh, PerspectiveCamera, Scene, unlit } from "@solidrt/3d"
@@ -27,16 +29,46 @@ The scene compiles to one depth-buffered GPU draw target: one draw entry
27
29
  per mesh, one shared pipeline per material class, cross-mesh occlusion
28
30
  from the shared depth buffer. A static scene costs zero GPU passes - the
29
31
  runtime re-renders the target only when something changes - and a moved
30
- mesh costs one uniform write.
32
+ mesh costs one uniform write. By default `<Scene>` composites the target
33
+ as a plain `<texture>` leaf; the `output` prop receives the texture id
34
+ and replaces that leaf - place a `<d-texture>`, add paint or pointer
35
+ props, chain a post-effect shader target, or return null and composite
36
+ `scene.texture` yourself.
31
37
 
32
38
  There is also an imperative layer underneath (`createScene`, `createMesh`,
33
39
  `setTransform`, ...) usable without components, plus a small math module
34
- (`@solidrt/3d/math`: column-major mat4, perspective, lookAt).
40
+ (`@solidrt/3d/math`: column-major mat4, perspective, lookAt). To aim a
41
+ node, `lookAt(node, target, up?)` points its local +z at a world point,
42
+ Three's `Object3D.lookAt`; `worldPosition(node)` is the companion for
43
+ aiming along a direction, and `quatFromTo` aims any other axis. For HUD
44
+ overlays, `scene.project(point)` maps a world point to scene pixels.
45
+
46
+ Rotation is stored as a quaternion (`quaternion` prop, `node.quaternion`),
47
+ so aiming and interpolation are gimbal-free and there is no second
48
+ rotation field to fall out of step. Euler triples stay the easy way to
49
+ author one - the `rotation` prop takes radians in XYZ order, matching
50
+ Three's `Euler` default, and `getRotation(node)` reads one back. The
51
+ verbs: `quatFromAxisAngle`, `quatMultiply`, and `quatSlerp` (smooth
52
+ tracking, damped follows) round out `quatFromTo`; `examples/aim.tsx`
53
+ shows each aiming style live.
54
+ Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
55
+ shared `uViewProj`/`uCamPos`, each written once per change - plus your own
56
+ uniforms per mesh, declaratively via the `params` prop on `<Mesh>` or
57
+ imperatively via `setMeshParams` - and
58
+ `@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
59
+ blinn, fresnel, a standard vertex stage) to compose your own lit looks
60
+ from plain template literals.
35
61
 
36
62
  v1 scope: unlit color/textured materials plus `shaderMaterial` (your own
37
- GLSL as a first-class material), box/plane/sphere geometry, one
38
- perspective camera with an orbit control (`createOrbitCamera`: drag,
39
- zoom, auto-orbit). Lights, transparency, model loading and picking are
63
+ GLSL as a first-class material), geometry generators (box, plane, circle,
64
+ ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
65
+ solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
66
+ mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
67
+ helpers), a per-vertex data channel
68
+ (`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
69
+ to any geometry, for materials that read it), one perspective camera
70
+ with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit).
71
+ Lights, transparency, model loading and picking are
40
72
  staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
41
73
  usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
42
74
  [examples/](examples/).
@@ -7,3 +7,11 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
7
7
  texture leaf, `<PerspectiveCamera>`, a ground plane, a spinning
8
8
  `<Group>` of unlit meshes with real depth-buffer occlusion, geometry
9
9
  and pipeline sharing, and the one-signal onFrame drive.
10
+ - `sweep-paths.tsx` - swept solids along polylines: a flat strap folding
11
+ over a crate (bare path points crease on the mitred bends) and a coiled
12
+ tube (smooth-tagged helix, one continuous mesh), lit via the exported
13
+ GLSL so the creased-vs-smooth normals actually show.
14
+ - `aim.tsx` - the rotation verbs, one pointer each tracking an orbiting
15
+ target: `lookAt` for a +z solid, `quatFromTo` for aiming a y-axis cone,
16
+ and a `quatSlerp` damped follow that visibly lags; all driven from
17
+ onFrame through refs, no per-frame signals.
@@ -0,0 +1,119 @@
1
+ // Aiming and rotation: a target orbits, and three fixed pointers track it,
2
+ // each through a different rotation verb.
3
+ // - lookAt(node, target) the z-axis rod: one call, world semantics
4
+ // - quatFromTo(y, direction) the cone: aiming an axis other than +z
5
+ // - quatSlerp damped follow the lazy cone: visibly lags, then catches up
6
+ // All three drive their nodes from onFrame through refs - the frame-rate
7
+ // escape hatch - so no per-frame signals exist; the one signal-free scene
8
+ // re-renders because setTransform marks the moved nodes dirty.
9
+ import { onFrame, pct, render } from "@solidrt/core"
10
+ import {
11
+ cone,
12
+ lookAt,
13
+ Mesh,
14
+ PerspectiveCamera,
15
+ plane,
16
+ quat,
17
+ quatFromTo,
18
+ quatSlerp,
19
+ Scene,
20
+ setTransform,
21
+ sphere,
22
+ tube,
23
+ unlit,
24
+ worldPosition,
25
+ } from "@solidrt/3d"
26
+ import type { MeshNode, Vec3 } from "@solidrt/3d"
27
+
28
+ const SIZE = 720
29
+ const Y_AXIS: Vec3 = [0, 1, 0]
30
+
31
+ function App() {
32
+ let target!: MeshNode
33
+ let rod!: MeshNode
34
+ let cannon!: MeshNode
35
+ let lazy!: MeshNode
36
+
37
+ // Allocated once; every per-frame write reuses them.
38
+ let targetPos: Vec3 = [0, 0, 0]
39
+ let dir: Vec3 = [0, 0, 0]
40
+ let aimQ = quat()
41
+
42
+ // A world-space direction from a node to the target: the documented
43
+ // recipe, worldPosition + subtract (exact here - the pointers have no
44
+ // transformed ancestors - and correct even if they get some).
45
+ let aimFrom = (node: MeshNode) => {
46
+ let p = worldPosition(node, dir)
47
+ dir[0] = targetPos[0] - p[0]
48
+ dir[1] = targetPos[1] - p[1]
49
+ dir[2] = targetPos[2] - p[2]
50
+ return dir
51
+ }
52
+
53
+ onFrame(tick => {
54
+ let t = tick / 1500
55
+ targetPos[0] = Math.cos(t) * 1.9
56
+ targetPos[1] = 1.1 + Math.sin(t * 0.7) * 0.7
57
+ targetPos[2] = Math.sin(t) * 1.9
58
+ setTransform(target, { position: targetPos })
59
+
60
+ // The rod is a +z solid (tube paths run along z): lookAt is the whole
61
+ // aiming story, a world-space point in, done.
62
+ lookAt(rod, targetPos)
63
+
64
+ // The cone points along +y, not +z, so lookAt would aim its side.
65
+ // quatFromTo rotates the axis you name onto the direction you want.
66
+ quatFromTo(aimQ, Y_AXIS, aimFrom(cannon))
67
+ setTransform(cannon, { quaternion: aimQ })
68
+
69
+ // Damped follow: slerp the CURRENT rotation a fixed fraction of the
70
+ // way toward the aimed one each frame. The 0.04 makes the lag obvious;
71
+ // a real app uses 1 - Math.exp(-k * dt) to stay frame-rate independent.
72
+ quatFromTo(aimQ, Y_AXIS, aimFrom(lazy))
73
+ quatSlerp(aimQ, lazy.quaternion, aimQ, 0.04)
74
+ setTransform(lazy, { quaternion: aimQ })
75
+ })
76
+
77
+ let ball = sphere(0.22)
78
+ let pointer = cone(0.3, 0.9)
79
+
80
+ return (
81
+ <window>
82
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
83
+ <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="aim">
84
+ <PerspectiveCamera fov={55} position={[0, 2.6, 4.6]} lookAt={[0, 0.7, 0]} />
85
+ <Mesh
86
+ geometry={plane(7, 7, "floor")}
87
+ material={unlit({ color: [0.16, 0.17, 0.22] })}
88
+ rotation={[-Math.PI / 2, 0, 0]}
89
+ />
90
+ <Mesh
91
+ geometry={ball}
92
+ material={unlit({ color: [0.95, 0.85, 0.4] })}
93
+ ref={n => (target = n)}
94
+ />
95
+ <Mesh
96
+ geometry={tube([[0, 0, 0], [0, 0, 1.1]], 0.09, 10, "rod")}
97
+ material={unlit({ color: [0.85, 0.3, 0.3] })}
98
+ position={[-1.4, 0.5, 0]}
99
+ ref={n => (rod = n)}
100
+ />
101
+ <Mesh
102
+ geometry={pointer}
103
+ material={unlit({ color: [0.35, 0.65, 0.9] })}
104
+ position={[1.4, 0.5, 0]}
105
+ ref={n => (cannon = n)}
106
+ />
107
+ <Mesh
108
+ geometry={pointer}
109
+ material={unlit({ color: [0.45, 0.8, 0.5] })}
110
+ position={[0, 0.5, -1.4]}
111
+ ref={n => (lazy = n)}
112
+ />
113
+ </Scene>
114
+ </view>
115
+ </window>
116
+ )
117
+ }
118
+
119
+ render(() => <App />)
@@ -0,0 +1,82 @@
1
+ // The `output` prop: Scene hands you its texture id and you compose the
2
+ // leaf yourself, in place of the built-in `<texture>`. Here a fragment
3
+ // pass samples the scene and adds chromatic aberration plus a vignette,
4
+ // and the scene renders at 2x display size - the post pass doubles as the
5
+ // downsample (the default linear sampler box-averages the 2x2 quad), so
6
+ // the same chain is also free supersampling. Sampled textures are live
7
+ // dependencies: the post pass re-renders exactly when the scene target
8
+ // does, and a static scene still costs zero passes.
9
+ //
10
+ // The same slot takes a `<d-texture>`, a leaf with blendMode/fit/pointer
11
+ // props, or `() => null` (headless - composite scene.texture elsewhere).
12
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
13
+ import { createShaderTexture } from "@solidrt/core/gpu"
14
+ import { box, Group, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
15
+
16
+ const SIZE = 720 // display pixels (the leaf)
17
+ const RENDER = SIZE * 2 // target pixels (the scene)
18
+
19
+ // vUV, iResolution, and fragColor come from the standard shader-texture
20
+ // preamble; uSource is bound via the `textures` option.
21
+ const POST = `
22
+ uniform sampler2D uSource;
23
+ void main() {
24
+ vec2 c = vUV - 0.5;
25
+ float r2 = dot(c, c);
26
+ // Chromatic aberration: red and blue sample at slightly shifted radii.
27
+ vec2 shift = c * r2 * 0.04;
28
+ vec3 col = vec3(
29
+ texture(uSource, vUV + shift).r,
30
+ texture(uSource, vUV).g,
31
+ texture(uSource, vUV - shift).b);
32
+ // Vignette.
33
+ col *= 1.0 - 1.1 * r2;
34
+ fragColor = vec4(col, 1.0);
35
+ }`
36
+
37
+ function App() {
38
+ let [spin, setSpin] = createSignal(0)
39
+ onFrame(tick => setSpin(tick / 2000))
40
+
41
+ let cube = box()
42
+ let floor = plane(6, 6, "floor")
43
+ let ball = sphere(0.35)
44
+
45
+ return (
46
+ <window>
47
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
48
+ <Scene
49
+ width={RENDER}
50
+ height={RENDER}
51
+ clearColor={[0.07, 0.07, 0.1, 1]}
52
+ label="scene-post"
53
+ output={tex => {
54
+ // Created inside the callback, the post target disposes with
55
+ // the Scene.
56
+ let post = createShaderTexture(POST, SIZE, SIZE, null, { textures: { uSource: tex } })
57
+ return <texture src={post} width={SIZE} height={SIZE} />
58
+ }}
59
+ >
60
+ <PerspectiveCamera fov={55} position={[0, 1.6, 3.6]} lookAt={[0, 0.3, 0]} />
61
+ <Mesh
62
+ geometry={floor}
63
+ material={unlit({ color: [0.16, 0.17, 0.22] })}
64
+ rotation={[-Math.PI / 2, 0, 0]}
65
+ />
66
+ <Group rotation={[0, spin(), 0]}>
67
+ <Mesh geometry={cube} material={unlit({ color: [0.85, 0.3, 0.3] })} position={[0, 0.5, 0]} />
68
+ <Mesh
69
+ geometry={cube}
70
+ material={unlit({ color: [0.9, 0.8, 0.35] })}
71
+ position={[-1.1, 0.7, 0]}
72
+ scale={[0.5, 1.4, 0.5]}
73
+ />
74
+ <Mesh geometry={ball} material={unlit({ color: [0.35, 0.65, 0.9] })} position={[1.1, 0.35, 0]} />
75
+ </Group>
76
+ </Scene>
77
+ </view>
78
+ </window>
79
+ )
80
+ }
81
+
82
+ render(() => <App />)