@solidrt/3d 0.0.57 → 0.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/AGENTS.md +1479 -678
  2. package/README.md +4 -2
  3. package/examples/README.md +56 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +129 -0
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +187 -22
  51. package/src/geometry.ts +1718 -203
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +457 -154
  54. package/src/index.ts +32 -21
  55. package/src/input.ts +11 -2
  56. package/src/light.ts +7 -8
  57. package/src/lod.ts +148 -0
  58. package/src/material.ts +322 -190
  59. package/src/mesh.ts +492 -259
  60. package/src/mixer.ts +27 -14
  61. package/src/model-file.ts +54 -15
  62. package/src/model.ts +151 -54
  63. package/src/motion.ts +17 -0
  64. package/src/node.ts +423 -23
  65. package/src/orbit.ts +378 -116
  66. package/src/profile.ts +4 -25
  67. package/src/resolve.ts +181 -0
  68. package/src/scene-shadows.ts +94 -93
  69. package/src/scene.ts +608 -185
  70. package/src/skeleton.ts +211 -0
  71. package/src/sweep.ts +27 -8
  72. package/tools/model.ts +3 -1
package/AGENTS.md CHANGED
@@ -6,260 +6,563 @@ 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
+ - [Level of detail](#level-of-detail)
28
+ - [Shadows](#shadows)
29
+ - [Retargeted motion](#retargeted-motion)
30
+ - [Geometry layout, indices and topology](#geometry-layout-indices-and-topology)
31
+ - [Material classes](#material-classes)
32
+ - [Pure pieces and checks](#pure-pieces-and-checks)
33
+ - [Components](#components)
34
+ - [Component props](#component-props)
35
+ - [Output composition](#output-composition)
36
+ - [Fill and fixed sizes](#fill-and-fixed-sizes)
37
+ - [Input](#input)
38
+ - [Orbit camera](#orbit-camera)
39
+ - [First-person camera](#first-person-camera)
40
+ - [Overlay projection](#overlay-projection)
41
+ - [Picking](#picking)
42
+ - [Collision](#collision)
43
+ - [Pointer events](#pointer-events)
44
+ - [Geometry generators](#geometry-generators)
45
+ - [Geometry as data](#geometry-as-data)
46
+ - [Profile kit](#profile-kit)
47
+ - [Materials](#materials)
48
+ - [Instancing](#instancing)
49
+ - [Background](#background)
50
+ - [Cube-map convention](#cube-map-convention)
51
+ - [Environment](#environment)
52
+ - [Reflection probes](#reflection-probes)
53
+ - [Baked sky](#baked-sky)
54
+ - [Panoramas](#panoramas)
55
+ - [Fog](#fog)
56
+ - [Color](#color)
57
+ - [Lighting GLSL](#lighting-glsl)
58
+ - [Custom looks: three tiers](#custom-looks-three-tiers)
59
+ - [Lights](#lights)
60
+ - [phong](#phong)
61
+ - [standard](#standard)
62
+ - [Models](#models)
63
+ - [Three layers](#three-layers)
64
+ - [Async loading](#async-loading)
65
+ - [Placement and sockets](#placement-and-sockets)
66
+ - [Wardrobe pieces](#wardrobe-pieces)
67
+ - [Applied glTF material fields](#applied-gltf-material-fields)
68
+ - [Animation](#animation)
69
+ - [Root motion](#root-motion)
70
+ - [Skins](#skins)
71
+ - [Not in the subset](#not-in-the-subset)
72
+ - [Traps](#traps)
73
+ - [Models and skinning](#models-and-skinning)
74
+ - [Materials and color](#materials-and-color)
75
+ - [Environment and background](#environment-and-background)
76
+ - [Coordinates and rotation](#coordinates-and-rotation)
77
+ - [Visibility and instancing](#visibility-and-instancing)
78
+ - [Shadows](#shadows)
79
+ - [Views and scene params](#views-and-scene-params)
80
+ - [Shader materials](#shader-materials)
81
+ - [Picking, pointer and collision](#picking-pointer-and-collision)
82
+ - [Geometry and components](#geometry-and-components)
83
+
9
84
  ## The model
10
85
 
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.
86
+ ### Two layers: core and components
87
+
88
+ Two layers. The imperative core is Solid-free: `createScene`,
89
+ `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
90
+ `lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects
91
+ over the spatial core (`flux:spatial`, `alloy/src/spatial/`): every node
92
+ in a scene has a core node, JS keeps the LOCAL position/quaternion/scale
93
+ as the readable truth and forwards each write, and the core's flush
94
+ (one call per microtask) recomputes only the moved subtrees and writes
95
+ each entry's uModel (plus uNormal for materials declaring it) and its
96
+ visibility switch itself - a move costs its subtree, never the scene.
97
+ ONE `setTargetParams` (the shared uViewProj + uCamPos) per camera
98
+ change, however many meshes. World matrices live in the core only:
99
+ `worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
100
+ writes included). See okf/backlog/spatial-core.md for what still runs
101
+ in JS and why. The component face (`Scene`/`View3d`/`Group`/`Mesh`/
102
+ `PerspectiveCamera`) syncs props into that core over context and renders
103
+ nothing itself.
104
+
105
+ ### Node lifecycle
106
+
107
+ Two removal verbs, one meaning each, the same words with the same
108
+ meanings in @solidrt/2d. `remove(child)` DETACHES an intact subtree -
109
+ Three's `parent.remove`, Godot's remove_child: children stay under the
110
+ removed node, core nodes free on leave and recreate on re-enter, so a
111
+ removed subtree re-adds cleanly (`add(parent, child)` re-parents through
112
+ it); it SNAPS, since a detached node is coming back. `destroy(node)` is
113
+ Unity's Destroy, Godot's queue_free: the subtree is gone for good, every
114
+ handle in it inert (writes are no-ops, add throws, remove skips), and it
115
+ is the removal an `exit` rides on (see Retargeted motion). The components
116
+ destroy on unmount. Nothing else is destroyed: GPU resources stay on
117
+ their own disposers (`disposeGeometry`, `disposeInstances`,
118
+ `model.dispose`, the scene's `dispose`), each of which frees a node still
119
+ animating out on the spot. An instance node (addInstance on an instanced
120
+ mesh) is slot-bound to its mesh: `destroy` frees it (the slot recycles at
121
+ the free) and the generic add/remove throw on it. (@solidrt/2d has no
122
+ detach - a sprite cannot exist outside its layer - so `destroySprite` /
123
+ `destroyGroup` are its only removal, and a sprite that should come back
124
+ is hidden.)
125
+
126
+ ### Rendering is the runtime's
127
+
128
+ Rendering is the runtime's. The target is `render: "auto"`: it
129
+ re-renders when entries change, so a STATIC scene costs zero passes and
130
+ the library registers no frame loop. Continuous animation is the app's
131
+ own `onFrame` writing a signal (declarative) or `setTransform` on a
132
+ `ref`-grabbed node (the frame-rate escape hatch - signals carry
133
+ structure, per-frame motion goes straight to the scene).
134
+
135
+ ### Views and layers
136
+
137
+ VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
138
+ clearColor?, ... })` renders the same scene into a second target from
139
+ its own camera (`view.setCamera`, the scene's CameraUpdate shape; in a
140
+ component tree `<View3d>` is the same as a Scene child, see Components).
141
+ Each mesh gets one entry in the view's target bound as one more draw sink of
142
+ its CORE node, so the one flush writes every target - the app writes
143
+ nothing per view. Geometry buffers and (without an override) materials
144
+ are shared; the light set and `scene.setParams` names fan out to every
145
+ view, `view.setParams` is the view's own channel - and names a view
146
+ sets itself (or its `fog` option, below) become VIEW-OWNED: the
147
+ scene's setParams/setFog fan-out skips them from then on, so a view
148
+ override survives scene-wide writes. A view's backdrop is its
149
+ clearColor (the scene background draws on PROBES, not views); a view
150
+ has no picking. A view is a buffer plus a resolve like the scene
151
+ (`view.hdrTexture`, `view.texture`, the `resolve` option and
152
+ `view.setResolve` - see Color); a tiled view (`into`) renders LINEAR
153
+ light into the atlas and the app resolves the atlas once: a
154
+ `createShaderTexture` over `resolveFragment()` from `/glsl` (the stock
155
+ source with `uScene` and the RESOLVE set declared) with the atlas bound
156
+ as `uScene` and `resolveParams({ toneMapping, exposure })` as its params
157
+ - `examples/scene-atlas.tsx`. LAYERS select what a target
158
+ draws, Three's model exactly: `layers` on a mesh is its membership
159
+ bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
160
+ Groups), and each target carries a mask (default 1) - `layers` on
161
+ createScene/createView, live via `setLayers` on the scene handle and
162
+ each view. A mesh draws where mask & layers is non-zero, so a minimap's
163
+ marker meshes live on bit 2: invisible in the main render, drawn by
164
+ the map view whose mask admits them. Shadow views follow the SCENE's
165
+ mask (what the scene cannot see must not darken it), and
166
+ pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
167
+ invisible ones - unless the query passes its own `{ layers }`, which is
168
+ how a low-poly collision mesh lives undrawn in the scene yet answers
169
+ ground and collision queries (the physics-collider pattern).
170
+ Per-view fog: `fog: FogOptions | null` on createView overrides the
171
+ scene's fog for that view (null = unfogged - the clear minimap over a
172
+ fogged scene); absent follows the scene. `overrideMaterial` (Three's
173
+ `scene.overrideMaterial`, scoped to the view) draws every mesh with one
174
+ material - a depth pass, a normal/id visualizer - skips instanced
175
+ meshes (unless the override itself declares their exact
176
+ `instanceBuffers` record layouts) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
177
+ input; the same option on createScene exposes `scene.depthTexture`,
178
+ the input for a depth-reading post effect in `output` (not combinable
179
+ with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
180
+ perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
181
+ the scene's own camera takes it too, and pick() follows.
182
+ `examples/scene-views.tsx` is the shape.
183
+
184
+ ### Culling
185
+
186
+ CULLING is the core's, per target, on by default: every camera write
187
+ (the scene's, a view's, a shadow tile's) also sets that target's
188
+ frustum in the spatial core, and the flush switches an entry whose
189
+ world box falls wholly outside it to instance count 0 - the same
190
+ switch as `visible`, so a culled mesh costs nothing per frame and a
191
+ still camera re-tests nothing (a camera move re-tests every sink on
192
+ that target, in Rust; a node move re-tests its own). The box is the
193
+ picking box (the local bounds through the world matrix, the
194
+ Godot/Unity AABB test; Three uses spheres); an instanced mesh without
195
+ explicit `bounds` is culled by the union of its live instances' boxes
196
+ (the joint group below over the instance nodes, so the box follows
197
+ them: Godot's MultiMesh AABB, with `bounds` as its custom_aabb; Three
198
+ computes an InstancedMesh sphere once and leaves it stale, Unity wants
199
+ worldBounds), and a record mesh without `bounds` is never culled. Per
200
+ mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
201
+ a vertex stage moves beyond its box - a fullscreen quad, a custom
202
+ displacement - and `cullMargin` (world units, Godot's
203
+ `extra_cull_margin`) for bounded displacement such as wind. Sprites
204
+ cull by their quad's reach at any facing. A SKINNED part is culled by
205
+ the union of its joints' boxes (the bake computes each joint's
206
+ influence box in joint space, `ModelSkin.jointBounds`, .srtm VERSION
207
+ 5; the joint nodes carry them as culling-only bounds, outside the
208
+ picking index), so the box follows the pose with no per-frame JS -
209
+ Unity's bone bounds, Godot's per-bone AABBs; no `updateWhenOffscreen`
210
+ knob is needed. Probe faces set no frustum (six cameras, one target).
211
+ Shadow tiles cull casters against their light frustum, which is what
212
+ makes `shadow.distance` and cascades cheaper.
213
+
214
+ ### Level of detail
215
+
216
+ LOD is the core's too, beside culling: a group's level is picked per
217
+ target after each flush by PROJECTED SIZE - the sphere around the
218
+ group's boxes (radius = half the box diagonal, conservative like the
219
+ frustum box), its diameter as a fraction of the viewport height,
220
+ `radius / (distance * tan(fov / 2))` under a perspective camera and
221
+ `radius * 2 / (top - bottom)` under an orthographic one (Unity's
222
+ screen-relative model; Three's distance is wrong for an ortho minimap,
223
+ a zoomed camera or a scaled-up tree). `createLod([{ node, size }, ...],
224
+ { fade? })` / `<Lod fade?>` with `lodSize` on its direct children: the
225
+ levels nearest first, `size` the projected size BELOW which a level
226
+ hands over to the next, the last one's the cull threshold (0 = never
227
+ culled); a level is a direct child (a parentless node is adopted by
228
+ `setLod`, one parented elsewhere is refused, never moved). Every camera write also sets its target's LOD view (eye,
229
+ focal, ortho flag, `lodBias`): the scene and each view pick by their
230
+ own camera (a minimap sees the far level of the same tree), shadow
231
+ tiles by the SCENE camera (a caster draws the level the camera sees, so
232
+ its shadow matches - Unity's and Godot's rule), probe faces pick
233
+ nothing. The gate composes with the frustum and `visible` into the same
234
+ instance-count switch, so a still camera re-tests nothing and a thousand
235
+ groups cost no JS per frame; a child of the group that carries no
236
+ `lodSize` is drawn always. Without a `fade` the switch is hard with a
237
+ one-sided hysteresis band (a level is left at its threshold and
238
+ re-entered only once the size climbs a tenth above it), so a boundary
239
+ never flickers; with one, each threshold `s` widens into `[s, s * (1 +
240
+ fade))` where both levels draw with COMPLEMENTARY screen-hash dithers
241
+ (the near one keeps the pixels below the band position, the far one
242
+ the rest - a partition, never a double draw) through the `uLodFade`
243
+ vec2 every stock fragment composes, its band position in 64 steps so an
244
+ entry is rewritten when its step changes, not every frame, and written
245
+ solid again when the entry switches off; a custom class opts in by composing
246
+ `LOD_FADE` from `@solidrt/3d/glsl` and calling `lodFade()` first in
247
+ main. Shadow tiles never fade: their depth pass switches hard at the
248
+ band's midpoint, invisible for two variants of one shape.
249
+ `createInstancedLod([{ geometry, material, size }, ...], opts)` /
250
+ `<InstancedLod levels capacity?>` is the population form: one instance
251
+ set, one matrix buffer PER LEVEL, and the core stages each instance's
252
+ record into the level ITS OWN size picks - a spread forest as three
253
+ entries, near trees full and far ones cards. Returned as the first
254
+ level's mesh (the other levels are its children at identity, sharing
255
+ its instance slots; `setLayers`/`setCastShadow` on it reach them,
256
+ `setInstanceStyle` writes every level's style stream; a pick or pointer
257
+ event on an instance names that first mesh whatever level the instance
258
+ is drawn at). Records are
259
+ target-agnostic, so instances pick by the SCENE camera and every view
260
+ draws that choice; instanced levels switch hard (`fade` is refused).
261
+ `scene.setLodBias` (Unity's lodBias) multiplies every measured size:
262
+ below 1 hands over sooner, the one-line quality knob for a low-end
263
+ device. Picking and collision see the level the queried target draws
264
+ (`scene.pick`/overlap/sweep/moveAndSlide the scene's, `view.pick` the
265
+ view's); a collider that must not follow the camera is its own undrawn
266
+ mesh on a collision layer. Nested groups chain. Mesh simplification is
267
+ a bake job, not a runtime one. `examples/lod.tsx` is the shape;
268
+ `probes/3d-lod-bench.tsx` the cost.
269
+
270
+ ### Shadows
271
+
272
+ SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
273
+ bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
274
+ shadow })`, `setLight`) makes the scene own an internal
275
+ `createView({ depth: "texture", overrideMaterial: depth pass })` drawing
276
+ the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
277
+ orthographic camera at the light's WORLD position along its world
278
+ direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
279
+ Any light may cast, bounded by the shadow-slot budget
280
+ (`MAX_SHADOW_MAPS` = 8, its own constant, exported from the root and
281
+ `/glsl` with `MAX_LIGHTS` and `MAX_CASCADES`: a directional light claims
282
+ `shadow.cascades` consecutive slots, a point light six, a spot one).
283
+ The budget is checked when the set settles (the scene's sync), never
284
+ at attach, so a `<Show>`/`<Switch>` swap whose branches each fit is
285
+ fine even though both are attached for an instant. A set over budget
286
+ once settled is reported once per change of the set: `<Scene>`
287
+ rethrows it inside the tree, so the app's error boundary (or an
288
+ `<Errored>` closer in) shows it, and Reset returns to the scene, which
289
+ kept rendering - the casters that fit in attach order keep their maps,
290
+ the rest light the scene unshadowed. A bare `createScene` throws it
291
+ from its sync unless given `onError`. A deliberate overlap must fit
292
+ alongside its successor: a casting light with its own exit transition
293
+ keeps casting until it frees. `<SpotLight castShadow
294
+ shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
295
+ machinery with a PERSPECTIVE camera: at the light's world position
296
+ along its world direction, fov = its cone (2 * angle), near from
297
+ `shadow.near` (default 0.5), far from the light's `distance` (or the
298
+ directional default 500 when 0) - one map, one slot, the same atlas
299
+ and lookup. A perspective map's depth is nonlinear, so `normalBias`
300
+ (world units) is the acne knob to reach for; `bias` acts in that
301
+ nonlinear depth. `<PointLight castShadow>` casts in every direction
302
+ with the same option set: six 90-degree face maps (world-axis
303
+ aligned, slot order +X, -X, +Y, -Y, +Z, -Z) as six consecutive tiles
304
+ of the same atlas, far from `distance` like a spot - so give a
305
+ casting bulb a distance. No cube map: a receiver picks the face by
306
+ the dominant axis of the light-to-point vector (SHADOW_LOOKUP), one
307
+ projection, one hardware-compare tap - the Three/Godot/Unity-URP
308
+ atlas route. `shadow.radius` (every casting light, default 1 = that
309
+ one tap, a 2x2 bilinear compare) softens the edge: above 1 a 3x3 grid
310
+ of hardware taps `radius` texels apart, Three's `shadow.radius` /
311
+ Godot's `shadow_blur`, nine taps per receiver fragment. A first-person
312
+ eye over a floor shows the texel stairs at any map size; 2 is the
313
+ usual figure, past ~3 the taps separate into bands. Each face map renders a few degrees wider than its face
314
+ (URP's fovBias) so a seam fragment's occluder is inside the map it
315
+ samples - without the guard band every seam shows a lit slit - and
316
+ PCF taps clamp at face-tile edges, so a face seam hardens slightly
317
+ instead of bleeding into the neighbour.
318
+ `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
319
+ N maps fitted to slices of the SCENE camera's frustum (near ..
320
+ `shadow.distance`, default the camera far; the practical split; each
321
+ slice's bounding sphere as an ortho box along the light, its centre
322
+ snapped to the map's texel grid so edges do not swim; re-fitted
323
+ whenever the scene camera or the light moves) - a receiver samples the
324
+ tightest map that covers the point, fading into the next over the
325
+ map's outer 10% (`SHADOW_BLEND`) so the hand-over is a band, not a
326
+ seam; contact shadows stay sharp near the camera while the horizon
327
+ still has coarse ones, and pulling `distance` in sharpens all of them.
328
+ The box is the honest tier for a bounded scene; cascades are for a
329
+ scene that outgrows it, at N times the shadow fill. Every map is a
330
+ TILE of the scene's one shadow atlas (a `depth: "texture"` draw target,
331
+ a grid of cells the largest `mapSize` wide, scaled down uniformly
332
+ against `limits.maxTextureSize`), so N maps are ONE pass: the atlas
333
+ depth binds as the target-level `uShadowAtlas` of the scene and every
334
+ non-shadow view (a white texel when nothing casts); maps are MAP slots
335
+ dealt in light order, a light's cascades consecutive and tightest
336
+ first - `uShadowRect[j]` slot j's tile in atlas UV, `uShadowMatrix[j]`
337
+ its view's own view-projection (the whole array is one write per
338
+ shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
339
+ name its slots (count 0 = it does not cast) with
340
+ `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
341
+ glsl declares the set. Every `phong` material RECEIVES by default
342
+ (Godot's and Three's default); `phong({ receiveShadow: false })` opts a
343
+ material out and drops the map from its program - a material option,
344
+ as with vertexColors/triplanar, because the material picks the program
345
+ (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
346
+ hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
347
+ on each casting light's own term. `examples/shadows.tsx` (three
348
+ casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
349
+
350
+ ### Retargeted motion
351
+
352
+ RETARGETED motion is native: `setTransition(node, { position:
353
+ { duration: 400 }, ... })` makes setTransform writes TARGETS the core
354
+ animates toward every frame (position/scale per lane, rotation along
355
+ the quaternion geodesic - a spring keeps its velocity through
356
+ retargets), so a mesh gliding to a slot or a camera rig easing costs
357
+ one JS write per target change, zero per frame. The declaration lives
358
+ on the SceneNode and re-applies on every scene enter; the pose a node
359
+ enters with snaps, unless a component's `from` (`position: { duration,
360
+ from: [x, y, z] }`, a quaternion for `rotation`) animates it in from
361
+ there - at every scene enter, since each enter creates the core node
362
+ anew. A component's `exit` is where it animates to when `destroy` lets
363
+ go of the node: it stays drawn while it leaves and is a GHOST meanwhile -
364
+ no pick, raycast, overlap, sweep or pointer event sees it - then frees
365
+ when the last exit settles; children go first and the parent stays their
366
+ frame until the last of them has settled, so a dying character's parts
367
+ leave in its frame (a mixer driving its joints keeps driving them). Either
368
+ endpoint takes `{ value, duration?, curve?, bounce?, delay? }` to own its
369
+ direction's motion (an ease-out enter, an ease-in exit), and `delay` on
370
+ an entry holds its writes on the animation clock (a late frame catches
371
+ up). `stagger` (ms) goes on an ANCESTOR's declaration (a `<Group
372
+ transition={{ stagger: 40 }}>`), never on the animating nodes: it spaces
373
+ the enters and exits of its descendants that begin in one frame by
374
+ `index * stagger` (add order in, tree order out whatever order the
375
+ unmount lets them go in; nearest declaring ancestor wins) and cascades
376
+ nothing unless they declare `from`/`exit` -
377
+ a squad of instances spawning in is one `stagger` on their group, and
378
+ for the nodes straight under the scene root it is `<Scene stagger>` /
379
+ `createScene({ stagger })` (a declaration on `scene.root`). A populated
380
+ mesh unmounts as `destroy(mesh)` then `disposeInstances(mesh)`, and the
381
+ dispose waits for a mesh still animating out, so an `<Instance>`'s exit
382
+ plays through its `<InstancedMesh>`'s unmount (`model.dispose` waits the
383
+ same way); the scene's and a layer's `dispose` cut a corpse short - the
384
+ whole target is going. `examples/exits.tsx` is the live example: crates
385
+ that pop in and shrink away on tap, the shelf cascading out and back
386
+ through `<Show>`. Each natural settle calls the node's `onTransitionEnd`
387
+ (plain field like the pointer handlers) with `{ component }` - never on
388
+ a cancel, snap, leave or exit; the raw "spatialTransitionEnd" engine
389
+ event (srt:events, carrying the CORE node id `_node`) stays for
390
+ flux:spatial consumers.
391
+
392
+ ### Geometry layout, indices and topology
393
+
394
+ One interleaved vertex buffer per geometry, described by an open layout
395
+ (`Geometry.layout`, absent = "base"): an ordered attribute list that
396
+ starts with `aPos` float32x3 and may carry any named channels after it,
397
+ each in a vertex format. Formats are WebGPU's spelling of (component
398
+ type, count, normalized): the float32 family (`float32` .. `float32x4`),
399
+ `float16x2/x4`, the normalized integers `unorm8x4`, `snorm8x4`,
400
+ `unorm16x2/x4`, `snorm16x2/x4`, the unsigned integers `uint8x4`,
401
+ `uint16x2/x4`, `uint32` .. `uint32x4` and the signed integers `sint8x4`,
402
+ `sint16x2/x4`, `sint32` .. `sint32x4`; `VERTEX_FORMATS` is the table.
403
+ Every format is a multiple of 4 bytes, so offsets and strides are
404
+ 4-aligned by construction. A format feeds the shader `in` of its KIND
405
+ and component count, WebGPU's rule: float and normalized formats feed
406
+ `in vec4` (the fetch converts: `in vec4 aColor` reads a `unorm8x4` as
407
+ 0..1), `uint*` formats feed `in uvec4` and `sint*` formats `in ivec4`,
408
+ exact at every width - so a per-vertex id or an index into a wide
409
+ table rides as `uint32` into `in uint aId`, and the "skinned" layout's
410
+ `aJoints` is `uint8x4` into `in uvec4 aJoints`. A layout that crosses
411
+ kinds (`uint8x4` into `in vec4`) is the missing-attribute error at
412
+ add() (`formatFeeds` is the rule). The base prefix `aPos` float32x3 + `aNormal`
413
+ float32x3 + `aUV` float32x2 is what every generator emits and what the
414
+ stock materials read, not a rule: a hand-built geometry declares what
415
+ it has, so a point cloud may carry `[aPos float32x3, aData float32]` at
416
+ 16 bytes a point instead of 36, or a color as `unorm8x4` at 4 bytes
417
+ instead of 16. Pair a prefixless layout with a material that reads only
418
+ what it carries: `unlit` (aPos only) or a shaderMaterial; a stock lit
419
+ material over it throws the ordinary missing-attribute error at add().
420
+ A layout handed to a generator must start with the prefix and stay
421
+ float32-family (the generator writes floats); packing is a pass over
422
+ the result. `withAttribute(geometry, { name, format }, fill)` appends
423
+ one channel in any format (Three's `setAttribute` for an interleave;
424
+ the fill is given in float and encoded on write); "colored" names the
425
+ common case, the prefix plus `aColor` float32x4 - the per-vertex data
426
+ channel (a tint, baked AO, any four scalars; standard name, your
427
+ contents) - and `withColors(geometry, fill)` is its spelling. Fill is a
428
+ flat size-per-vertex array or a per-vertex callback receiving `(index,
429
+ pos, normal, uv)` (zeros for a channel the layout lacks). The fill is
430
+ raw floats: a tint the stock materials read under `vertexColors` is
431
+ premultiplied linear like every shader color, so encode an sRGB pick
432
+ with `premultipliedColor(color)`. `Geometry.vertices` is an
433
+ `ArrayBufferView` (a Float32Array for an all-float layout, a Uint8Array
434
+ when a channel is packed; never the contract): read and write a channel
435
+ through `geometryAttribute(geometry, name)`, an accessor whose
436
+ `get(i, k)`/`set(i, k, v)` speak floats whatever the format, and count
437
+ vertices with `vertexCount`. Materials read attributes BY NAME: a
438
+ material's vertex stage may declare any subset of its geometry's
439
+ channels, and a channel the program reads that the geometry lacks (by
440
+ name, or with a different component count) throws at add(). What a program reads is the ENGINE's word (`material.attributes()`
441
+ = `programAttributes` reflection of the linked program, instance
442
+ attributes excluded), not a parse of the GLSL: an `in` the compiler
443
+ dropped does not count, and the engine also rejects a pipeline whose
444
+ attribute lists leave a read attribute uncovered. The material
445
+ keeps one program and builds one pipeline per layout its meshes bring,
446
+ so a geometry may carry more than a material reads. The whole layout
447
+ ships whether a material reads every attribute or not (inactive
448
+ attributes only keep the stride), so extra channels cost their bytes on
449
+ every draw of that geometry - keep data-light passes (a wireframe
450
+ reading only aPos) on base-layout geometry. `layoutStride` (bytes)/
451
+ `layoutSlot` (byte offset, format, components)/`layoutKey`/
452
+ `layoutAttributes` are the layout arithmetic; two layouts with equal
453
+ keys interleave identically (merge requires that).
454
+ STREAMS: a geometry may carry more than one vertex buffer.
455
+ `Geometry.vertices` under `layout` is stream 0 (where aPos lives) and
456
+ `Geometry.streams` holds extra `{ layout, vertices }` buffers, each with
457
+ its own attribute list and the same vertex count (Unity's vertex
458
+ streams; Three's one buffer per channel when a stream carries one
459
+ attribute). `withAttribute(geometry, attr, fill, { stream })` appends a
460
+ channel to stream 1..n or opens stream n + 1; `geometryAttribute`,
461
+ `geometrySlot`, `geometryKey`, `geometryLayouts`, `geometryStreams`
462
+ and `geometryVertexCount` read across streams, and a material's
463
+ pipeline is one per `geometryKey` (every stream's layout, `|` between
464
+ them). Streams exist for data on its own schedule: `updateVertices(
465
+ geometry, { stream?, first?, count? })` re-uploads that vertex range of
466
+ that stream from the geometry's own array in place (write the array
467
+ through the accessor first; Three's `needsUpdate` with an update
468
+ range), reaching every mesh, view and wireframe over the geometry
469
+ while the other streams never move; a stream-0 update drops the cached
470
+ bounds and rewrites the same range of the core's copy of the positions,
471
+ so the mesh picks and culls where it is now drawn (the box follows at
472
+ the next flush, the triangle index is rebuilt by the next query - a
473
+ deforming mesh needs no re-attach). transformGeometry and
474
+ the edge builders share extra streams with their source; merge
475
+ concatenates every stream; the `.srtm` container refuses streams (run
476
+ time data). `setDrawRange(mesh, first, count?)` draws indices `[first,
477
+ first + count)` of the mesh's geometry (Three's `setDrawRange`, on the
478
+ mesh because the scene owns the entries), applied to every entry of the
479
+ mesh and reset by setGeometry. The worked example is
480
+ `examples/streams.tsx`.
481
+ Indices are uint16 or uint32 - the `Geometry.indices` array type picks
482
+ the draw's index format, so hand-built geometry past 64k vertices just
483
+ uses a Uint32Array (generators emit uint16). What the indices LIST is
484
+ `Geometry.topology` (`"triangles"` when absent; `"lines"`,
485
+ `"line-strip"`, `"points"`, `"triangle-strip"`): the index buffer and
486
+ its primitive travel together (Godot's surface primitive, Unity's
487
+ SetIndices topology), a material builds one pipeline per (layout,
488
+ topology) pair its meshes bring, and validateGeometry enforces the
489
+ count rule at add() (a whole number of triangles or lines, one
490
+ primitive's worth for a strip). Materials have no topology of their
491
+ own. Only a triangle list gets a triangle narrowphase; lines and points
492
+ pick and collide by their bounds box (which follows `updateVertices` all
493
+ the same), and cast no shadow. `geometryTopology`
494
+ reads the field with the default applied. POINTS: a point cloud is
495
+ ordinary indexed geometry - one vertex per point, the index buffer
496
+ listing them, `topology: "points"`, no constraint on the index count.
497
+ Under `"points"` the vertex stage writes `gl_PointSize` (honored to the
498
+ pixel; `size / gl_Position.w` attenuates it with distance) and the
499
+ fragment reads `gl_PointCoord` (0..1 across the sprite) to shape the
500
+ splat - `discard` outside the inscribed circle for round ones. The
501
+ worked example is `@solidrt/core`'s `examples/gpu-particles.tsx`. Do
502
+ NOT reach for instancing to draw a cloud (Instancing, below, has the
503
+ cost model). Geometry GPU buffers are lazy, shared, and
504
+ reference-counted by draw entries: removing the last
505
+ entry frees them at the end of the microtask (a same-tick rebuild keeps
506
+ the upload), so swapping `<Mesh geometry>` reactively never accumulates
507
+ old generations; the vertex upload is keyed on the vertices view
508
+ itself, so geometries sharing a vertex array (a wireframe over its
509
+ source) share one buffer. `disposeGeometry` is the immediate explicit
510
+ free.
511
+
512
+ ### Material classes
513
+
514
+ Materials dedupe hard: one program + one pipeline per material CLASS
515
+ (a `shaderMaterialClass` per option combination for unlit, lit and
516
+ sprite alike: map x transparent x cull x alphaTest, phong's extras on
517
+ top), `depth: true` + `cull: "back"` unless the material says otherwise
518
+ (`cull: "none"` for double-sided geometry; phong flips the normal on back
519
+ faces); an instance is just per-entry uniforms (`uColor`) and bindings
520
+ (`uMap`).
521
+
522
+ ### Pure pieces and checks
523
+
524
+ The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
525
+ `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
526
+ GPU-free BY DESIGN so they can be checked headless (and, for the two
527
+ model modules, run under bun in `tools/model.ts`); keep them that way.
528
+ The rigs under `checks/`
529
+ (`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
530
+ `order-check`, `gltf-check`) run on
531
+ flux from the repo root: `bunx srt bundle -f --stdout
532
+ packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
533
+ touching what you changed. `raycast-check.tsx` and
534
+ `collision-check.tsx` are the exceptions: they assert the documented
535
+ picking contract (triangle accuracy, the box tier, pick/raycast
536
+ parity, layer masks, the `{ meshes }` filter) and collision contract
537
+ (exact sweep times, the surface rule, the slide filter, layers and
538
+ meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
539
+ so they run on the playback client instead:
540
+ `bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
541
+ --size 128x128`. Run them whenever a doc edit touches picking or
542
+ collision claims - two copies of this contract have drifted before.
246
543
 
247
544
  ## Components
248
545
 
546
+ ### Component props
547
+
249
548
  | Component | Props |
250
549
  | --- | --- |
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) |
252
- | `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
- | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
550
+ | `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, `"aces"`, `"agx"` or `"neutral"`), `exposure?` (default 1), `bloom?` (`{ threshold?, intensity?, radius? }`, the stock bloom on the resolve, reactive), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `resolve?` (the source, `{ source, textures }`, or a function of the buffer id returning either; fixed at creation - see Color), `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) |
551
+ | `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), `resolve?` (as Scene's; not with `into`), `bloom?` (BloomOptions overrides the scene's, null turns it off in this view, absent follows the scene; not with `into`), `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 |
552
+ | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, the bubbling pointer events (below: down/move/up/wheel/tap from a hit descendant; a group is never the struck node, so it takes no `onPointerEnter`/`onPointerLeave` - `Lod` likewise), `ref?(node)` |
553
+ | `Lod` | a Group whose direct children carrying `lodSize` (a prop every node component takes: the projected size below which that child hands over, see Level of detail) are its levels, sorted by size, never by JSX position; plus `fade?` (the cross-fade band fraction, default 0); a child without `lodSize` is drawn always |
554
+ | `InstancedLod` | as InstancedMesh minus `material`, plus `levels` (`[{ geometry, material, size }]` nearest first, fixed at creation; instanced materials as InstancedMesh's), `castShadow?` (every level); `<Instance>` children populate it as under `InstancedMesh`, each drawing the level its own projected size picks |
254
555
  | `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)` |
255
556
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
256
557
  | `InstancedMesh` | as Mesh (an instanced `material`: a stock one with `instanced`/`instanceColors`, or a class declaring INSTANCE_MATRIX_ATTRIBUTES), plus `capacity?` (instance slots, default 64; the buffers double past it), `bounds?` (optional: instances pick by themselves and the mesh culls by their union), `label?`; a PARENT: `<Instance>` children populate it, `<Group>` children are squads; the record buffers are component-owned and freed on unmount |
257
- | `Instance` | one instance of the enclosing `InstancedMesh`: transforms, `transition`, pointer events as Group, plus `style?` (the material's slot-1 floats - `[r, g, b, a]` under `instanceColors`), `ref?(instance)`; a parent too (a `<Mesh>` under an instance rides with it) |
258
- | `RecordMesh` | as Mesh, plus `records` (interleaved per-instance floats; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffer is component-owned and freed on unmount |
558
+ | `Instance` | one instance of the enclosing `InstancedMesh`: transforms, `transition`, pointer events as Group, plus `style?` (the material's style record, one value per component - `[r, g, b, a]` under `instanceColors`), `ref?(instance)`; a parent too (a `<Mesh>` under an instance rides with it) |
559
+ | `RecordMesh` | as Mesh, plus `records` (the per-instance records in the material's first instance layout, an ArrayBufferView; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffers are component-owned and freed on unmount |
259
560
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` - or the Scene `camera` prop, the same state (last write wins) |
260
561
  | `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
562
  | `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
563
 
564
+ ### Output composition
565
+
263
566
  Output composition: without `output`, `Scene` emits a minimal
264
567
  `<texture width height>` leaf and nothing else is forwarded - anything
265
568
  more goes through `output(texture)`, which renders in place of that leaf:
@@ -267,11 +570,15 @@ a `<d-texture>`, a leaf with blendMode/fit/pointer/layout props, or a
267
570
  post-effect chain (`createShaderTarget` sampling the id with a
268
571
  covering-triangle pass; created in the callback it disposes with the
269
572
  Scene). Return null for no leaf at all and compose `scene.texture`
270
- elsewhere. Called once, untracked, inside the scene context. Scene
573
+ elsewhere. Called once, untracked, inside the scene context. `output`
574
+ composes the DISPLAYED texture, the resolve's output: an LDR effect
575
+ lives here; one that needs the scene's radiance is a `resolve` (Color). Scene
271
576
  `width`/`height` are target pixels and the leaf's own width/height are
272
577
  layout, so render and display size separate - render at 2x and display
273
578
  smaller for supersampling.
274
579
 
580
+ ### Fill and fixed sizes
581
+
275
582
  Fill (the default): omit `width`/`height` and the built-in leaf is laid
276
583
  out at 100% of its parent's box (give it a sized parent, as on the web)
277
584
  while the target follows the leaf's on-screen size in DEVICE pixels -
@@ -302,6 +609,8 @@ everything: the `pointer` feed listens at the scene's root, so
302
609
  handlers and the feed alike (a detached leaf gives its feed a `layout`,
303
610
  it has no layout box to normalize by).
304
611
 
612
+ ### Input
613
+
305
614
  Input, the rule (ARCHITECTURE.md): a camera control consumes a
306
615
  device-free abstraction and never handles events or reads a device
307
616
  itself. The controls expose `axes` (core's createAxes contract - rates
@@ -334,23 +643,46 @@ sticks move the CAMERA where a drag moves the content, so the presets
334
643
  bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
335
644
  a drag and a stick both turn the eye.
336
645
 
646
+ ### Orbit camera
647
+
337
648
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
338
649
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
339
- zoomSpeed?, panSpeed?, clampTarget?, zoomAnchor?, rotateAnchor? })` -
340
- azimuth/elevation/distance around a target with optional auto-orbit. The
341
- first argument is anything with the scene's `setCamera` and `camera()`
342
- (the fov maps pan travel to world): a Scene, or a View to drive one view's
343
- camera independently. Its `axes` are `rotate` (a delta of one element
344
- height sweeps one full turn, Three's OrbitControls convention, so a drag
345
- feels the same on a phone and a 4k window; a rate turns at 0.5 turn/s at
346
- full deflection), `zoom` (octaves: a delta of 1 halves the distance, a
347
- rate of 1 halves it per second) and `pan` (the target slides so the scene
348
- tracks the fingers 1:1 at the target's depth, three.js DOLLY_PAN, weighted
349
- by `panSpeed`; `clampTarget(target)` bounds where a pan may put the
350
- pivot). The verbs: `rotateBy(azimuth, elevation)` radians, `zoomBy(factor,
351
- anchor?)` (factor > 1 in, as the 2d camera's zoomAt), `panBy(right, up)`
352
- world units, `setPivot(point)`, `set(pose)`; every one pushes the pose at
353
- once. Zoom aims at the target unless `zoomAnchor(focal, {eye, target})`
650
+ zoomSpeed?, panSpeed?, damping?, clampPose?, zoomAnchor?, rotateAnchor? })`
651
+ - azimuth/elevation/distance around a target with optional auto-orbit. The
652
+ first argument is anything with the scene's `setCamera`, `camera()` (the
653
+ fov maps pan travel to world) and `size()` (the aspect `fit` frames
654
+ against): a Scene, or a View to drive one view's camera independently.
655
+ Its `axes` are `rotate` (a delta of one element height sweeps one full
656
+ turn, Three's OrbitControls convention, so a drag feels the same on a
657
+ phone and a 4k window; a rate turns at 0.5 turn/s at full deflection),
658
+ `zoom` (octaves: a delta of 1 halves the distance, a rate of 1 halves it
659
+ per second) and `pan` (the target slides so the scene tracks the fingers
660
+ 1:1 at the target's depth, three.js DOLLY_PAN, weighted by `panSpeed`). A
661
+ delta bracketed by a gesture (a drag, a pinch, two fingers) applies at
662
+ once, the content staying under the fingers; an unbracketed one (a wheel
663
+ notch, a key step) is an impulse and glides in - `damping` scales that
664
+ settle time, 0 applies it at once - notches compounding on the pending
665
+ value so a fast scroll is one push, the 2d camera's rule and the same
666
+ knob there. The verbs: `rotateBy(azimuth, elevation)` radians,
667
+ `zoomBy(factor, anchor?)` (factor > 1 in, as the 2d camera's zoomAt),
668
+ `panBy(right, up)` world units, `setPivot(point)`, `set(pose)`; every one
669
+ pushes the pose at once. Two commanded moves ease instead, inside
670
+ update(dt): `glideTo({ azimuth?, elevation?, distance?, target? })`, and
671
+ `fit(bounds, { glide? })`, which frames a `[minX, minY, minZ, maxX, maxY,
672
+ maxZ]` box (geometryBounds, a model's `bounds`) - target to its centre,
673
+ distance to where the bounding sphere fills the tighter of the vertical
674
+ and horizontal fov at the target's size, azimuth and elevation kept,
675
+ clamps applied; a snap unless `glide`, so a park-then-snapshot repeats
676
+ (under ortho only the target moves). Any input drops a glide (a finger
677
+ landing holds the view), and a `set()` that writes a pose field snaps
678
+ and drops any motion; `set({})` and `set({ orbiting })` leave it running.
679
+ Every write - input, verb, glide frame, set() - goes through the range
680
+ clamps and then `clampPose(pose)`, which sees the whole pose and returns
681
+ the fields to change: bound where a pan may put the target, or hold the
682
+ eye above a floor (an elevation floor that depends on the distance, which
683
+ a fixed `minElevation` cannot say); a glide's goal is clamped when set
684
+ and its frames as they land, so it never shows an illegal pose.
685
+ Zoom aims at the target unless `zoomAnchor(focal, {eye, target})`
354
686
  maps the gesture's focal point - a FRACTION of the element, [0..1, 0..1] -
355
687
  to a world point (ground hit, target-depth plane, ...): then that point
356
688
  stays pinned under the pointer and the target slides toward it; only the
@@ -363,13 +695,14 @@ called when a rotate gesture begins, its point is projected onto the
363
695
  view axis and re-seats the pivot without moving the picture, so a drag
364
696
  after an anchored zoom orbits what the camera looks at, not wherever the
365
697
  zoom left the target. Call `orbit.update(dt)` from your onFrame to
366
- integrate the rates and the auto-orbit (no frame loop of its own), and
367
- use its return - true when the pose changed since the previous update,
368
- nudges included - to gate per-frame dependents like reprojecting HUD
369
- overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
370
- frame-loop gate: orbiting with a non-zero rate, or any rate driving - the
371
- predicate every camera control shares) are reactive (HUD-safe); the pose
372
- is plain state via `pose()`/`set()` (also the debug-command shape). It
698
+ integrate the rates, the auto-orbit and any glide (no frame loop of its
699
+ own), and use its return - true when the pose changed since the previous
700
+ update, nudges included - to gate per-frame dependents like reprojecting
701
+ HUD overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
702
+ frame-loop gate: orbiting with a non-zero rate, a glide or damped notch
703
+ in flight, or any rate driving - the predicate every camera control
704
+ shares) are reactive (HUD-safe); the pose is plain state via
705
+ `pose()`/`set()` (also the debug-command shape). It
373
706
  drives position and target only; fov/near/far stay on scene.setCamera
374
707
  (or the Scene `camera` prop). The auto-orbit pauses while a gesture is
375
708
  open.
@@ -383,10 +716,15 @@ reconnects) and nothing else, and runs a frame loop only while
383
716
  `active()`, so a camera moved by drags alone keeps the app demand-driven
384
717
  idle. The pose props are initial values: runtime pose changes (and the
385
718
  debug-command hookup) go through `ref`'s handle, whose set() and verbs
386
- push the pose. Every other prop is live - forwarded to the control as a
387
- getter and read where it applies, never snapshotted - so clamps, rates
388
- and anchors follow their props without a remount, and a clamp change
389
- re-clamps the pose at once.
719
+ push the pose and whose glideTo/fit ease it. Every other prop is live -
720
+ forwarded to the control as a getter and read where it applies, never
721
+ snapshotted - so clamps, rates, `damping` and anchors follow their props
722
+ without a remount, and a clamp change (`clampPose` included) re-clamps
723
+ the pose at once. A `fit` from `ref` at mount reads the scene's size of
724
+ that moment: a fill-mode `<Scene>` has its creation size until its first
725
+ layout, so fit after the first frame (or hand a fixed-size scene).
726
+
727
+ ### First-person camera
390
728
 
391
729
  First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
392
730
  pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
@@ -403,7 +741,12 @@ length so they walk no faster) and `rise` (world up at `moveSpeed`, fly
403
741
  mode only). Walking (the default) flattens the heading onto the ground
404
742
  plane at fixed height; `fly` moves along the view. The verbs:
405
743
  `lookBy(yaw, pitch)` radians, `moveBy(right, forward, up?)` in the
406
- walker's frame, `set(pose)`. Every option but the initial pose is read
744
+ walker's frame, `set(pose)`, and `glideTo({ position?, yaw?, pitch? })`,
745
+ which eases there inside update(dt) (yaw to the number given, not the
746
+ shortest turn), dropped by any input and by a set() of a pose field -
747
+ the orbit camera's glide, without its damping: this control's unbracketed
748
+ delta is mouse motion under pointer lock, and that is never eased. Every
749
+ option but the initial pose is read
407
750
  where it applies (`fly` per step, `clampPosition` per move, the rates and
408
751
  pitch clamps per input), so a field changed on the options object takes
409
752
  effect on the next move: walk and fly are one control. The control NEVER
@@ -413,8 +756,10 @@ collision of its own: `clampPosition(next, current)` is the whole hook -
413
756
  bounds, a floor height, or `moveAndSlide` over `next - current` against
414
757
  the collision layer (see `examples/collision.tsx`, whose `jump` is one
415
758
  more action on the same map: Space or the pad's south button, read by
416
- name). Call `update(dt)` from onFrame; `active()` is reactive - a rate
417
- driving (a held key, a deflected stick) - and gates the loop. The map
759
+ name); a glide consults it every frame as a walk does every step. Call
760
+ `update(dt)` from onFrame; `active()` is reactive - a rate driving (a
761
+ held key, a deflected stick), or a glide in flight - and gates the loop.
762
+ The map
418
763
  tracks held keys from the down/up pair through `input.handlers`; spread
419
764
  its `onBlur` too, since the up never arrives once focus has left.
420
765
 
@@ -426,10 +771,15 @@ its `pointer` feed. The frame loop runs only while `active()`. Every prop
426
771
  but the initial pose is live: `fly={flying()}` toggles walk/fly on the
427
772
  running control (pose carries over, no remount, and `clampPosition` may
428
773
  swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
429
- clamp change re-clamps at once. checks/orbit-check.ts and
774
+ clamp change re-clamps at once. Like the orbit control it drives
775
+ position and yaw/pitch only: fov/near/far stay on scene.setCamera (or
776
+ the Scene `camera` prop), so a walker in a scene bigger than the default
777
+ `far` of 100 sets one there. checks/orbit-check.ts and
430
778
  checks/first-person-check.ts pin both controls headless (they import
431
779
  `@solidrt/core/input` only).
432
780
 
781
+ ### Overlay projection
782
+
433
783
  Overlay projection: `scene.project(point)` maps a world point to scene
434
784
  pixels (top-left origin, y down - the output texture's own space; `w` is
435
785
  the camera-forward distance in world units under either projection) and
@@ -444,6 +794,8 @@ unproject each move). `scene.viewProj(out?)` copies the view-projection
444
794
  matrix for batch work. Never rebuild the camera matrices by hand for a
445
795
  HUD.
446
796
 
797
+ ### Picking
798
+
447
799
  Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
448
800
  through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
449
801
  world units, nearest first; every hit along the ray, not just the front
@@ -461,19 +813,25 @@ The index and the narrowphase live in the spatial core: every attached
461
813
  mesh's local box is a leaf in a dynamic AABB tree the flush refits from
462
814
  the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
463
815
  an ordinary mesh is then tested per triangle against its geometry's
464
- shape (one CPU copy per distinct geometry, created with its GPU buffers),
816
+ shape (one CPU copy of the positions per distinct geometry, created with
817
+ its GPU buffers and rewritten by `updateVertices`; the mesh's box comes
818
+ from the same copy, so a deforming mesh is found where it is drawn),
465
819
  so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
466
820
  a ray through a knot's hole misses. A large geometry's triangles are
467
821
  BVH-indexed too - built by the first ray that reaches the shape, log-cost
468
822
  after - so raycasting a merged static scene stays cheap (see the batching
469
823
  advice). An instanced mesh is box-only (its
470
- explicit population bounds; records are opaque): it is tested by the
824
+ explicit population bounds; records are opaque), and so is a mesh whose
825
+ geometry is not a triangle list (lines, points, strips have no triangle
826
+ narrowphase): it is tested by the
471
827
  box's twelve triangles, so its hits carry the struck face's `normal` and
472
828
  no `face`/`uv` - and a ray from inside it meets the far side, the
473
829
  surface contract overlap/sweep share. Both methods
474
830
  flush pending writes first (the lookAt/project immediacy contract), and
475
831
  both skip invisible meshes.
476
832
 
833
+ ### Collision
834
+
477
835
  Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
478
836
  motion, opts?)` are the same index's other two questions - what a volume
479
837
  touches, and where a moving one first touches: Unity's
@@ -531,6 +889,8 @@ one overlap per move. Deliberately absent and additive when asked: a
531
889
  step offset (Unity's stepOffset; Godot has none either) and a cylinder
532
890
  volume (Godot only).
533
891
 
892
+ ### Pointer events
893
+
534
894
  Pointer events (scene-pointer.ts): the element event model one tree
535
895
  deeper, with the SCENE as the root of the walk (under a `<View3d>` leaf:
536
896
  the view). `onPointerDown/Move/Up/Enter/Leave/Wheel/Tap` are plain
@@ -569,7 +929,9 @@ pointerType, button, modifiers) plus `x`/`y` in scene pixels
569
929
  (screenRay's input - a drag plane is one intersection away). Root
570
930
  listeners all run, in registration order (the root is the last stop,
571
931
  nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
572
- `scene.handlers` automatically (opt out: `events={false}`); an `output`
932
+ `scene.handlers` automatically (opt out: `events={false}`, which throws
933
+ at mount together with a `pointer` feed - the feed would listen at a root
934
+ no event reaches; the same on `<View3d>`); an `output`
573
935
  leaf or imperative composition spreads `{...scene.handlers}` onto the
574
936
  element showing the texture. `scene.handlers` assumes that leaf is LAID
575
937
  OUT at the target size - true for the built-in leaf and a d-texture at
@@ -580,21 +942,44 @@ leaf laid out at a different size (the supersampling pattern) uses
580
942
  checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
581
943
  and tap rules headless; examples/pick.tsx is the live guard.
582
944
 
945
+ ### Geometry generators
946
+
583
947
  Geometry generators take ONE options object, every field optional with
584
948
  a default, named as Three names them: `box({ width, height, depth })`
585
949
  (1x1x1); `plane({ width, height })`, `circle({ radius, segments })` and
586
950
  `ring({ innerRadius, outerRadius, segments })` (XY, facing +z - rotate
587
951
  `[-Math.PI/2, 0, 0]` for a floor); `sphere({ radius, widthSegments,
588
952
  heightSegments })`; `cylinder({ radiusTop, radiusBottom, height,
589
- radialSegments })` (y axis, capped; unequal radii taper it) and
590
- `cone({ radius, height, radialSegments })`; `torus({ radius, tube,
953
+ radialSegments, heightSegments })` (y axis, capped; unequal radii taper
954
+ it) and `cone({ radius, height, radialSegments, heightSegments })`;
955
+ `capsule({ radius, height, capSegments, radialSegments, heightSegments
956
+ })` (y axis, `height` the TOTAL extent like
957
+ cylinder's and Godot's/Unity's where Three's is the middle section
958
+ only, so `height: 2 * radius` is a sphere and less throws; its collision
959
+ volume is `{ a: [0, -(height / 2 - radius), 0], b: [0, height / 2 -
960
+ radius, 0], radius }`); `torus({ radius, tube,
591
961
  radialSegments, tubularSegments })` (lying flat, hole on the y axis) and
592
962
  `torusKnot({ radius, tube, tubularSegments, radialSegments, p, q })`
593
963
  (standing y-up) - both oriented for the y-up world, unlike Three's z-up.
594
964
  No positional form: `box()` is the default cube, `box({ label: "rock" })`
595
- names it. Every options object (the profile kit's `extrude`/`lathe`/
965
+ names it. The polyhedra: `tetrahedron`, `octahedron`, `icosahedron` and
966
+ `dodecahedron` (`{ radius, detail }`, radius 0.5 like sphere where
967
+ Three's is 1) and the generic `polyhedron(vertices, indices, { radius,
968
+ detail })` over Three's data form (flat xyz list, CCW triangle list, the
969
+ data first like `box3Helper`), each solid projected onto its
970
+ circumsphere. `detail` splits every edge `detail + 1` ways first, so
971
+ `detail: 0` is the flat-shaded solid (face normals) and anything above a
972
+ sphere of uniform triangles with radial normals: `icosahedron({ detail:
973
+ 3 })` is the icosphere, no pole pinch. Non-indexed like Three, every
974
+ triangle owning its three vertices; `edgesGeometry` welds by position,
975
+ so `edgesGeometry(dodecahedron())` is the twelve pentagons (the fan
976
+ diagonals are coplanar and drop out) where `wireframeGeometry` shows the
977
+ triangulation. UVs are the spherical map with Three's seam patch,
978
+ stretched toward the poles: the textured sphere stays `sphere()`, the
979
+ polyhedra are for flat-shaded, low-poly and procedurally shaded looks
980
+ (examples/polyhedra.tsx). Every options object (the profile kit's `extrude`/`lathe`/
596
981
  `sweep`/`tube` too) also takes `label` and `layout` - `layout` makes the
597
- generator emit that layout in one pass (standard channels written, the
982
+ generator emit that layout in one pass (base channels written, the
598
983
  extra slots zero), so `box({ layout: "colored" })` then
599
984
  `fillColors(g, fill)` builds colored geometry without the
600
985
  generate-then-repack copy; the result is byte-identical to
@@ -603,7 +988,8 @@ the tail every generator ends in, for your own generators.
603
988
  `withAttribute(geometry, attr, fill, label?)` derives a copy of any
604
989
  geometry (generator or hand-built) with one more channel after its
605
990
  current layout; the source is untouched. `withColors(geometry, fill,
606
- label?)` is the aColor vec4 case, keeping the "colored" preset name.
991
+ label?)` is the aColor float32x4 case, keeping the "colored" preset
992
+ name (`withAttribute` with `unorm8x4` is the 4-byte color).
607
993
  `fillAttribute(geometry, name, fill, first?, count?)` is the in-place
608
994
  primitive under both: overwrites one channel the geometry's layout
609
995
  already carries (withAttribute ADDS one), reading pos/normal/uv from the
@@ -611,6 +997,8 @@ buffer itself - so a builder that bakes transforms while writing hands
611
997
  the baker world-space vertices. `fill` indexes relative to `first`.
612
998
  `fillColors(geometry, fill, first?, count?)` is its aColor spelling.
613
999
 
1000
+ ### Geometry as data
1001
+
614
1002
  Geometry as data: `transformGeometry(geometry, { position?, rotation?,
615
1003
  quaternion?, scale? }, label?)` bakes a placement into a copy (the
616
1004
  setTransform shape: Euler XYZ radians or a quaternion, number = uniform
@@ -626,13 +1014,100 @@ does not tax picking: a merged geometry's raycast narrowphase runs
626
1014
  through its triangle BVH (built on the first ray), so merge for draw
627
1015
  count without giving up ground queries. Both are
628
1016
  pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
629
- call, and the source geometries are untouched. `geometryBounds(geometry)`
1017
+ call, and the source geometries are untouched. `wireframeGeometry(geometry,
1018
+ label?)` and `edgesGeometry(geometry, thresholdAngle?, label?)` (Three's
1019
+ WireframeGeometry/EdgesGeometry) build `"lines"` geometry over a triangle
1020
+ geometry's OWN vertex array and layout, shared by reference: every edge
1021
+ once for the wireframe, for the edges only those where two faces meet at
1022
+ the threshold (degrees, default 1) or more plus the open borders. Edges
1023
+ are matched by position, so a uv seam or a per-face normal split draws
1024
+ one line; a generated round shape is faceted and keeps its facet lines
1025
+ until the threshold passes 360 / radialSegments. The wireframe of a
1026
+ "skinned" part draws in its pose under `unlit({ skinned: true })` with
1027
+ the mesh's palette, and the swap onto a live mesh is
1028
+ `setGeometry` + `setMaterial` (`examples/wireframe.tsx`). Lines are one
1029
+ pixel wide on GL ES; thick lines are quad geometry. The debug helpers are
1030
+ `"lines"` builders too, Three's helper classes camelCased:
1031
+ `gridHelper({ size?, divisions?, color?, centerColor? })` is the XZ grid
1032
+ at y 0 (defaults 10 by 10, Three's grays; the two lines through the
1033
+ origin take `centerColor` and exist only for an even `divisions`),
1034
+ `axesHelper({ size? })` the X red, Y green, Z blue triad from the origin,
1035
+ `box3Helper(bounds, options?)` the twelve edges of a
1036
+ `[minX, minY, minZ, maxX, maxY, maxZ]` box in local space, and
1037
+ `planeHelper({ size? })` a square outline with its diagonals and a unit
1038
+ normal tick in the XY plane facing +z, placed like `plane()`, and
1039
+ `arrowHelper({ length?, headLength?, headWidth? })` an arrow along +y
1040
+ with a pyramid-outline head, aimed by the node's rotation
1041
+ (`quatFromTo(out, [0, 1, 0], direction)`), and `capsuleHelper(volume,
1042
+ { segments? })` the outline of a `{ a, b, radius }` collision capsule
1043
+ (rings at both ends, four lines, two half circles per cap; `a == b`
1044
+ draws a sphere's three great circles) in the volume's own space, the
1045
+ gizmo Unity and Godot draw for a capsule collider. Godot and
1046
+ Unity keep these in the editor and Three makes them scene objects; here
1047
+ they are plain geometry a node places and a material draws. The grid and
1048
+ the triad are "colored" layout (sRGB colors in, premultiplied linear
1049
+ aColor out, the material contract) drawn by `unlit({ vertexColors:
1050
+ true })`; a material that reads no aColor draws them in its own color.
1051
+ The box, the plane, the arrow and the capsule are base layout. For a bounds box that moves
1052
+ every frame, draw `edgesGeometry(box())` on a node whose position is the
1053
+ box center and whose scale is its size and update the transform (Three's
1054
+ Box3Helper does exactly that) instead of rebuilding.
1055
+ `examples/wireframe.tsx` draws a grid, the triad and the rover's bounds
1056
+ box. `geometryBounds(geometry)`
630
1057
  returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
631
1058
  `rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
632
1059
  slab test (entry t >= 0 in units of the direction's length, 0 from
633
1060
  inside, -1 for a miss) - for ray-testing boxes you keep yourself
634
1061
  (triggers, collision volumes) without meshes you do not want to draw.
635
1062
 
1063
+ Normals as data: two ops, split by whether the vertex count survives.
1064
+ `computeVertexNormals(geometry)` recomputes a triangle geometry's
1065
+ normals IN PLACE from the faces that name each vertex by index (Three's
1066
+ `computeVertexNormals`, Unity's `RecalculateNormals`, Godot's
1067
+ `generate_normals`, all in place), each face weighted by its corner
1068
+ angle so the result does not depend on how a surface was triangulated.
1069
+ The math runs in the core in one call over the vertex floats (so aNormal
1070
+ must be float32x3; a packed normal is an authoring format, pack after
1071
+ withNormals), and it returns the stream carrying aNormal, which
1072
+ `updateVertices` re-uploads: the CPU deformation loop is
1073
+ `fillAttribute(g, "aPos", ...)`, `computeVertexNormals(g)`,
1074
+ `updateVertices(g)`. That loop is for data that genuinely changes on the
1075
+ CPU - a streamed cloud, a cloth solved in JS, an editor edit. A
1076
+ per-frame ripple, sway or wave belongs in a vertex shader: displace and
1077
+ derive the normal in the vertex stage (`shaderMaterial`), and no
1078
+ per-vertex work touches the CPU at all; the fillAttribute callback alone
1079
+ is an interpreter call per vertex per frame. A vertex shared across faces
1080
+ shades smooth and a split one per face, so a merged or deformed
1081
+ generator geometry comes out right; a uv seam's copies each see one side
1082
+ and the seam shows (Three's artifact too). `withNormals(
1083
+ geometry, creaseAngle = 60, label?)` is the authoring path and a COPY:
1084
+ faces are matched by position, so a triangle soup, a hand-written face
1085
+ list, a merged result or a geometry with no aNormal channel at all (one
1086
+ is added, float32x3) all work and a seam shades smooth across it; 0 is
1087
+ flat shading, 180 smooths everything, 60 (Three's `toCreasedNormals`
1088
+ and Unity's import smoothing angle) keeps hard edges hard. It is
1089
+ `toNonIndexed` + per-corner normals + `mergeVertices`, so the result is
1090
+ indexed and split only where a crease or a seam needs it (a box stays
1091
+ 24 vertices under 90 degrees). Both pieces are exported:
1092
+ `toNonIndexed(geometry, label?)` gives every index its own vertex
1093
+ (identity indices; per-face colors via fillAttribute, flat shading via
1094
+ computeVertexNormals), `mergeVertices(geometry, tolerance = 1e-4,
1095
+ label?)` welds vertices equal in every channel back together, the exact
1096
+ inverse (a normal or uv that differs keeps them apart). Neither takes a
1097
+ morphed geometry: split or weld before `withMorphTargets`.
1098
+ `normalsHelper(geometry, { size?, color? })` draws one line per vertex
1099
+ along its normal (Three's VertexNormalsHelper as a static builder,
1100
+ "colored" layout, red by default, drawn by `unlit({ vertexColors: true
1101
+ })` under the mesh's node), the way to look at any of this;
1102
+ `examples/normals.tsx` shows the three crease angles on one soup, a
1103
+ rippling sphere on the per-frame loop, and the helper refreshed in place
1104
+ with the same loop. Three's `center()` is `transformGeometry(g, {
1105
+ position: [-(b[0] + b[3]) / 2, -(b[1] + b[4]) / 2, -(b[2] + b[5]) / 2]
1106
+ })` over `geometryBounds`; there is no bounding sphere op, the box is
1107
+ what every query and the LOD read.
1108
+
1109
+ ### Profile kit
1110
+
636
1111
  Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
637
1112
  closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
638
1113
  share an averaged normal - `fillet(points, radius, segs?)` and
@@ -653,92 +1128,137 @@ Closed loops are NOT supported yet - overlap the ends by a segment to
653
1128
  fake one. `tube(path, { radius, radialSegments })` is the round-profile
654
1129
  shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
655
1130
  per-segment frames (tangents, cross-section axes, arc lengths) for
656
- custom work along a path. `shape(profile, options?)` fills one flat (facing +z,
657
- like circle); `triangulate(points)` is the ear-clipping core (fan
1131
+ custom work along a path. `polygon(profile, options?)` fills one flat (facing
1132
+ +z, like circle); `triangulate(points)` is the ear-clipping core (fan
658
1133
  fallback, never drops a cap), exported for custom flat work. These pick
659
1134
  uint16/uint32 indices by vertex count automatically.
660
1135
 
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.
1136
+ ### Materials
1137
+
1138
+ #### unlit
1139
+
1140
+ `unlit({ color?, map?, vertexColors?, transparent?, blend?, cull?, alphaTest?, fog? })` -
1141
+ straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
1142
+ below), premultiplied internally; `vertexColors: true` multiplies by the
1143
+ "colored" layout's aColor (the geometry must carry it: withColors, a
1144
+ gridHelper or axesHelper); `blend` the factors of a transparent draw,
1145
+ "alpha" when absent, `"add"` for a glow (any mode but "none" implies
1146
+ `transparent` unless told `transparent: false`, the shaderMaterial
1147
+ rule); `cull` and
1148
+ `alphaTest` as on phong (a mapped cutout casts its cutout); `fog: false`
1149
+ opts out of the scene's fog (all four library materials take it).
1150
+
1151
+ #### sprite
1152
+
1153
+ `sprite({ color?, map?, transparent?, blend?, billboard? })` - unlit on a quad
1154
+ that turns to face the camera IN THE VERTEX STAGE (off the shared
1155
+ uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
1156
+ only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
1157
+ tree/character sprite; the default `"full"` is Three's Sprite, flat to
1158
+ the screen). No per-frame JS however many sprites. `transparent`
1159
+ defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
1160
+ off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
1161
+ shared unit plane, no geometry argument, `scale` = world size, rotation
1162
+ ignored. Picks by a unit box around its center (its reach at any
1163
+ facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
1164
+
1165
+ #### shaderMaterial
1166
+
1167
+ `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
1168
+ depthWrite?, blend?, cull?, label? })` - your own GLSL, the
1169
+ custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
1170
+ MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
1171
+ per entry) and `uniform mat4 uViewProj` (the camera, shared
1172
+ target-level params) - transform with
1173
+ `uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
1174
+ at shaderMaterial() creation. The rest is opt-in by declare-and-use:
1175
+ `uniform vec3 uCamPos` (the camera's world position, shared and written
1176
+ with uViewProj - the specular/fresnel view vector is
1177
+ `normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
1178
+ (the camera's world-space view axes, shared likewise - a billboard is
1179
+ `center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
1180
+ uViewProj rows, that carries the clip flip), `uniform mat4
1181
+ uInvViewProj` (the camera's inverse view-projection, shared likewise -
1182
+ a clip position back to world, the world-space ray through a pixel
1183
+ without knowing the projection) and `uniform mat4 uNormal` (the world
1184
+ inverse-transpose, written beside uModel for this material's meshes;
1185
+ take `mat3(uNormal)` - correct under non-uniform scale, where
1186
+ mat3(uModel) bends normals off the surface). Attributes come from the
1187
+ geometry's layout by name; the ones the linked program actually reads
1188
+ (engine reflection, instance attributes excluded) must all be in the
1189
+ mesh's geometry layout or add() throws - so a used `in vec4 aColor`
1190
+ needs `withColors()` geometry and a custom channel needs
1191
+ `withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
1192
+ pipeline preamble. App-driven uniforms beyond the standard set: seed
1193
+ via `params`, then write per mesh with
1194
+ `setMeshParams(mesh, { name: value })` (validated names; values persist
1195
+ across entry rebuilds; frame-rate-safe like setTransform) or declaratively
1196
+ with the `Mesh` `params` prop (same merge semantics - a key that
1197
+ disappears from the object keeps its old value; for per-frame values
1198
+ prefer `ref` + setMeshParams from onFrame, the setTransform split).
1199
+ Scene-wide values (a clock, a sun direction, fog) go through
1200
+ `scene.setParams({ uTime })` instead - one write for every mesh.
1201
+
1202
+ #### shaderMaterialClass
1203
+
1204
+ `shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
1205
+ class/instance split for your own GLSL: compiles once, and
1206
+ `cls.instance({ params?, textures? })` returns a Material sharing that
1207
+ pipeline with its own values. `dispose()` lives on the class alone.
1208
+ `shaderMaterial(opts)` is exactly a class with one instance (its
1209
+ `dispose` forwards to the class).
1210
+
1211
+ #### Instanced materials
1212
+
1213
+ `instanceBuffers: [{ attributes: [{ name, format }] }]` (one layout per
1214
+ per-instance buffer, instance-step by definition and tightly packed in
1215
+ list order like a geometry layout; `format` is any of the vertex
1216
+ vocabulary, so a per-instance tint is a `unorm8x4` at four bytes and a
1217
+ frame index a `uint16x2`; explicit strides and offsets stay the
1218
+ engine's, through core's pipeline API) on either shader-material form
1219
+ makes an INSTANCED material: the vertex stage reads the attributes as
1220
+ `in` variables beside the layout's own, and each drawn instance gets one
1221
+ record from each of the mesh's instance buffers. An instance buffer IS a
1222
+ vertex stream stepped per instance: on the mesh it is an `InstanceStream`
1223
+ (the layout, a byte mirror, a mesh-owned GPU buffer), written through
1224
+ `instanceAttribute(mesh, name)` - the accessor `geometryAttribute`
1225
+ returns, record index in, values as the shader sees them, the codec
1226
+ packing the bytes - and published with `updateRecords(mesh, { stream?,
1227
+ first?, count? })`, which is `updateVertices` for records: one coalesced
1228
+ buffer write per dirty stream at the scene's sync, so ten moved records
1229
+ of ten thousand cost ten. Its meshes come from `createInstancedMesh`
1230
+ (the first buffer is the core-written matrix, the rest are streams; the
1231
+ second is the STYLE record, below) or `createRecordMesh` (every buffer a
1232
+ stream); a `createMesh` mesh is rejected at add(). `instanceStyle: [..]`
1233
+ is the style record a fresh instance starts with, one value per
1234
+ component. The stock materials do the same with one flag:
1235
+ `lit/standard/unlit({ instanced: true })` places by the instance matrix
1236
+ (the shadow pass too, so the fleet casts), and `{ instanceColors: true
1237
+ }` adds a per-instance premultiplied `[r, g, b, a]` style record
1238
+ starting white, stored as `float16x4` (eight bytes, no banding in the
1239
+ darks) - Unity's per-material instancing switch with Three's setColorAt.
1240
+
1241
+ ### Instancing
737
1242
 
738
1243
  Instancing - one draw entry covering a population, in two forms. The
739
1244
  axis between them is WHERE MOTION IS COMPUTED, the same split as
740
1245
  @solidrt/2d's sprite and record layers.
741
1246
 
1247
+ FIRST, whether to instance at all. An instance costs the GPU a fixed
1248
+ per-instance setup, so records buy cheap MOTION, not cheap VERTICES:
1249
+ below roughly a hundred vertices per instance that setup dominates, and
1250
+ a plain geometry with a bigger vertex buffer draws faster. A point cloud
1251
+ is the extreme case - one vertex per point in an ordinary indexed
1252
+ `topology: "points"` geometry, no instancing, measured at 4x the frame
1253
+ rate of the same points as records. Packing more points per record does
1254
+ not recover it: every vertex of an instance fetches ALL of that
1255
+ instance's attributes, so attribute bandwidth grows with the packing.
1256
+ Reach for instancing when each instance is a real object (a ship, a
1257
+ tree, a crowd member), not to submit a lot of vertices. The tell in
1258
+ `/gpu` is a large `instanceCount` beside a tiny `indexCount`.
1259
+
1260
+ #### createInstancedMesh
1261
+
742
1262
  `createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
743
1263
  draws the geometry once per instance NODE: `addInstance(mesh,
744
1264
  transform?, parent?)` returns a scene node (kind "instance") placed
@@ -748,32 +1268,36 @@ matrix RELATIVE to the mesh the spatial core writes into the record
748
1268
  buffer (`bindMatrixRecord` anchored on the mesh node: one coalesced
749
1269
  buffer write per flush however many instances moved, so native
750
1270
  transitions and clip players move instances with zero per-frame JS).
751
- The material declares `INSTANCE_MATRIX_ATTRIBUTES` in slot 0 (four vec4
752
- columns, 16 floats; anything else throws at creation) and its vertex
1271
+ The material declares `INSTANCE_MATRIX_ATTRIBUTES` as its first instance
1272
+ buffer (four vec4 columns, 16 floats; anything else throws at creation)
1273
+ and its vertex
753
1274
  stage splices `INSTANCE_MATRIX` from `@solidrt/3d/glsl`: `uModel *
754
1275
  instanceMatrix() * vec4(aPos, 1.0)`, normals through
755
1276
  `instanceNormalMatrix()` (Three's derivation, exact for any rotation and
756
1277
  scale at no per-instance data; a sheared hierarchy - a non-uniformly
757
1278
  scaled group above a rotated instance - takes
758
1279
  `transpose(inverse(mat3(instanceMatrix())))` instead) - or is a stock
759
- material with `instanced`. Slot-1 attributes, when the material declares
760
- any, give every instance a STYLE record beside its matrix
761
- (`MeshInstances.style`): app-owned floats in a second, mesh-owned
762
- buffer, written per instance with `setInstanceStyle(instance, floats)`
763
- into a JS mirror and published as ONE coalesced buffer write per mesh at
764
- the scene's sync (a frame-rate path, like setTransform); a fresh or
765
- recycled slot starts from the material's `instanceStyle` (white under
766
- `instanceColors`, zeros otherwise); under `alphaTest` the cutout shadow
1280
+ material with `instanced`. A second instance buffer, when the material
1281
+ declares one, gives every instance a STYLE record beside its matrix
1282
+ (`MeshInstances.streams[0]`): an app-owned stream in the material's
1283
+ layout, written per instance with `setInstanceStyle(instance, values)`
1284
+ (one value per attribute component, encoded by the codecs) or by name
1285
+ through `instanceAttribute` + `updateRecords`, into a JS mirror and
1286
+ published as ONE coalesced buffer write per stream at the scene's sync
1287
+ (a frame-rate path, like setTransform); a fresh or recycled slot starts
1288
+ from the material's `instanceStyle` (white under `instanceColors`, zeros
1289
+ otherwise); a third buffer is a further stream, reached by name only;
1290
+ under `alphaTest` the cutout shadow
767
1291
  multiplies by the same color, so an instance faded below the cutoff
768
1292
  casts nothing, like its pixels (Godot's alpha scissor holds in its
769
- shadow pass too). The core's pose slot and the app's style slot are
1293
+ shadow pass too). The core's pose buffer and the app's style buffer are
770
1294
  @solidrt/2d's split one dimension up. Slots are fixed
771
- for an instance's life and recycle on `removeInstance` (remove means
772
- destroy: an instance cannot exist outside its mesh, and the generic
773
- add/remove throw on one), the drawn count is the slot high-water mark,
774
- and past `capacity` (default 64) both buffers double into replacements
775
- with the live records retargeted in one core call and the style mirror
776
- republished. `parent` may be a group inside the mesh's subtree (a squad
1295
+ for an instance's life and recycle on `destroy` (at the free: an
1296
+ instance cannot exist outside its mesh, so the generic add/remove throw
1297
+ on one), the drawn count is the slot high-water mark,
1298
+ and past `capacity` (default 64) every buffer doubles into a
1299
+ replacement, with the live matrix records retargeted in one core call
1300
+ and each stream's mirror republished. `parent` may be a group inside the mesh's subtree (a squad
777
1301
  in a fleet): the record stays mesh-relative through it. Picking is per
778
1302
  instance: every instance node carries the geometry's bounds and
779
1303
  triangle shape, so `Hit`, `Overlap` and `Impact` name the `instance` and
@@ -787,40 +1311,51 @@ MultiMesh is the record form. The components: `<InstancedMesh capacity>`
787
1311
  with `<Instance position style transition onPointerDown>` children
788
1312
  (`<Group>` children are squads, an `<Instance>` is a parent too);
789
1313
  `examples/fleet.tsx` is a thousand of them springing between formations
790
- with tap-to-tint, `probes/3d-instance-probe.tsx` the function face.
1314
+ with tap-to-tint.
791
1315
  Mount cost is the one difference between the faces: an `<Instance>` row
792
1316
  costs about eight times an `addInstance` call (the component machinery -
793
1317
  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
1318
+ provider is built only when it has children, the biggest single
1319
+ saving), so a few thousand
796
1320
  mount in tens of milliseconds either way; populations spawned per frame,
797
1321
  or in five figures, belong to the function face.
798
1322
 
1323
+ #### createRecordMesh
1324
+
799
1325
  `createRecordMesh(geometry, material, records, count?, { bounds?,
800
- label? })` is the raw form: `records` is the interleaved per-instance
801
- data (stride = the material's slot-0 instanceAttributes summed, a
802
- mismatch throws; slot 1 is an instanced mesh's and throws here),
803
- uploaded to a mesh-owned buffer whose capacity starts at the
804
- records given, and `count` picks how many draw (default all).
1326
+ label? })` is the raw form: `records` is the per-instance data laid out
1327
+ in the material's first instance layout - a Float32Array over an
1328
+ all-float layout, otherwise bytes built with `vertexView(layout, buffer)`
1329
+ and `attributeAccess` like a geometry stream (a length that is not whole
1330
+ records throws) - copied into the first stream's mirror, whose capacity
1331
+ starts at the records given; every further instance buffer the material
1332
+ declares is a zeroed stream of the same capacity, reached by name
1333
+ through `instanceAttribute`. `count` picks how many draw (default all).
805
1334
  `setRecords(mesh, records, count?)` rewrites from the start (count
806
1335
  defaults to the records written; more than capacity GROWS: capacity
807
- doubles into a replacement buffer, the entry is re-pointed via
808
- `setDrawBuffers`, the old buffer is freed) and `setRecordCount(mesh, n)`
809
- is the population dial (clamped to capacity; frame-rate-safe). Records
810
- are opaque data (position/yaw/tint/whatever your shader reads), so the
1336
+ doubles into replacement buffers, the entry is re-pointed via
1337
+ `setDrawBuffers`, the old ones are freed), `instanceAttribute` +
1338
+ `updateRecords(mesh, { first, count })` rewrites a few (the range is
1339
+ against capacity, so write ahead and dial after), and
1340
+ `setRecordCount(mesh, n)` is the population dial (clamped to capacity;
1341
+ frame-rate-safe). Records are opaque data (position/yaw/tint/whatever
1342
+ your shader reads), so the
811
1343
  library cannot know where they place instances: a record mesh has NO
812
1344
  picking leaf unless you pass `bounds` (local, covering the population) -
813
1345
  then it picks and transparent-sorts conservatively as one box. The
814
1346
  escape hatch for motion only JS can compute at scale (a particle sim, a
815
1347
  crowd stepped in a worker).
816
1348
 
1349
+ #### Common to both
1350
+
817
1351
  Everything mesh works on both: setTransform moves the whole population
818
1352
  through one uModel, setVisible zeroes the drawn count and restores it on
819
1353
  unhide, renderOrder/params/geometry/material swaps apply, and
820
1354
  `disposeInstances(mesh)` detaches and frees the record buffers - the one
821
1355
  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.
1356
+ components) and `examples/instanced.tsx` (records) are the live proofs.
1357
+
1358
+ ### Background
824
1359
 
825
1360
  Background: `scene.setBackground(source | null)`, the `background` option
826
1361
  on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
@@ -840,11 +1375,10 @@ no separate resize plumbing. Two forms:
840
1375
  written through `scene.setParams` (an app clock for an animated sky).
841
1376
  Godot's sky shader and Unity's skybox material are the same idea; the
842
1377
  radiance bake for environment lighting will consume this same source
843
- later, so a procedural sky written here lights the scene then. The
844
- preamble declares the OUTPUT set: end with `fragColor =
845
- outputColor(rgb, 1.0)` for a sky that takes the scene's exposure and
846
- tone mapping (the skybox form does); a direct fragColor write is final
847
- encoded pixels.
1378
+ later, so a procedural sky written here lights the scene then. A sky
1379
+ writes LINEAR light to fragColor (the skybox form does) and the
1380
+ scene's resolve exposes, tone maps and encodes it with everything
1381
+ else.
848
1382
  - A skybox `{ cube, intensity?, rotation? }` (SkyboxOptions): a cube
849
1383
  map from createCubeTexture sampled along the same ray - Three's
850
1384
  `scene.background = cubeTexture` with `backgroundIntensity` and
@@ -856,6 +1390,8 @@ no separate resize plumbing. Two forms:
856
1390
  the same way, so a skybox is one flat color there. A 2D texture id
857
1391
  throws at the samplerCube binding. `examples/skybox.tsx`.
858
1392
 
1393
+ ### Cube-map convention
1394
+
859
1395
  The cube-map convention: a cube map holds what a GL lookup returns -
860
1396
  each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
861
1397
  and every library lookup is a plain `texture(cube, dir)` in world space.
@@ -871,13 +1407,15 @@ number, so the object form keeps it unambiguous). Translucent grounds
871
1407
  over a background still need blend factors (a separate shader texture
872
1408
  underneath until then).
873
1409
 
1410
+ ### Environment
1411
+
874
1412
  Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
875
1413
  null)`, the `environment` option on createScene and the reactive `Scene`
876
1414
  prop - the cube map every `standard` material is lit by (always: the
877
1415
  split sum `envRadiance` at its roughness times PBR's `envBrdf` for the
878
1416
  specular, `envIrradiance` - the fully rough sample along the normal, as
879
1417
  Three's getIBLIrradiance and Godot read it - added to the hemisphere
880
- for the diffuse) and every `lit({ reflectivity })` material mirrors,
1418
+ for the diffuse) and every `phong({ reflectivity })` material mirrors,
881
1419
  typically the skybox's own cube turned with it. The cube to use is a
882
1420
  BAKED one: `bunx srt tool 3d/environment sky.hdr -o assets/sky.srte`
883
1421
  turns an equirectangular Radiance .hdr (Poly Haven's are CC0) into the
@@ -913,19 +1451,25 @@ tints instead; not offered). The reflection blurs with `shininess`:
913
1451
  roughness `sqrt(2 / (shininess + 2))` picks a mip level of the cube
914
1452
  (`textureLod`), so the environment cube wants `mipmap: true`; a cube
915
1453
  without mipmaps stays sharp. `specularMap`'s red scales it like
916
- `specular`. For `lit` it is not an ambient light source: the hemisphere
1454
+ `specular`. For `phong` it is not an ambient light source: the hemisphere
917
1455
  light stays its only ambient term (`standard` adds envIrradiance; SH9
918
1456
  is a later, additive mode). A declared `reflectivity` with no
919
1457
  environment set contributes nothing (uEnvOn 0), not a black reflection.
920
1458
  `examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
921
1459
  baked HDRI lighting the scene alone).
922
1460
 
1461
+ ### Reflection probes
1462
+
923
1463
  Reflection probes: `scene.createReflectionProbe({ position, size?,
924
1464
  near?, far?, layers?, clearColor?, label? })` renders the scene into a
925
1465
  cube map from a point - Three's CubeCamera, Unity's and Godot's
926
1466
  realtime ReflectionProbe - and returns `{ cube, setPosition, update(),
927
1467
  dispose() }`; `cube` is what `environment={{ cube }}` (a chrome ball
928
- mirroring its surroundings) or `background` takes. A view under the
1468
+ mirroring its surroundings) or `background` takes. `dispose()` destroys
1469
+ the cube, and a scene whose environment or background names it drops
1470
+ that first (setEnvironment(null) / setBackground(null)), so a subtree
1471
+ that owned the environment leaves the scene unlit by it rather than
1472
+ sampling a destroyed texture; set another when it goes. A view under the
929
1473
  hood: one entry list mirrored from the scene, the light set and scene
930
1474
  params fanned out, its own layer mask (keep the mirroring object out of
931
1475
  its own probe with `layers`), the scene's background drawn first on
@@ -952,24 +1496,24 @@ costs ~14 ms of GPU there against ~1 ms for the six face passes: fine at
952
1496
  probes are the expensive option in every engine (Unity time-slices
953
1497
  them): update a prefiltered probe when the surroundings changed, or
954
1498
  every few frames, and keep `prefilter: false` for a probe that must
955
- refresh every frame on a tight budget. The faces hold LINEAR light (the probe owns
956
- `uOutputEncode` 0, `uToneMapping` 0 and `uExposure` 1 on its target,
957
- names the scene's fan-out then skips), HALF FLOAT where the device
1499
+ refresh every frame on a tight budget. The faces hold LINEAR light like every buffer (a
1500
+ probe is a buffer without a resolve), HALF FLOAT where the device
958
1501
  renders it (`limits.halfFloatRenderable`, every GLES 3 device here: a
959
1502
  sun's or an emissive's range survives into the reflection, as in every
960
1503
  engine's HDR probe) and 8-bit clamped elsewhere - the renderer decides
961
- for all probes (Godot), not a per-probe knob (`probeFormat()` in
1504
+ for every target (Godot), not a per-probe knob (`bufferFormat()` in
962
1505
  environment.ts) - and the probe never samples its own cube while
963
1506
  rendering (a black environment stands in: one bounce). The face cameras are plain world-up cameras through an
964
1507
  x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
965
1508
  from outside; the engine inverts the front-face rule on cube target
966
1509
  passes so cull modes keep their meaning. `examples/probe.tsx`.
967
1510
 
1511
+ ### Baked sky
1512
+
968
1513
  Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
969
1514
  origin that sees no mesh (layer mask 0): the scene's background - the
970
- GLSL sky or the skybox - alone on its six faces, LINEAR (a sky ending in
971
- `outputColor` bakes its light; one writing fragColor raw bakes those
972
- bytes as light), prefilters it like a probe and returns the chain's
1515
+ GLSL sky or the skybox - alone on its six faces, LINEAR (a sky fragment writes light, so the bake stores exactly
1516
+ what the backdrop draws), prefilters it like a probe and returns the chain's
973
1517
  TextureId for `environment={{ cube }}`: Godot's sky-to-radiance bake, so
974
1518
  a procedural sky lights the scene with no light nodes, and the runtime
975
1519
  way to turn a hi-res LDR skybox into a properly convolved environment
@@ -977,13 +1521,15 @@ way to turn a hi-res LDR skybox into a properly convolved environment
977
1521
  alternative). A snapshot at the probe format (half float where
978
1522
  renderable; default size 128): bake again when the sky changes, and
979
1523
  destroy the old cube - it is not auto-freed (an environment normally
980
- lives as long as the app). `outputColor` leaves linear output UNCLAMPED
981
- (the clamp sits in the display encode), so a sky's sun disc of 40.0
1524
+ lives as long as the app). A buffer is UNCLAMPED (the clamp sits in the
1525
+ resolve's encode), so a sky's sun disc of 40.0
982
1526
  bakes as 40.0 and shows as the broad bright highlight on rough metal
983
1527
  that HDR is for. A sky that reads
984
1528
  `uCamPos` bakes from the origin; scene params (an app clock) are seen as
985
1529
  of the call. `examples/sky-lit.tsx`.
986
1530
 
1531
+ ### Panoramas
1532
+
987
1533
  Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
988
1534
  equirectangular 2D texture (createImage, createTexture) into a cube
989
1535
  TextureId on the GPU, synchronously (six face passes straight into a
@@ -999,6 +1545,8 @@ the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
999
1545
  decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
1000
1546
  pure TypeScript and bun-tested - there is no runtime .hdr decoder.
1001
1547
 
1548
+ ### Fog
1549
+
1002
1550
  Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
1003
1551
  the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
1004
1552
  far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
@@ -1023,37 +1571,81 @@ material drops the code from the program (Three's `material.fog`) - a
1023
1571
  sky sphere, a far backdrop. A shaderMaterial opts in by composing `FOG`
1024
1572
  from `/glsl` (declares the set; `fog(rgb, alpha, worldPos, camPos)`, or
1025
1573
  `fogAdditive(rgb, worldPos, camPos)` for a `blend: "add"` look, which
1026
- fades toward black instead of the fog color).
1574
+ fades toward black instead of the fog color; a stock material with
1575
+ `blend: "add"` composes that form by itself).
1027
1576
  The BACKGROUND is not fogged: it is entry zero with no depth or
1028
1577
  distance, so match the fog color to `clearColor` or the background's
1029
1578
  horizon, and put `far` at or inside the camera's far plane to hide the
1030
1579
  clip. `examples/fog.tsx` cycles the forms over a valley;
1031
1580
  `examples/cascades.tsx` fogs its field to the sky.
1032
1581
 
1582
+ ### Color
1583
+
1033
1584
  Color: the scene shades in LINEAR light and outputs sRGB, like Three
1034
1585
  (ColorManagement), Godot and Unity's linear space - no gamma mode. Every
1035
1586
  `[r, g, b]` color option is sRGB, what a color picker shows: material
1036
1587
  `color` and `emissive`, light `color`, the hemisphere's `sky`/`ground`,
1037
- fog `color`; the library decodes it when it writes the uniform
1038
- (`srgbToLinear`/`linearColor` are exported for values you write straight
1039
- to a uniform yourself). Color MAPS decode through their format: create a
1040
- base color, emissive or sky image with `format: "rgba8-srgb"`
1041
- (createTexture, createCubeTexture; createModel does it for glTF's base
1042
- color and emissive images) - a plain rgba8 map reads as linear data and
1043
- renders washed out; data maps (normal, specular, roughness, light maps)
1044
- stay rgba8, and an HDR image is "rgba16f". Vertex colors are linear, as
1045
- glTF stores them. Every library fragment ends in the OUTPUT set's
1046
- `outputColor(rgb, alpha)`: exposure (`scene.setExposure`, default 1),
1047
- tone mapping (`scene.setToneMapping("none" | "aces")`, the reactive
1048
- `toneMapping`/`exposure` props) and the sRGB encode, premultiplied. The
1049
- scene target therefore holds encoded pixels like every texture the
1050
- runtime displays; `clearColor` is written as given and is NOT tone
1051
- mapped (with a curve on, draw the backdrop as a background), and a
1052
- transparent mesh blends in encoded space (Three's compromise; the
1053
- hardware-encode alternative would display wrong through the runtime's
1054
- raw sampling). What changed for a scene tuned before this: terminators
1055
- soften, mid-tones brighten, highlights widen - drop ambient rather than
1056
- lights. `emissiveIntensity` scales the emissive in linear light.
1588
+ fog `color`, the scene's and a view's `clearColor`; the library decodes
1589
+ it when it writes the uniform or the clear (`srgbToLinear`/`linearColor`
1590
+ are exported for values you write straight to a uniform yourself). Color
1591
+ MAPS decode through their format: create a base color, emissive or sky
1592
+ image with `format: "rgba8-srgb"` (createTexture, createCubeTexture;
1593
+ createModel does it for glTF's base color and emissive images) - a plain
1594
+ rgba8 map reads as linear data and renders washed out; data maps
1595
+ (normal, specular, roughness, light maps) stay rgba8, and an HDR image
1596
+ is "rgba16f". Vertex colors are linear, as glTF stores them.
1597
+
1598
+ Every target is a BUFFER plus a RESOLVE, Godot's and Unity's pipeline:
1599
+ the meshes draw into a linear buffer (`scene.hdrTexture` /
1600
+ `view.hdrTexture`: premultiplied linear light, half float where the
1601
+ device renders it - `limits.halfFloatRenderable`, every GLES 3 device
1602
+ here - else rgba8 linear and clamped; the renderer decides, no knob),
1603
+ and one full-screen pass, the resolve, writes the displayed rgba8
1604
+ `scene.texture`: exposure (`scene.setExposure`, default 1), tone mapping
1605
+ (`scene.setToneMapping("none" | "aces" | "agx" | "neutral")` - ACES the
1606
+ filmic curve every engine ships, AgX Blender's (Three, Godot 4.3+),
1607
+ Neutral the Khronos PBR curve (Three, Unity) that keeps product colors
1608
+ where a filmic curve shifts them; the reactive `toneMapping`/`exposure`
1609
+ props), then the sRGB encode with an ordered dither against banding,
1610
+ premultiplied. So a fragment - stock or custom -
1611
+ writes LINEAR light and never encodes (`sceneOutput` is fog only; a
1612
+ material has no output stage), a `transparent` mesh blends in linear
1613
+ space, the clearColor is tone mapped like a background is, and the
1614
+ resolve is an auto target, so a static scene still costs zero passes.
1615
+ The resolve is the post-effect slot: `resolve` on createScene/createView
1616
+ and the `<Scene>`/`<View3d>` prop - a source, `{ source, textures }`,
1617
+ or a function of the buffer id returning either, so a chain over
1618
+ `hdrTexture` is built before the resolve compiles - and
1619
+ `scene.setResolve` live with the same input. The
1620
+ source gets the shader-target contract (vUV, iResolution, fragColor),
1621
+ `uniform sampler2D uScene` (the buffer) and the RESOLVE set from `/glsl`
1622
+ declared: end with `fragColor = resolveColor(rgb, alpha)`; any
1623
+ `scene.setParams` name is readable; `DEFAULT_RESOLVE` is the stock one
1624
+ sample. BLOOM is stock: `bloom: { threshold?, intensity?, radius? }` on
1625
+ createScene, `scene.setBloom(opts | null)`, the reactive `bloom` prop -
1626
+ radiance above `threshold` (default 1, what a fully lit white surface
1627
+ reaches) blurred over `radius` rounds (default 2) of a separable blur
1628
+ at a quarter of the target's size and added back at `intensity`
1629
+ (default 0.5): Godot's glow, Unity's Bloom, Three's UnrealBloomPass. A
1630
+ chain of small auto passes per resolving target, re-rendered when the
1631
+ buffer is; views follow the scene's bloom unless they carry a `bloom`
1632
+ of their own (null = off, a clean minimap), the fog model. The glow is
1633
+ light: over a transparent backdrop (a view with no clearColor over UI)
1634
+ it still shows, its alpha its own brightness, and an opaque pixel is the
1635
+ plain sum. The default resolve composes it; a custom resolve does by declaring `uniform
1636
+ sampler2D uBloom; uniform float uBloomIntensity;` and adding the term
1637
+ (start from `BLOOM_RESOLVE` in `/glsl`; `examples/bloom.tsx` adds a
1638
+ vignette that way). `output` composes the DISPLAYED texture after the
1639
+ resolve, the place for an LDR effect (`examples/scene-post-effect.tsx`). Cost: one full-screen pass and
1640
+ double the color bandwidth per target, about 0.2 ms per 1080p target on
1641
+ an Intel iGPU (21 full-HD targets at once still ran at 36 fps); a
1642
+ device that cannot afford it renders a smaller buffer and the leaf
1643
+ scales it up. What changed for a scene tuned before linear light:
1644
+ terminators soften, mid-tones brighten, highlights widen - drop ambient
1645
+ rather than lights. `emissiveIntensity` scales the emissive in linear
1646
+ light.
1647
+
1648
+ ### Lighting GLSL
1057
1649
 
1058
1650
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
1059
1651
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
@@ -1084,27 +1676,34 @@ three) and `SHADOW_LOOKUP`
1084
1676
  cast; it walks the light's slots and samples the first map that covers
1085
1677
  the point, which is the cascade select, blended into the next map over
1086
1678
  the outer `SHADOW_BLEND` of the map). A receiving fragment
1087
- multiplies light i's term by `lightShadow(i, ...)`, exactly what `lit`
1679
+ multiplies light i's term by `lightShadow(i, ...)`, exactly what `phong`
1088
1680
  composes; a non-receiving one composes none of the three and declares no
1089
1681
  samplers. Lights, colors and exponents are arguments, so
1090
- nothing is pinned but the function names; `lit` is composed from these
1682
+ nothing is pinned but the function names; `phong` is composed from these
1091
1683
  same constants - customizing never means leaving the system.
1092
1684
 
1093
- A custom look is a citizen of the scene - lit by its lights, shadowed,
1094
- fogged, exposed and tone mapped like the stock materials - at one of
1095
- three tiers, top first:
1685
+ ### Custom looks: three tiers
1686
+
1687
+ A custom look is a citizen of the scene - lit by its lights, shadowed
1688
+ and fogged like the stock materials, exposed and tone mapped by the same
1689
+ resolve - at one of three tiers, top first:
1096
1690
 
1097
1691
  1. STANDARD FRAGMENT, CUSTOM VERTEX. Any vertex stage that writes the lit
1098
1692
  varyings (vWorldPos, vNormal, vUv, plus vColor with `vertexColors`,
1099
- vUv2 with `lightMap`) pairs with `litFragment(options)` /
1100
- `standardFragment(options)` (`/glsl`): the exact fragment `lit` /
1693
+ vUv2 with `lightMap`) pairs with `phongFragment(options)` /
1694
+ `standardFragment(options)` (`/glsl`): the exact fragment `phong` /
1101
1695
  `standard` compile, the same option names and defaults as their
1102
1696
  options with the texture options boolean. An instanced or displaced
1103
1697
  mesh keeps the stock shading whole (`examples/instanced.tsx`: the
1104
1698
  per-instance tint rides vColor). Instance it with the per-entry
1105
1699
  uniforms the source declares (uColor, uSpecular/uShininess or
1106
- uMetalness/uRoughness, the maps opted into).
1107
- 2. A SURFACE FUNCTION inside the stock fragment. `litFragment({ surface,
1700
+ uMetalness/uRoughness, the maps opted into) - SEED THEM: a uniform
1701
+ no one wrote is zero, and the program shades `base * uColor`, so an
1702
+ unseeded uColor renders pure black with no error. `instance({
1703
+ params: { uColor: [1, 1, 1, 1], uSpecular: 0.12, uShininess: 24 } })`
1704
+ is the white starting point (the stock materials seed exactly this
1705
+ from their `color` option).
1706
+ 2. A SURFACE FUNCTION inside the stock fragment. `phongFragment({ surface,
1108
1707
  prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
1109
1708
  it declares is an ordinary `instance()` param), `surface` declares
1110
1709
  `void surface(inout Surface s)`, called once the program has filled
@@ -1117,26 +1716,30 @@ three tiers, top first:
1117
1716
  generated program is; colors are linear light, premultiplied
1118
1717
  throughout, `Surface.base` included. The material describes the
1119
1718
  surface, the package shades it (Godot's fragment(), Filament's
1120
- material()).
1719
+ material()). This is the tier for anything whose geometry is not a
1720
+ surface: a point splat discarding outside `gl_PointCoord`'s inscribed
1721
+ circle and flipping a fitted normal towards the viewer keeps the
1722
+ scene's whole light model in about fifteen lines, where hand-rolling
1723
+ the light loop (tier 3) would have to match every light's falloff,
1724
+ cone and shadow by hand.
1121
1725
  3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
1122
1726
  `sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
1123
1727
  declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
1124
- light list, the shadow set, the environment, fog and OUTPUT - declare
1125
- none of them yourself. Build a `Surface` with `surfaceOf(base,
1728
+ light list, the shadow set, the environment and fog - declare none
1729
+ of them yourself. Build a `Surface` with `surfaceOf(base,
1126
1730
  normal)`, set the fields you mean, call `shadeBlinn(s, position)` or
1127
1731
  `shadePbr(s, position)` (premultiplied rgb back: hemisphere, every
1128
1732
  light with its shadow, the environment term, the emissive), add your
1129
1733
  own terms times the alpha, and end with `sceneOutput(rgb, alpha,
1130
- position)` (fog, exposure, tone mapping, encode). A custom LIGHT MODEL
1734
+ position)` (fog; the pixel is linear light, the resolve exposes, tone
1735
+ maps and encodes it). A custom LIGHT MODEL
1131
1736
  loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
1132
1737
  shade function: light i's direction and its color already attenuated,
1133
1738
  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.
1739
+ materials are built from this same set, so the tiers cannot drift.
1136
1740
  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.
1139
- `standardFragment(options)` is the same for `standard`: lit's options
1741
+ (the knot's rim term).
1742
+ `standardFragment(options)` is the same for `standard`: phong's options
1140
1743
  minus `specularMap`/`env` (the environment is always composed) plus
1141
1744
  `metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
1142
1745
  `uMetalness`/`uRoughness` in place of `uSpecular`/`uShininess`.
@@ -1153,7 +1756,9 @@ a shadow program that never reads `n` (no triplanar, no surface function
1153
1756
  using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
1154
1757
  instance or every caster move warns about the skipped write.
1155
1758
 
1156
- Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
1759
+ ### Lights
1760
+
1761
+ Lights and `phong`: lights are graph NODES, like Three. `createDirectionalLight({
1157
1762
  direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
1158
1763
  travelling along `direction` in the node's LOCAL space (default `[0, -1,
1159
1764
  0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
@@ -1175,10 +1780,12 @@ only, same falloff, no cone. `createHemisphereLight({ sky?, ground?, intensity?
1175
1780
  normal's tilt (fixed to world up, the node's transform is ignored); one per
1176
1781
  scene, the last attached wins. Placement goes through setTransform, the
1177
1782
  light's own fields through `setLight(light, { ... })` (frame-rate-safe,
1178
- like setMeshParams). At most `MAX_LIGHTS` (8, exported from `/glsl`)
1783
+ like setMeshParams). At most `MAX_LIGHTS` (8, exported from the root and `/glsl`)
1179
1784
  lights per scene, directional, spot and point together (the hemisphere
1180
- is not in the list) - the ninth throws at add(); it is a shader-source
1181
- constant, fixed per app. `uLightDir` and `uLightPos` are core-driven:
1785
+ is not in the list) - a ninth is reported at the sync that settles the
1786
+ set, not at add() (a declarative swap's transient overlap is fine),
1787
+ the way a shadow-budget overflow is (see there), and the lights past
1788
+ the cap are not lit; it is a shader-source constant, fixed per app. `uLightDir` and `uLightPos` are core-driven:
1182
1789
  each light's slots are spatial-core shared-slot sinks following the
1183
1790
  node's world rotation (direction, negated so the shader reads the
1184
1791
  vector TOWARD the light) and world position, so a MOVING light costs no
@@ -1189,7 +1796,7 @@ LIGHT_POINT), `uLightDir[N]`/`uLightPos[N]`/`uLightColor[N]` (intensity
1189
1796
  folded into the color) and `uLightParams[N]` (cosInner, cosOuter,
1190
1797
  distance, decay) - so a custom fragment composing `LIGHT_SLOTS` +
1191
1798
  `LIGHT_LOOKUP` from `/glsl` reads the same list through `lightVector(i,
1192
- worldPos, out l)` (returns the attenuation, 0 = skip the light; `lit`
1799
+ worldPos, out l)` (returns the attenuation, 0 = skip the light; `phong`
1193
1800
  is the shape), and a light change costs one write however many meshes.
1194
1801
  A custom fragment that declares only the old directional subset
1195
1802
  (`uLightCount`/`uLightDir`/`uLightColor`) still works - it just shades
@@ -1198,13 +1805,14 @@ scenes. Everything starts black: a lit scene with no light shows
1198
1805
  nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
1199
1806
  shape (soft vs hard cone, casting spots, an orbiting bulb).
1200
1807
 
1201
- `lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
1202
- the directional list, Lambert diffuse, Blinn-Phong highlight when
1808
+ ### phong
1809
+
1810
+ `phong(opts)` is the Blinn-Phong material (Three's MeshPhongMaterial, Unity
1811
+ URP's Simple Lit) beside `unlit`: hemisphere ambient plus the light list, Lambert diffuse, Blinn-Phong highlight when
1203
1812
  `specular` (0..1 strength) is set with `shininess` (default 30), a
1204
1813
  mirror of the scene's environment when `reflectivity` (0..1, the
1205
1814
  face-on weight; see Environment above) is set, blurred by the same
1206
- `shininess`, the same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
1207
- multiply by the colored layout's aColor (so the geometry must carry it),
1815
+ `shininess`, the same `color`/`map`/`transparent`/`blend`/`vertexColors` as unlit,
1208
1816
  `triplanar: n` to sample `map` by world position at `n` repeats per
1209
1817
  world unit, blended across the three axis planes by the normal, and
1210
1818
  `alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
@@ -1223,6 +1831,8 @@ or more beside it, or trilinear smears the far half into the mip its long
1223
1831
  axis picked (`createModel` uploads its images with both; the device clamps
1224
1832
  the level, `limits.maxAnisotropy` reports it).
1225
1833
 
1834
+ #### Surface maps
1835
+
1226
1836
  The surface maps, each an option beside `map` and sampled at its uv:
1227
1837
 
1228
1838
  - `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
@@ -1257,6 +1867,8 @@ The surface maps, each an option beside `map` and sampled at its uv:
1257
1867
 
1258
1868
  `examples/materials.tsx` shows all five.
1259
1869
 
1870
+ ### standard
1871
+
1260
1872
  `standard(opts)` is the metalness/roughness material, the look authored
1261
1873
  assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
1262
1874
  Unity's Standard): every lit option but the Blinn-Phong knobs
@@ -1276,7 +1888,7 @@ split sum, `envRadiance` at the roughness over the cube's mip chain
1276
1888
  times the analytic `envBrdf` for the specular, `envIrradiance` on the
1277
1889
  diffuse beside the hemisphere - with no `reflectivity` switch: the
1278
1890
  environment is intrinsic to the model, and a baked environment with no
1279
- lights at all lights a scene (`examples/environment.tsx`). Light intensities read as lit's
1891
+ lights at all lights a scene (`examples/environment.tsx`). Light intensities read as phong's
1280
1892
  (1 lights a white matte surface to 1; Godot's and Unity's convention -
1281
1893
  a Three scene's intensities divide by pi). Without an environment a
1282
1894
  metal shows only its highlights: no diffuse, nothing to reflect (Three
@@ -1295,70 +1907,90 @@ classes do not warn about an inactive uniform.
1295
1907
 
1296
1908
  ## Models
1297
1909
 
1910
+ ### Three layers
1911
+
1298
1912
  Authored models come in as glTF 2.0 (.gltf with its .bin and image files
1299
1913
  next to it, or single-file .glb) and become a Group carrying the file's
1300
1914
  node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
1301
1915
  fits:
1302
1916
 
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.
1917
+ #### parseGltf
1918
+
1919
+ `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
1920
+ bun and on flux): `ModelData` = `nodes` (the retained hierarchy in
1921
+ pre-order - name, parent index, local TRS; matrix-form nodes are
1922
+ TRS-decomposed, shear dropped; nodes that carry no part, joint or
1923
+ animation target anywhere - cameras, lights, unused empties - are
1924
+ pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
1925
+ index, vertices in the base layout LOCAL to the node - except
1926
+ skinned parts: "skinned" layout, model-space bind pose, `skin` index),
1927
+ `skins` (joint node indices + inverse binds), `clips` (the animations
1928
+ as baked channel buffers: node/path/interpolation, times, values),
1929
+ `materials` (base color factor, `map` =
1930
+ index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
1931
+ `alphaMode` as written and `alphaCutoff`, spec default 0.5, the
1932
+ normal and emissive slots, `metalness`/`roughness` factors and the
1933
+ packed `metalnessRoughnessMap` - standard's inputs), `images`
1934
+ (the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
1935
+ pose: a part's box through its node, a skinned part's per-joint boxes
1936
+ through the joints' rest transforms - where the skin places it, armature
1937
+ scale included; conservative under rotation). External
1938
+ files come through `resolve(uri)` (uri as written, still
1939
+ percent-encoded; `gltfExternalUris(bytes)` lists them so an async
1940
+ caller can read them first) - for a .gltf AND for a .glb, which is
1941
+ usually self-contained but may legally reference external images
1942
+ (real exporters do); data: uris need no resolver. Missing
1943
+ normals produce FLAT shading (the spec's rule): the primitive is
1944
+ un-indexed, one vertex per corner. A mirroring node chain (negative
1945
+ rest-pose world determinant) flips the part's index winding so
1946
+ `cull: "back"` still keeps the outside. Non-triangle primitives are
1947
+ skipped; a required extension the parser does not implement throws
1948
+ naming it, and Draco or meshopt compression throws "re-export without
1949
+ mesh compression" - Blender exports Draco by DEFAULT, so that is the
1950
+ first error a real file hits.
1951
+
1952
+ #### createModel
1953
+
1954
+ `createModel(data, { material?, label?, autoFree? })` - uploads the images (repeat
1955
+ wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
1956
+ with the file's color, maps, normal scale, metalness/roughness and
1957
+ packed map, emissive and transparency - the glTF material model, so a
1958
+ scene showing a model wants an `environment` (a glTF metal in a scene
1959
+ with none renders near black); `material: (m, maps, skinned) =>
1960
+ phong({ color: m.color, map: maps.map ?? undefined, skinned })` for the
1961
+ Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
1962
+ Groups with the file's local TRS, and one mesh per part under its node,
1963
+ all inside the returned `Model` (a Group): `add(scene.root, model)`,
1964
+ place it with `setTransform`, find parts by name in `model.parts`
1965
+ (`{ name, mesh }`), spin a wheel relative to its axle through
1966
+ `model.nodes` (`{ name, node }` in table order, parents first; names
1967
+ repeat when the file's do - `.find()` yours), `model.bounds` for
1968
+ framing a camera. Skinned parts get the `skinned: true` material
1969
+ variant and hang off the model ROOT (the spec ignores their node's
1970
+ transform; the palette places them - see the mixer below). `dispose()`
1971
+ detaches it and frees the geometry buffers and textures - the model owns
1972
+ them, nothing else frees them.
1973
+
1974
+ #### loadGltf and loadModel
1975
+
1976
+ `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
1977
+ and build. Like every resource creator, a model frees with the owning
1978
+ reactive scope (`dispose()` at its cleanup; `autoFree: false` opts out):
1979
+ for the loaders that scope is the caller's at the call, captured before
1980
+ the await, so a model loaded inside a memo goes with the memo's rerun
1981
+ and one loaded in a component with its unmount. A load that settles
1982
+ after its scope is gone (unmounted mid-load) is freed on arrival and
1983
+ resolves already disposed - nothing owns it any more. `loadModel` reads the baked `.srtm` written by `srt tool
1984
+ 3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
1985
+ under bun, stored in the GPU layout, so loading is views onto the file's
1986
+ bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
1987
+ on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
1988
+ 40 ms for the whole baked load - the runtime parse is fine for small
1989
+ models and a binary import (`import bytes from "./x.glb" with { type:
1990
+ "binary" }` then `createModel(parseGltf(bytes))`, see
1991
+ `examples/model.tsx`); bake anything big.
1992
+
1993
+ ### Async loading
1362
1994
 
1363
1995
  Loading is async everywhere but the binary import: loadGltf/loadModel
1364
1996
  return promises, and the async value must be read the way Solid 2 async
@@ -1378,6 +2010,8 @@ a source glTF must be parsed at runtime, do the parse in an isolate
1378
2010
  (parseGltf's result is plain data and copies across) and keep
1379
2011
  createModel on main.
1380
2012
 
2013
+ ### Placement and sockets
2014
+
1381
2015
  Placement: pieces of one authored set (a body and its fitted cosmetics)
1382
2016
  export in one world space, so composing them is `add(group, model)` per
1383
2017
  piece and nothing else - no placement math. SOCKETED items (a weapon in
@@ -1385,8 +2019,8 @@ a hand) are different: they bind to a joint, so they only land once a
1385
2019
  skeleton exists (a rig-less export cannot place them at all). The joint
1386
2020
  is an ordinary Group in `model.nodes` - find it by name and `add()` the
1387
2021
  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
2022
+ like any child transform. Two authoring cases: an item authored about
2023
+ its own socket
1390
2024
  origin needs the plain `add()` and nothing more; one authored in the
1391
2025
  RIG'S model space needs a socket Group between joint and item carrying
1392
2026
  the joint's rest-pose inverse (at rest the item then sits exactly where
@@ -1395,6 +2029,34 @@ transform on top of the authored placement. Skinned PARTS are the one
1395
2029
  thing that never needs this: they hang off the model root and the
1396
2030
  palette places them.
1397
2031
 
2032
+ ### Wardrobe pieces
2033
+
2034
+ Wardrobe pieces (a hood, a cape, cuffs) are the third case: exported
2035
+ WITH a skin over the body's joints and WITHOUT clips, so beside an
2036
+ animated body they hold their bind pose. `bindSkeleton(body, piece)`
2037
+ drives them from the body's skeleton - Three's SkinnedMesh.bind, Unity's
2038
+ `bones =`, Godot's shared Skeleton3D. The piece's palette rows re-bind
2039
+ onto the body's joint nodes (matched by name, case-insensitive) with
2040
+ the piece's own inverse binds, so the flush writes the body's pose into
2041
+ the piece's skin and no per-frame code exists; joints with no body
2042
+ counterpart (a hat's internal bones) and rigid parts hanging off a
2043
+ matched node are grafted under the matched body joint and ride it. The
2044
+ piece then hangs under the body at the body's placement (its skinned
2045
+ vertices are in the body's model space), and its own joint nodes are no
2046
+ longer posed by anything: read poses from and socket items on the
2047
+ BODY's joints. The piece's node tree is never composed - exporters
2048
+ truncate it above the spine or skip an ancestor in the middle, and
2049
+ copying locals through such a tree hangs joints off the wrong place
2050
+ while looking right for every piece whose tree happens to match; the
2051
+ body's world matrices are what a bone matrix needs. A shared joint
2052
+ whose bind pose differs (a piece exported against another rest pose or
2053
+ scale) throws at bind instead of rendering wrong. A piece comes off by
2054
+ disposing it; disposing a body disposes what it wears. Culling follows
2055
+ the wear: a body joint's box is the union of every skin reaching it,
2056
+ its own and the pieces'.
2057
+
2058
+ ### Applied glTF material fields
2059
+
1398
2060
  Applied: `doubleSided` (the default material draws it with `cull:
1399
2061
  "none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
1400
2062
  (+ scale; the derivative frame needs no tangents), `emissiveFactor` x
@@ -1402,14 +2064,26 @@ Applied: `doubleSided` (the default material draws it with `cull:
1402
2064
  factor (a zero factor skips the map too - glTF's product rule, emission
1403
2065
  off), `pbrMetallicRoughness` factors as `m.metalness`/`m.roughness` and
1404
2066
  its packed texture as BOTH `maps.metalnessMap` and `maps.roughnessMap`
1405
- (standard's channel-select options; the default `lit` ignores them).
1406
- The `material(m, maps)` callback receives every uploaded texture
1407
- by lit()/standard() option name (`maps.map`/`maps.normalMap`/
1408
- `maps.emissiveMap`/`maps.metalnessMap`/`maps.roughnessMap`);
1409
- `data.materials` is in file order, so the calls arrive in file order.
2067
+ (standard's channel-select options; the default `phong` ignores them).
2068
+ The `material(m, maps, skinned, vertexColors)` callback receives every
2069
+ uploaded texture by phong()/standard() option name (`maps.map`/
2070
+ `maps.normalMap`/`maps.emissiveMap`/`maps.metalnessMap`/
2071
+ `maps.roughnessMap`); `data.materials` is in file order, so the calls
2072
+ arrive in file order. A primitive's `COLOR_0` lands in its geometry's
2073
+ aColor (the "colored" layout, or the skinned list plus aColor for a
2074
+ rigged one; linear and premultiplied as glTF stores it) and the default
2075
+ material takes `vertexColors: true` for such parts - the callback's
2076
+ fourth argument says so, and a material shared by painted and unpainted
2077
+ parts is made once per variant, like the skinned split.
1410
2078
  A `.srtm` baked before the material records carried the PBR fields
1411
- (file version 3) is rejected by loadModel - re-bake with `srt tool
1412
- 3d/model`.
2079
+ (file version 3), or before the vertex formats took the WebGPU spelling
2080
+ and byte counts (file version 5), is rejected by loadModel - re-bake
2081
+ with `srt tool 3d/model`. A baked part keeps a quantized export's
2082
+ bytes: u8 colors, u8/u16 joints and normalized weights, u16 uvs land in
2083
+ their own formats, positions and normals as floats.
2084
+
2085
+ ### Animation
2086
+
1413
2087
  Animation: `createMixer(model)` plays `model.clips` by name -
1414
2088
  `mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
1415
2089
  named clip fades in, everything else fades out - Unity's CrossFade,
@@ -1437,6 +2111,9 @@ so a TRANSPARENT mesh parented under a player-animated joint does not
1437
2111
  re-trigger the back-to-front re-sort while it animates (opaque meshes,
1438
2112
  palettes and picking are unaffected) - nudge the scene with any
1439
2113
  setTransform if it shows, until the core-side transparent sort lands.
2114
+
2115
+ ### Root motion
2116
+
1440
2117
  Root motion: `play(name, { inPlace? })` strips a clip's root travel
1441
2118
  (Unity's applyRootMotion off, Godot's root_motion_track): the root's
1442
2119
  x/z hold at the clip's first key and its height rebases onto the root's
@@ -1473,7 +2150,11 @@ horizontal travel (Unity's bake-into-pose Y - for a controller that
1473
2150
  owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
1474
2151
  the swing-twist about up (exact under any lean); a cubic rotation
1475
2152
  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
2153
+ Mixamo's standing turns (external/mixamo-turn).
2154
+
2155
+ ### Skins
2156
+
2157
+ Skins need nothing further: each skin's uBones palette
1477
2158
  (model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
1478
2159
  float texture, 4 texels wide, one row per joint, sampled in the vertex
1479
2160
  stage via texelFetch, so there is no joint cap) is composed by the
@@ -1483,15 +2164,71 @@ computed-once texture. `sampleChannel` (pure, from the root) stays the
1483
2164
  JS sampling core for checks and custom drivers;
1484
2165
  okf/done/animation-core.md records the design.
1485
2166
 
1486
- Not in the subset, dropped: vertex colors, tangents and further UV sets;
1487
- morph targets (the "weights" channel path); samplers are ignored (every
1488
- texture repeats); additive blending draws as base color. The follow-ups
1489
- are filed in okf/backlog/3d-model-loader.md. The `.srtm` container is
1490
- VERSION 3 (node table in the header, node-local vertices, skins, clips);
1491
- older bakes are rejected - re-bake with `srt tool 3d/model`.
2167
+ ### Morph targets
2168
+
2169
+ Morph targets (blend shapes): a geometry carries named per-vertex
2170
+ deltas - `withMorphTargets(geometry, [{ name, position, normal? }])`
2171
+ for a hand-built shape, the loader's primitive targets for a glTF (its
2172
+ `extras.targetNames`, else `target<i>`; sparse accessors read) - packed
2173
+ SPARSE BY VERTEX on `geometry.morphs` (a header texel per vertex, then
2174
+ only the entries of the targets that move it; memory follows the
2175
+ authored deltas, never vertices x targets) and uploaded once per
2176
+ geometry as one rgba32f texture. A `morph: true` material (`phong`,
2177
+ `unlit`, `standard`; a custom class splices MORPH_DECLS and MORPH_APPLY
2178
+ from `@solidrt/3d/glsl` before its skin math) walks each vertex's
2179
+ entries in the vertex stage, before any skinning; a mesh under it
2180
+ whose geometry carries no targets is rejected at add(), and a plain
2181
+ material over morphed geometry draws the base shape. WEIGHTS belong to
2182
+ a NODE, glTF's `node.weights` model: a standalone mesh owns its own; a
2183
+ model's parts follow their glTF node (`model.nodes`), so a face split
2184
+ into skin, eyes and teeth parts takes one write. `setMorphWeights(node,
2185
+ { smile: 0.7 })` writes by name (the others untouched), an array writes
2186
+ every target in order, the `<Mesh morphWeights>` prop is the
2187
+ declarative form; `getMorphWeights(node)` reads them back (from the
2188
+ core while in a scene), `getMorphNames(node)` lists the targets. The
2189
+ write lands in the spatial core's register (a row of a small weights
2190
+ texture, published at the flush like a skin palette - Three's
2191
+ morphTargetInfluences, Godot's set_blend_shape_value); a file's
2192
+ `mesh.weights` seed it. Weights ANIMATE two ways, both native: a glTF
2193
+ `weights` animation channel plays through the mixer like a TRS
2194
+ channel (into the owning node's register, crossfading across players
2195
+ by the same weighted average), and a write is a TARGET like a
2196
+ transform write - `setTransition(node, { weights: { duration: 300 } })`
2197
+ makes every setMorphWeights animate (springs by default; `from`/`exit`
2198
+ endpoints are one number per target, `delay` and `stagger` apply), and
2199
+ `setMorphWeights(node, { smile: 1 }, { duration: 300, bounce: 0.2 })`
2200
+ animates that one write on its own motion, declaration or not; a
2201
+ settle calls `onTransitionEnd` with component "weights". A playing
2202
+ weights track and a JS write are both producers of the register: the
2203
+ players advance before the frame's JS, so a write from onFrame wins
2204
+ that frame. `getMorphWeights` reads the core, so it shows a playing
2205
+ track and a mid-flight transition; the by-name partial write merges
2206
+ into the last JS-WRITTEN set, not the core's - write every target
2207
+ while a clip plays. An INSTANCED mesh morphs per instance: the
2208
+ population owns one weights texture with a row per record slot, each
2209
+ `<Instance>` (or addInstance node) owns a register published into its
2210
+ slot's row, and the shader reads the row by `gl_InstanceID` - so
2211
+ `setMorphWeights(instance, { smile: 1 })`, the `<Instance morphWeights>`
2212
+ prop, a `transition.weights` spring and a clip's weights track all work
2213
+ per copy, the shadow variants included, with no per-record data
2214
+ (Three's InstancedMesh morphs). Skinned plus morphed plus instanced
2215
+ composes. A custom class splicing MORPH_DECLS reads the row as
2216
+ `uMorphRow + gl_InstanceID`, which the stock decls already do. A record
2217
+ mesh (JS-written records, no nodes) cannot morph: rejected at add().
2218
+
2219
+ ### Not in the subset
2220
+
2221
+ Not in the subset, dropped: tangents and further UV sets; samplers are
2222
+ ignored (every texture repeats); additive blending draws as base color.
2223
+ The follow-ups are filed in okf/backlog/3d-model-loader.md. The `.srtm`
2224
+ container is VERSION 8 (node table in the header, node-local vertices,
2225
+ skins, clips, packed morph targets); older bakes are rejected - re-bake
2226
+ with `srt tool 3d/model`.
1492
2227
 
1493
2228
  ## Traps
1494
2229
 
2230
+ ### Models and skinning
2231
+
1495
2232
  - A model's vertices are LOCAL to their node; the file's placement lives
1496
2233
  in the node-table TRS, composed by the scene like any Group chain. So
1497
2234
  a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
@@ -1499,6 +2236,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1499
2236
  subtree. The winding flip for a mirroring node chain is baked into the
1500
2237
  index order from the REST pose - re-scaling a node across zero at
1501
2238
  runtime shows mesh interiors, so do not do that.
2239
+ - A worn piece's joints are dead after `bindSkeleton`: `piece.nodes`
2240
+ still lists them, but nothing poses them and their palette rows sit
2241
+ on the body's nodes now. Pose, read and socket on the body.
1502
2242
  - Skinning is a VERTEX-STAGE effect: everything that runs off the
1503
2243
  retained tree sees the bind pose. A skinned mesh picks by its
1504
2244
  bind-pose triangles at the model root and its transparent sort key is
@@ -1506,81 +2246,29 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1506
2246
  MODEL moves both. Shadows are the exception: the shadow variants
1507
2247
  (depth and cutout) skin by the same uBones palette, so a caster casts
1508
2248
  its pose.
2249
+ - Morphing is the same kind of effect: picking and the transparent sort
2250
+ see the base shape, the shadow variants morph. The one thing the
2251
+ retained side knows is the box: `geometryBounds` (and a model's
2252
+ bounds) grow by the targets' extent, so a morphed shape is never
2253
+ culled while its weights stay in 0..1 - a weight past 1 can leave it.
2254
+ Normal deltas blend linearly like positions (Three's, Godot's and
2255
+ glTF's rule): exact at a target, an approximation between two, so a
2256
+ half-blend of EXTREME targets can show a ragged self-shadow
2257
+ terminator where the lit and shadowed halves disagree; a modeller's
2258
+ blend shapes are far too small for it to show.
2259
+
2260
+ ### Materials and color
2261
+
1509
2262
  - A `standard` metal in a scene with no environment renders near black:
1510
2263
  its diffuse is zero and the black placeholder cube is all there is to
1511
2264
  reflect (Three and Godot render the same; Unity falls back to an
1512
2265
  ambient probe, this does not). glTF's default metallic factor is 1,
1513
2266
  so an untextured asset is all metal, and createModel's default is
1514
2267
  `standard`: give a model scene an `environment` (loadEnvironment's
1515
- 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
- - Light intensities are the same numbers for `lit` and `standard`: 1
2268
+ baked .srte, or the skybox's cube), or pass `phong` as the material.
2269
+ - Light intensities are the same numbers for `phong` and `standard`: 1
1528
2270
  lights a white matte surface to 1 face-on. A Three scene's intensities
1529
2271
  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
2272
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
1585
2273
  [r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
1586
2274
  classes write alpha 1 when not `transparent` (the scene target is
@@ -1592,8 +2280,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1592
2280
  (or `shaderMaterial({ transparent: true })`) builds the pipeline with
1593
2281
  `blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
1594
2282
  behind opaques without occluding other translucents). The one inference:
1595
- a `shaderMaterial` with any `blend` but "none" is transparent unless told
1596
- `transparent: false` - every blended draw belongs after the opaques, and
2283
+ a stock material or `shaderMaterial` with any `blend` but "none" is transparent
2284
+ unless told `transparent: false` - every blended draw belongs after the opaques, and
1597
2285
  back-to-front is harmless for add/multiply. The scene owns the
1598
2286
  order: background, opaque meshes by `renderOrder` then add order,
1599
2287
  transparent meshes by `renderOrder` then back-to-front by the CENTER of
@@ -1607,6 +2295,56 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1607
2295
  translucents can sort wrong (center distance, not per-pixel) - that is the
1608
2296
  engine contract, no OIT. A `shaderMaterial({ transparent: true })`
1609
2297
  fragment must write PREMULTIPLIED output (`vec4(rgb * a, a)`).
2298
+ - A color map created as plain rgba8 renders WASHED OUT: the fragment
2299
+ reads its encoded bytes as linear light and encodes them again. Create
2300
+ color images (base color, emissive, a sky's faces or panorama) with
2301
+ `format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
2302
+ (a scene view, a shader target, a UI capture) holds encoded pixels and
2303
+ cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
2304
+ custom fragment (no material option yet); a draw target you create
2305
+ yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
2306
+ is sampler-only: no display, readback or copy).
2307
+ - Reading `scene.texture` back (readTexture, a snapshot) gives ENCODED
2308
+ pixels: an expected linear value v shows as `linearToSrgb(v) * 255` -
2309
+ intensity 0.5 reads 188, not 128. The buffer, `scene.hdrTexture`, is
2310
+ sampler-only (no readback, copy or display): read radiance through a
2311
+ `createShaderTexture` pass over it, scaled into 0..1.
2312
+
2313
+ ### Environment and background
2314
+
2315
+ - A reflection probe is a mirrored render (x-flipped projection, winding
2316
+ inverted by the engine): anything built from screen-space derivatives
2317
+ flips with it, so a normal-mapped surface shows its bumps INVERTED in
2318
+ a probe's reflection. Known and shared with every engine's mirrored
2319
+ views; a `uMirror` sign on the derivative frame is the fix when it
2320
+ matters.
2321
+ - A generated cube chain (`mipmap: true` from six faces) is a box
2322
+ filter, not the GGX convolution the roughness-to-level rule assumes:
2323
+ rough reflections read too sharp and the diffuse `envIrradiance` is a
2324
+ 4x4 average. Bake with `srt tool 3d/environment` for anything
2325
+ photographed; the JS sky gradients in the examples get away with it.
2326
+ - The background covers the whole target with depth off, drawn first: it
2327
+ REPLACES the clearColor visually (the clear still runs; you just never
2328
+ see it), and a `transparent: true` mesh blends over it in-pass since the
2329
+ background is always entry zero.
2330
+ - The background pipeline/program are SCENE-OWNED (unlike shared
2331
+ material pipelines): setBackground(null), replacement, and dispose()
2332
+ destroy them. Do not hand the background's pipeline to anything else.
2333
+ A skybox is the same slot with the library's fragment; only a
2334
+ skybox-to-skybox replace keeps the entry (params and cube rewritten).
2335
+ - The environment binds through the light rewrite's map set (uEnv
2336
+ beside uShadowAtlas on every receiving target, new views included) and
2337
+ directly on setEnvironment; the placeholder cube is app-lifetime like
2338
+ the shadow placeholder. A `phong` without `reflectivity` declares no
2339
+ environment sampler - the flag is part of the class key.
2340
+
2341
+ ### Coordinates and rotation
2342
+
2343
+ - The y-down clip flip is baked into `perspective()`; scene code and
2344
+ geometry are plain y-up right-handed, and CCW-outward winding culls
2345
+ correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
2346
+ "fix" the negated row of `perspective()` - both would mirror the winding
2347
+ and show mesh interiors.
1610
2348
  - Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
1611
2349
  always unit). There is exactly one rotation field: no `node.rotation`
1612
2350
  shadowing it, because a second field is a second thing to go stale (an
@@ -1678,14 +2416,86 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1678
2416
  - Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
1679
2417
  the props that call them). Mutating `node.position` directly does not
1680
2418
  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.
2419
+ - Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
2420
+ `setCamera`, params), so ONE scratch array reused every frame is safe -
2421
+ allocating three arrays per node per frame is pure waste. The node's own
2422
+ `position`/`quaternion`/`scale` are the live arrays: read them, do not
2423
+ hand them out and do not mutate them (that write does not sync).
2424
+ - `setTransform` early-outs on an unchanged value (rotation compared AFTER
2425
+ euler conversion), so driving every node unconditionally from `onFrame`
2426
+ costs only the compare for nodes that did not move. Compares are exact,
2427
+ like `setVisible`.
2428
+
2429
+ ### Visibility and instancing
2430
+
2431
+ - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
2432
+ cheap off switch); unhiding writes 1, or the mesh's own record count
2433
+ when it is instanced - never a bare 1 into an instanced entry. Hidden
2434
+ meshes skip uModel writes; the fresh matrix is
2435
+ written on unhide. A freshly attached entry starts off the same way and
2436
+ the core's flush turns it on when it writes uModel - never add one
2437
+ live: it has no world matrix yet, and drawn before the sync microtask it
2438
+ flashes at the world origin for a frame.
2439
+ - Instancing pairs strictly at add(), like layout: an instanced material
2440
+ needs a createInstancedMesh or createRecordMesh mesh and vice versa,
2441
+ and every instance buffer the material declares must be a layout the
2442
+ mesh carries - the matrix (INSTANCE_MATRIX_ATTRIBUTES, the first
2443
+ buffer of an instanced mesh) or a stream with the same attribute names
2444
+ AND formats (a byte-equal layout in other formats decodes differently)
2445
+ - each mismatch throws there, at creation for the mesh's own material
2446
+ and at add() for a swapped one. The instance buffers are MESH-owned
2447
+ (unlike shared geometry buffers): `disposeInstances` is their one
2448
+ free, and the mesh cannot be re-added afterwards. Capacity grows by
2449
+ REPLACEMENT, never resize: an addInstance or setRecords past capacity
2450
+ doubles into new buffers and swaps them in (an instanced mesh's live
2451
+ matrix records move in one core `retargetRecords`, every stream's
2452
+ mirror republishes) - amortized like a dynamic array, same policy as
2453
+ @solidrt/2d; size `capacity` or the initial records to skip the
2454
+ copies. A mesh's stream layouts come from its material AT CREATION: a
2455
+ later setMaterial to a class with another layout throws at the
2456
+ rebuild.
2457
+ - Record writes are mirrored, not immediate: `setInstanceStyle`,
2458
+ `setRecords` and an `instanceAttribute` write land in the stream's
2459
+ `data` and the scene's sync publishes the `dirty` range (`updateRecords`
2460
+ marks it for accessor writes) - so a write on a mesh outside any scene
2461
+ shows once it is added (the dirty range waits), and reading the GPU
2462
+ buffer back mid-frame can lag the mirror by one sync. Growth replaces
2463
+ `data`: hold the accessor, not the view.
2464
+ - The sugar is for FEW records, the mirror is for MANY. `setInstanceStyle`
2465
+ over an all-float layout is indexed stores; over a packed layout (the
2466
+ stock `float16x4` color included) it is a codec call per component,
2467
+ and so is every accessor write. Measured at ten thousand instances
2468
+ restyled every frame on a laptop under QuickJS: about 11 ms of JS per
2469
+ frame through setInstanceStyle on a float layout, 19 ms on the half
2470
+ color, 9 ms through the accessor, 5 ms through the mirror - and that
2471
+ 5 ms is the app's own per-instance tint math, the write itself is
2472
+ free. A population restyled per frame writes the mirror in bulk:
2473
+ a typed view over `stream.data` (a Float32Array as handed out, a
2474
+ `Float16Array` over the stock color, indexed by slot times components)
2475
+ filled in a plain loop, then ONE `updateRecords(mesh, { first, count
2476
+ })` - zero per-component calls, the same shape as `setRecords`.
2477
+ - Instanced casters: `castShadow` on a populated mesh needs a depth pass
2478
+ with the instance placement in it - the stock materials' `instanced`
2479
+ carries one, a custom class needs `shadowVertex` (see shadows below);
2480
+ a class without one is skipped by shadow views, silently.
2481
+ - A record mesh without explicit `bounds` has no BVH leaf: it never
2482
+ picks, pointer events never target it, and its transparent sort key
2483
+ falls back to the node's world position. That is deliberate - records
2484
+ are opaque to the library, so any inferred box would be a guess. Supply
2485
+ `bounds` for anything pickable or transparent. An instanced mesh picks
2486
+ per instance regardless and culls by its instances' union; its
2487
+ `bounds` replace that union and give the transparent sort a center.
2488
+ - An instance node's own fields hold its LOCAL pose (relative to its
2489
+ parent, as for any node); the record the core writes is its pose
2490
+ relative to the MESH, composed through any group between them. A
2491
+ destroyed instance takes its children with it, like any destroyed
2492
+ node. `disposeInstances` on an instanced mesh flushes the
2493
+ core before freeing the buffer, so the destroyed instances' hiding
2494
+ writes land in a live buffer; keep that order if you ever free a
2495
+ record buffer by hand.
2496
+
2497
+ ### Shadows
2498
+
1689
2499
  - A CASTING light's position matters (nothing else about a directional
1690
2500
  light's position does): the shadow camera is placed AT the light node's
1691
2501
  world position, Three's rule, so a `castShadow` sun at the origin
@@ -1715,12 +2525,26 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1715
2525
  is a full extra pass over the casters plus a sampler unit on every
1716
2526
  receiving program (MAX_LIGHTS of those are always bound, placeholders
1717
2527
  included), so cast from the lights that matter, not all of them.
2528
+
2529
+ ### Views and scene params
2530
+
2531
+ - A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
2532
+ target state), independent of mesh count - never reintroduce per-mesh
2533
+ camera writes (uEye-style per-mesh params are exactly the O(scene) cost
2534
+ the shared channel removed). Scene scale honestly: hundreds to a
2535
+ few thousand objects, bounded by the interpreter, not the GPU. A view
2536
+ is one more such write per camera change and one more entry per mesh
2537
+ at attach; a view's per-frame cost is the core's (one params write per
2538
+ sink per moved node), never JS.
1718
2539
  - A mesh's entries are mirrored into every view at attach and dropped at
1719
2540
  detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
1720
2541
  `overrideMaterial` is validated against every mesh's layout (at
1721
2542
  createView for the meshes present, at add() for later ones) exactly like
1722
2543
  a mesh's own material, so an override reading `aColor` throws for a
1723
- standard-layout mesh. Views are disposed by the scene; `view.dispose()`
2544
+ base-layout mesh. An instanced mesh the override cannot place (it
2545
+ declares no `instanceBuffers`) is skipped, which the view's output
2546
+ cannot show, so the scene warns once per view naming it and the count
2547
+ (over the settled set, at the flush after the attach). Views are disposed by the scene; `view.dispose()`
1724
2548
  only for dropping one early.
1725
2549
  - SCENE-WIDE uniforms go through that same shared channel via
1726
2550
  `scene.setParams({ uTime })`, and this is the single highest-leverage
@@ -1732,34 +2556,12 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1732
2556
  however many meshes read it, with the motion itself in vertex shaders
1733
2557
  off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
1734
2558
  is O(meshes) per frame; reach for it only when the value genuinely
1735
- differs per mesh. (`scene.texture` IS the draw target id, so
1736
- `setTargetParams(scene.texture, ...)` is the same write - setParams is
1737
- 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).
2559
+ differs per mesh. (`scene.texture` is the RESOLVE's id, not the buffer the meshes
2560
+ draw into, so `setTargetParams(scene.texture, ...)` reaches the
2561
+ resolve alone - setParams is the only spelling.)
2562
+
2563
+ ### Shader materials
2564
+
1763
2565
  - A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
1764
2566
  compile twice - no dedupe by source value (deliberate; hidden
1765
2567
  content-keyed caches are the anti-pattern the GPU layer avoids). Create
@@ -1786,10 +2588,25 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1786
2588
  vertex source - a comment counts - selects the "colored" layout, and
1787
2589
  the material then rejects standard geometry at add(). Do not mention
1788
2590
  aColor you do not read.
2591
+ - A custom fragment that ends in `fragColor = vec4(...)` bypasses the
2592
+ scene: no fog (exposure, tone mapping and the encode still happen, in
2593
+ the resolve, so it must write LINEAR premultiplied light) - and a
2594
+ hand-rolled loop over `uLightDir` renders a spot light as a
2595
+ directional one (a lit rectangle on the floor, no cone). What you
2596
+ declare is what runs, and the engine injects nothing, so pick a tier
2597
+ (the three after the GLSL exports under "The model"): a stock fragment
2598
+ on your vertex stage,
2599
+ a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
2600
+ `sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
2601
+ `SHADOW_*` trio by hand still works and is no longer the shape.
2602
+
2603
+ ### Picking, pointer and collision
2604
+
1789
2605
  - Picking is triangle-accurate for ordinary meshes (`point` is a surface
1790
2606
  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
2607
+ meshes and for lines/points geometry: there `point` is where the ray
2608
+ meets the population `bounds` box (the geometry's box for lines),
2609
+ `normal` that face's, and `face`/`uv` are absent. Never present an
1793
2610
  instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
1794
2611
  add a per-triangle path in JS - rays at mesh scale are
1795
2612
  interpreter-hostile, and the core already does it.
@@ -1801,50 +2618,10 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1801
2618
  leaf under a design size. Only a leaf whose layout size deliberately
1802
2619
  differs from the target (supersampling) needs `handlersFor`, fed the
1803
2620
  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
2621
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
1815
2622
  under a still pointer fires nothing until the next move - the same
1816
2623
  limit the element hit test has (hit-test-per-frame is an open platform
1817
2624
  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
2625
  - overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
1849
2626
  volume wholly inside a closed mesh with no triangle in reach touches
1850
2627
  nothing, and a body whose center has passed through a wall reports
@@ -1856,3 +2633,27 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1856
2633
  floor absorbs the vertical part (a body never creeps down a slope it
1857
2634
  can stand on), and with `floorSnap: 0` a standing body reports no
1858
2635
  floor unless its motion presses into one.
2636
+
2637
+ ### Geometry and components
2638
+
2639
+ - Per-generator conventions - orientation, UV mapping, which axis a solid
2640
+ stands on, what a cap looks like - live on each generator's doc comment,
2641
+ not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
2642
+ lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
2643
+ map inscribed in the unit square) but the doc comment is the source.
2644
+ - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
2645
+ list END and dirty the order, so the next sync() re-sorts and the mesh
2646
+ keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
2647
+ (setMaterial swaps `mesh.material` before the rebuild, so _detach must
2648
+ not read the new material's flag).
2649
+ - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
2650
+ to the axis at x = 0) - it is a solid of revolution, NOT Three's open
2651
+ polyline shell. An "open" outline must be closed by the author;
2652
+ otherwise the shape is simply wrong, there is no open-profile mode.
2653
+ - `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
2654
+ context).
2655
+ - Geometry local bounds cache on the Geometry (like its GPU buffers and
2656
+ the core's picking copy): mutating `vertices` after a mesh used them
2657
+ leaves stale bounds, a stale GPU buffer AND a stale picking shape
2658
+ until `updateVertices` publishes the range - write through the
2659
+ accessor and call it, or make a new Geometry.