@solidrt/3d 0.0.56 → 0.0.58

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.
Files changed (48) hide show
  1. package/AGENTS.md +1052 -639
  2. package/README.md +2 -1
  3. package/examples/README.md +19 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/examples/wireframe.tsx +107 -0
  15. package/package.json +2 -2
  16. package/src/collision.ts +14 -193
  17. package/src/components/context.tsx +35 -0
  18. package/src/components/directional-light.tsx +48 -0
  19. package/src/components/first-person-camera.tsx +72 -0
  20. package/src/components/group.tsx +18 -0
  21. package/src/components/hemisphere-light.tsx +24 -0
  22. package/src/components/index.ts +46 -0
  23. package/src/components/instanced-mesh.tsx +105 -0
  24. package/src/components/mesh.tsx +92 -0
  25. package/src/components/node-props.ts +90 -0
  26. package/src/components/orbit-camera.tsx +75 -0
  27. package/src/components/perspective-camera.tsx +32 -0
  28. package/src/components/point-light.tsx +52 -0
  29. package/src/components/record-mesh.tsx +68 -0
  30. package/src/components/scene.tsx +281 -0
  31. package/src/components/spot-light.tsx +65 -0
  32. package/src/components/sprite.tsx +38 -0
  33. package/src/components/view3d.tsx +152 -0
  34. package/src/first-person.ts +140 -188
  35. package/src/geometry-gpu.ts +45 -13
  36. package/src/geometry.ts +186 -5
  37. package/src/gltf.ts +97 -16
  38. package/src/index.ts +15 -10
  39. package/src/input.ts +79 -0
  40. package/src/material.ts +26 -8
  41. package/src/mesh.ts +5 -6
  42. package/src/model.ts +65 -33
  43. package/src/node.ts +100 -26
  44. package/src/orbit.ts +266 -235
  45. package/src/scene-pointer.ts +229 -101
  46. package/src/scene.ts +256 -79
  47. package/src/skeleton.ts +211 -0
  48. package/src/components.tsx +0 -1101
package/AGENTS.md CHANGED
@@ -6,248 +6,381 @@ one `addDraw` entry per mesh); the scene's output is an ordinary texture
6
6
  id composited as a `<texture>` leaf, so it takes layout, transforms,
7
7
  blendMode and pointer events like any element.
8
8
 
9
+ The layer below is worth knowing by name: `@solidrt/core`'s
10
+ `examples/gpu-*.tsx` are the raw pipeline vocabulary this package
11
+ composes - `gpu-particles.tsx` (points topology, `gl_PointSize` /
12
+ `gl_PointCoord` splats, additive blend), `gpu-instancing.tsx`
13
+ (`instanceCount` and `gl_InstanceID`), `gpu-pipeline.tsx` (a custom
14
+ vertex + fragment pair over an interleaved buffer), `gpu-draw-list.tsx`
15
+ (a mutable list of draws sharing one depth buffer), `gpu-raw-program.tsx`
16
+ (compile/link/pipeline by hand). What the GPU layer can do is answered
17
+ there, not here.
18
+
19
+ Contents:
20
+
21
+ - [The model](#the-model)
22
+ - [Two layers: core and components](#two-layers-core-and-components)
23
+ - [Node lifecycle](#node-lifecycle)
24
+ - [Rendering is the runtime's](#rendering-is-the-runtimes)
25
+ - [Views and layers](#views-and-layers)
26
+ - [Culling](#culling)
27
+ - [Shadows](#shadows)
28
+ - [Retargeted motion](#retargeted-motion)
29
+ - [Geometry layout, indices and topology](#geometry-layout-indices-and-topology)
30
+ - [Material classes](#material-classes)
31
+ - [Pure pieces and checks](#pure-pieces-and-checks)
32
+ - [Components](#components)
33
+ - [Component props](#component-props)
34
+ - [Output composition](#output-composition)
35
+ - [Fill and fixed sizes](#fill-and-fixed-sizes)
36
+ - [Input](#input)
37
+ - [Orbit camera](#orbit-camera)
38
+ - [First-person camera](#first-person-camera)
39
+ - [Overlay projection](#overlay-projection)
40
+ - [Picking](#picking)
41
+ - [Collision](#collision)
42
+ - [Pointer events](#pointer-events)
43
+ - [Geometry generators](#geometry-generators)
44
+ - [Geometry as data](#geometry-as-data)
45
+ - [Profile kit](#profile-kit)
46
+ - [Materials](#materials)
47
+ - [Instancing](#instancing)
48
+ - [Background](#background)
49
+ - [Cube-map convention](#cube-map-convention)
50
+ - [Environment](#environment)
51
+ - [Reflection probes](#reflection-probes)
52
+ - [Baked sky](#baked-sky)
53
+ - [Panoramas](#panoramas)
54
+ - [Fog](#fog)
55
+ - [Color](#color)
56
+ - [Lighting GLSL](#lighting-glsl)
57
+ - [Custom looks: three tiers](#custom-looks-three-tiers)
58
+ - [Lights](#lights)
59
+ - [lit](#lit)
60
+ - [standard](#standard)
61
+ - [Models](#models)
62
+ - [Three layers](#three-layers)
63
+ - [Async loading](#async-loading)
64
+ - [Placement and sockets](#placement-and-sockets)
65
+ - [Wardrobe pieces](#wardrobe-pieces)
66
+ - [Applied glTF material fields](#applied-gltf-material-fields)
67
+ - [Animation](#animation)
68
+ - [Root motion](#root-motion)
69
+ - [Skins](#skins)
70
+ - [Not in the subset](#not-in-the-subset)
71
+ - [Traps](#traps)
72
+ - [Models and skinning](#models-and-skinning)
73
+ - [Materials and color](#materials-and-color)
74
+ - [Environment and background](#environment-and-background)
75
+ - [Coordinates and rotation](#coordinates-and-rotation)
76
+ - [Visibility and instancing](#visibility-and-instancing)
77
+ - [Shadows](#shadows)
78
+ - [Views and scene params](#views-and-scene-params)
79
+ - [Shader materials](#shader-materials)
80
+ - [Picking, pointer and collision](#picking-pointer-and-collision)
81
+ - [Geometry and components](#geometry-and-components)
82
+
9
83
  ## The model
10
84
 
11
- - Two layers. The imperative core is Solid-free: `createScene`,
12
- `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
13
- `lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects
14
- over the spatial core (`flux:spatial`, `alloy/src/spatial/`): every node
15
- in a scene has a core node, JS keeps the LOCAL position/quaternion/scale
16
- as the readable truth and forwards each write, and the core's flush
17
- (one call per microtask) recomputes only the moved subtrees and writes
18
- each entry's uModel (plus uNormal for materials declaring it) and its
19
- visibility switch itself - a move costs its subtree, never the scene.
20
- ONE `setTargetParams` (the shared uViewProj + uCamPos) per camera
21
- change, however many meshes. World matrices live in the core only:
22
- `worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
23
- writes included). See okf/backlog/spatial-core.md for what still runs
24
- in JS and why. The component face (`Scene`/`Group`/`Mesh`/
25
- `PerspectiveCamera`) syncs props into that core over context and renders
26
- nothing itself.
27
- - Node lifecycle: `add(parent, child)` attaches (re-parenting detaches
28
- first), `remove(child)` DETACHES an intact subtree - children stay
29
- under the removed node, core nodes free on leave and recreate on
30
- re-enter, so a removed subtree re-adds cleanly. Nothing is destroyed
31
- except instance record buffers (`disposeInstances`) and the scene
32
- itself. (@solidrt/2d's `removeGroup` DESTROYS its subtree instead - a
33
- sprite cannot exist outside its layer, so there remove means destroy.)
34
- An instance node (addInstance on an instanced mesh) follows the 2d
35
- rule: slot-bound to its mesh, `removeInstance` destroys it, and the
36
- generic add/remove throw on it.
37
- - Rendering is the runtime's. The target is `render: "auto"`: it
38
- re-renders when entries change, so a STATIC scene costs zero passes and
39
- the library registers no frame loop. Continuous animation is the app's
40
- own `onFrame` writing a signal (declarative) or `setTransform` on a
41
- `ref`-grabbed node (the frame-rate escape hatch - signals carry
42
- structure, per-frame motion goes straight to the scene).
43
- - VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
44
- clearColor?, ... })` renders the same scene into a second target from
45
- its own camera (`view.setCamera`, the scene's CameraUpdate shape). Each
46
- mesh gets one entry in the view's target bound as one more draw sink of
47
- its CORE node, so the one flush writes every target - the app writes
48
- nothing per view. Geometry buffers and (without an override) materials
49
- are shared; the light set and `scene.setParams` names fan out to every
50
- view, `view.setParams` is the view's own channel - and names a view
51
- sets itself (or its `fog` option, below) become VIEW-OWNED: the
52
- scene's setParams/setFog fan-out skips them from then on, so a view
53
- override survives scene-wide writes. A view's backdrop is its
54
- clearColor (the scene background draws on PROBES, not views); a view
55
- has no picking. LAYERS select what a target
56
- draws, Three's model exactly: `layers` on a mesh is its membership
57
- bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
58
- Groups), and each target carries a mask (default 1) - `layers` on
59
- createScene/createView, live via `setLayers` on the scene handle and
60
- each view. A mesh draws where mask & layers is non-zero, so a minimap's
61
- marker meshes live on bit 2: invisible in the main render, drawn by
62
- the map view whose mask admits them. Shadow views follow the SCENE's
63
- mask (what the scene cannot see must not darken it), and
64
- pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
65
- invisible ones - unless the query passes its own `{ layers }`, which is
66
- how a low-poly collision mesh lives undrawn in the scene yet answers
67
- ground and collision queries (the physics-collider pattern).
68
- Per-view fog: `fog: FogOptions | null` on createView overrides the
69
- scene's fog for that view (null = unfogged - the clear minimap over a
70
- fogged scene); absent follows the scene. `overrideMaterial` (Three's
71
- `scene.overrideMaterial`, scoped to the view) draws every mesh with one
72
- material - a depth pass, a normal/id visualizer - skips instanced
73
- meshes (unless the override itself declares their exact
74
- `instanceAttributes` record layout) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
75
- input; the same option on createScene exposes `scene.depthTexture`,
76
- the input for a depth-reading post effect in `output` (not combinable
77
- with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
78
- perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
79
- the scene's own camera takes it too, and pick() follows.
80
- `examples/scene-views.tsx` is the shape.
81
- - CULLING is the core's, per target, on by default: every camera write
82
- (the scene's, a view's, a shadow tile's) also sets that target's
83
- frustum in the spatial core, and the flush switches an entry whose
84
- world box falls wholly outside it to instance count 0 - the same
85
- switch as `visible`, so a culled mesh costs nothing per frame and a
86
- still camera re-tests nothing (a camera move re-tests every sink on
87
- that target, in Rust; a node move re-tests its own). The box is the
88
- picking box (the local bounds through the world matrix, the
89
- Godot/Unity AABB test; Three uses spheres); an instanced mesh without
90
- explicit `bounds` is culled by the union of its live instances' boxes
91
- (the joint group below over the instance nodes, so the box follows
92
- them: Godot's MultiMesh AABB, with `bounds` as its custom_aabb; Three
93
- computes an InstancedMesh sphere once and leaves it stale, Unity wants
94
- worldBounds), and a record mesh without `bounds` is never culled. Per
95
- mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
96
- a vertex stage moves beyond its box - a fullscreen quad, a custom
97
- displacement - and `cullMargin` (world units, Godot's
98
- `extra_cull_margin`) for bounded displacement such as wind. Sprites
99
- cull by their quad's reach at any facing. A SKINNED part is culled by
100
- the union of its joints' boxes (the bake computes each joint's
101
- influence box in joint space, `ModelSkin.jointBounds`, .srtm VERSION
102
- 5; the joint nodes carry them as culling-only bounds, outside the
103
- picking index), so the box follows the pose with no per-frame JS -
104
- Unity's bone bounds, Godot's per-bone AABBs; no `updateWhenOffscreen`
105
- knob is needed. Probe faces set no frustum (six cameras, one target).
106
- Shadow tiles cull casters against their light frustum, which is what
107
- makes `shadow.distance` and cascades cheaper.
108
- - SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
109
- bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
110
- shadow })`, `setLight`) makes the scene own an internal
111
- `createView({ depth: "texture", overrideMaterial: depth pass })` drawing
112
- the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
113
- orthographic camera at the light's WORLD position along its world
114
- direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
115
- Any light may cast, bounded by the shadow-slot budget
116
- (MAX_SHADOW_MAPS = 8, its own constant: a directional light claims
117
- `shadow.cascades` consecutive slots, a point light six, a spot one,
118
- and a caster past the budget throws at attach). `<SpotLight castShadow
119
- shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
120
- machinery with a PERSPECTIVE camera: at the light's world position
121
- along its world direction, fov = its cone (2 * angle), near from
122
- `shadow.near` (default 0.5), far from the light's `distance` (or the
123
- directional default 500 when 0) - one map, one slot, the same atlas
124
- and lookup. A perspective map's depth is nonlinear, so `normalBias`
125
- (world units) is the acne knob to reach for; `bias` acts in that
126
- nonlinear depth. `<PointLight castShadow>` casts in every direction
127
- with the same option set: six 90-degree face maps (world-axis
128
- aligned, slot order +X, -X, +Y, -Y, +Z, -Z) as six consecutive tiles
129
- of the same atlas, far from `distance` like a spot - so give a
130
- casting bulb a distance. No cube map: a receiver picks the face by
131
- the dominant axis of the light-to-point vector (SHADOW_LOOKUP), one
132
- projection, one hardware-compare tap - the Three/Godot/Unity-URP
133
- atlas route. `shadow.radius` (every casting light, default 1 = that
134
- one tap, a 2x2 bilinear compare) softens the edge: above 1 a 3x3 grid
135
- of hardware taps `radius` texels apart, Three's `shadow.radius` /
136
- Godot's `shadow_blur`, nine taps per receiver fragment. A first-person
137
- eye over a floor shows the texel stairs at any map size; 2 is the
138
- usual figure, past ~3 the taps separate into bands. Each face map renders a few degrees wider than its face
139
- (URP's fovBias) so a seam fragment's occluder is inside the map it
140
- samples - without the guard band every seam shows a lit slit - and
141
- PCF taps clamp at face-tile edges, so a face seam hardens slightly
142
- instead of bleeding into the neighbour.
143
- `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
144
- N maps fitted to slices of the SCENE camera's frustum (near ..
145
- `shadow.distance`, default the camera far; the practical split; each
146
- slice's bounding sphere as an ortho box along the light, its centre
147
- snapped to the map's texel grid so edges do not swim; re-fitted
148
- whenever the scene camera or the light moves) - a receiver samples the
149
- tightest map that covers the point, fading into the next over the
150
- map's outer 10% (`SHADOW_BLEND`) so the hand-over is a band, not a
151
- seam; contact shadows stay sharp near the camera while the horizon
152
- still has coarse ones, and pulling `distance` in sharpens all of them.
153
- The box is the honest tier for a bounded scene; cascades are for a
154
- scene that outgrows it, at N times the shadow fill. Every map is a
155
- TILE of the scene's one shadow atlas (a `depth: "texture"` draw target,
156
- a grid of cells the largest `mapSize` wide, scaled down uniformly
157
- against `limits.maxTextureSize`), so N maps are ONE pass: the atlas
158
- depth binds as the target-level `uShadowAtlas` of the scene and every
159
- non-shadow view (a white texel when nothing casts); maps are MAP slots
160
- dealt in light order, a light's cascades consecutive and tightest
161
- first - `uShadowRect[j]` slot j's tile in atlas UV, `uShadowMatrix[j]`
162
- its view's own view-projection (the whole array is one write per
163
- shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
164
- name its slots (count 0 = it does not cast) with
165
- `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
166
- glsl declares the set. Every `lit` material RECEIVES by default
167
- (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
168
- material out and drops the map from its program - a material option,
169
- as with vertexColors/triplanar, because the material picks the program
170
- (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
171
- hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
172
- on each casting light's own term. `examples/shadows.tsx` (three
173
- casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
174
- - RETARGETED motion is native: `setTransition(node, { position:
175
- { duration: 400 }, ... })` makes setTransform writes TARGETS the core
176
- animates toward every frame (position/scale per lane, rotation along
177
- the quaternion geodesic - a spring keeps its velocity through
178
- retargets), so a mesh gliding to a slot or a camera rig easing costs
179
- one JS write per target change, zero per frame. The declaration lives
180
- on the SceneNode and re-applies on every scene enter; the pose a node
181
- enters with always snaps. Each natural settle calls the node's
182
- `onTransitionEnd` (plain field like the pointer handlers) with
183
- `{ component }`; the raw "spatialTransitionEnd" engine event
184
- (srt:events, carrying the CORE node id `_node`) stays for flux:spatial
185
- consumers.
186
- - One interleaved vertex buffer per geometry, described by an open layout
187
- (`Geometry.layout`, absent = "standard"): an ordered attribute list that
188
- always starts with the standard prefix `aPos` vec3 + `aNormal` vec3 +
189
- `aUV` vec2 (what every generator emits) and may carry any named channels
190
- after it. `withAttribute(geometry, { name, format }, fill)` appends one
191
- (Three's `setAttribute` for an interleave); "colored" names the common
192
- case, the prefix plus `aColor` vec4 - the per-vertex data channel (a
193
- tint, baked AO, any four scalars; standard name, your contents) - and
194
- `withColors(geometry, fill)` is its spelling. Fill is a flat
195
- size-per-vertex array or a per-vertex callback receiving `(index, pos,
196
- normal, uv)`. Materials read attributes BY NAME: a material's vertex
197
- stage may declare any subset of its geometry's channels, and a channel
198
- the program reads that the geometry lacks (name + format) throws at
199
- add(). What a program reads is the ENGINE's word (`material.attributes()`
200
- = `programAttributes` reflection of the linked program, instance
201
- attributes excluded), not a parse of the GLSL: an `in` the compiler
202
- dropped does not count, and the engine also rejects a pipeline whose
203
- attribute lists leave a read attribute uncovered. The material
204
- keeps one program and builds one pipeline per layout its meshes bring,
205
- so a geometry may carry more than a material reads. The whole layout
206
- ships whether a material reads every attribute or not (inactive
207
- attributes only keep the stride), so extra channels cost their floats on
208
- every draw of that geometry - keep data-light passes (a wireframe
209
- reading only aPos) on standard geometry. `layoutStride`/`layoutSlot`/
210
- `layoutKey`/`layoutAttributes` are the layout arithmetic; two layouts
211
- with equal keys interleave identically (merge requires that).
212
- Indices are uint16 or uint32 - the `Geometry.indices` array type picks
213
- the draw's index format, so hand-built geometry past 64k vertices just
214
- uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
215
- lazy, shared, and reference-counted by draw entries: removing the last
216
- entry frees them at the end of the microtask (a same-tick rebuild keeps
217
- the upload), so swapping `<Mesh geometry>` reactively never accumulates
218
- old generations. `disposeGeometry` is the immediate explicit free.
219
- - Materials dedupe hard: one program + one pipeline per material CLASS
220
- (a `shaderMaterialClass` per option combination for unlit, lit and
221
- sprite alike: map x transparent x cull x alphaTest, lit's extras on
222
- top), `depth: true` + `cull: "back"` unless the material says otherwise
223
- (`cull: "none"` for double-sided geometry; lit flips the normal on back
224
- faces); an instance is just per-entry uniforms (`uColor`) and bindings
225
- (`uMap`).
226
- - The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
227
- `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
228
- GPU-free BY DESIGN so they can be checked headless (and, for the two
229
- model modules, run under bun in `tools/model.ts`); keep them that way.
230
- The rigs under `checks/`
231
- (`geometry-check`, `sweep-check`, `pick-check`, `order-check`,
232
- `gltf-check`) run on
233
- flux from the repo root: `bunx srt bundle -f --stdout
234
- packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
235
- touching what you changed. `raycast-check.tsx` and
236
- `collision-check.tsx` are the exceptions: they assert the documented
237
- picking contract (triangle accuracy, the box tier, pick/raycast
238
- parity, layer masks, the `{ meshes }` filter) and collision contract
239
- (exact sweep times, the surface rule, the slide filter, layers and
240
- meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
241
- so they run on the playback client instead:
242
- `bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
243
- --size 128x128`. Run them whenever a doc edit touches picking or
244
- collision claims - two copies of this contract have drifted before.
85
+ ### Two layers: core and components
86
+
87
+ Two layers. The imperative core is Solid-free: `createScene`,
88
+ `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
89
+ `lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects
90
+ over the spatial core (`flux:spatial`, `alloy/src/spatial/`): every node
91
+ in a scene has a core node, JS keeps the LOCAL position/quaternion/scale
92
+ as the readable truth and forwards each write, and the core's flush
93
+ (one call per microtask) recomputes only the moved subtrees and writes
94
+ each entry's uModel (plus uNormal for materials declaring it) and its
95
+ visibility switch itself - a move costs its subtree, never the scene.
96
+ ONE `setTargetParams` (the shared uViewProj + uCamPos) per camera
97
+ change, however many meshes. World matrices live in the core only:
98
+ `worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
99
+ writes included). See okf/backlog/spatial-core.md for what still runs
100
+ in JS and why. The component face (`Scene`/`View3d`/`Group`/`Mesh`/
101
+ `PerspectiveCamera`) syncs props into that core over context and renders
102
+ nothing itself.
103
+
104
+ ### Node lifecycle
105
+
106
+ Node lifecycle: `add(parent, child)` attaches (re-parenting detaches
107
+ first), `remove(child)` DETACHES an intact subtree - children stay
108
+ under the removed node, core nodes free on leave and recreate on
109
+ re-enter, so a removed subtree re-adds cleanly. Nothing is destroyed
110
+ except instance record buffers (`disposeInstances`) and the scene
111
+ itself. (@solidrt/2d's `removeGroup` DESTROYS its subtree instead - a
112
+ sprite cannot exist outside its layer, so there remove means destroy.)
113
+ An instance node (addInstance on an instanced mesh) follows the 2d
114
+ rule: slot-bound to its mesh, `removeInstance` destroys it, and the
115
+ generic add/remove throw on it.
116
+
117
+ ### Rendering is the runtime's
118
+
119
+ Rendering is the runtime's. The target is `render: "auto"`: it
120
+ re-renders when entries change, so a STATIC scene costs zero passes and
121
+ the library registers no frame loop. Continuous animation is the app's
122
+ own `onFrame` writing a signal (declarative) or `setTransform` on a
123
+ `ref`-grabbed node (the frame-rate escape hatch - signals carry
124
+ structure, per-frame motion goes straight to the scene).
125
+
126
+ ### Views and layers
127
+
128
+ VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
129
+ clearColor?, ... })` renders the same scene into a second target from
130
+ its own camera (`view.setCamera`, the scene's CameraUpdate shape; in a
131
+ component tree `<View3d>` is the same as a Scene child, see Components).
132
+ Each mesh gets one entry in the view's target bound as one more draw sink of
133
+ its CORE node, so the one flush writes every target - the app writes
134
+ nothing per view. Geometry buffers and (without an override) materials
135
+ are shared; the light set and `scene.setParams` names fan out to every
136
+ view, `view.setParams` is the view's own channel - and names a view
137
+ sets itself (or its `fog` option, below) become VIEW-OWNED: the
138
+ scene's setParams/setFog fan-out skips them from then on, so a view
139
+ override survives scene-wide writes. A view's backdrop is its
140
+ clearColor (the scene background draws on PROBES, not views); a view
141
+ has no picking. LAYERS select what a target
142
+ draws, Three's model exactly: `layers` on a mesh is its membership
143
+ bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
144
+ Groups), and each target carries a mask (default 1) - `layers` on
145
+ createScene/createView, live via `setLayers` on the scene handle and
146
+ each view. A mesh draws where mask & layers is non-zero, so a minimap's
147
+ marker meshes live on bit 2: invisible in the main render, drawn by
148
+ the map view whose mask admits them. Shadow views follow the SCENE's
149
+ mask (what the scene cannot see must not darken it), and
150
+ pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
151
+ invisible ones - unless the query passes its own `{ layers }`, which is
152
+ how a low-poly collision mesh lives undrawn in the scene yet answers
153
+ ground and collision queries (the physics-collider pattern).
154
+ Per-view fog: `fog: FogOptions | null` on createView overrides the
155
+ scene's fog for that view (null = unfogged - the clear minimap over a
156
+ fogged scene); absent follows the scene. `overrideMaterial` (Three's
157
+ `scene.overrideMaterial`, scoped to the view) draws every mesh with one
158
+ material - a depth pass, a normal/id visualizer - skips instanced
159
+ meshes (unless the override itself declares their exact
160
+ `instanceAttributes` record layout) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
161
+ input; the same option on createScene exposes `scene.depthTexture`,
162
+ the input for a depth-reading post effect in `output` (not combinable
163
+ with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
164
+ perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
165
+ the scene's own camera takes it too, and pick() follows.
166
+ `examples/scene-views.tsx` is the shape.
167
+
168
+ ### Culling
169
+
170
+ CULLING is the core's, per target, on by default: every camera write
171
+ (the scene's, a view's, a shadow tile's) also sets that target's
172
+ frustum in the spatial core, and the flush switches an entry whose
173
+ world box falls wholly outside it to instance count 0 - the same
174
+ switch as `visible`, so a culled mesh costs nothing per frame and a
175
+ still camera re-tests nothing (a camera move re-tests every sink on
176
+ that target, in Rust; a node move re-tests its own). The box is the
177
+ picking box (the local bounds through the world matrix, the
178
+ Godot/Unity AABB test; Three uses spheres); an instanced mesh without
179
+ explicit `bounds` is culled by the union of its live instances' boxes
180
+ (the joint group below over the instance nodes, so the box follows
181
+ them: Godot's MultiMesh AABB, with `bounds` as its custom_aabb; Three
182
+ computes an InstancedMesh sphere once and leaves it stale, Unity wants
183
+ worldBounds), and a record mesh without `bounds` is never culled. Per
184
+ mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
185
+ a vertex stage moves beyond its box - a fullscreen quad, a custom
186
+ displacement - and `cullMargin` (world units, Godot's
187
+ `extra_cull_margin`) for bounded displacement such as wind. Sprites
188
+ cull by their quad's reach at any facing. A SKINNED part is culled by
189
+ the union of its joints' boxes (the bake computes each joint's
190
+ influence box in joint space, `ModelSkin.jointBounds`, .srtm VERSION
191
+ 5; the joint nodes carry them as culling-only bounds, outside the
192
+ picking index), so the box follows the pose with no per-frame JS -
193
+ Unity's bone bounds, Godot's per-bone AABBs; no `updateWhenOffscreen`
194
+ knob is needed. Probe faces set no frustum (six cameras, one target).
195
+ Shadow tiles cull casters against their light frustum, which is what
196
+ makes `shadow.distance` and cascades cheaper.
197
+
198
+ ### Shadows
199
+
200
+ SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
201
+ bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
202
+ shadow })`, `setLight`) makes the scene own an internal
203
+ `createView({ depth: "texture", overrideMaterial: depth pass })` drawing
204
+ the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
205
+ orthographic camera at the light's WORLD position along its world
206
+ direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
207
+ Any light may cast, bounded by the shadow-slot budget
208
+ (MAX_SHADOW_MAPS = 8, its own constant: a directional light claims
209
+ `shadow.cascades` consecutive slots, a point light six, a spot one,
210
+ and a caster past the budget throws at attach). `<SpotLight castShadow
211
+ shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
212
+ machinery with a PERSPECTIVE camera: at the light's world position
213
+ along its world direction, fov = its cone (2 * angle), near from
214
+ `shadow.near` (default 0.5), far from the light's `distance` (or the
215
+ directional default 500 when 0) - one map, one slot, the same atlas
216
+ and lookup. A perspective map's depth is nonlinear, so `normalBias`
217
+ (world units) is the acne knob to reach for; `bias` acts in that
218
+ nonlinear depth. `<PointLight castShadow>` casts in every direction
219
+ with the same option set: six 90-degree face maps (world-axis
220
+ aligned, slot order +X, -X, +Y, -Y, +Z, -Z) as six consecutive tiles
221
+ of the same atlas, far from `distance` like a spot - so give a
222
+ casting bulb a distance. No cube map: a receiver picks the face by
223
+ the dominant axis of the light-to-point vector (SHADOW_LOOKUP), one
224
+ projection, one hardware-compare tap - the Three/Godot/Unity-URP
225
+ atlas route. `shadow.radius` (every casting light, default 1 = that
226
+ one tap, a 2x2 bilinear compare) softens the edge: above 1 a 3x3 grid
227
+ of hardware taps `radius` texels apart, Three's `shadow.radius` /
228
+ Godot's `shadow_blur`, nine taps per receiver fragment. A first-person
229
+ eye over a floor shows the texel stairs at any map size; 2 is the
230
+ usual figure, past ~3 the taps separate into bands. Each face map renders a few degrees wider than its face
231
+ (URP's fovBias) so a seam fragment's occluder is inside the map it
232
+ samples - without the guard band every seam shows a lit slit - and
233
+ PCF taps clamp at face-tile edges, so a face seam hardens slightly
234
+ instead of bleeding into the neighbour.
235
+ `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
236
+ N maps fitted to slices of the SCENE camera's frustum (near ..
237
+ `shadow.distance`, default the camera far; the practical split; each
238
+ slice's bounding sphere as an ortho box along the light, its centre
239
+ snapped to the map's texel grid so edges do not swim; re-fitted
240
+ whenever the scene camera or the light moves) - a receiver samples the
241
+ tightest map that covers the point, fading into the next over the
242
+ map's outer 10% (`SHADOW_BLEND`) so the hand-over is a band, not a
243
+ seam; contact shadows stay sharp near the camera while the horizon
244
+ still has coarse ones, and pulling `distance` in sharpens all of them.
245
+ The box is the honest tier for a bounded scene; cascades are for a
246
+ scene that outgrows it, at N times the shadow fill. Every map is a
247
+ TILE of the scene's one shadow atlas (a `depth: "texture"` draw target,
248
+ a grid of cells the largest `mapSize` wide, scaled down uniformly
249
+ against `limits.maxTextureSize`), so N maps are ONE pass: the atlas
250
+ depth binds as the target-level `uShadowAtlas` of the scene and every
251
+ non-shadow view (a white texel when nothing casts); maps are MAP slots
252
+ dealt in light order, a light's cascades consecutive and tightest
253
+ first - `uShadowRect[j]` slot j's tile in atlas UV, `uShadowMatrix[j]`
254
+ its view's own view-projection (the whole array is one write per
255
+ shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
256
+ name its slots (count 0 = it does not cast) with
257
+ `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
258
+ glsl declares the set. Every `lit` material RECEIVES by default
259
+ (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
260
+ material out and drops the map from its program - a material option,
261
+ as with vertexColors/triplanar, because the material picks the program
262
+ (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
263
+ hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
264
+ on each casting light's own term. `examples/shadows.tsx` (three
265
+ casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
266
+
267
+ ### Retargeted motion
268
+
269
+ RETARGETED motion is native: `setTransition(node, { position:
270
+ { duration: 400 }, ... })` makes setTransform writes TARGETS the core
271
+ animates toward every frame (position/scale per lane, rotation along
272
+ the quaternion geodesic - a spring keeps its velocity through
273
+ retargets), so a mesh gliding to a slot or a camera rig easing costs
274
+ one JS write per target change, zero per frame. The declaration lives
275
+ on the SceneNode and re-applies on every scene enter; the pose a node
276
+ enters with always snaps. Each natural settle calls the node's
277
+ `onTransitionEnd` (plain field like the pointer handlers) with
278
+ `{ component }`; the raw "spatialTransitionEnd" engine event
279
+ (srt:events, carrying the CORE node id `_node`) stays for flux:spatial
280
+ consumers.
281
+
282
+ ### Geometry layout, indices and topology
283
+
284
+ One interleaved vertex buffer per geometry, described by an open layout
285
+ (`Geometry.layout`, absent = "standard"): an ordered attribute list that
286
+ always starts with the standard prefix `aPos` vec3 + `aNormal` vec3 +
287
+ `aUV` vec2 (what every generator emits) and may carry any named channels
288
+ after it. `withAttribute(geometry, { name, format }, fill)` appends one
289
+ (Three's `setAttribute` for an interleave); "colored" names the common
290
+ case, the prefix plus `aColor` vec4 - the per-vertex data channel (a
291
+ tint, baked AO, any four scalars; standard name, your contents) - and
292
+ `withColors(geometry, fill)` is its spelling. Fill is a flat
293
+ size-per-vertex array or a per-vertex callback receiving `(index, pos,
294
+ normal, uv)`. Materials read attributes BY NAME: a material's vertex
295
+ stage may declare any subset of its geometry's channels, and a channel
296
+ the program reads that the geometry lacks (name + format) throws at
297
+ add(). What a program reads is the ENGINE's word (`material.attributes()`
298
+ = `programAttributes` reflection of the linked program, instance
299
+ attributes excluded), not a parse of the GLSL: an `in` the compiler
300
+ dropped does not count, and the engine also rejects a pipeline whose
301
+ attribute lists leave a read attribute uncovered. The material
302
+ keeps one program and builds one pipeline per layout its meshes bring,
303
+ so a geometry may carry more than a material reads. The whole layout
304
+ ships whether a material reads every attribute or not (inactive
305
+ attributes only keep the stride), so extra channels cost their floats on
306
+ every draw of that geometry - keep data-light passes (a wireframe
307
+ reading only aPos) on standard geometry. The prefix has no opt-out, so
308
+ geometry that is not a surface pays for it anyway: a lidar point
309
+ carrying a position and one packed channel still costs the 8-float
310
+ prefix, doubling a large cloud's vertex buffer
311
+ (okf/backlog/non-surface-vertex-layouts.md). `layoutStride`/`layoutSlot`/
312
+ `layoutKey`/`layoutAttributes` are the layout arithmetic; two layouts
313
+ with equal keys interleave identically (merge requires that).
314
+ Indices are uint16 or uint32 - the `Geometry.indices` array type picks
315
+ the draw's index format, so hand-built geometry past 64k vertices just
316
+ uses a Uint32Array (generators emit uint16). What the indices LIST is
317
+ `Geometry.topology` (`"triangles"` when absent; `"lines"`,
318
+ `"line-strip"`, `"points"`, `"triangle-strip"`): the index buffer and
319
+ its primitive travel together (Godot's surface primitive, Unity's
320
+ SetIndices topology), a material builds one pipeline per (layout,
321
+ topology) pair its meshes bring, and validateGeometry enforces the
322
+ count rule at add() (a whole number of triangles or lines, one
323
+ primitive's worth for a strip). Materials have no topology of their
324
+ own. Only a triangle list gets a picking shape; lines and points pick
325
+ and collide by their bounds box, and cast no shadow. `geometryTopology`
326
+ reads the field with the default applied. POINTS: a point cloud is
327
+ ordinary indexed geometry - one vertex per point, the index buffer
328
+ listing them, `topology: "points"`, no constraint on the index count.
329
+ Under `"points"` the vertex stage writes `gl_PointSize` (honored to the
330
+ pixel; `size / gl_Position.w` attenuates it with distance) and the
331
+ fragment reads `gl_PointCoord` (0..1 across the sprite) to shape the
332
+ splat - `discard` outside the inscribed circle for round ones. The
333
+ worked example is `@solidrt/core`'s `examples/gpu-particles.tsx`. Do
334
+ NOT reach for instancing to draw a cloud (Instancing, below, has the
335
+ cost model). Geometry GPU buffers are lazy, shared, and
336
+ reference-counted by draw entries: removing the last
337
+ entry frees them at the end of the microtask (a same-tick rebuild keeps
338
+ the upload), so swapping `<Mesh geometry>` reactively never accumulates
339
+ old generations; the vertex upload is keyed on the `Float32Array`
340
+ itself, so geometries sharing a vertex array (a wireframe over its
341
+ source) share one buffer. `disposeGeometry` is the immediate explicit
342
+ free.
343
+
344
+ ### Material classes
345
+
346
+ Materials dedupe hard: one program + one pipeline per material CLASS
347
+ (a `shaderMaterialClass` per option combination for unlit, lit and
348
+ sprite alike: map x transparent x cull x alphaTest, lit's extras on
349
+ top), `depth: true` + `cull: "back"` unless the material says otherwise
350
+ (`cull: "none"` for double-sided geometry; lit flips the normal on back
351
+ faces); an instance is just per-entry uniforms (`uColor`) and bindings
352
+ (`uMap`).
353
+
354
+ ### Pure pieces and checks
355
+
356
+ The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
357
+ `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
358
+ GPU-free BY DESIGN so they can be checked headless (and, for the two
359
+ model modules, run under bun in `tools/model.ts`); keep them that way.
360
+ The rigs under `checks/`
361
+ (`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
362
+ `order-check`, `gltf-check`) run on
363
+ flux from the repo root: `bunx srt bundle -f --stdout
364
+ packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
365
+ touching what you changed. `raycast-check.tsx` and
366
+ `collision-check.tsx` are the exceptions: they assert the documented
367
+ picking contract (triangle accuracy, the box tier, pick/raycast
368
+ parity, layer masks, the `{ meshes }` filter) and collision contract
369
+ (exact sweep times, the surface rule, the slide filter, layers and
370
+ meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
371
+ so they run on the playback client instead:
372
+ `bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
373
+ --size 128x128`. Run them whenever a doc edit touches picking or
374
+ collision claims - two copies of this contract have drifted before.
245
375
 
246
376
  ## Components
247
377
 
378
+ ### Component props
379
+
248
380
  | Component | Props |
249
381
  | --- | --- |
250
- | `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
382
+ | `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form; the camera CONTROLS are not a third form - `OrbitCamera`/`FirstPersonCamera` drive position and target only, so `fov`/`near`/`far` come from here even while a control moves the camera, and the default `far` of 100 is what clips a scene in metres), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (pointer events, default on), `pointer?` (the leaf's pointer feed, fed from the scene's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the scene's own handlers, the last stop of the walk - `event.mesh` null over empty space) |
383
+ | `View3d` | a Scene child rendering the scene again from a camera of its own (scene.createView as a component): `width`, `height` (target pixels, live; fixed-size only for now), `x?`, `y?` (the tile's top-left in `into`, live), `into?` (tile an app-owned draw target - one pass for every view into it; fixed at creation), `camera?` (partial CameraUpdate on the view's camera, live; same state as a `PerspectiveCamera` child), `layers?` (the view's mask, live), `clearColor?`, `label?`, `overrideMaterial?`, `fog?` (FogOptions, or null for none), `depth?`, `samples?`, `filter?`, `wrap?` (createView's, fixed), `ref?(view)`, `output?(texture)` (else a built-in `<texture>` leaf at the target size, a tile shown through srcX/srcY), `events?`, `pointer?` (the view leaf's feed, fed from the view's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the view's own handlers); camera-control children drive the VIEW (inside, `useScene()` reports the view as `viewport` and the view's feed as `pointer`); node children mount to the scene as outside, and under the view's leaf get their ordinary pointer handlers, picked through the view's camera (`view.pick`), the view as the root of that walk |
251
384
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
252
385
  | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `renderOrder?`, `castShadow?`, `layers?` (membership bitmask, default 1), pointer events (below), `ref?(mesh)` |
253
386
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
@@ -258,6 +391,8 @@ blendMode and pointer events like any element.
258
391
  | `SpotLight` | transforms as Group, `direction?` (local aim, default [0, -1, 0]), `color?`, `intensity?`, `distance?` (falloff cutoff, 0 = none), `angle?` (cone half-angle DEGREES, default 60), `penumbra?` (0..1 rim fade, default 0), `decay?` (falloff exponent, default 2), `castShadow?`, `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
259
392
  | `PointLight` | transforms as Group (position is what matters), `color?`, `intensity?`, `distance?`, `decay?`, `castShadow?` (six face maps, six shadow slots), `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
260
393
 
394
+ ### Output composition
395
+
261
396
  Output composition: without `output`, `Scene` emits a minimal
262
397
  `<texture width height>` leaf and nothing else is forwarded - anything
263
398
  more goes through `output(texture)`, which renders in place of that leaf:
@@ -270,15 +405,18 @@ elsewhere. Called once, untracked, inside the scene context. Scene
270
405
  layout, so render and display size separate - render at 2x and display
271
406
  smaller for supersampling.
272
407
 
408
+ ### Fill and fixed sizes
409
+
273
410
  Fill (the default): omit `width`/`height` and the built-in leaf is laid
274
411
  out at 100% of its parent's box (give it a sized parent, as on the web)
275
412
  while the target follows the leaf's on-screen size in DEVICE pixels -
276
413
  display scale, `designSize` fits and ancestor transforms included
277
414
  (getBoundingBoxViewport x displayScale, applied from onLayout, so no
278
415
  frame draws at a stale size). A bare `<Scene>` in a pane renders at
279
- native density on any display, mesh events and `<OrbitCamera>` input are
280
- wired automatically (event scaling reads the leaf's untransformed box
281
- back with `getLayoutBox`), and the camera aspect follows the box. Fill
416
+ native density on any display, mesh events are wired automatically
417
+ (event scaling reads the leaf's untransformed box back with
418
+ `getLayoutBox`), the `pointer` feed you hand in is spread on the leaf,
419
+ and the camera aspect follows the box. Fill
282
420
  or fixed is decided at mount; `output` requires explicit sizes (the
283
421
  target cannot follow a leaf it does not own), and giving exactly one of
284
422
  width/height throws.
@@ -293,98 +431,150 @@ examples fill; scene-views and scene-post-effect keep fixed sizes to
293
431
  show multi-view composition and supersampling). A custom `output` leaf
294
432
  whose layout differs from the target
295
433
  takes `handlersFor` (below), not `handlers`; `useScene()` works inside
296
- `output` because it runs in the scene context. With an `<OrbitCamera>`
297
- (or any SceneInput listener) in the scene, also spread
298
- `{...useScene().input.handlersFor(layout)}` on the leaf - the mesh-event
299
- and control-input channels are separate spreads with the same layout.
434
+ `output` because it runs in the scene context. One spread carries
435
+ everything: the `pointer` feed listens at the scene's root, so
436
+ `{...useScene().scene.handlers}` feeds the nodes, the scene's own
437
+ handlers and the feed alike (a detached leaf gives its feed a `layout`,
438
+ it has no layout box to normalize by).
439
+
440
+ ### Input
441
+
442
+ Input, the rule (ARCHITECTURE.md): a camera control consumes a
443
+ device-free abstraction and never handles events or reads a device
444
+ itself. The controls expose `axes` (core's createAxes contract - rates
445
+ sampled per frame, immediate deltas in device-free units) and pose verbs;
446
+ an input map (`createInputMap`, core AGENTS.md) drives the axes by action
447
+ name, and the APP binds devices to the map: the scene leaf's pointer feed
448
+ (`createPointerFeed()`, handed to `<Scene pointer>` / `<View3d pointer>`,
449
+ where it listens at the root of the pointer walk, so a mesh that claims
450
+ its press keeps the control out of that drag; an imperative scene or
451
+ view feeds it with `feedPointer(scene, feed)`), a pad (`gamepad(0)`, or `gamepad.next()`
452
+ for the next pad to press a button), the keyboard (`keyboard`, its key
453
+ events through `input.handlers` on the window).
454
+ Nothing binds by default: `<OrbitCamera />` without `input` moves only
455
+ through its handle. The standard wiring is a preset, plain bindings the
456
+ app applies and edits:
457
+
458
+ ```tsx
459
+ let pointer = createPointerFeed()
460
+ let input = createInputMap(orbitActions)
461
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
462
+ <Scene pointer={pointer}>
463
+ <OrbitCamera input={input} distance={7} />
464
+ ```
465
+
466
+ The vocabulary is shared with @solidrt/2d, one kind and unit per word:
467
+ `rotate` vec2 (orbit turns), `zoom` axis (octaves, positive in), `pan`
468
+ vec2 (element heights), `look` vec2 (turns), `move` vec2 ([right,
469
+ forward], forward = -y in the screen convention), `rise` axis. Keys and
470
+ sticks move the CAMERA where a drag moves the content, so the presets
471
+ bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
472
+ a drag and a stick both turn the eye.
473
+
474
+ ### Orbit camera
300
475
 
301
476
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
302
477
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
303
- zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
304
- - drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
305
- auto-orbit. The first argument is anything with the scene's `setCamera`: a
306
- Scene, or a View to drive one view's camera independently (one orbit per
307
- view, each handed the handlers of its own viewport element). Input runs on
308
- core's `createTransform` recognizer, so drag and pinch arbitrate in the
309
- app-wide gesture arena (a viewport inside a scroller
310
- does not double-handle) and rotation starts after the recognizer's slop;
311
- `zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
312
- scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
313
- `viewport()` supplies `{ height, fov }` for the pixel-to-world mapping -
314
- without it, it rotates like one finger; `clampTarget(target)` bounds where
315
- a pan may put the pivot. Zoom aims
316
- at the target unless `zoomAnchor(x, y, {eye, target})` maps the pinch focal
317
- / wheel cursor to a world point (ground hit, target-depth plane, ...) - then
318
- that point stays pinned under the pointer and the target slides toward it;
319
- only the app can build that mapping, since fov and aspect are app state
320
- (the point arrives in the input element's own pixels). Pair it with `rotateAnchor({eye, target})`: called at gesture
321
- start, its point is projected onto the view axis and re-seats the pivot
322
- without moving the picture, so a drag after an anchored zoom orbits what the
323
- camera looks at, not wherever the zoom left the target. Spread
324
- `orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
325
- from your onFrame (no frame loop of its own), and use its return - true
326
- when the pose changed - to gate per-frame dependents like reprojecting
327
- HUD overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
328
- frame-loop gate: orbiting with a non-zero rate - the predicate the 2d
329
- camera and the first-person camera share) are reactive (HUD-safe); the
330
- pose is plain state via `pose()`/`set()` (also the debug-command shape). It drives position and
331
- target only; fov/near/far stay on scene.setCamera (or the Scene `camera`
332
- prop).
333
-
334
- In a component tree, skip the wiring: `<OrbitCamera azimuth={1.2}
335
- distance={7} />` as a Scene child reaches the scene through context,
336
- receives input from the scene's leaf (the built-in one automatically; a
337
- custom `output` leaf spreads `{...useScene().input.handlersFor(layout)}`
338
- beside its scene.handlersFor spread, same `layout`), defaults `viewport`
339
- to the leaf's laid-out size plus the scene camera's fov, and pushes input
340
- poses synchronously - no ref plumbing, no onFrame. Auto-orbit runs a
341
- frame loop only while `active()`, so a paused camera keeps the app
342
- demand-driven idle. The pose props are
343
- initial values: runtime pose changes (and the debug-command hookup) go
344
- through `ref`'s handle, whose set() also pushes the pose. Every other
345
- prop is live - forwarded to the control as a getter and read where it
346
- applies, never snapshotted - so clamps, rates, anchors and `viewport`
347
- follow their props without a remount, and a clamp change re-clamps the
348
- pose at once.
478
+ zoomSpeed?, panSpeed?, clampTarget?, zoomAnchor?, rotateAnchor? })` -
479
+ azimuth/elevation/distance around a target with optional auto-orbit. The
480
+ first argument is anything with the scene's `setCamera` and `camera()`
481
+ (the fov maps pan travel to world): a Scene, or a View to drive one view's
482
+ camera independently. Its `axes` are `rotate` (a delta of one element
483
+ height sweeps one full turn, Three's OrbitControls convention, so a drag
484
+ feels the same on a phone and a 4k window; a rate turns at 0.5 turn/s at
485
+ full deflection), `zoom` (octaves: a delta of 1 halves the distance, a
486
+ rate of 1 halves it per second) and `pan` (the target slides so the scene
487
+ tracks the fingers 1:1 at the target's depth, three.js DOLLY_PAN, weighted
488
+ by `panSpeed`; `clampTarget(target)` bounds where a pan may put the
489
+ pivot). The verbs: `rotateBy(azimuth, elevation)` radians, `zoomBy(factor,
490
+ anchor?)` (factor > 1 in, as the 2d camera's zoomAt), `panBy(right, up)`
491
+ world units, `setPivot(point)`, `set(pose)`; every one pushes the pose at
492
+ once. Zoom aims at the target unless `zoomAnchor(focal, {eye, target})`
493
+ maps the gesture's focal point - a FRACTION of the element, [0..1, 0..1] -
494
+ to a world point (ground hit, target-depth plane, ...): then that point
495
+ stays pinned under the pointer and the target slides toward it; only the
496
+ app can build that mapping, since it needs the projection
497
+ (scene.unproject over the scene's size). A pinch holds one anchor for its
498
+ whole gesture (the fingers' interleaved events make the span oscillate,
499
+ and re-anchoring per delta turns that into a crawl); the wheel, arriving
500
+ unbracketed, anchors per notch. Pair it with `rotateAnchor({eye, target})`:
501
+ called when a rotate gesture begins, its point is projected onto the
502
+ view axis and re-seats the pivot without moving the picture, so a drag
503
+ after an anchored zoom orbits what the camera looks at, not wherever the
504
+ zoom left the target. Call `orbit.update(dt)` from your onFrame to
505
+ integrate the rates and the auto-orbit (no frame loop of its own), and
506
+ use its return - true when the pose changed since the previous update,
507
+ nudges included - to gate per-frame dependents like reprojecting HUD
508
+ overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
509
+ frame-loop gate: orbiting with a non-zero rate, or any rate driving - the
510
+ predicate every camera control shares) are reactive (HUD-safe); the pose
511
+ is plain state via `pose()`/`set()` (also the debug-command shape). It
512
+ drives position and target only; fov/near/far stay on scene.setCamera
513
+ (or the Scene `camera` prop). The auto-orbit pauses while a gesture is
514
+ open.
515
+
516
+ In a component tree, skip the wiring: `<OrbitCamera input={input}
517
+ azimuth={1.2} distance={7} />` as a Scene child reaches the scene through
518
+ context (as a `<View3d>` child, that view: the context's `viewport` is
519
+ the nearest owner's), drives from the map in `input` (its `rotate`,
520
+ `zoom`, `pan` actions, or the names in `actions`; live - a new map
521
+ reconnects) and nothing else, and runs a frame loop only while
522
+ `active()`, so a camera moved by drags alone keeps the app demand-driven
523
+ idle. The pose props are initial values: runtime pose changes (and the
524
+ debug-command hookup) go through `ref`'s handle, whose set() and verbs
525
+ push the pose. Every other prop is live - forwarded to the control as a
526
+ getter and read where it applies, never snapshotted - so clamps, rates
527
+ and anchors follow their props without a remount, and a clamp change
528
+ re-clamps the pose at once.
529
+
530
+ ### First-person camera
349
531
 
350
532
  First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
351
- pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, viewport?,
352
- clampPosition? })` - a position plus yaw/pitch (yaw 0 faces -z, positive
353
- turns left; pitch positive looks up), Unity's FirstPersonController shape
354
- (look AND move in one control) where Three splits PointerLockControls
355
- from a hand-written key loop. Look comes from pointer-move
356
- movementX/movementY while `pointerLocked()`, from a one-finger drag
357
- (arena-arbitrated through createTransform, viewport-relative with
358
- `viewport`) while not, and from the right stick; move from WASD/arrows
359
- (physical codes and logical keys both), the left stick, and Q/E for
360
- down/up - bound always, inert unless `fly` is on. Walking (the default)
361
- flattens the heading onto the ground plane at fixed height. Every option
362
- but the initial pose is read where it applies (`fly` per update,
363
- `clampPosition` per move, the rates and pitch clamps per input), so a
364
- field changed on the options object takes effect on the next move: walk
365
- and fly are one control. The control NEVER calls `lockPointer`
366
- - click-to-lock and Escape-to-release are the app's window-level
367
- decisions (see `examples/first-person.tsx`) - and has no collision of
368
- its own: `clampPosition(next, current)` is the whole hook - bounds, a
369
- floor height, or `moveAndSlide` over `next - current` against the
370
- collision layer (see `examples/collision.tsx`). Spread `handlers` (pointer + key +
371
- onBlur; keys reach only the FOCUSED node, so that element must hold
372
- focus or be the window), call `update(dt)` from onFrame; `active()` is
373
- reactive - a key held or a stick deflected - and gates the loop. Keys
374
- cannot be polled (core has no key-state accessor), so held keys are
375
- tracked from the down/up pair and `onBlur` drops them.
376
-
377
- `<FirstPersonCamera>` as a Scene child wires all of it: with a
378
- key-driven control registered, the built-in leaf becomes `focusable`
379
- and takes focus on pointer down (the web canvas gesture), which routes
380
- the keys to the control; a pointer-only scene (`<OrbitCamera>`) never
381
- steals focus. A custom `output` leaf spreads
382
- `useScene().input.handlersFor(layout, () => node)` and declares
383
- `focusable` itself. The frame loop runs only while `active()`. Every prop
533
+ pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
534
+ a position plus yaw/pitch (yaw 0 faces -z, positive turns left; pitch
535
+ positive looks up), Unity's FirstPersonController shape (look AND move in
536
+ one control) where Three splits PointerLockControls from a hand-written
537
+ key loop. Its `axes`: `look` (a delta of one element height sweeps half a
538
+ turn - a drag across the screen turns the walker around - and the
539
+ pointer feed's `mouseDelta` delivers mouse motion under pointer lock in
540
+ the same unit; a rate turns at 0.4 turn/s), `move` ([right, forward],
541
+ forward = -y: a stick pushed up or W reads [0, -1]; a rate walks at
542
+ `moveSpeed`, a delta is a step in world units, diagonals clamped to unit
543
+ length so they walk no faster) and `rise` (world up at `moveSpeed`, fly
544
+ mode only). Walking (the default) flattens the heading onto the ground
545
+ plane at fixed height; `fly` moves along the view. The verbs:
546
+ `lookBy(yaw, pitch)` radians, `moveBy(right, forward, up?)` in the
547
+ walker's frame, `set(pose)`. Every option but the initial pose is read
548
+ where it applies (`fly` per step, `clampPosition` per move, the rates and
549
+ pitch clamps per input), so a field changed on the options object takes
550
+ effect on the next move: walk and fly are one control. The control NEVER
551
+ calls `lockPointer` - click-to-lock and Escape-to-release are the app's
552
+ window-level decisions (see `examples/first-person.tsx`) - and has no
553
+ collision of its own: `clampPosition(next, current)` is the whole hook -
554
+ bounds, a floor height, or `moveAndSlide` over `next - current` against
555
+ the collision layer (see `examples/collision.tsx`, whose `jump` is one
556
+ more action on the same map: Space or the pad's south button, read by
557
+ name). Call `update(dt)` from onFrame; `active()` is reactive - a rate
558
+ driving (a held key, a deflected stick) - and gates the loop. The map
559
+ tracks held keys from the down/up pair through `input.handlers`; spread
560
+ its `onBlur` too, since the up never arrives once focus has left.
561
+
562
+ `<FirstPersonCamera input={input}>` as a Scene child wires the control
563
+ to the scene and the map, nothing else: the app spreads
564
+ `input.handlers` on the window (keys bubble there from wherever focus
565
+ is, so a click on the scene needs no focus dance) and hands the scene
566
+ its `pointer` feed. The frame loop runs only while `active()`. Every prop
384
567
  but the initial pose is live: `fly={flying()}` toggles walk/fly on the
385
- running control (pose and held keys carry over, no remount, and
386
- `clampPosition` may swap with it), `moveSpeed`/`lookSpeed` follow their
387
- props, and a pitch clamp change re-clamps at once.
568
+ running control (pose carries over, no remount, and `clampPosition` may
569
+ swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
570
+ clamp change re-clamps at once. Like the orbit control it drives
571
+ position and yaw/pitch only: fov/near/far stay on scene.setCamera (or
572
+ the Scene `camera` prop), so a walker in a scene bigger than the default
573
+ `far` of 100 sets one there. checks/orbit-check.ts and
574
+ checks/first-person-check.ts pin both controls headless (they import
575
+ `@solidrt/core/input` only).
576
+
577
+ ### Overlay projection
388
578
 
389
579
  Overlay projection: `scene.project(point)` maps a world point to scene
390
580
  pixels (top-left origin, y down - the output texture's own space; `w` is
@@ -400,6 +590,8 @@ unproject each move). `scene.viewProj(out?)` copies the view-projection
400
590
  matrix for batch work. Never rebuild the camera matrices by hand for a
401
591
  HUD.
402
592
 
593
+ ### Picking
594
+
403
595
  Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
404
596
  through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
405
597
  world units, nearest first; every hit along the ray, not just the front
@@ -423,13 +615,17 @@ a ray through a knot's hole misses. A large geometry's triangles are
423
615
  BVH-indexed too - built by the first ray that reaches the shape, log-cost
424
616
  after - so raycasting a merged static scene stays cheap (see the batching
425
617
  advice). An instanced mesh is box-only (its
426
- explicit population bounds; records are opaque): it is tested by the
618
+ explicit population bounds; records are opaque), and so is a mesh whose
619
+ geometry is not a triangle list (lines, points, strips have no triangle
620
+ narrowphase): it is tested by the
427
621
  box's twelve triangles, so its hits carry the struck face's `normal` and
428
622
  no `face`/`uv` - and a ray from inside it meets the far side, the
429
623
  surface contract overlap/sweep share. Both methods
430
624
  flush pending writes first (the lookAt/project immediacy contract), and
431
625
  both skip invisible meshes.
432
626
 
627
+ ### Collision
628
+
433
629
  Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
434
630
  motion, opts?)` are the same index's other two questions - what a volume
435
631
  touches, and where a moving one first touches: Unity's
@@ -453,12 +649,21 @@ closed mesh with no triangle in reach touches nothing - the trimesh rule
453
649
  in every engine), and a sweep from a volume already in contact reports
454
650
  time 0 only while the motion closes in; leaving or sliding along the
455
651
  contact is no hit, which is what lets a slide along a wall proceed. A
456
- zero motion touches nothing.
457
- `moveAndSlide(scene, volume, motion, opts?)` is the controller over
458
- them: Godot's CharacterBody3D.move_and_slide and Unity's
459
- CharacterController.Move as one PURE function - no node, no velocity;
460
- the first-person camera composes it in `clampPosition`, a node-driven
461
- body applies `result.motion` with setTransform. It pushes the body out
652
+ zero motion touches nothing. The filters run in the core: every query
653
+ hands `flux:spatial` a `{ root, layers, nodes }` filter (the scene root,
654
+ the mask, the `meshes` include-list as node ids - an instanced mesh
655
+ contributing every instance node), and a mesh's `layers` is written to
656
+ its core node (`setLayers`), so a query never sees another scene's or a
657
+ 2d layer's nodes and JS only maps hits back to meshes.
658
+ `scene.moveAndSlide(volume, motion, opts?)` (and the free
659
+ `moveAndSlide(scene, ...)` spelling of it) is the controller over them:
660
+ Godot's CharacterBody3D.move_and_slide and Unity's
661
+ CharacterController.Move as one PURE call - no node, no velocity; the
662
+ first-person camera composes it in `clampPosition`, a node-driven body
663
+ applies `result.motion` with setTransform. The loop runs in the spatial
664
+ core (`alloy/src/spatial/mover.rs`, one FFI call per body per frame; the
665
+ 2d layer's mover is the same loop), the JS side only packs the volume
666
+ and maps hits back to meshes. It pushes the body out
462
667
  of anything it starts inside, sweeps, stops a skin short, slides the
463
668
  rest along the contact plane up to `maxSlides` times, then snaps down
464
669
  onto a floor within `floorSnap` unless the motion rises - and the
@@ -478,25 +683,58 @@ one overlap per move. Deliberately absent and additive when asked: a
478
683
  step offset (Unity's stepOffset; Godot has none either) and a cylinder
479
684
  volume (Godot only).
480
685
 
481
- Mesh pointer events - the element vocabulary one tree deeper:
482
- `onPointerDown/Move/Up/Enter/Leave` as plain fields on any node (and as
483
- Mesh/Group props). The nearest hit mesh is the target; down/move/up
484
- bubble mesh -> ancestor groups (`stopPropagation()` stops the walk);
485
- enter/leave fire on the mesh alone, pairing on hover changes. A
486
- pointer-down CAPTURES its mesh until the up: moves and the up keep
487
- dispatching to it off-mesh (the platform's captured-drag rule), with
488
- `point`/`distance` null while the ray misses it. The event carries the
489
- element fields (pointerId, pointerType, button, modifiers) plus `mesh`,
490
- `currentTarget`, `point`, `distance`, and `x`/`y` in scene pixels.
491
- Wiring: the built-in `<Scene>` leaf carries `scene.handlers`
492
- automatically (opt out: `events={false}`); an `output` leaf or
493
- imperative composition spreads `{...scene.handlers}` onto the element
494
- showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
495
- the target size - true for the built-in leaf and a d-texture at natural
496
- size, under any ancestor transforms or design-size fits (the hit test
497
- undoes them; localX/localY arrive in the leaf's layout frame). A leaf
498
- laid out at a different size (the supersampling pattern) uses
686
+ ### Pointer events
687
+
688
+ Pointer events (scene-pointer.ts): the element event model one tree
689
+ deeper, with the SCENE as the root of the walk (under a `<View3d>` leaf:
690
+ the view). `onPointerDown/Move/Up/Enter/Leave/Wheel/Tap` are plain
691
+ fields on any node (and Mesh/Group/Instance props); the nearest hit is
692
+ the target - the struck instance of an instanced mesh, else the mesh -
693
+ and down/move/up/wheel dispatch there, bubble through the ancestors and
694
+ END AT THE SCENE's listeners (`scene.listen({...})`, the `<Scene>`
695
+ `onPointer*`/`onWheel`/`onTap` props); over empty space the walk is the
696
+ scene alone, with `event.mesh` null (a tap there is the deselect idiom).
697
+ `event.mesh` stays the hit (`NodePointerEvent` in the chain,
698
+ `ScenePointerEvent` at the root with `mesh` nullable), `currentTarget`
699
+ the node whose handler runs (the scene at the end), `stopPropagation()`
700
+ stops the walk, and a stopped DOWN claims the whole press: that
701
+ pointer's move, up and tap never reach the scene either (the chain still
702
+ bubbles). That one rule is how a mesh drags itself under an
703
+ `<OrbitCamera>` without the view turning: stop the down, own the
704
+ captured moves - the control's pointer feed listens at the root
705
+ (`<Scene pointer>` does it, `feedPointer(scene, feed)` by hand) and sees
706
+ only what the nodes let through. Capture is per pointerId to the press
707
+ target, the scene included (a drag from empty space keeps delivering to
708
+ the root as it crosses meshes; a drag from a mesh keeps naming it, with
709
+ `point`/`distance` null while the ray misses it). Enter/leave fire on
710
+ the struck node alone - a group never receives them - while the root
711
+ sees every move, so "hovering empty space" is a root move with `mesh`
712
+ null. Taps are synthesized by the dispatch (DOM click, Unity's click
713
+ handler): `onTap` fires after the up when the press released on the
714
+ target it pressed within the slop (8 window px, core's recognizer slop,
715
+ so a press is never both a tap and a drag), was the only pointer down
716
+ for its whole press (a pinch never taps), with `tapCount` counting
717
+ repeats within 300 ms and 20 px on the same target (a double tap is
718
+ `tapCount === 2`); a release on another instance of the same mesh is no
719
+ tap. Wheel walks like a move with `deltaX/deltaY` (a node stopping it
720
+ keeps the zoom out). Every event carries `native`, the leaf's element
721
+ event, for core's recognizers, and the element fields (pointerId,
722
+ pointerType, button, modifiers) plus `x`/`y` in scene pixels
723
+ (screenRay's input - a drag plane is one intersection away). Root
724
+ listeners all run, in registration order (the root is the last stop,
725
+ nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
726
+ `scene.handlers` automatically (opt out: `events={false}`); an `output`
727
+ leaf or imperative composition spreads `{...scene.handlers}` onto the
728
+ element showing the texture. `scene.handlers` assumes that leaf is LAID
729
+ OUT at the target size - true for the built-in leaf and a d-texture at
730
+ natural size, under any ancestor transforms or design-size fits (the hit
731
+ test undoes them; localX/localY arrive in the leaf's layout frame). A
732
+ leaf laid out at a different size (the supersampling pattern) uses
499
733
  `scene.handlersFor(() => ({ width, height }))` with its layout size.
734
+ checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
735
+ and tap rules headless; examples/pick.tsx is the live guard.
736
+
737
+ ### Geometry generators
500
738
 
501
739
  Geometry generators take ONE options object, every field optional with
502
740
  a default, named as Three names them: `box({ width, height, depth })`
@@ -529,6 +767,8 @@ buffer itself - so a builder that bakes transforms while writing hands
529
767
  the baker world-space vertices. `fill` indexes relative to `first`.
530
768
  `fillColors(geometry, fill, first?, count?)` is its aColor spelling.
531
769
 
770
+ ### Geometry as data
771
+
532
772
  Geometry as data: `transformGeometry(geometry, { position?, rotation?,
533
773
  quaternion?, scale? }, label?)` bakes a placement into a copy (the
534
774
  setTransform shape: Euler XYZ radians or a quaternion, number = uniform
@@ -544,13 +784,27 @@ does not tax picking: a merged geometry's raycast narrowphase runs
544
784
  through its triangle BVH (built on the first ray), so merge for draw
545
785
  count without giving up ground queries. Both are
546
786
  pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
547
- call, and the source geometries are untouched. `geometryBounds(geometry)`
787
+ call, and the source geometries are untouched. `wireframeGeometry(geometry,
788
+ label?)` and `edgesGeometry(geometry, thresholdAngle?, label?)` (Three's
789
+ WireframeGeometry/EdgesGeometry) build `"lines"` geometry over a triangle
790
+ geometry's OWN vertex array and layout, shared by reference: every edge
791
+ once for the wireframe, for the edges only those where two faces meet at
792
+ the threshold (degrees, default 1) or more plus the open borders. Edges
793
+ are matched by position, so a uv seam or a per-face normal split draws
794
+ one line; a generated round shape is faceted and keeps its facet lines
795
+ until the threshold passes 360 / radialSegments. The wireframe of a
796
+ "skinned" part draws in its pose under `unlit({ skinned: true })` with
797
+ the mesh's palette, and the swap onto a live mesh is
798
+ `setGeometry` + `setMaterial` (`examples/wireframe.tsx`). Lines are one
799
+ pixel wide on GL ES; thick lines are quad geometry. `geometryBounds(geometry)`
548
800
  returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
549
801
  `rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
550
802
  slab test (entry t >= 0 in units of the direction's length, 0 from
551
803
  inside, -1 for a miss) - for ray-testing boxes you keep yourself
552
804
  (triggers, collision volumes) without meshes you do not want to draw.
553
805
 
806
+ ### Profile kit
807
+
554
808
  Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
555
809
  closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
556
810
  share an averaged normal - `fillet(points, radius, segs?)` and
@@ -576,87 +830,120 @@ like circle); `triangulate(points)` is the ear-clipping core (fan
576
830
  fallback, never drops a cap), exported for custom flat work. These pick
577
831
  uint16/uint32 indices by vertex count automatically.
578
832
 
579
- Materials:
580
-
581
- - `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
582
- straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
583
- below), premultiplied internally; `cull` and
584
- `alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
585
- opts out of the scene's fog (all four library materials take it).
586
- - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
587
- that turns to face the camera IN THE VERTEX STAGE (off the shared
588
- uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
589
- only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
590
- tree/character sprite; the default `"full"` is Three's Sprite, flat to
591
- the screen). No per-frame JS however many sprites. `transparent`
592
- defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
593
- off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
594
- shared unit plane, no geometry argument, `scale` = world size, rotation
595
- ignored. Picks by a unit box around its center (its reach at any
596
- facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
597
- - `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
598
- depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
599
- custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
600
- MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
601
- per entry) and `uniform mat4 uViewProj` (the camera, shared
602
- target-level params) - transform with
603
- `uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
604
- at shaderMaterial() creation. The rest is opt-in by declare-and-use:
605
- `uniform vec3 uCamPos` (the camera's world position, shared and written
606
- with uViewProj - the specular/fresnel view vector is
607
- `normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
608
- (the camera's world-space view axes, shared likewise - a billboard is
609
- `center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
610
- uViewProj rows, that carries the clip flip), `uniform mat4
611
- uInvViewProj` (the camera's inverse view-projection, shared likewise -
612
- a clip position back to world, the world-space ray through a pixel
613
- without knowing the projection), the output stage's `uniform float
614
- uExposure` / `uToneMapping` (compose `OUTPUT` from `/glsl` and end with
615
- `fragColor = outputColor(rgb, alpha)` to take the scene's exposure and
616
- tone mapping and encode like the library materials do; a fragment
617
- writing fragColor directly writes final encoded pixels) and `uniform
618
- mat4 uNormal` (the world
619
- inverse-transpose, written beside uModel for this material's meshes;
620
- take `mat3(uNormal)` - correct under non-uniform scale, where
621
- mat3(uModel) bends normals off the surface). Attributes come from the
622
- geometry's layout by name; the ones the linked program actually reads
623
- (engine reflection, instance attributes excluded) must all be in the
624
- mesh's geometry layout or add() throws - so a used `in vec4 aColor`
625
- needs `withColors()` geometry and a custom channel needs
626
- `withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
627
- pipeline preamble. App-driven uniforms beyond the standard set: seed
628
- via `params`, then write per mesh with
629
- `setMeshParams(mesh, { name: value })` (validated names; values persist
630
- across entry rebuilds; frame-rate-safe like setTransform) or declaratively
631
- with the `Mesh` `params` prop (same merge semantics - a key that
632
- disappears from the object keeps its old value; for per-frame values
633
- prefer `ref` + setMeshParams from onFrame, the setTransform split).
634
- Scene-wide values (a clock, a sun direction, fog) go through
635
- `scene.setParams({ uTime })` instead - one write for every mesh.
636
- - `shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
637
- class/instance split for your own GLSL: compiles once, and
638
- `cls.instance({ params?, textures? })` returns a Material sharing that
639
- pipeline with its own values. `dispose()` lives on the class alone.
640
- `shaderMaterial(opts)` is exactly a class with one instance (its
641
- `dispose` forwards to the class).
642
- - `instanceAttributes: [{ name, format, slot? }]` on either shader-material
643
- form makes an INSTANCED material: the vertex stage reads them as `in`
644
- variables beside the layout's own, and each drawn instance gets one
645
- record per slot from the mesh's instance buffers - slot 0 (default)
646
- is the record buffer, slot 1 an instanced mesh's STYLE buffer (below).
647
- Its meshes come from `createInstancedMesh` or `createRecordMesh`; a
648
- `createMesh` mesh is rejected at add(). `instanceStyle: [..]` is the
649
- slot-1 record a fresh instance starts with. The stock materials do
650
- the same with one flag: `lit/standard/unlit({ instanced: true })`
651
- places by the instance matrix (the shadow pass too, so the fleet
652
- casts), and `{ instanceColors: true }` adds a per-instance
653
- premultiplied `[r, g, b, a]` style record starting white - Unity's
654
- per-material instancing switch with Three's setColorAt.
833
+ ### Materials
834
+
835
+ #### unlit
836
+
837
+ `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
838
+ straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
839
+ below), premultiplied internally; `cull` and
840
+ `alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
841
+ opts out of the scene's fog (all four library materials take it).
842
+
843
+ #### sprite
844
+
845
+ `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
846
+ that turns to face the camera IN THE VERTEX STAGE (off the shared
847
+ uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
848
+ only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
849
+ tree/character sprite; the default `"full"` is Three's Sprite, flat to
850
+ the screen). No per-frame JS however many sprites. `transparent`
851
+ defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
852
+ off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
853
+ shared unit plane, no geometry argument, `scale` = world size, rotation
854
+ ignored. Picks by a unit box around its center (its reach at any
855
+ facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
856
+
857
+ #### shaderMaterial
858
+
859
+ `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
860
+ depthWrite?, blend?, cull?, label? })` - your own GLSL, the
861
+ custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
862
+ MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
863
+ per entry) and `uniform mat4 uViewProj` (the camera, shared
864
+ target-level params) - transform with
865
+ `uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
866
+ at shaderMaterial() creation. The rest is opt-in by declare-and-use:
867
+ `uniform vec3 uCamPos` (the camera's world position, shared and written
868
+ with uViewProj - the specular/fresnel view vector is
869
+ `normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
870
+ (the camera's world-space view axes, shared likewise - a billboard is
871
+ `center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
872
+ uViewProj rows, that carries the clip flip), `uniform mat4
873
+ uInvViewProj` (the camera's inverse view-projection, shared likewise -
874
+ a clip position back to world, the world-space ray through a pixel
875
+ without knowing the projection), the output stage's `uniform float
876
+ uExposure` / `uToneMapping` (compose `OUTPUT` from `/glsl` and end with
877
+ `fragColor = outputColor(rgb, alpha)` to take the scene's exposure and
878
+ tone mapping and encode like the library materials do; a fragment
879
+ writing fragColor directly writes final encoded pixels) and `uniform
880
+ mat4 uNormal` (the world
881
+ inverse-transpose, written beside uModel for this material's meshes;
882
+ take `mat3(uNormal)` - correct under non-uniform scale, where
883
+ mat3(uModel) bends normals off the surface). Attributes come from the
884
+ geometry's layout by name; the ones the linked program actually reads
885
+ (engine reflection, instance attributes excluded) must all be in the
886
+ mesh's geometry layout or add() throws - so a used `in vec4 aColor`
887
+ needs `withColors()` geometry and a custom channel needs
888
+ `withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
889
+ pipeline preamble. App-driven uniforms beyond the standard set: seed
890
+ via `params`, then write per mesh with
891
+ `setMeshParams(mesh, { name: value })` (validated names; values persist
892
+ across entry rebuilds; frame-rate-safe like setTransform) or declaratively
893
+ with the `Mesh` `params` prop (same merge semantics - a key that
894
+ disappears from the object keeps its old value; for per-frame values
895
+ prefer `ref` + setMeshParams from onFrame, the setTransform split).
896
+ Scene-wide values (a clock, a sun direction, fog) go through
897
+ `scene.setParams({ uTime })` instead - one write for every mesh.
898
+
899
+ #### shaderMaterialClass
900
+
901
+ `shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
902
+ class/instance split for your own GLSL: compiles once, and
903
+ `cls.instance({ params?, textures? })` returns a Material sharing that
904
+ pipeline with its own values. `dispose()` lives on the class alone.
905
+ `shaderMaterial(opts)` is exactly a class with one instance (its
906
+ `dispose` forwards to the class).
907
+
908
+ #### Instanced materials
909
+
910
+ `instanceAttributes: [{ name, format, slot? }]` (`format` is the vertex
911
+ vocabulary, `"f32" | "vec2" | "vec3" | "vec4"`, NOT WebGPU's
912
+ `float32x3` spelling) on either shader-material form makes an INSTANCED
913
+ material: the vertex stage reads them as `in` variables beside the
914
+ layout's own, and each drawn instance gets one
915
+ record per slot from the mesh's instance buffers - slot 0 (default)
916
+ is the record buffer, slot 1 an instanced mesh's STYLE buffer (below).
917
+ Its meshes come from `createInstancedMesh` or `createRecordMesh`; a
918
+ `createMesh` mesh is rejected at add(). `instanceStyle: [..]` is the
919
+ slot-1 record a fresh instance starts with. The stock materials do
920
+ the same with one flag: `lit/standard/unlit({ instanced: true })`
921
+ places by the instance matrix (the shadow pass too, so the fleet
922
+ casts), and `{ instanceColors: true }` adds a per-instance
923
+ premultiplied `[r, g, b, a]` style record starting white - Unity's
924
+ per-material instancing switch with Three's setColorAt.
925
+
926
+ ### Instancing
655
927
 
656
928
  Instancing - one draw entry covering a population, in two forms. The
657
929
  axis between them is WHERE MOTION IS COMPUTED, the same split as
658
930
  @solidrt/2d's sprite and record layers.
659
931
 
932
+ FIRST, whether to instance at all. An instance costs the GPU a fixed
933
+ per-instance setup, so records buy cheap MOTION, not cheap VERTICES:
934
+ below roughly a hundred vertices per instance that setup dominates, and
935
+ a plain geometry with a bigger vertex buffer draws faster. A point cloud
936
+ is the extreme case - one vertex per point in an ordinary indexed
937
+ `topology: "points"` geometry, no instancing, measured at 4x the frame
938
+ rate of the same points as records. Packing more points per record does
939
+ not recover it: every vertex of an instance fetches ALL of that
940
+ instance's attributes, so attribute bandwidth grows with the packing.
941
+ Reach for instancing when each instance is a real object (a ship, a
942
+ tree, a crowd member), not to submit a lot of vertices. The tell in
943
+ `/gpu` is a large `instanceCount` beside a tiny `indexCount`.
944
+
945
+ #### createInstancedMesh
946
+
660
947
  `createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
661
948
  draws the geometry once per instance NODE: `addInstance(mesh,
662
949
  transform?, parent?)` returns a scene node (kind "instance") placed
@@ -705,15 +992,17 @@ MultiMesh is the record form. The components: `<InstancedMesh capacity>`
705
992
  with `<Instance position style transition onPointerDown>` children
706
993
  (`<Group>` children are squads, an `<Instance>` is a parent too);
707
994
  `examples/fleet.tsx` is a thousand of them springing between formations
708
- with tap-to-tint, `probes/3d-instance-probe.tsx` the function face.
995
+ with tap-to-tint.
709
996
  Mount cost is the one difference between the faces: an `<Instance>` row
710
997
  costs about eight times an `addInstance` call (the component machinery -
711
998
  a Solid row, two effects, the style effect; a node component's context
712
- provider is built only when it has children, the biggest single saving -
713
- `probes/3d-instance-mount-bench.tsx` measures it), so a few thousand
999
+ provider is built only when it has children, the biggest single
1000
+ saving), so a few thousand
714
1001
  mount in tens of milliseconds either way; populations spawned per frame,
715
1002
  or in five figures, belong to the function face.
716
1003
 
1004
+ #### createRecordMesh
1005
+
717
1006
  `createRecordMesh(geometry, material, records, count?, { bounds?,
718
1007
  label? })` is the raw form: `records` is the interleaved per-instance
719
1008
  data (stride = the material's slot-0 instanceAttributes summed, a
@@ -732,13 +1021,16 @@ then it picks and transparent-sorts conservatively as one box. The
732
1021
  escape hatch for motion only JS can compute at scale (a particle sim, a
733
1022
  crowd stepped in a worker).
734
1023
 
1024
+ #### Common to both
1025
+
735
1026
  Everything mesh works on both: setTransform moves the whole population
736
1027
  through one uModel, setVisible zeroes the drawn count and restores it on
737
1028
  unhide, renderOrder/params/geometry/material swaps apply, and
738
1029
  `disposeInstances(mesh)` detaches and frees the record buffers - the one
739
1030
  explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
740
- components), `probes/3d-instance-probe.tsx` (instances, functions) and
741
- `examples/instanced.tsx` (records) are the live proofs.
1031
+ components) and `examples/instanced.tsx` (records) are the live proofs.
1032
+
1033
+ ### Background
742
1034
 
743
1035
  Background: `scene.setBackground(source | null)`, the `background` option
744
1036
  on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
@@ -774,6 +1066,8 @@ no separate resize plumbing. Two forms:
774
1066
  the same way, so a skybox is one flat color there. A 2D texture id
775
1067
  throws at the samplerCube binding. `examples/skybox.tsx`.
776
1068
 
1069
+ ### Cube-map convention
1070
+
777
1071
  The cube-map convention: a cube map holds what a GL lookup returns -
778
1072
  each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
779
1073
  and every library lookup is a plain `texture(cube, dir)` in world space.
@@ -789,6 +1083,8 @@ number, so the object form keeps it unambiguous). Translucent grounds
789
1083
  over a background still need blend factors (a separate shader texture
790
1084
  underneath until then).
791
1085
 
1086
+ ### Environment
1087
+
792
1088
  Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
793
1089
  null)`, the `environment` option on createScene and the reactive `Scene`
794
1090
  prop - the cube map every `standard` material is lit by (always: the
@@ -838,6 +1134,8 @@ environment set contributes nothing (uEnvOn 0), not a black reflection.
838
1134
  `examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
839
1135
  baked HDRI lighting the scene alone).
840
1136
 
1137
+ ### Reflection probes
1138
+
841
1139
  Reflection probes: `scene.createReflectionProbe({ position, size?,
842
1140
  near?, far?, layers?, clearColor?, label? })` renders the scene into a
843
1141
  cube map from a point - Three's CubeCamera, Unity's and Godot's
@@ -883,6 +1181,8 @@ x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
883
1181
  from outside; the engine inverts the front-face rule on cube target
884
1182
  passes so cull modes keep their meaning. `examples/probe.tsx`.
885
1183
 
1184
+ ### Baked sky
1185
+
886
1186
  Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
887
1187
  origin that sees no mesh (layer mask 0): the scene's background - the
888
1188
  GLSL sky or the skybox - alone on its six faces, LINEAR (a sky ending in
@@ -902,6 +1202,8 @@ that HDR is for. A sky that reads
902
1202
  `uCamPos` bakes from the origin; scene params (an app clock) are seen as
903
1203
  of the call. `examples/sky-lit.tsx`.
904
1204
 
1205
+ ### Panoramas
1206
+
905
1207
  Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
906
1208
  equirectangular 2D texture (createImage, createTexture) into a cube
907
1209
  TextureId on the GPU, synchronously (six face passes straight into a
@@ -917,6 +1219,8 @@ the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
917
1219
  decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
918
1220
  pure TypeScript and bun-tested - there is no runtime .hdr decoder.
919
1221
 
1222
+ ### Fog
1223
+
920
1224
  Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
921
1225
  the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
922
1226
  far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
@@ -948,6 +1252,8 @@ horizon, and put `far` at or inside the camera's far plane to hide the
948
1252
  clip. `examples/fog.tsx` cycles the forms over a valley;
949
1253
  `examples/cascades.tsx` fogs its field to the sky.
950
1254
 
1255
+ ### Color
1256
+
951
1257
  Color: the scene shades in LINEAR light and outputs sRGB, like Three
952
1258
  (ColorManagement), Godot and Unity's linear space - no gamma mode. Every
953
1259
  `[r, g, b]` color option is sRGB, what a color picker shows: material
@@ -973,6 +1279,8 @@ raw sampling). What changed for a scene tuned before this: terminators
973
1279
  soften, mid-tones brighten, highlights widen - drop ambient rather than
974
1280
  lights. `emissiveIntensity` scales the emissive in linear light.
975
1281
 
1282
+ ### Lighting GLSL
1283
+
976
1284
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
977
1285
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
978
1286
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
@@ -1008,6 +1316,8 @@ samplers. Lights, colors and exponents are arguments, so
1008
1316
  nothing is pinned but the function names; `lit` is composed from these
1009
1317
  same constants - customizing never means leaving the system.
1010
1318
 
1319
+ ### Custom looks: three tiers
1320
+
1011
1321
  A custom look is a citizen of the scene - lit by its lights, shadowed,
1012
1322
  fogged, exposed and tone mapped like the stock materials - at one of
1013
1323
  three tiers, top first:
@@ -1021,7 +1331,12 @@ three tiers, top first:
1021
1331
  mesh keeps the stock shading whole (`examples/instanced.tsx`: the
1022
1332
  per-instance tint rides vColor). Instance it with the per-entry
1023
1333
  uniforms the source declares (uColor, uSpecular/uShininess or
1024
- uMetalness/uRoughness, the maps opted into).
1334
+ uMetalness/uRoughness, the maps opted into) - SEED THEM: a uniform
1335
+ no one wrote is zero, and the program shades `base * uColor`, so an
1336
+ unseeded uColor renders pure black with no error. `instance({
1337
+ params: { uColor: [1, 1, 1, 1], uSpecular: 0.12, uShininess: 24 } })`
1338
+ is the white starting point (the stock materials seed exactly this
1339
+ from their `color` option).
1025
1340
  2. A SURFACE FUNCTION inside the stock fragment. `litFragment({ surface,
1026
1341
  prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
1027
1342
  it declares is an ordinary `instance()` param), `surface` declares
@@ -1035,7 +1350,12 @@ three tiers, top first:
1035
1350
  generated program is; colors are linear light, premultiplied
1036
1351
  throughout, `Surface.base` included. The material describes the
1037
1352
  surface, the package shades it (Godot's fragment(), Filament's
1038
- material()).
1353
+ material()). This is the tier for anything whose geometry is not a
1354
+ surface: a point splat discarding outside `gl_PointCoord`'s inscribed
1355
+ circle and flipping a fitted normal towards the viewer keeps the
1356
+ scene's whole light model in about fifteen lines, where hand-rolling
1357
+ the light loop (tier 3) would have to match the tone mapping and
1358
+ output encoding by hand.
1039
1359
  3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
1040
1360
  `sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
1041
1361
  declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
@@ -1049,11 +1369,9 @@ three tiers, top first:
1049
1369
  loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
1050
1370
  shade function: light i's direction and its color already attenuated,
1051
1371
  cone-faded and shadowed (zero when it cannot reach). The stock
1052
- materials are built from this same set, so the tiers cannot drift;
1053
- `probes/scene-set-probe.tsx` is the byte-identity rig that checks it.
1372
+ materials are built from this same set, so the tiers cannot drift.
1054
1373
  The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
1055
- (the knot's rim term); `probes/spot-custom-material-probe.tsx` a
1056
- sceneLight loop beside a lit() floor.
1374
+ (the knot's rim term).
1057
1375
  `standardFragment(options)` is the same for `standard`: lit's options
1058
1376
  minus `specularMap`/`env` (the environment is always composed) plus
1059
1377
  `metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
@@ -1071,6 +1389,8 @@ a shadow program that never reads `n` (no triplanar, no surface function
1071
1389
  using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
1072
1390
  instance or every caster move warns about the skipped write.
1073
1391
 
1392
+ ### Lights
1393
+
1074
1394
  Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
1075
1395
  direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
1076
1396
  travelling along `direction` in the node's LOCAL space (default `[0, -1,
@@ -1116,6 +1436,8 @@ scenes. Everything starts black: a lit scene with no light shows
1116
1436
  nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
1117
1437
  shape (soft vs hard cone, casting spots, an orbiting bulb).
1118
1438
 
1439
+ ### lit
1440
+
1119
1441
  `lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
1120
1442
  the directional list, Lambert diffuse, Blinn-Phong highlight when
1121
1443
  `specular` (0..1 strength) is set with `shininess` (default 30), a
@@ -1141,6 +1463,8 @@ or more beside it, or trilinear smears the far half into the mip its long
1141
1463
  axis picked (`createModel` uploads its images with both; the device clamps
1142
1464
  the level, `limits.maxAnisotropy` reports it).
1143
1465
 
1466
+ #### Surface maps
1467
+
1144
1468
  The surface maps, each an option beside `map` and sampled at its uv:
1145
1469
 
1146
1470
  - `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
@@ -1175,6 +1499,8 @@ The surface maps, each an option beside `map` and sampled at its uv:
1175
1499
 
1176
1500
  `examples/materials.tsx` shows all five.
1177
1501
 
1502
+ ### standard
1503
+
1178
1504
  `standard(opts)` is the metalness/roughness material, the look authored
1179
1505
  assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
1180
1506
  Unity's Standard): every lit option but the Blinn-Phong knobs
@@ -1213,70 +1539,82 @@ classes do not warn about an inactive uniform.
1213
1539
 
1214
1540
  ## Models
1215
1541
 
1542
+ ### Three layers
1543
+
1216
1544
  Authored models come in as glTF 2.0 (.gltf with its .bin and image files
1217
1545
  next to it, or single-file .glb) and become a Group carrying the file's
1218
1546
  node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
1219
1547
  fits:
1220
1548
 
1221
- - `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
1222
- bun and on flux): `ModelData` = `nodes` (the retained hierarchy in
1223
- pre-order - name, parent index, local TRS; matrix-form nodes are
1224
- TRS-decomposed, shear dropped; nodes that carry no part, joint or
1225
- animation target anywhere - cameras, lights, unused empties - are
1226
- pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
1227
- index, vertices in the standard layout LOCAL to the node - except
1228
- skinned parts: "skinned" layout, model-space bind pose, `skin` index),
1229
- `skins` (joint node indices + inverse binds), `clips` (the animations
1230
- as baked channel buffers: node/path/interpolation, times, values),
1231
- `materials` (base color factor, `map` =
1232
- index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
1233
- `alphaMode` as written and `alphaCutoff`, spec default 0.5, the
1234
- normal and emissive slots, `metalness`/`roughness` factors and the
1235
- packed `metalnessRoughnessMap` - standard's inputs), `images`
1236
- (the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
1237
- pose, conservative for parts under rotated nodes). External
1238
- files come through `resolve(uri)` (uri as written, still
1239
- percent-encoded; `gltfExternalUris(bytes)` lists them so an async
1240
- caller can read them first) - for a .gltf AND for a .glb, which is
1241
- usually self-contained but may legally reference external images
1242
- (real exporters do); data: uris need no resolver. Missing
1243
- normals produce FLAT shading (the spec's rule): the primitive is
1244
- un-indexed, one vertex per corner. A mirroring node chain (negative
1245
- rest-pose world determinant) flips the part's index winding so
1246
- `cull: "back"` still keeps the outside. Non-triangle primitives are
1247
- skipped; a required extension the parser does not implement throws
1248
- naming it, and Draco or meshopt compression throws "re-export without
1249
- mesh compression" - Blender exports Draco by DEFAULT, so that is the
1250
- first error a real file hits.
1251
- - `createModel(data, { material?, label? })` - uploads the images (repeat
1252
- wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
1253
- with the file's color, maps, normal scale, metalness/roughness and
1254
- packed map, emissive and transparency - the glTF material model, so a
1255
- scene showing a model wants an `environment` (a glTF metal in a scene
1256
- with none renders near black); `material: (m, maps, skinned) =>
1257
- lit({ color: m.color, map: maps.map ?? undefined, skinned })` for the
1258
- Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
1259
- Groups with the file's local TRS, and one mesh per part under its node,
1260
- all inside the returned `Model` (a Group): `add(scene.root, model)`,
1261
- place it with `setTransform`, find parts by name in `model.parts`
1262
- (`{ name, mesh }`), spin a wheel relative to its axle through
1263
- `model.nodes` (`{ name, node }` in table order, parents first; names
1264
- repeat when the file's do - `.find()` yours), `model.bounds` for
1265
- framing a camera. Skinned parts get the `skinned: true` material
1266
- variant and hang off the model ROOT (the spec ignores their node's
1267
- transform; the palette places them - see the mixer below). `dispose()`
1268
- detaches it and frees the geometry buffers and textures - the model owns
1269
- them, nothing else frees them.
1270
- - `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
1271
- and build. `loadModel` reads the baked `.srtm` written by `srt tool
1272
- 3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
1273
- under bun, stored in the GPU layout, so loading is views onto the file's
1274
- bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
1275
- on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
1276
- 40 ms for the whole baked load - the runtime parse is fine for small
1277
- models and a binary import (`import bytes from "./x.glb" with { type:
1278
- "binary" }` then `createModel(parseGltf(bytes))`, see
1279
- `examples/model.tsx`); bake anything big.
1549
+ #### parseGltf
1550
+
1551
+ `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
1552
+ bun and on flux): `ModelData` = `nodes` (the retained hierarchy in
1553
+ pre-order - name, parent index, local TRS; matrix-form nodes are
1554
+ TRS-decomposed, shear dropped; nodes that carry no part, joint or
1555
+ animation target anywhere - cameras, lights, unused empties - are
1556
+ pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
1557
+ index, vertices in the standard layout LOCAL to the node - except
1558
+ skinned parts: "skinned" layout, model-space bind pose, `skin` index),
1559
+ `skins` (joint node indices + inverse binds), `clips` (the animations
1560
+ as baked channel buffers: node/path/interpolation, times, values),
1561
+ `materials` (base color factor, `map` =
1562
+ index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
1563
+ `alphaMode` as written and `alphaCutoff`, spec default 0.5, the
1564
+ normal and emissive slots, `metalness`/`roughness` factors and the
1565
+ packed `metalnessRoughnessMap` - standard's inputs), `images`
1566
+ (the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
1567
+ pose, conservative for parts under rotated nodes). External
1568
+ files come through `resolve(uri)` (uri as written, still
1569
+ percent-encoded; `gltfExternalUris(bytes)` lists them so an async
1570
+ caller can read them first) - for a .gltf AND for a .glb, which is
1571
+ usually self-contained but may legally reference external images
1572
+ (real exporters do); data: uris need no resolver. Missing
1573
+ normals produce FLAT shading (the spec's rule): the primitive is
1574
+ un-indexed, one vertex per corner. A mirroring node chain (negative
1575
+ rest-pose world determinant) flips the part's index winding so
1576
+ `cull: "back"` still keeps the outside. Non-triangle primitives are
1577
+ skipped; a required extension the parser does not implement throws
1578
+ naming it, and Draco or meshopt compression throws "re-export without
1579
+ mesh compression" - Blender exports Draco by DEFAULT, so that is the
1580
+ first error a real file hits.
1581
+
1582
+ #### createModel
1583
+
1584
+ `createModel(data, { material?, label? })` - uploads the images (repeat
1585
+ wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
1586
+ with the file's color, maps, normal scale, metalness/roughness and
1587
+ packed map, emissive and transparency - the glTF material model, so a
1588
+ scene showing a model wants an `environment` (a glTF metal in a scene
1589
+ with none renders near black); `material: (m, maps, skinned) =>
1590
+ lit({ color: m.color, map: maps.map ?? undefined, skinned })` for the
1591
+ Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
1592
+ Groups with the file's local TRS, and one mesh per part under its node,
1593
+ all inside the returned `Model` (a Group): `add(scene.root, model)`,
1594
+ place it with `setTransform`, find parts by name in `model.parts`
1595
+ (`{ name, mesh }`), spin a wheel relative to its axle through
1596
+ `model.nodes` (`{ name, node }` in table order, parents first; names
1597
+ repeat when the file's do - `.find()` yours), `model.bounds` for
1598
+ framing a camera. Skinned parts get the `skinned: true` material
1599
+ variant and hang off the model ROOT (the spec ignores their node's
1600
+ transform; the palette places them - see the mixer below). `dispose()`
1601
+ detaches it and frees the geometry buffers and textures - the model owns
1602
+ them, nothing else frees them.
1603
+
1604
+ #### loadGltf and loadModel
1605
+
1606
+ `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
1607
+ and build. `loadModel` reads the baked `.srtm` written by `srt tool
1608
+ 3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
1609
+ under bun, stored in the GPU layout, so loading is views onto the file's
1610
+ bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
1611
+ on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
1612
+ 40 ms for the whole baked load - the runtime parse is fine for small
1613
+ models and a binary import (`import bytes from "./x.glb" with { type:
1614
+ "binary" }` then `createModel(parseGltf(bytes))`, see
1615
+ `examples/model.tsx`); bake anything big.
1616
+
1617
+ ### Async loading
1280
1618
 
1281
1619
  Loading is async everywhere but the binary import: loadGltf/loadModel
1282
1620
  return promises, and the async value must be read the way Solid 2 async
@@ -1296,6 +1634,8 @@ a source glTF must be parsed at runtime, do the parse in an isolate
1296
1634
  (parseGltf's result is plain data and copies across) and keep
1297
1635
  createModel on main.
1298
1636
 
1637
+ ### Placement and sockets
1638
+
1299
1639
  Placement: pieces of one authored set (a body and its fitted cosmetics)
1300
1640
  export in one world space, so composing them is `add(group, model)` per
1301
1641
  piece and nothing else - no placement math. SOCKETED items (a weapon in
@@ -1303,8 +1643,8 @@ a hand) are different: they bind to a joint, so they only land once a
1303
1643
  skeleton exists (a rig-less export cannot place them at all). The joint
1304
1644
  is an ordinary Group in `model.nodes` - find it by name and `add()` the
1305
1645
  item under it; it then follows the pose, mixer-driven or hand-posed,
1306
- like any child transform. Two authoring cases (verified in
1307
- `probes/joint-cap-probe.tsx`): an item authored about its own socket
1646
+ like any child transform. Two authoring cases: an item authored about
1647
+ its own socket
1308
1648
  origin needs the plain `add()` and nothing more; one authored in the
1309
1649
  RIG'S model space needs a socket Group between joint and item carrying
1310
1650
  the joint's rest-pose inverse (at rest the item then sits exactly where
@@ -1313,6 +1653,34 @@ transform on top of the authored placement. Skinned PARTS are the one
1313
1653
  thing that never needs this: they hang off the model root and the
1314
1654
  palette places them.
1315
1655
 
1656
+ ### Wardrobe pieces
1657
+
1658
+ Wardrobe pieces (a hood, a cape, cuffs) are the third case: exported
1659
+ WITH a skin over the body's joints and WITHOUT clips, so beside an
1660
+ animated body they hold their bind pose. `bindSkeleton(body, piece)`
1661
+ drives them from the body's skeleton - Three's SkinnedMesh.bind, Unity's
1662
+ `bones =`, Godot's shared Skeleton3D. The piece's palette rows re-bind
1663
+ onto the body's joint nodes (matched by name, case-insensitive) with
1664
+ the piece's own inverse binds, so the flush writes the body's pose into
1665
+ the piece's skin and no per-frame code exists; joints with no body
1666
+ counterpart (a hat's internal bones) and rigid parts hanging off a
1667
+ matched node are grafted under the matched body joint and ride it. The
1668
+ piece then hangs under the body at the body's placement (its skinned
1669
+ vertices are in the body's model space), and its own joint nodes are no
1670
+ longer posed by anything: read poses from and socket items on the
1671
+ BODY's joints. The piece's node tree is never composed - exporters
1672
+ truncate it above the spine or skip an ancestor in the middle, and
1673
+ copying locals through such a tree hangs joints off the wrong place
1674
+ while looking right for every piece whose tree happens to match; the
1675
+ body's world matrices are what a bone matrix needs. A shared joint
1676
+ whose bind pose differs (a piece exported against another rest pose or
1677
+ scale) throws at bind instead of rendering wrong. A piece comes off by
1678
+ disposing it; disposing a body disposes what it wears. Culling follows
1679
+ the wear: a body joint's box is the union of every skin reaching it,
1680
+ its own and the pieces'.
1681
+
1682
+ ### Applied glTF material fields
1683
+
1316
1684
  Applied: `doubleSided` (the default material draws it with `cull:
1317
1685
  "none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
1318
1686
  (+ scale; the derivative frame needs no tangents), `emissiveFactor` x
@@ -1328,6 +1696,9 @@ by lit()/standard() option name (`maps.map`/`maps.normalMap`/
1328
1696
  A `.srtm` baked before the material records carried the PBR fields
1329
1697
  (file version 3) is rejected by loadModel - re-bake with `srt tool
1330
1698
  3d/model`.
1699
+
1700
+ ### Animation
1701
+
1331
1702
  Animation: `createMixer(model)` plays `model.clips` by name -
1332
1703
  `mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
1333
1704
  named clip fades in, everything else fades out - Unity's CrossFade,
@@ -1355,6 +1726,9 @@ so a TRANSPARENT mesh parented under a player-animated joint does not
1355
1726
  re-trigger the back-to-front re-sort while it animates (opaque meshes,
1356
1727
  palettes and picking are unaffected) - nudge the scene with any
1357
1728
  setTransform if it shows, until the core-side transparent sort lands.
1729
+
1730
+ ### Root motion
1731
+
1358
1732
  Root motion: `play(name, { inPlace? })` strips a clip's root travel
1359
1733
  (Unity's applyRootMotion off, Godot's root_motion_track): the root's
1360
1734
  x/z hold at the clip's first key and its height rebases onto the root's
@@ -1391,7 +1765,11 @@ horizontal travel (Unity's bake-into-pose Y - for a controller that
1391
1765
  owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
1392
1766
  the swing-twist about up (exact under any lean); a cubic rotation
1393
1767
  track is linearized at 60 keys/s before its yaw is held. Verified on
1394
- Mixamo's standing turns (external/mixamo-turn). Skins need nothing further: each skin's uBones palette
1768
+ Mixamo's standing turns (external/mixamo-turn).
1769
+
1770
+ ### Skins
1771
+
1772
+ Skins need nothing further: each skin's uBones palette
1395
1773
  (model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
1396
1774
  float texture, 4 texels wide, one row per joint, sampled in the vertex
1397
1775
  stage via texelFetch, so there is no joint cap) is composed by the
@@ -1401,6 +1779,8 @@ computed-once texture. `sampleChannel` (pure, from the root) stays the
1401
1779
  JS sampling core for checks and custom drivers;
1402
1780
  okf/done/animation-core.md records the design.
1403
1781
 
1782
+ ### Not in the subset
1783
+
1404
1784
  Not in the subset, dropped: vertex colors, tangents and further UV sets;
1405
1785
  morph targets (the "weights" channel path); samplers are ignored (every
1406
1786
  texture repeats); additive blending draws as base color. The follow-ups
@@ -1410,6 +1790,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1410
1790
 
1411
1791
  ## Traps
1412
1792
 
1793
+ ### Models and skinning
1794
+
1413
1795
  - A model's vertices are LOCAL to their node; the file's placement lives
1414
1796
  in the node-table TRS, composed by the scene like any Group chain. So
1415
1797
  a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
@@ -1417,6 +1799,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1417
1799
  subtree. The winding flip for a mirroring node chain is baked into the
1418
1800
  index order from the REST pose - re-scaling a node across zero at
1419
1801
  runtime shows mesh interiors, so do not do that.
1802
+ - A worn piece's joints are dead after `bindSkeleton`: `piece.nodes`
1803
+ still lists them, but nothing poses them and their palette rows sit
1804
+ on the body's nodes now. Pose, read and socket on the body.
1420
1805
  - Skinning is a VERTEX-STAGE effect: everything that runs off the
1421
1806
  retained tree sees the bind pose. A skinned mesh picks by its
1422
1807
  bind-pose triangles at the model root and its transparent sort key is
@@ -1424,6 +1809,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1424
1809
  MODEL moves both. Shadows are the exception: the shadow variants
1425
1810
  (depth and cutout) skin by the same uBones palette, so a caster casts
1426
1811
  its pose.
1812
+
1813
+ ### Materials and color
1814
+
1427
1815
  - A `standard` metal in a scene with no environment renders near black:
1428
1816
  its diffuse is zero and the black placeholder cube is all there is to
1429
1817
  reflect (Three and Godot render the same; Unity falls back to an
@@ -1431,74 +1819,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1431
1819
  so an untextured asset is all metal, and createModel's default is
1432
1820
  `standard`: give a model scene an `environment` (loadEnvironment's
1433
1821
  baked .srte, or the skybox's cube), or pass `lit` as the material.
1434
- - A reflection probe is a mirrored render (x-flipped projection, winding
1435
- inverted by the engine): anything built from screen-space derivatives
1436
- flips with it, so a normal-mapped surface shows its bumps INVERTED in
1437
- a probe's reflection. Known and shared with every engine's mirrored
1438
- views; a `uMirror` sign on the derivative frame is the fix when it
1439
- matters.
1440
- - A generated cube chain (`mipmap: true` from six faces) is a box
1441
- filter, not the GGX convolution the roughness-to-level rule assumes:
1442
- rough reflections read too sharp and the diffuse `envIrradiance` is a
1443
- 4x4 average. Bake with `srt tool 3d/environment` for anything
1444
- photographed; the JS sky gradients in the examples get away with it.
1445
1822
  - Light intensities are the same numbers for `lit` and `standard`: 1
1446
1823
  lights a white matte surface to 1 face-on. A Three scene's intensities
1447
1824
  are a factor pi larger for the same look; divide when porting.
1448
- - The y-down clip flip is baked into `perspective()`; scene code and
1449
- geometry are plain y-up right-handed, and CCW-outward winding culls
1450
- correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
1451
- "fix" the negated row of `perspective()` - both would mirror the winding
1452
- and show mesh interiors.
1453
- - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
1454
- cheap off switch); unhiding writes 1, or the mesh's own record count
1455
- when it is instanced - never a bare 1 into an instanced entry. Hidden
1456
- meshes skip uModel writes; the fresh matrix is
1457
- written on unhide. A freshly attached entry starts off the same way and
1458
- the core's flush turns it on when it writes uModel - never add one
1459
- live: it has no world matrix yet, and drawn before the sync microtask it
1460
- flashes at the world origin for a frame.
1461
- - Instancing pairs strictly at add(), like layout: an instanced material
1462
- needs a createInstancedMesh or createRecordMesh mesh and vice versa,
1463
- and the record strides must match the material's attributes per slot
1464
- (the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
1465
- mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
1466
- mismatch throws there, at creation for the mesh's own material and at
1467
- add() for a swapped one. The instance buffers are MESH-owned (unlike
1468
- shared geometry buffers): `disposeInstances` is their one free, and
1469
- the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
1470
- never resize: an addInstance or setRecords past capacity doubles into
1471
- new buffers and swaps them in (an instanced mesh's live records move
1472
- in one core `retargetRecords`, its style mirror republishes) -
1473
- amortized like a dynamic array, same policy as @solidrt/2d; size
1474
- `capacity` or the initial records to skip the copies. An instanced
1475
- mesh's style stride comes from its material AT CREATION: a later
1476
- setMaterial to a class with another slot-1 layout throws at the
1477
- rebuild.
1478
- - Style writes are mirrored, not immediate: `setInstanceStyle` lands in
1479
- `MeshInstances.style.data` and the scene's sync publishes the dirty
1480
- range - so a write on a mesh outside any scene shows once it is added
1481
- (the attach republishes the whole mirror), and reading the GPU buffer
1482
- back mid-frame can lag the mirror by one sync.
1483
- - Instanced casters: `castShadow` on a populated mesh needs a depth pass
1484
- with the instance placement in it - the stock materials' `instanced`
1485
- carries one, a custom class needs `shadowVertex` (see shadows below);
1486
- a class without one is skipped by shadow views, silently.
1487
- - A record mesh without explicit `bounds` has no BVH leaf: it never
1488
- picks, pointer events never target it, and its transparent sort key
1489
- falls back to the node's world position. That is deliberate - records
1490
- are opaque to the library, so any inferred box would be a guess. Supply
1491
- `bounds` for anything pickable or transparent. An instanced mesh picks
1492
- per instance regardless and culls by its instances' union; its
1493
- `bounds` replace that union and give the transparent sort a center.
1494
- - An instance node's own fields hold its LOCAL pose (relative to its
1495
- parent, as for any node); the record the core writes is its pose
1496
- relative to the MESH, composed through any group between them. A
1497
- removed instance's children stay parented to the dead node: remove()
1498
- one to re-use it. `disposeInstances` on an instanced mesh flushes the
1499
- core before freeing the buffer, so the destroyed instances' hiding
1500
- writes land in a live buffer; keep that order if you ever free a
1501
- record buffer by hand.
1502
1825
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
1503
1826
  [r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
1504
1827
  classes write alpha 1 when not `transparent` (the scene target is
@@ -1525,6 +1848,54 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1525
1848
  translucents can sort wrong (center distance, not per-pixel) - that is the
1526
1849
  engine contract, no OIT. A `shaderMaterial({ transparent: true })`
1527
1850
  fragment must write PREMULTIPLIED output (`vec4(rgb * a, a)`).
1851
+ - A color map created as plain rgba8 renders WASHED OUT: the fragment
1852
+ reads its encoded bytes as linear light and encodes them again. Create
1853
+ color images (base color, emissive, a sky's faces or panorama) with
1854
+ `format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
1855
+ (a scene view, a shader target, a UI capture) holds encoded pixels and
1856
+ cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
1857
+ custom fragment (no material option yet); a draw target you create
1858
+ yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
1859
+ is sampler-only: no display, readback or copy).
1860
+ - Reading the scene texture back (a probe's readTexture, a snapshot)
1861
+ gives ENCODED pixels: an expected linear value v shows as
1862
+ `linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
1863
+
1864
+ ### Environment and background
1865
+
1866
+ - A reflection probe is a mirrored render (x-flipped projection, winding
1867
+ inverted by the engine): anything built from screen-space derivatives
1868
+ flips with it, so a normal-mapped surface shows its bumps INVERTED in
1869
+ a probe's reflection. Known and shared with every engine's mirrored
1870
+ views; a `uMirror` sign on the derivative frame is the fix when it
1871
+ matters.
1872
+ - A generated cube chain (`mipmap: true` from six faces) is a box
1873
+ filter, not the GGX convolution the roughness-to-level rule assumes:
1874
+ rough reflections read too sharp and the diffuse `envIrradiance` is a
1875
+ 4x4 average. Bake with `srt tool 3d/environment` for anything
1876
+ photographed; the JS sky gradients in the examples get away with it.
1877
+ - The background covers the whole target with depth off, drawn first: it
1878
+ REPLACES the clearColor visually (the clear still runs; you just never
1879
+ see it), and a `transparent: true` mesh blends over it in-pass since the
1880
+ background is always entry zero.
1881
+ - The background pipeline/program are SCENE-OWNED (unlike shared
1882
+ material pipelines): setBackground(null), replacement, and dispose()
1883
+ destroy them. Do not hand the background's pipeline to anything else.
1884
+ A skybox is the same slot with the library's fragment; only a
1885
+ skybox-to-skybox replace keeps the entry (params and cube rewritten).
1886
+ - The environment binds through the light rewrite's map set (uEnv
1887
+ beside uShadowAtlas on every receiving target, new views included) and
1888
+ directly on setEnvironment; the placeholder cube is app-lifetime like
1889
+ the shadow placeholder. A `lit` without `reflectivity` declares no
1890
+ environment sampler - the flag is part of the class key.
1891
+
1892
+ ### Coordinates and rotation
1893
+
1894
+ - The y-down clip flip is baked into `perspective()`; scene code and
1895
+ geometry are plain y-up right-handed, and CCW-outward winding culls
1896
+ correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
1897
+ "fix" the negated row of `perspective()` - both would mirror the winding
1898
+ and show mesh interiors.
1528
1899
  - Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
1529
1900
  always unit). There is exactly one rotation field: no `node.rotation`
1530
1901
  shadowing it, because a second field is a second thing to go stale (an
@@ -1596,14 +1967,70 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1596
1967
  - Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
1597
1968
  the props that call them). Mutating `node.position` directly does not
1598
1969
  sync. Components have no `lookAt` prop - aim through a `ref`.
1599
- - A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
1600
- target state), independent of mesh count - never reintroduce per-mesh
1601
- camera writes (uEye-style per-mesh params are exactly the O(scene) cost
1602
- the shared channel removed). Scene scale honestly: hundreds to a
1603
- few thousand objects, bounded by the interpreter, not the GPU. A view
1604
- is one more such write per camera change and one more entry per mesh
1605
- at attach; a view's per-frame cost is the core's (one params write per
1606
- sink per moved node), never JS.
1970
+ - Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
1971
+ `setCamera`, params), so ONE scratch array reused every frame is safe -
1972
+ allocating three arrays per node per frame is pure waste. The node's own
1973
+ `position`/`quaternion`/`scale` are the live arrays: read them, do not
1974
+ hand them out and do not mutate them (that write does not sync).
1975
+ - `setTransform` early-outs on an unchanged value (rotation compared AFTER
1976
+ euler conversion), so driving every node unconditionally from `onFrame`
1977
+ costs only the compare for nodes that did not move. Compares are exact,
1978
+ like `setVisible`.
1979
+
1980
+ ### Visibility and instancing
1981
+
1982
+ - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
1983
+ cheap off switch); unhiding writes 1, or the mesh's own record count
1984
+ when it is instanced - never a bare 1 into an instanced entry. Hidden
1985
+ meshes skip uModel writes; the fresh matrix is
1986
+ written on unhide. A freshly attached entry starts off the same way and
1987
+ the core's flush turns it on when it writes uModel - never add one
1988
+ live: it has no world matrix yet, and drawn before the sync microtask it
1989
+ flashes at the world origin for a frame.
1990
+ - Instancing pairs strictly at add(), like layout: an instanced material
1991
+ needs a createInstancedMesh or createRecordMesh mesh and vice versa,
1992
+ and the record strides must match the material's attributes per slot
1993
+ (the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
1994
+ mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
1995
+ mismatch throws there, at creation for the mesh's own material and at
1996
+ add() for a swapped one. The instance buffers are MESH-owned (unlike
1997
+ shared geometry buffers): `disposeInstances` is their one free, and
1998
+ the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
1999
+ never resize: an addInstance or setRecords past capacity doubles into
2000
+ new buffers and swaps them in (an instanced mesh's live records move
2001
+ in one core `retargetRecords`, its style mirror republishes) -
2002
+ amortized like a dynamic array, same policy as @solidrt/2d; size
2003
+ `capacity` or the initial records to skip the copies. An instanced
2004
+ mesh's style stride comes from its material AT CREATION: a later
2005
+ setMaterial to a class with another slot-1 layout throws at the
2006
+ rebuild.
2007
+ - Style writes are mirrored, not immediate: `setInstanceStyle` lands in
2008
+ `MeshInstances.style.data` and the scene's sync publishes the dirty
2009
+ range - so a write on a mesh outside any scene shows once it is added
2010
+ (the attach republishes the whole mirror), and reading the GPU buffer
2011
+ back mid-frame can lag the mirror by one sync.
2012
+ - Instanced casters: `castShadow` on a populated mesh needs a depth pass
2013
+ with the instance placement in it - the stock materials' `instanced`
2014
+ carries one, a custom class needs `shadowVertex` (see shadows below);
2015
+ a class without one is skipped by shadow views, silently.
2016
+ - A record mesh without explicit `bounds` has no BVH leaf: it never
2017
+ picks, pointer events never target it, and its transparent sort key
2018
+ falls back to the node's world position. That is deliberate - records
2019
+ are opaque to the library, so any inferred box would be a guess. Supply
2020
+ `bounds` for anything pickable or transparent. An instanced mesh picks
2021
+ per instance regardless and culls by its instances' union; its
2022
+ `bounds` replace that union and give the transparent sort a center.
2023
+ - An instance node's own fields hold its LOCAL pose (relative to its
2024
+ parent, as for any node); the record the core writes is its pose
2025
+ relative to the MESH, composed through any group between them. A
2026
+ removed instance's children stay parented to the dead node: remove()
2027
+ one to re-use it. `disposeInstances` on an instanced mesh flushes the
2028
+ core before freeing the buffer, so the destroyed instances' hiding
2029
+ writes land in a live buffer; keep that order if you ever free a
2030
+ record buffer by hand.
2031
+
2032
+ ### Shadows
2033
+
1607
2034
  - A CASTING light's position matters (nothing else about a directional
1608
2035
  light's position does): the shadow camera is placed AT the light node's
1609
2036
  world position, Three's rule, so a `castShadow` sun at the origin
@@ -1633,6 +2060,17 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1633
2060
  is a full extra pass over the casters plus a sampler unit on every
1634
2061
  receiving program (MAX_LIGHTS of those are always bound, placeholders
1635
2062
  included), so cast from the lights that matter, not all of them.
2063
+
2064
+ ### Views and scene params
2065
+
2066
+ - A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
2067
+ target state), independent of mesh count - never reintroduce per-mesh
2068
+ camera writes (uEye-style per-mesh params are exactly the O(scene) cost
2069
+ the shared channel removed). Scene scale honestly: hundreds to a
2070
+ few thousand objects, bounded by the interpreter, not the GPU. A view
2071
+ is one more such write per camera change and one more entry per mesh
2072
+ at attach; a view's per-frame cost is the core's (one params write per
2073
+ sink per moved node), never JS.
1636
2074
  - A mesh's entries are mirrored into every view at attach and dropped at
1637
2075
  detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
1638
2076
  `overrideMaterial` is validated against every mesh's layout (at
@@ -1653,31 +2091,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1653
2091
  differs per mesh. (`scene.texture` IS the draw target id, so
1654
2092
  `setTargetParams(scene.texture, ...)` is the same write - setParams is
1655
2093
  the sanctioned spelling.)
1656
- - Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
1657
- `setCamera`, params), so ONE scratch array reused every frame is safe -
1658
- allocating three arrays per node per frame is pure waste. The node's own
1659
- `position`/`quaternion`/`scale` are the live arrays: read them, do not
1660
- hand them out and do not mutate them (that write does not sync).
1661
- - `setTransform` early-outs on an unchanged value (rotation compared AFTER
1662
- euler conversion), so driving every node unconditionally from `onFrame`
1663
- costs only the compare for nodes that did not move. Compares are exact,
1664
- like `setVisible`.
1665
- - Per-generator conventions - orientation, UV mapping, which axis a solid
1666
- stands on, what a cap looks like - live on each generator's doc comment,
1667
- not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
1668
- lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
1669
- map inscribed in the unit square) but the doc comment is the source.
1670
- - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
1671
- list END and dirty the order, so the next sync() re-sorts and the mesh
1672
- keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
1673
- (setMaterial swaps `mesh.material` before the rebuild, so _detach must
1674
- not read the new material's flag).
1675
- - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
1676
- to the axis at x = 0) - it is a solid of revolution, NOT Three's open
1677
- polyline shell. An "open" outline must be closed by the author;
1678
- otherwise the shape is simply wrong, there is no open-profile mode.
1679
- - `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
1680
- context).
2094
+
2095
+ ### Shader materials
2096
+
1681
2097
  - A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
1682
2098
  compile twice - no dedupe by source value (deliberate; hidden
1683
2099
  content-keyed caches are the anti-pattern the GPU layer avoids). Create
@@ -1704,10 +2120,24 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1704
2120
  vertex source - a comment counts - selects the "colored" layout, and
1705
2121
  the material then rejects standard geometry at add(). Do not mention
1706
2122
  aColor you do not read.
2123
+ - A custom fragment that ends in `fragColor = vec4(...)` bypasses the
2124
+ scene: no fog, no exposure or tone mapping, no encode - and a
2125
+ hand-rolled loop over `uLightDir` renders a spot light as a
2126
+ directional one (a lit rectangle on the floor, no cone). What you
2127
+ declare is what runs, and the engine injects nothing, so pick a tier
2128
+ (the three after the GLSL exports under "The model"): a stock fragment
2129
+ on your vertex stage,
2130
+ a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
2131
+ `sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
2132
+ `SHADOW_*` trio by hand still works and is no longer the shape.
2133
+
2134
+ ### Picking, pointer and collision
2135
+
1707
2136
  - Picking is triangle-accurate for ordinary meshes (`point` is a surface
1708
2137
  point, hits carry `face`/`uv`/`normal`) but box-only for instanced
1709
- meshes: there `point` is where the ray meets the population `bounds`
1710
- box, `normal` that face's, and `face`/`uv` are absent. Never present an
2138
+ meshes and for lines/points geometry: there `point` is where the ray
2139
+ meets the population `bounds` box (the geometry's box for lines),
2140
+ `normal` that face's, and `face`/`uv` are absent. Never present an
1711
2141
  instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
1712
2142
  add a per-triangle path in JS - rays at mesh scale are
1713
2143
  interpreter-hostile, and the core already does it.
@@ -1719,50 +2149,10 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1719
2149
  leaf under a design size. Only a leaf whose layout size deliberately
1720
2150
  differs from the target (supersampling) needs `handlersFor`, fed the
1721
2151
  layout size the app itself set.
1722
- - A custom fragment that ends in `fragColor = vec4(...)` bypasses the
1723
- scene: no fog, no exposure or tone mapping, no encode - and a
1724
- hand-rolled loop over `uLightDir` renders a spot light as a
1725
- directional one (a lit rectangle on the floor, no cone). What you
1726
- declare is what runs, and the engine injects nothing, so pick a tier
1727
- (the three after the GLSL exports under "The model"): a stock fragment
1728
- on your vertex stage,
1729
- a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
1730
- `sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
1731
- `SHADOW_*` trio by hand still works and is no longer the shape.
1732
2152
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
1733
2153
  under a still pointer fires nothing until the next move - the same
1734
2154
  limit the element hit test has (hit-test-per-frame is an open platform
1735
2155
  item). Do not poll pick() per frame to fake it.
1736
- - Geometry local bounds cache on the Geometry (like its GPU buffers):
1737
- geometry is immutable after creation. Mutating `vertices` after a mesh
1738
- used them leaves stale bounds AND a stale GPU buffer - make a new
1739
- Geometry instead.
1740
- - The background covers the whole target with depth off, drawn first: it
1741
- REPLACES the clearColor visually (the clear still runs; you just never
1742
- see it), and a `transparent: true` mesh blends over it in-pass since the
1743
- background is always entry zero.
1744
- - A color map created as plain rgba8 renders WASHED OUT: the fragment
1745
- reads its encoded bytes as linear light and encodes them again. Create
1746
- color images (base color, emissive, a sky's faces or panorama) with
1747
- `format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
1748
- (a scene view, a shader target, a UI capture) holds encoded pixels and
1749
- cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
1750
- custom fragment (no material option yet); a draw target you create
1751
- yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
1752
- is sampler-only: no display, readback or copy).
1753
- - Reading the scene texture back (a probe's readTexture, a snapshot)
1754
- gives ENCODED pixels: an expected linear value v shows as
1755
- `linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
1756
- - The background pipeline/program are SCENE-OWNED (unlike shared
1757
- material pipelines): setBackground(null), replacement, and dispose()
1758
- destroy them. Do not hand the background's pipeline to anything else.
1759
- A skybox is the same slot with the library's fragment; only a
1760
- skybox-to-skybox replace keeps the entry (params and cube rewritten).
1761
- - The environment binds through the light rewrite's map set (uEnv
1762
- beside uShadowAtlas on every receiving target, new views included) and
1763
- directly on setEnvironment; the placeholder cube is app-lifetime like
1764
- the shadow placeholder. A `lit` without `reflectivity` declares no
1765
- environment sampler - the flag is part of the class key.
1766
2156
  - overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
1767
2157
  volume wholly inside a closed mesh with no triangle in reach touches
1768
2158
  nothing, and a body whose center has passed through a wall reports
@@ -1774,3 +2164,26 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1774
2164
  floor absorbs the vertical part (a body never creeps down a slope it
1775
2165
  can stand on), and with `floorSnap: 0` a standing body reports no
1776
2166
  floor unless its motion presses into one.
2167
+
2168
+ ### Geometry and components
2169
+
2170
+ - Per-generator conventions - orientation, UV mapping, which axis a solid
2171
+ stands on, what a cap looks like - live on each generator's doc comment,
2172
+ not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
2173
+ lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
2174
+ map inscribed in the unit square) but the doc comment is the source.
2175
+ - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
2176
+ list END and dirty the order, so the next sync() re-sorts and the mesh
2177
+ keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
2178
+ (setMaterial swaps `mesh.material` before the rebuild, so _detach must
2179
+ not read the new material's flag).
2180
+ - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
2181
+ to the axis at x = 0) - it is a solid of revolution, NOT Three's open
2182
+ polyline shell. An "open" outline must be closed by the author;
2183
+ otherwise the shape is simply wrong, there is no open-profile mode.
2184
+ - `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
2185
+ context).
2186
+ - Geometry local bounds cache on the Geometry (like its GPU buffers):
2187
+ geometry is immutable after creation. Mutating `vertices` after a mesh
2188
+ used them leaves stale bounds AND a stale GPU buffer - make a new
2189
+ Geometry instead.