@solidrt/3d 0.0.57 → 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.
package/AGENTS.md CHANGED
@@ -6,249 +6,380 @@ 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`/`View3d`/`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; in a
46
- component tree `<View3d>` is the same as a Scene child, see Components).
47
- Each mesh gets one entry in the view's target bound as one more draw sink of
48
- its CORE node, so the one flush writes every target - the app writes
49
- nothing per view. Geometry buffers and (without an override) materials
50
- are shared; the light set and `scene.setParams` names fan out to every
51
- view, `view.setParams` is the view's own channel - and names a view
52
- sets itself (or its `fog` option, below) become VIEW-OWNED: the
53
- scene's setParams/setFog fan-out skips them from then on, so a view
54
- override survives scene-wide writes. A view's backdrop is its
55
- clearColor (the scene background draws on PROBES, not views); a view
56
- has no picking. LAYERS select what a target
57
- draws, Three's model exactly: `layers` on a mesh is its membership
58
- bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
59
- Groups), and each target carries a mask (default 1) - `layers` on
60
- createScene/createView, live via `setLayers` on the scene handle and
61
- each view. A mesh draws where mask & layers is non-zero, so a minimap's
62
- marker meshes live on bit 2: invisible in the main render, drawn by
63
- the map view whose mask admits them. Shadow views follow the SCENE's
64
- mask (what the scene cannot see must not darken it), and
65
- pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
66
- invisible ones - unless the query passes its own `{ layers }`, which is
67
- how a low-poly collision mesh lives undrawn in the scene yet answers
68
- ground and collision queries (the physics-collider pattern).
69
- Per-view fog: `fog: FogOptions | null` on createView overrides the
70
- scene's fog for that view (null = unfogged - the clear minimap over a
71
- fogged scene); absent follows the scene. `overrideMaterial` (Three's
72
- `scene.overrideMaterial`, scoped to the view) draws every mesh with one
73
- material - a depth pass, a normal/id visualizer - skips instanced
74
- meshes (unless the override itself declares their exact
75
- `instanceAttributes` record layout) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
76
- input; the same option on createScene exposes `scene.depthTexture`,
77
- the input for a depth-reading post effect in `output` (not combinable
78
- with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
79
- perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
80
- the scene's own camera takes it too, and pick() follows.
81
- `examples/scene-views.tsx` is the shape.
82
- - CULLING is the core's, per target, on by default: every camera write
83
- (the scene's, a view's, a shadow tile's) also sets that target's
84
- frustum in the spatial core, and the flush switches an entry whose
85
- world box falls wholly outside it to instance count 0 - the same
86
- switch as `visible`, so a culled mesh costs nothing per frame and a
87
- still camera re-tests nothing (a camera move re-tests every sink on
88
- that target, in Rust; a node move re-tests its own). The box is the
89
- picking box (the local bounds through the world matrix, the
90
- Godot/Unity AABB test; Three uses spheres); an instanced mesh without
91
- explicit `bounds` is culled by the union of its live instances' boxes
92
- (the joint group below over the instance nodes, so the box follows
93
- them: Godot's MultiMesh AABB, with `bounds` as its custom_aabb; Three
94
- computes an InstancedMesh sphere once and leaves it stale, Unity wants
95
- worldBounds), and a record mesh without `bounds` is never culled. Per
96
- mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
97
- a vertex stage moves beyond its box - a fullscreen quad, a custom
98
- displacement - and `cullMargin` (world units, Godot's
99
- `extra_cull_margin`) for bounded displacement such as wind. Sprites
100
- cull by their quad's reach at any facing. A SKINNED part is culled by
101
- the union of its joints' boxes (the bake computes each joint's
102
- influence box in joint space, `ModelSkin.jointBounds`, .srtm VERSION
103
- 5; the joint nodes carry them as culling-only bounds, outside the
104
- picking index), so the box follows the pose with no per-frame JS -
105
- Unity's bone bounds, Godot's per-bone AABBs; no `updateWhenOffscreen`
106
- knob is needed. Probe faces set no frustum (six cameras, one target).
107
- Shadow tiles cull casters against their light frustum, which is what
108
- makes `shadow.distance` and cascades cheaper.
109
- - SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
110
- bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
111
- shadow })`, `setLight`) makes the scene own an internal
112
- `createView({ depth: "texture", overrideMaterial: depth pass })` drawing
113
- the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
114
- orthographic camera at the light's WORLD position along its world
115
- direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
116
- Any light may cast, bounded by the shadow-slot budget
117
- (MAX_SHADOW_MAPS = 8, its own constant: a directional light claims
118
- `shadow.cascades` consecutive slots, a point light six, a spot one,
119
- and a caster past the budget throws at attach). `<SpotLight castShadow
120
- shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
121
- machinery with a PERSPECTIVE camera: at the light's world position
122
- along its world direction, fov = its cone (2 * angle), near from
123
- `shadow.near` (default 0.5), far from the light's `distance` (or the
124
- directional default 500 when 0) - one map, one slot, the same atlas
125
- and lookup. A perspective map's depth is nonlinear, so `normalBias`
126
- (world units) is the acne knob to reach for; `bias` acts in that
127
- nonlinear depth. `<PointLight castShadow>` casts in every direction
128
- with the same option set: six 90-degree face maps (world-axis
129
- aligned, slot order +X, -X, +Y, -Y, +Z, -Z) as six consecutive tiles
130
- of the same atlas, far from `distance` like a spot - so give a
131
- casting bulb a distance. No cube map: a receiver picks the face by
132
- the dominant axis of the light-to-point vector (SHADOW_LOOKUP), one
133
- projection, one hardware-compare tap - the Three/Godot/Unity-URP
134
- atlas route. `shadow.radius` (every casting light, default 1 = that
135
- one tap, a 2x2 bilinear compare) softens the edge: above 1 a 3x3 grid
136
- of hardware taps `radius` texels apart, Three's `shadow.radius` /
137
- Godot's `shadow_blur`, nine taps per receiver fragment. A first-person
138
- eye over a floor shows the texel stairs at any map size; 2 is the
139
- usual figure, past ~3 the taps separate into bands. Each face map renders a few degrees wider than its face
140
- (URP's fovBias) so a seam fragment's occluder is inside the map it
141
- samples - without the guard band every seam shows a lit slit - and
142
- PCF taps clamp at face-tile edges, so a face seam hardens slightly
143
- instead of bleeding into the neighbour.
144
- `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
145
- N maps fitted to slices of the SCENE camera's frustum (near ..
146
- `shadow.distance`, default the camera far; the practical split; each
147
- slice's bounding sphere as an ortho box along the light, its centre
148
- snapped to the map's texel grid so edges do not swim; re-fitted
149
- whenever the scene camera or the light moves) - a receiver samples the
150
- tightest map that covers the point, fading into the next over the
151
- map's outer 10% (`SHADOW_BLEND`) so the hand-over is a band, not a
152
- seam; contact shadows stay sharp near the camera while the horizon
153
- still has coarse ones, and pulling `distance` in sharpens all of them.
154
- The box is the honest tier for a bounded scene; cascades are for a
155
- scene that outgrows it, at N times the shadow fill. Every map is a
156
- TILE of the scene's one shadow atlas (a `depth: "texture"` draw target,
157
- a grid of cells the largest `mapSize` wide, scaled down uniformly
158
- against `limits.maxTextureSize`), so N maps are ONE pass: the atlas
159
- depth binds as the target-level `uShadowAtlas` of the scene and every
160
- non-shadow view (a white texel when nothing casts); maps are MAP slots
161
- dealt in light order, a light's cascades consecutive and tightest
162
- first - `uShadowRect[j]` slot j's tile in atlas UV, `uShadowMatrix[j]`
163
- its view's own view-projection (the whole array is one write per
164
- shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
165
- name its slots (count 0 = it does not cast) with
166
- `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
167
- glsl declares the set. Every `lit` material RECEIVES by default
168
- (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
169
- material out and drops the map from its program - a material option,
170
- as with vertexColors/triplanar, because the material picks the program
171
- (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
172
- hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
173
- on each casting light's own term. `examples/shadows.tsx` (three
174
- casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
175
- - RETARGETED motion is native: `setTransition(node, { position:
176
- { duration: 400 }, ... })` makes setTransform writes TARGETS the core
177
- animates toward every frame (position/scale per lane, rotation along
178
- the quaternion geodesic - a spring keeps its velocity through
179
- retargets), so a mesh gliding to a slot or a camera rig easing costs
180
- one JS write per target change, zero per frame. The declaration lives
181
- on the SceneNode and re-applies on every scene enter; the pose a node
182
- enters with always snaps. Each natural settle calls the node's
183
- `onTransitionEnd` (plain field like the pointer handlers) with
184
- `{ component }`; the raw "spatialTransitionEnd" engine event
185
- (srt:events, carrying the CORE node id `_node`) stays for flux:spatial
186
- consumers.
187
- - One interleaved vertex buffer per geometry, described by an open layout
188
- (`Geometry.layout`, absent = "standard"): an ordered attribute list that
189
- always starts with the standard prefix `aPos` vec3 + `aNormal` vec3 +
190
- `aUV` vec2 (what every generator emits) and may carry any named channels
191
- after it. `withAttribute(geometry, { name, format }, fill)` appends one
192
- (Three's `setAttribute` for an interleave); "colored" names the common
193
- case, the prefix plus `aColor` vec4 - the per-vertex data channel (a
194
- tint, baked AO, any four scalars; standard name, your contents) - and
195
- `withColors(geometry, fill)` is its spelling. Fill is a flat
196
- size-per-vertex array or a per-vertex callback receiving `(index, pos,
197
- normal, uv)`. Materials read attributes BY NAME: a material's vertex
198
- stage may declare any subset of its geometry's channels, and a channel
199
- the program reads that the geometry lacks (name + format) throws at
200
- add(). What a program reads is the ENGINE's word (`material.attributes()`
201
- = `programAttributes` reflection of the linked program, instance
202
- attributes excluded), not a parse of the GLSL: an `in` the compiler
203
- dropped does not count, and the engine also rejects a pipeline whose
204
- attribute lists leave a read attribute uncovered. The material
205
- keeps one program and builds one pipeline per layout its meshes bring,
206
- so a geometry may carry more than a material reads. The whole layout
207
- ships whether a material reads every attribute or not (inactive
208
- attributes only keep the stride), so extra channels cost their floats on
209
- every draw of that geometry - keep data-light passes (a wireframe
210
- reading only aPos) on standard geometry. `layoutStride`/`layoutSlot`/
211
- `layoutKey`/`layoutAttributes` are the layout arithmetic; two layouts
212
- with equal keys interleave identically (merge requires that).
213
- Indices are uint16 or uint32 - the `Geometry.indices` array type picks
214
- the draw's index format, so hand-built geometry past 64k vertices just
215
- uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
216
- lazy, shared, and reference-counted by draw entries: removing the last
217
- entry frees them at the end of the microtask (a same-tick rebuild keeps
218
- the upload), so swapping `<Mesh geometry>` reactively never accumulates
219
- old generations. `disposeGeometry` is the immediate explicit free.
220
- - Materials dedupe hard: one program + one pipeline per material CLASS
221
- (a `shaderMaterialClass` per option combination for unlit, lit and
222
- sprite alike: map x transparent x cull x alphaTest, lit's extras on
223
- top), `depth: true` + `cull: "back"` unless the material says otherwise
224
- (`cull: "none"` for double-sided geometry; lit flips the normal on back
225
- faces); an instance is just per-entry uniforms (`uColor`) and bindings
226
- (`uMap`).
227
- - The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
228
- `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
229
- GPU-free BY DESIGN so they can be checked headless (and, for the two
230
- model modules, run under bun in `tools/model.ts`); keep them that way.
231
- The rigs under `checks/`
232
- (`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
233
- `order-check`, `gltf-check`) run on
234
- flux from the repo root: `bunx srt bundle -f --stdout
235
- packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
236
- touching what you changed. `raycast-check.tsx` and
237
- `collision-check.tsx` are the exceptions: they assert the documented
238
- picking contract (triangle accuracy, the box tier, pick/raycast
239
- parity, layer masks, the `{ meshes }` filter) and collision contract
240
- (exact sweep times, the surface rule, the slide filter, layers and
241
- meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
242
- so they run on the playback client instead:
243
- `bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
244
- --size 128x128`. Run them whenever a doc edit touches picking or
245
- 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.
246
375
 
247
376
  ## Components
248
377
 
378
+ ### Component props
379
+
249
380
  | Component | Props |
250
381
  | --- | --- |
251
- | `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?` (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) |
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) |
252
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 |
253
384
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
254
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)` |
@@ -260,6 +391,8 @@ blendMode and pointer events like any element.
260
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)` |
261
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)` |
262
393
 
394
+ ### Output composition
395
+
263
396
  Output composition: without `output`, `Scene` emits a minimal
264
397
  `<texture width height>` leaf and nothing else is forwarded - anything
265
398
  more goes through `output(texture)`, which renders in place of that leaf:
@@ -272,6 +405,8 @@ elsewhere. Called once, untracked, inside the scene context. Scene
272
405
  layout, so render and display size separate - render at 2x and display
273
406
  smaller for supersampling.
274
407
 
408
+ ### Fill and fixed sizes
409
+
275
410
  Fill (the default): omit `width`/`height` and the built-in leaf is laid
276
411
  out at 100% of its parent's box (give it a sized parent, as on the web)
277
412
  while the target follows the leaf's on-screen size in DEVICE pixels -
@@ -302,6 +437,8 @@ everything: the `pointer` feed listens at the scene's root, so
302
437
  handlers and the feed alike (a detached leaf gives its feed a `layout`,
303
438
  it has no layout box to normalize by).
304
439
 
440
+ ### Input
441
+
305
442
  Input, the rule (ARCHITECTURE.md): a camera control consumes a
306
443
  device-free abstraction and never handles events or reads a device
307
444
  itself. The controls expose `axes` (core's createAxes contract - rates
@@ -334,6 +471,8 @@ sticks move the CAMERA where a drag moves the content, so the presets
334
471
  bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
335
472
  a drag and a stick both turn the eye.
336
473
 
474
+ ### Orbit camera
475
+
337
476
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
338
477
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
339
478
  zoomSpeed?, panSpeed?, clampTarget?, zoomAnchor?, rotateAnchor? })` -
@@ -388,6 +527,8 @@ getter and read where it applies, never snapshotted - so clamps, rates
388
527
  and anchors follow their props without a remount, and a clamp change
389
528
  re-clamps the pose at once.
390
529
 
530
+ ### First-person camera
531
+
391
532
  First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
392
533
  pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
393
534
  a position plus yaw/pitch (yaw 0 faces -z, positive turns left; pitch
@@ -426,10 +567,15 @@ its `pointer` feed. The frame loop runs only while `active()`. Every prop
426
567
  but the initial pose is live: `fly={flying()}` toggles walk/fly on the
427
568
  running control (pose carries over, no remount, and `clampPosition` may
428
569
  swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
429
- clamp change re-clamps at once. checks/orbit-check.ts and
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
430
574
  checks/first-person-check.ts pin both controls headless (they import
431
575
  `@solidrt/core/input` only).
432
576
 
577
+ ### Overlay projection
578
+
433
579
  Overlay projection: `scene.project(point)` maps a world point to scene
434
580
  pixels (top-left origin, y down - the output texture's own space; `w` is
435
581
  the camera-forward distance in world units under either projection) and
@@ -444,6 +590,8 @@ unproject each move). `scene.viewProj(out?)` copies the view-projection
444
590
  matrix for batch work. Never rebuild the camera matrices by hand for a
445
591
  HUD.
446
592
 
593
+ ### Picking
594
+
447
595
  Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
448
596
  through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
449
597
  world units, nearest first; every hit along the ray, not just the front
@@ -467,13 +615,17 @@ a ray through a knot's hole misses. A large geometry's triangles are
467
615
  BVH-indexed too - built by the first ray that reaches the shape, log-cost
468
616
  after - so raycasting a merged static scene stays cheap (see the batching
469
617
  advice). An instanced mesh is box-only (its
470
- 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
471
621
  box's twelve triangles, so its hits carry the struck face's `normal` and
472
622
  no `face`/`uv` - and a ray from inside it meets the far side, the
473
623
  surface contract overlap/sweep share. Both methods
474
624
  flush pending writes first (the lookAt/project immediacy contract), and
475
625
  both skip invisible meshes.
476
626
 
627
+ ### Collision
628
+
477
629
  Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
478
630
  motion, opts?)` are the same index's other two questions - what a volume
479
631
  touches, and where a moving one first touches: Unity's
@@ -531,6 +683,8 @@ one overlap per move. Deliberately absent and additive when asked: a
531
683
  step offset (Unity's stepOffset; Godot has none either) and a cylinder
532
684
  volume (Godot only).
533
685
 
686
+ ### Pointer events
687
+
534
688
  Pointer events (scene-pointer.ts): the element event model one tree
535
689
  deeper, with the SCENE as the root of the walk (under a `<View3d>` leaf:
536
690
  the view). `onPointerDown/Move/Up/Enter/Leave/Wheel/Tap` are plain
@@ -580,6 +734,8 @@ leaf laid out at a different size (the supersampling pattern) uses
580
734
  checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
581
735
  and tap rules headless; examples/pick.tsx is the live guard.
582
736
 
737
+ ### Geometry generators
738
+
583
739
  Geometry generators take ONE options object, every field optional with
584
740
  a default, named as Three names them: `box({ width, height, depth })`
585
741
  (1x1x1); `plane({ width, height })`, `circle({ radius, segments })` and
@@ -611,6 +767,8 @@ buffer itself - so a builder that bakes transforms while writing hands
611
767
  the baker world-space vertices. `fill` indexes relative to `first`.
612
768
  `fillColors(geometry, fill, first?, count?)` is its aColor spelling.
613
769
 
770
+ ### Geometry as data
771
+
614
772
  Geometry as data: `transformGeometry(geometry, { position?, rotation?,
615
773
  quaternion?, scale? }, label?)` bakes a placement into a copy (the
616
774
  setTransform shape: Euler XYZ radians or a quaternion, number = uniform
@@ -626,13 +784,27 @@ does not tax picking: a merged geometry's raycast narrowphase runs
626
784
  through its triangle BVH (built on the first ray), so merge for draw
627
785
  count without giving up ground queries. Both are
628
786
  pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
629
- 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)`
630
800
  returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
631
801
  `rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
632
802
  slab test (entry t >= 0 in units of the direction's length, 0 from
633
803
  inside, -1 for a miss) - for ray-testing boxes you keep yourself
634
804
  (triggers, collision volumes) without meshes you do not want to draw.
635
805
 
806
+ ### Profile kit
807
+
636
808
  Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
637
809
  closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
638
810
  share an averaged normal - `fillet(points, radius, segs?)` and
@@ -658,87 +830,120 @@ like circle); `triangulate(points)` is the ear-clipping core (fan
658
830
  fallback, never drops a cap), exported for custom flat work. These pick
659
831
  uint16/uint32 indices by vertex count automatically.
660
832
 
661
- Materials:
662
-
663
- - `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
664
- straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
665
- below), premultiplied internally; `cull` and
666
- `alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
667
- opts out of the scene's fog (all four library materials take it).
668
- - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
669
- that turns to face the camera IN THE VERTEX STAGE (off the shared
670
- uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
671
- only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
672
- tree/character sprite; the default `"full"` is Three's Sprite, flat to
673
- the screen). No per-frame JS however many sprites. `transparent`
674
- defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
675
- off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
676
- shared unit plane, no geometry argument, `scale` = world size, rotation
677
- ignored. Picks by a unit box around its center (its reach at any
678
- facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
679
- - `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
680
- depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
681
- custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
682
- MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
683
- per entry) and `uniform mat4 uViewProj` (the camera, shared
684
- target-level params) - transform with
685
- `uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
686
- at shaderMaterial() creation. The rest is opt-in by declare-and-use:
687
- `uniform vec3 uCamPos` (the camera's world position, shared and written
688
- with uViewProj - the specular/fresnel view vector is
689
- `normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
690
- (the camera's world-space view axes, shared likewise - a billboard is
691
- `center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
692
- uViewProj rows, that carries the clip flip), `uniform mat4
693
- uInvViewProj` (the camera's inverse view-projection, shared likewise -
694
- a clip position back to world, the world-space ray through a pixel
695
- without knowing the projection), the output stage's `uniform float
696
- uExposure` / `uToneMapping` (compose `OUTPUT` from `/glsl` and end with
697
- `fragColor = outputColor(rgb, alpha)` to take the scene's exposure and
698
- tone mapping and encode like the library materials do; a fragment
699
- writing fragColor directly writes final encoded pixels) and `uniform
700
- mat4 uNormal` (the world
701
- inverse-transpose, written beside uModel for this material's meshes;
702
- take `mat3(uNormal)` - correct under non-uniform scale, where
703
- mat3(uModel) bends normals off the surface). Attributes come from the
704
- geometry's layout by name; the ones the linked program actually reads
705
- (engine reflection, instance attributes excluded) must all be in the
706
- mesh's geometry layout or add() throws - so a used `in vec4 aColor`
707
- needs `withColors()` geometry and a custom channel needs
708
- `withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
709
- pipeline preamble. App-driven uniforms beyond the standard set: seed
710
- via `params`, then write per mesh with
711
- `setMeshParams(mesh, { name: value })` (validated names; values persist
712
- across entry rebuilds; frame-rate-safe like setTransform) or declaratively
713
- with the `Mesh` `params` prop (same merge semantics - a key that
714
- disappears from the object keeps its old value; for per-frame values
715
- prefer `ref` + setMeshParams from onFrame, the setTransform split).
716
- Scene-wide values (a clock, a sun direction, fog) go through
717
- `scene.setParams({ uTime })` instead - one write for every mesh.
718
- - `shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
719
- class/instance split for your own GLSL: compiles once, and
720
- `cls.instance({ params?, textures? })` returns a Material sharing that
721
- pipeline with its own values. `dispose()` lives on the class alone.
722
- `shaderMaterial(opts)` is exactly a class with one instance (its
723
- `dispose` forwards to the class).
724
- - `instanceAttributes: [{ name, format, slot? }]` on either shader-material
725
- form makes an INSTANCED material: the vertex stage reads them as `in`
726
- variables beside the layout's own, and each drawn instance gets one
727
- record per slot from the mesh's instance buffers - slot 0 (default)
728
- is the record buffer, slot 1 an instanced mesh's STYLE buffer (below).
729
- Its meshes come from `createInstancedMesh` or `createRecordMesh`; a
730
- `createMesh` mesh is rejected at add(). `instanceStyle: [..]` is the
731
- slot-1 record a fresh instance starts with. The stock materials do
732
- the same with one flag: `lit/standard/unlit({ instanced: true })`
733
- places by the instance matrix (the shadow pass too, so the fleet
734
- casts), and `{ instanceColors: true }` adds a per-instance
735
- premultiplied `[r, g, b, a]` style record starting white - Unity's
736
- 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
737
927
 
738
928
  Instancing - one draw entry covering a population, in two forms. The
739
929
  axis between them is WHERE MOTION IS COMPUTED, the same split as
740
930
  @solidrt/2d's sprite and record layers.
741
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
+
742
947
  `createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
743
948
  draws the geometry once per instance NODE: `addInstance(mesh,
744
949
  transform?, parent?)` returns a scene node (kind "instance") placed
@@ -787,15 +992,17 @@ MultiMesh is the record form. The components: `<InstancedMesh capacity>`
787
992
  with `<Instance position style transition onPointerDown>` children
788
993
  (`<Group>` children are squads, an `<Instance>` is a parent too);
789
994
  `examples/fleet.tsx` is a thousand of them springing between formations
790
- with tap-to-tint, `probes/3d-instance-probe.tsx` the function face.
995
+ with tap-to-tint.
791
996
  Mount cost is the one difference between the faces: an `<Instance>` row
792
997
  costs about eight times an `addInstance` call (the component machinery -
793
998
  a Solid row, two effects, the style effect; a node component's context
794
- provider is built only when it has children, the biggest single saving -
795
- `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
796
1001
  mount in tens of milliseconds either way; populations spawned per frame,
797
1002
  or in five figures, belong to the function face.
798
1003
 
1004
+ #### createRecordMesh
1005
+
799
1006
  `createRecordMesh(geometry, material, records, count?, { bounds?,
800
1007
  label? })` is the raw form: `records` is the interleaved per-instance
801
1008
  data (stride = the material's slot-0 instanceAttributes summed, a
@@ -814,13 +1021,16 @@ then it picks and transparent-sorts conservatively as one box. The
814
1021
  escape hatch for motion only JS can compute at scale (a particle sim, a
815
1022
  crowd stepped in a worker).
816
1023
 
1024
+ #### Common to both
1025
+
817
1026
  Everything mesh works on both: setTransform moves the whole population
818
1027
  through one uModel, setVisible zeroes the drawn count and restores it on
819
1028
  unhide, renderOrder/params/geometry/material swaps apply, and
820
1029
  `disposeInstances(mesh)` detaches and frees the record buffers - the one
821
1030
  explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
822
- components), `probes/3d-instance-probe.tsx` (instances, functions) and
823
- `examples/instanced.tsx` (records) are the live proofs.
1031
+ components) and `examples/instanced.tsx` (records) are the live proofs.
1032
+
1033
+ ### Background
824
1034
 
825
1035
  Background: `scene.setBackground(source | null)`, the `background` option
826
1036
  on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
@@ -856,6 +1066,8 @@ no separate resize plumbing. Two forms:
856
1066
  the same way, so a skybox is one flat color there. A 2D texture id
857
1067
  throws at the samplerCube binding. `examples/skybox.tsx`.
858
1068
 
1069
+ ### Cube-map convention
1070
+
859
1071
  The cube-map convention: a cube map holds what a GL lookup returns -
860
1072
  each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
861
1073
  and every library lookup is a plain `texture(cube, dir)` in world space.
@@ -871,6 +1083,8 @@ number, so the object form keeps it unambiguous). Translucent grounds
871
1083
  over a background still need blend factors (a separate shader texture
872
1084
  underneath until then).
873
1085
 
1086
+ ### Environment
1087
+
874
1088
  Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
875
1089
  null)`, the `environment` option on createScene and the reactive `Scene`
876
1090
  prop - the cube map every `standard` material is lit by (always: the
@@ -920,6 +1134,8 @@ environment set contributes nothing (uEnvOn 0), not a black reflection.
920
1134
  `examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
921
1135
  baked HDRI lighting the scene alone).
922
1136
 
1137
+ ### Reflection probes
1138
+
923
1139
  Reflection probes: `scene.createReflectionProbe({ position, size?,
924
1140
  near?, far?, layers?, clearColor?, label? })` renders the scene into a
925
1141
  cube map from a point - Three's CubeCamera, Unity's and Godot's
@@ -965,6 +1181,8 @@ x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
965
1181
  from outside; the engine inverts the front-face rule on cube target
966
1182
  passes so cull modes keep their meaning. `examples/probe.tsx`.
967
1183
 
1184
+ ### Baked sky
1185
+
968
1186
  Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
969
1187
  origin that sees no mesh (layer mask 0): the scene's background - the
970
1188
  GLSL sky or the skybox - alone on its six faces, LINEAR (a sky ending in
@@ -984,6 +1202,8 @@ that HDR is for. A sky that reads
984
1202
  `uCamPos` bakes from the origin; scene params (an app clock) are seen as
985
1203
  of the call. `examples/sky-lit.tsx`.
986
1204
 
1205
+ ### Panoramas
1206
+
987
1207
  Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
988
1208
  equirectangular 2D texture (createImage, createTexture) into a cube
989
1209
  TextureId on the GPU, synchronously (six face passes straight into a
@@ -999,6 +1219,8 @@ the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
999
1219
  decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
1000
1220
  pure TypeScript and bun-tested - there is no runtime .hdr decoder.
1001
1221
 
1222
+ ### Fog
1223
+
1002
1224
  Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
1003
1225
  the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
1004
1226
  far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
@@ -1030,6 +1252,8 @@ horizon, and put `far` at or inside the camera's far plane to hide the
1030
1252
  clip. `examples/fog.tsx` cycles the forms over a valley;
1031
1253
  `examples/cascades.tsx` fogs its field to the sky.
1032
1254
 
1255
+ ### Color
1256
+
1033
1257
  Color: the scene shades in LINEAR light and outputs sRGB, like Three
1034
1258
  (ColorManagement), Godot and Unity's linear space - no gamma mode. Every
1035
1259
  `[r, g, b]` color option is sRGB, what a color picker shows: material
@@ -1055,6 +1279,8 @@ raw sampling). What changed for a scene tuned before this: terminators
1055
1279
  soften, mid-tones brighten, highlights widen - drop ambient rather than
1056
1280
  lights. `emissiveIntensity` scales the emissive in linear light.
1057
1281
 
1282
+ ### Lighting GLSL
1283
+
1058
1284
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
1059
1285
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
1060
1286
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
@@ -1090,6 +1316,8 @@ samplers. Lights, colors and exponents are arguments, so
1090
1316
  nothing is pinned but the function names; `lit` is composed from these
1091
1317
  same constants - customizing never means leaving the system.
1092
1318
 
1319
+ ### Custom looks: three tiers
1320
+
1093
1321
  A custom look is a citizen of the scene - lit by its lights, shadowed,
1094
1322
  fogged, exposed and tone mapped like the stock materials - at one of
1095
1323
  three tiers, top first:
@@ -1103,7 +1331,12 @@ three tiers, top first:
1103
1331
  mesh keeps the stock shading whole (`examples/instanced.tsx`: the
1104
1332
  per-instance tint rides vColor). Instance it with the per-entry
1105
1333
  uniforms the source declares (uColor, uSpecular/uShininess or
1106
- 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).
1107
1340
  2. A SURFACE FUNCTION inside the stock fragment. `litFragment({ surface,
1108
1341
  prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
1109
1342
  it declares is an ordinary `instance()` param), `surface` declares
@@ -1117,7 +1350,12 @@ three tiers, top first:
1117
1350
  generated program is; colors are linear light, premultiplied
1118
1351
  throughout, `Surface.base` included. The material describes the
1119
1352
  surface, the package shades it (Godot's fragment(), Filament's
1120
- 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.
1121
1359
  3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
1122
1360
  `sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
1123
1361
  declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
@@ -1131,11 +1369,9 @@ three tiers, top first:
1131
1369
  loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
1132
1370
  shade function: light i's direction and its color already attenuated,
1133
1371
  cone-faded and shadowed (zero when it cannot reach). The stock
1134
- materials are built from this same set, so the tiers cannot drift;
1135
- `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.
1136
1373
  The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
1137
- (the knot's rim term); `probes/spot-custom-material-probe.tsx` a
1138
- sceneLight loop beside a lit() floor.
1374
+ (the knot's rim term).
1139
1375
  `standardFragment(options)` is the same for `standard`: lit's options
1140
1376
  minus `specularMap`/`env` (the environment is always composed) plus
1141
1377
  `metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
@@ -1153,6 +1389,8 @@ a shadow program that never reads `n` (no triplanar, no surface function
1153
1389
  using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
1154
1390
  instance or every caster move warns about the skipped write.
1155
1391
 
1392
+ ### Lights
1393
+
1156
1394
  Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
1157
1395
  direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
1158
1396
  travelling along `direction` in the node's LOCAL space (default `[0, -1,
@@ -1198,6 +1436,8 @@ scenes. Everything starts black: a lit scene with no light shows
1198
1436
  nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
1199
1437
  shape (soft vs hard cone, casting spots, an orbiting bulb).
1200
1438
 
1439
+ ### lit
1440
+
1201
1441
  `lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
1202
1442
  the directional list, Lambert diffuse, Blinn-Phong highlight when
1203
1443
  `specular` (0..1 strength) is set with `shininess` (default 30), a
@@ -1223,6 +1463,8 @@ or more beside it, or trilinear smears the far half into the mip its long
1223
1463
  axis picked (`createModel` uploads its images with both; the device clamps
1224
1464
  the level, `limits.maxAnisotropy` reports it).
1225
1465
 
1466
+ #### Surface maps
1467
+
1226
1468
  The surface maps, each an option beside `map` and sampled at its uv:
1227
1469
 
1228
1470
  - `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
@@ -1257,6 +1499,8 @@ The surface maps, each an option beside `map` and sampled at its uv:
1257
1499
 
1258
1500
  `examples/materials.tsx` shows all five.
1259
1501
 
1502
+ ### standard
1503
+
1260
1504
  `standard(opts)` is the metalness/roughness material, the look authored
1261
1505
  assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
1262
1506
  Unity's Standard): every lit option but the Blinn-Phong knobs
@@ -1295,70 +1539,82 @@ classes do not warn about an inactive uniform.
1295
1539
 
1296
1540
  ## Models
1297
1541
 
1542
+ ### Three layers
1543
+
1298
1544
  Authored models come in as glTF 2.0 (.gltf with its .bin and image files
1299
1545
  next to it, or single-file .glb) and become a Group carrying the file's
1300
1546
  node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
1301
1547
  fits:
1302
1548
 
1303
- - `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
1304
- bun and on flux): `ModelData` = `nodes` (the retained hierarchy in
1305
- pre-order - name, parent index, local TRS; matrix-form nodes are
1306
- TRS-decomposed, shear dropped; nodes that carry no part, joint or
1307
- animation target anywhere - cameras, lights, unused empties - are
1308
- pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
1309
- index, vertices in the standard layout LOCAL to the node - except
1310
- skinned parts: "skinned" layout, model-space bind pose, `skin` index),
1311
- `skins` (joint node indices + inverse binds), `clips` (the animations
1312
- as baked channel buffers: node/path/interpolation, times, values),
1313
- `materials` (base color factor, `map` =
1314
- index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
1315
- `alphaMode` as written and `alphaCutoff`, spec default 0.5, the
1316
- normal and emissive slots, `metalness`/`roughness` factors and the
1317
- packed `metalnessRoughnessMap` - standard's inputs), `images`
1318
- (the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
1319
- pose, conservative for parts under rotated nodes). External
1320
- files come through `resolve(uri)` (uri as written, still
1321
- percent-encoded; `gltfExternalUris(bytes)` lists them so an async
1322
- caller can read them first) - for a .gltf AND for a .glb, which is
1323
- usually self-contained but may legally reference external images
1324
- (real exporters do); data: uris need no resolver. Missing
1325
- normals produce FLAT shading (the spec's rule): the primitive is
1326
- un-indexed, one vertex per corner. A mirroring node chain (negative
1327
- rest-pose world determinant) flips the part's index winding so
1328
- `cull: "back"` still keeps the outside. Non-triangle primitives are
1329
- skipped; a required extension the parser does not implement throws
1330
- naming it, and Draco or meshopt compression throws "re-export without
1331
- mesh compression" - Blender exports Draco by DEFAULT, so that is the
1332
- first error a real file hits.
1333
- - `createModel(data, { material?, label? })` - uploads the images (repeat
1334
- wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
1335
- with the file's color, maps, normal scale, metalness/roughness and
1336
- packed map, emissive and transparency - the glTF material model, so a
1337
- scene showing a model wants an `environment` (a glTF metal in a scene
1338
- with none renders near black); `material: (m, maps, skinned) =>
1339
- lit({ color: m.color, map: maps.map ?? undefined, skinned })` for the
1340
- Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
1341
- Groups with the file's local TRS, and one mesh per part under its node,
1342
- all inside the returned `Model` (a Group): `add(scene.root, model)`,
1343
- place it with `setTransform`, find parts by name in `model.parts`
1344
- (`{ name, mesh }`), spin a wheel relative to its axle through
1345
- `model.nodes` (`{ name, node }` in table order, parents first; names
1346
- repeat when the file's do - `.find()` yours), `model.bounds` for
1347
- framing a camera. Skinned parts get the `skinned: true` material
1348
- variant and hang off the model ROOT (the spec ignores their node's
1349
- transform; the palette places them - see the mixer below). `dispose()`
1350
- detaches it and frees the geometry buffers and textures - the model owns
1351
- them, nothing else frees them.
1352
- - `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
1353
- and build. `loadModel` reads the baked `.srtm` written by `srt tool
1354
- 3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
1355
- under bun, stored in the GPU layout, so loading is views onto the file's
1356
- bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
1357
- on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
1358
- 40 ms for the whole baked load - the runtime parse is fine for small
1359
- models and a binary import (`import bytes from "./x.glb" with { type:
1360
- "binary" }` then `createModel(parseGltf(bytes))`, see
1361
- `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
1362
1618
 
1363
1619
  Loading is async everywhere but the binary import: loadGltf/loadModel
1364
1620
  return promises, and the async value must be read the way Solid 2 async
@@ -1378,6 +1634,8 @@ a source glTF must be parsed at runtime, do the parse in an isolate
1378
1634
  (parseGltf's result is plain data and copies across) and keep
1379
1635
  createModel on main.
1380
1636
 
1637
+ ### Placement and sockets
1638
+
1381
1639
  Placement: pieces of one authored set (a body and its fitted cosmetics)
1382
1640
  export in one world space, so composing them is `add(group, model)` per
1383
1641
  piece and nothing else - no placement math. SOCKETED items (a weapon in
@@ -1385,8 +1643,8 @@ a hand) are different: they bind to a joint, so they only land once a
1385
1643
  skeleton exists (a rig-less export cannot place them at all). The joint
1386
1644
  is an ordinary Group in `model.nodes` - find it by name and `add()` the
1387
1645
  item under it; it then follows the pose, mixer-driven or hand-posed,
1388
- like any child transform. Two authoring cases (verified in
1389
- `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
1390
1648
  origin needs the plain `add()` and nothing more; one authored in the
1391
1649
  RIG'S model space needs a socket Group between joint and item carrying
1392
1650
  the joint's rest-pose inverse (at rest the item then sits exactly where
@@ -1395,6 +1653,34 @@ transform on top of the authored placement. Skinned PARTS are the one
1395
1653
  thing that never needs this: they hang off the model root and the
1396
1654
  palette places them.
1397
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
+
1398
1684
  Applied: `doubleSided` (the default material draws it with `cull:
1399
1685
  "none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
1400
1686
  (+ scale; the derivative frame needs no tangents), `emissiveFactor` x
@@ -1410,6 +1696,9 @@ by lit()/standard() option name (`maps.map`/`maps.normalMap`/
1410
1696
  A `.srtm` baked before the material records carried the PBR fields
1411
1697
  (file version 3) is rejected by loadModel - re-bake with `srt tool
1412
1698
  3d/model`.
1699
+
1700
+ ### Animation
1701
+
1413
1702
  Animation: `createMixer(model)` plays `model.clips` by name -
1414
1703
  `mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
1415
1704
  named clip fades in, everything else fades out - Unity's CrossFade,
@@ -1437,6 +1726,9 @@ so a TRANSPARENT mesh parented under a player-animated joint does not
1437
1726
  re-trigger the back-to-front re-sort while it animates (opaque meshes,
1438
1727
  palettes and picking are unaffected) - nudge the scene with any
1439
1728
  setTransform if it shows, until the core-side transparent sort lands.
1729
+
1730
+ ### Root motion
1731
+
1440
1732
  Root motion: `play(name, { inPlace? })` strips a clip's root travel
1441
1733
  (Unity's applyRootMotion off, Godot's root_motion_track): the root's
1442
1734
  x/z hold at the clip's first key and its height rebases onto the root's
@@ -1473,7 +1765,11 @@ horizontal travel (Unity's bake-into-pose Y - for a controller that
1473
1765
  owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
1474
1766
  the swing-twist about up (exact under any lean); a cubic rotation
1475
1767
  track is linearized at 60 keys/s before its yaw is held. Verified on
1476
- 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
1477
1773
  (model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
1478
1774
  float texture, 4 texels wide, one row per joint, sampled in the vertex
1479
1775
  stage via texelFetch, so there is no joint cap) is composed by the
@@ -1483,6 +1779,8 @@ computed-once texture. `sampleChannel` (pure, from the root) stays the
1483
1779
  JS sampling core for checks and custom drivers;
1484
1780
  okf/done/animation-core.md records the design.
1485
1781
 
1782
+ ### Not in the subset
1783
+
1486
1784
  Not in the subset, dropped: vertex colors, tangents and further UV sets;
1487
1785
  morph targets (the "weights" channel path); samplers are ignored (every
1488
1786
  texture repeats); additive blending draws as base color. The follow-ups
@@ -1492,6 +1790,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1492
1790
 
1493
1791
  ## Traps
1494
1792
 
1793
+ ### Models and skinning
1794
+
1495
1795
  - A model's vertices are LOCAL to their node; the file's placement lives
1496
1796
  in the node-table TRS, composed by the scene like any Group chain. So
1497
1797
  a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
@@ -1499,6 +1799,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1499
1799
  subtree. The winding flip for a mirroring node chain is baked into the
1500
1800
  index order from the REST pose - re-scaling a node across zero at
1501
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.
1502
1805
  - Skinning is a VERTEX-STAGE effect: everything that runs off the
1503
1806
  retained tree sees the bind pose. A skinned mesh picks by its
1504
1807
  bind-pose triangles at the model root and its transparent sort key is
@@ -1506,6 +1809,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1506
1809
  MODEL moves both. Shadows are the exception: the shadow variants
1507
1810
  (depth and cutout) skin by the same uBones palette, so a caster casts
1508
1811
  its pose.
1812
+
1813
+ ### Materials and color
1814
+
1509
1815
  - A `standard` metal in a scene with no environment renders near black:
1510
1816
  its diffuse is zero and the black placeholder cube is all there is to
1511
1817
  reflect (Three and Godot render the same; Unity falls back to an
@@ -1513,74 +1819,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1513
1819
  so an untextured asset is all metal, and createModel's default is
1514
1820
  `standard`: give a model scene an `environment` (loadEnvironment's
1515
1821
  baked .srte, or the skybox's cube), or pass `lit` as the material.
1516
- - A reflection probe is a mirrored render (x-flipped projection, winding
1517
- inverted by the engine): anything built from screen-space derivatives
1518
- flips with it, so a normal-mapped surface shows its bumps INVERTED in
1519
- a probe's reflection. Known and shared with every engine's mirrored
1520
- views; a `uMirror` sign on the derivative frame is the fix when it
1521
- matters.
1522
- - A generated cube chain (`mipmap: true` from six faces) is a box
1523
- filter, not the GGX convolution the roughness-to-level rule assumes:
1524
- rough reflections read too sharp and the diffuse `envIrradiance` is a
1525
- 4x4 average. Bake with `srt tool 3d/environment` for anything
1526
- photographed; the JS sky gradients in the examples get away with it.
1527
1822
  - Light intensities are the same numbers for `lit` and `standard`: 1
1528
1823
  lights a white matte surface to 1 face-on. A Three scene's intensities
1529
1824
  are a factor pi larger for the same look; divide when porting.
1530
- - The y-down clip flip is baked into `perspective()`; scene code and
1531
- geometry are plain y-up right-handed, and CCW-outward winding culls
1532
- correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
1533
- "fix" the negated row of `perspective()` - both would mirror the winding
1534
- and show mesh interiors.
1535
- - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
1536
- cheap off switch); unhiding writes 1, or the mesh's own record count
1537
- when it is instanced - never a bare 1 into an instanced entry. Hidden
1538
- meshes skip uModel writes; the fresh matrix is
1539
- written on unhide. A freshly attached entry starts off the same way and
1540
- the core's flush turns it on when it writes uModel - never add one
1541
- live: it has no world matrix yet, and drawn before the sync microtask it
1542
- flashes at the world origin for a frame.
1543
- - Instancing pairs strictly at add(), like layout: an instanced material
1544
- needs a createInstancedMesh or createRecordMesh mesh and vice versa,
1545
- and the record strides must match the material's attributes per slot
1546
- (the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
1547
- mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
1548
- mismatch throws there, at creation for the mesh's own material and at
1549
- add() for a swapped one. The instance buffers are MESH-owned (unlike
1550
- shared geometry buffers): `disposeInstances` is their one free, and
1551
- the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
1552
- never resize: an addInstance or setRecords past capacity doubles into
1553
- new buffers and swaps them in (an instanced mesh's live records move
1554
- in one core `retargetRecords`, its style mirror republishes) -
1555
- amortized like a dynamic array, same policy as @solidrt/2d; size
1556
- `capacity` or the initial records to skip the copies. An instanced
1557
- mesh's style stride comes from its material AT CREATION: a later
1558
- setMaterial to a class with another slot-1 layout throws at the
1559
- rebuild.
1560
- - Style writes are mirrored, not immediate: `setInstanceStyle` lands in
1561
- `MeshInstances.style.data` and the scene's sync publishes the dirty
1562
- range - so a write on a mesh outside any scene shows once it is added
1563
- (the attach republishes the whole mirror), and reading the GPU buffer
1564
- back mid-frame can lag the mirror by one sync.
1565
- - Instanced casters: `castShadow` on a populated mesh needs a depth pass
1566
- with the instance placement in it - the stock materials' `instanced`
1567
- carries one, a custom class needs `shadowVertex` (see shadows below);
1568
- a class without one is skipped by shadow views, silently.
1569
- - A record mesh without explicit `bounds` has no BVH leaf: it never
1570
- picks, pointer events never target it, and its transparent sort key
1571
- falls back to the node's world position. That is deliberate - records
1572
- are opaque to the library, so any inferred box would be a guess. Supply
1573
- `bounds` for anything pickable or transparent. An instanced mesh picks
1574
- per instance regardless and culls by its instances' union; its
1575
- `bounds` replace that union and give the transparent sort a center.
1576
- - An instance node's own fields hold its LOCAL pose (relative to its
1577
- parent, as for any node); the record the core writes is its pose
1578
- relative to the MESH, composed through any group between them. A
1579
- removed instance's children stay parented to the dead node: remove()
1580
- one to re-use it. `disposeInstances` on an instanced mesh flushes the
1581
- core before freeing the buffer, so the destroyed instances' hiding
1582
- writes land in a live buffer; keep that order if you ever free a
1583
- record buffer by hand.
1584
1825
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
1585
1826
  [r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
1586
1827
  classes write alpha 1 when not `transparent` (the scene target is
@@ -1607,6 +1848,54 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1607
1848
  translucents can sort wrong (center distance, not per-pixel) - that is the
1608
1849
  engine contract, no OIT. A `shaderMaterial({ transparent: true })`
1609
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.
1610
1899
  - Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
1611
1900
  always unit). There is exactly one rotation field: no `node.rotation`
1612
1901
  shadowing it, because a second field is a second thing to go stale (an
@@ -1678,14 +1967,70 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1678
1967
  - Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
1679
1968
  the props that call them). Mutating `node.position` directly does not
1680
1969
  sync. Components have no `lookAt` prop - aim through a `ref`.
1681
- - A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
1682
- target state), independent of mesh count - never reintroduce per-mesh
1683
- camera writes (uEye-style per-mesh params are exactly the O(scene) cost
1684
- the shared channel removed). Scene scale honestly: hundreds to a
1685
- few thousand objects, bounded by the interpreter, not the GPU. A view
1686
- is one more such write per camera change and one more entry per mesh
1687
- at attach; a view's per-frame cost is the core's (one params write per
1688
- 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
+
1689
2034
  - A CASTING light's position matters (nothing else about a directional
1690
2035
  light's position does): the shadow camera is placed AT the light node's
1691
2036
  world position, Three's rule, so a `castShadow` sun at the origin
@@ -1715,6 +2060,17 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1715
2060
  is a full extra pass over the casters plus a sampler unit on every
1716
2061
  receiving program (MAX_LIGHTS of those are always bound, placeholders
1717
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.
1718
2074
  - A mesh's entries are mirrored into every view at attach and dropped at
1719
2075
  detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
1720
2076
  `overrideMaterial` is validated against every mesh's layout (at
@@ -1735,31 +2091,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1735
2091
  differs per mesh. (`scene.texture` IS the draw target id, so
1736
2092
  `setTargetParams(scene.texture, ...)` is the same write - setParams is
1737
2093
  the sanctioned spelling.)
1738
- - Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
1739
- `setCamera`, params), so ONE scratch array reused every frame is safe -
1740
- allocating three arrays per node per frame is pure waste. The node's own
1741
- `position`/`quaternion`/`scale` are the live arrays: read them, do not
1742
- hand them out and do not mutate them (that write does not sync).
1743
- - `setTransform` early-outs on an unchanged value (rotation compared AFTER
1744
- euler conversion), so driving every node unconditionally from `onFrame`
1745
- costs only the compare for nodes that did not move. Compares are exact,
1746
- like `setVisible`.
1747
- - Per-generator conventions - orientation, UV mapping, which axis a solid
1748
- stands on, what a cap looks like - live on each generator's doc comment,
1749
- not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
1750
- lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
1751
- map inscribed in the unit square) but the doc comment is the source.
1752
- - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
1753
- list END and dirty the order, so the next sync() re-sorts and the mesh
1754
- keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
1755
- (setMaterial swaps `mesh.material` before the rebuild, so _detach must
1756
- not read the new material's flag).
1757
- - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
1758
- to the axis at x = 0) - it is a solid of revolution, NOT Three's open
1759
- polyline shell. An "open" outline must be closed by the author;
1760
- otherwise the shape is simply wrong, there is no open-profile mode.
1761
- - `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
1762
- context).
2094
+
2095
+ ### Shader materials
2096
+
1763
2097
  - A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
1764
2098
  compile twice - no dedupe by source value (deliberate; hidden
1765
2099
  content-keyed caches are the anti-pattern the GPU layer avoids). Create
@@ -1786,10 +2120,24 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1786
2120
  vertex source - a comment counts - selects the "colored" layout, and
1787
2121
  the material then rejects standard geometry at add(). Do not mention
1788
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
+
1789
2136
  - Picking is triangle-accurate for ordinary meshes (`point` is a surface
1790
2137
  point, hits carry `face`/`uv`/`normal`) but box-only for instanced
1791
- meshes: there `point` is where the ray meets the population `bounds`
1792
- 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
1793
2141
  instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
1794
2142
  add a per-triangle path in JS - rays at mesh scale are
1795
2143
  interpreter-hostile, and the core already does it.
@@ -1801,50 +2149,10 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1801
2149
  leaf under a design size. Only a leaf whose layout size deliberately
1802
2150
  differs from the target (supersampling) needs `handlersFor`, fed the
1803
2151
  layout size the app itself set.
1804
- - A custom fragment that ends in `fragColor = vec4(...)` bypasses the
1805
- scene: no fog, no exposure or tone mapping, no encode - and a
1806
- hand-rolled loop over `uLightDir` renders a spot light as a
1807
- directional one (a lit rectangle on the floor, no cone). What you
1808
- declare is what runs, and the engine injects nothing, so pick a tier
1809
- (the three after the GLSL exports under "The model"): a stock fragment
1810
- on your vertex stage,
1811
- a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
1812
- `sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
1813
- `SHADOW_*` trio by hand still works and is no longer the shape.
1814
2152
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
1815
2153
  under a still pointer fires nothing until the next move - the same
1816
2154
  limit the element hit test has (hit-test-per-frame is an open platform
1817
2155
  item). Do not poll pick() per frame to fake it.
1818
- - Geometry local bounds cache on the Geometry (like its GPU buffers):
1819
- geometry is immutable after creation. Mutating `vertices` after a mesh
1820
- used them leaves stale bounds AND a stale GPU buffer - make a new
1821
- Geometry instead.
1822
- - The background covers the whole target with depth off, drawn first: it
1823
- REPLACES the clearColor visually (the clear still runs; you just never
1824
- see it), and a `transparent: true` mesh blends over it in-pass since the
1825
- background is always entry zero.
1826
- - A color map created as plain rgba8 renders WASHED OUT: the fragment
1827
- reads its encoded bytes as linear light and encodes them again. Create
1828
- color images (base color, emissive, a sky's faces or panorama) with
1829
- `format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
1830
- (a scene view, a shader target, a UI capture) holds encoded pixels and
1831
- cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
1832
- custom fragment (no material option yet); a draw target you create
1833
- yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
1834
- is sampler-only: no display, readback or copy).
1835
- - Reading the scene texture back (a probe's readTexture, a snapshot)
1836
- gives ENCODED pixels: an expected linear value v shows as
1837
- `linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
1838
- - The background pipeline/program are SCENE-OWNED (unlike shared
1839
- material pipelines): setBackground(null), replacement, and dispose()
1840
- destroy them. Do not hand the background's pipeline to anything else.
1841
- A skybox is the same slot with the library's fragment; only a
1842
- skybox-to-skybox replace keeps the entry (params and cube rewritten).
1843
- - The environment binds through the light rewrite's map set (uEnv
1844
- beside uShadowAtlas on every receiving target, new views included) and
1845
- directly on setEnvironment; the placeholder cube is app-lifetime like
1846
- the shadow placeholder. A `lit` without `reflectivity` declares no
1847
- environment sampler - the flag is part of the class key.
1848
2156
  - overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
1849
2157
  volume wholly inside a closed mesh with no triangle in reach touches
1850
2158
  nothing, and a body whose center has passed through a wall reports
@@ -1856,3 +2164,26 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1856
2164
  floor absorbs the vertical part (a body never creeps down a slope it
1857
2165
  can stand on), and with `floorSnap: 0` a standing body reports no
1858
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.