@solidrt/3d 0.0.55 → 0.0.57

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 +1206 -224
  2. package/README.md +16 -7
  3. package/examples/README.md +88 -9
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +263 -0
  7. package/examples/first-person.tsx +129 -0
  8. package/examples/fleet.tsx +200 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +35 -9
  17. package/examples/pick.tsx +145 -37
  18. package/examples/probe.tsx +107 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +68 -48
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +92 -0
  24. package/examples/skybox.tsx +134 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +138 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +20 -0
  32. package/src/color.ts +32 -0
  33. package/src/components/context.tsx +35 -0
  34. package/src/components/directional-light.tsx +48 -0
  35. package/src/components/first-person-camera.tsx +72 -0
  36. package/src/components/group.tsx +18 -0
  37. package/src/components/hemisphere-light.tsx +24 -0
  38. package/src/components/index.ts +46 -0
  39. package/src/components/instanced-mesh.tsx +105 -0
  40. package/src/components/mesh.tsx +92 -0
  41. package/src/components/node-props.ts +90 -0
  42. package/src/components/orbit-camera.tsx +75 -0
  43. package/src/components/perspective-camera.tsx +32 -0
  44. package/src/components/point-light.tsx +52 -0
  45. package/src/components/record-mesh.tsx +68 -0
  46. package/src/components/scene.tsx +281 -0
  47. package/src/components/spot-light.tsx +65 -0
  48. package/src/components/sprite.tsx +38 -0
  49. package/src/components/view3d.tsx +152 -0
  50. package/src/environment-bake.ts +492 -0
  51. package/src/environment.ts +325 -0
  52. package/src/first-person.ts +258 -0
  53. package/src/geometry.ts +6 -2
  54. package/src/glsl.ts +1343 -82
  55. package/src/gltf.ts +432 -112
  56. package/src/index.ts +61 -9
  57. package/src/input.ts +70 -0
  58. package/src/light.ts +383 -0
  59. package/src/material.ts +667 -232
  60. package/src/math.ts +110 -0
  61. package/src/mesh.ts +728 -0
  62. package/src/mixer.ts +476 -0
  63. package/src/model-file.ts +80 -15
  64. package/src/model.ts +204 -35
  65. package/src/node.ts +579 -0
  66. package/src/orbit.ts +292 -212
  67. package/src/scene-pointer.ts +294 -0
  68. package/src/scene-shadows.ts +471 -0
  69. package/src/scene.ts +1435 -1576
  70. package/tools/environment.ts +69 -0
  71. package/tools/model.ts +4 -3
  72. package/src/components.tsx +0 -420
package/AGENTS.md CHANGED
@@ -21,9 +21,19 @@ blendMode and pointer events like any element.
21
21
  change, however many meshes. World matrices live in the core only:
22
22
  `worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
23
23
  writes included). See okf/backlog/spatial-core.md for what still runs
24
- in JS and why. The component face (`Scene`/`Group`/`Mesh`/
24
+ in JS and why. The component face (`Scene`/`View3d`/`Group`/`Mesh`/
25
25
  `PerspectiveCamera`) syncs props into that core over context and renders
26
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.
27
37
  - Rendering is the runtime's. The target is `render: "auto"`: it
28
38
  re-renders when entries change, so a STATIC scene costs zero passes and
29
39
  the library registers no frame loop. Continuous animation is the app's
@@ -32,29 +42,105 @@ blendMode and pointer events like any element.
32
42
  structure, per-frame motion goes straight to the scene).
33
43
  - VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
34
44
  clearColor?, ... })` renders the same scene into a second target from
35
- its own camera (`view.setCamera`, the scene's CameraUpdate shape). Each
36
- mesh gets one entry in the view's target bound as one more draw sink of
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
37
48
  its CORE node, so the one flush writes every target - the app writes
38
49
  nothing per view. Geometry buffers and (without an override) materials
39
50
  are shared; the light set and `scene.setParams` names fan out to every
40
- view, `view.setParams` is the view's own channel; the scene background
41
- is not mirrored; a view has no picking. `overrideMaterial` (Three's
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
42
72
  `scene.overrideMaterial`, scoped to the view) draws every mesh with one
43
73
  material - a depth pass, a normal/id visualizer - skips instanced
44
- meshes (the override cannot know their record layout) and draws in add
45
- order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
46
- input. `ortho: { left, right, top, bottom }` on any camera swaps
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
47
79
  perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
48
80
  the scene's own camera takes it too, and pick() follows.
49
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.
50
109
  - SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
51
- bias?, normalBias?, camera? }}>` (`createDirectionalLight({ castShadow,
110
+ bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
52
111
  shadow })`, `setLight`) makes the scene own an internal
53
112
  `createView({ depth: "texture", overrideMaterial: depth pass })` drawing
54
113
  the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
55
114
  orthographic camera at the light's WORLD position along its world
56
115
  direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
57
- Any directional light may cast (capped by MAX_LIGHTS = MAX_SHADOWS).
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.
58
144
  `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
59
145
  N maps fitted to slices of the SCENE camera's frustum (near ..
60
146
  `shadow.distance`, default the camera far; the practical split; each
@@ -82,7 +168,8 @@ blendMode and pointer events like any element.
82
168
  (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
83
169
  material out and drops the map from its program - a material option,
84
170
  as with vertexColors/triplanar, because the material picks the program
85
- (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s 3x3 PCF
171
+ (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
172
+ hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
86
173
  on each casting light's own term. `examples/shadows.tsx` (three
87
174
  casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
88
175
  - RETARGETED motion is native: `setTransition(node, { position:
@@ -142,22 +229,36 @@ blendMode and pointer events like any element.
142
229
  GPU-free BY DESIGN so they can be checked headless (and, for the two
143
230
  model modules, run under bun in `tools/model.ts`); keep them that way.
144
231
  The rigs under `checks/`
145
- (`geometry-check`, `sweep-check`, `pick-check`, `order-check`,
146
- `gltf-check`) run on
232
+ (`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
233
+ `order-check`, `gltf-check`) run on
147
234
  flux from the repo root: `bunx srt bundle -f --stdout
148
235
  packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
149
- touching what you changed.
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.
150
246
 
151
247
  ## Components
152
248
 
153
249
  | Component | Props |
154
250
  | --- | --- |
155
- | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `fog?` (`{ color, near, far }`, linear by camera distance), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
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 |
156
253
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
157
- | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
254
+ | `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)` |
158
255
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
159
- | `InstancedMesh` | 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 |
160
- | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
256
+ | `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 |
259
+ | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` - or the Scene `camera` prop, the same state (last write wins) |
260
+ | `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
+ | `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)` |
161
262
 
162
263
  Output composition: without `output`, `Scene` emits a minimal
163
264
  `<texture width height>` leaf and nothing else is forwarded - anything
@@ -171,106 +272,313 @@ elsewhere. Called once, untracked, inside the scene context. Scene
171
272
  layout, so render and display size separate - render at 2x and display
172
273
  smaller for supersampling.
173
274
 
174
- Filling the window: `width`/`height` are DEVICE pixels, the leaf's layout
175
- is LOGICAL. A `designSize` view fits the leaf to the window but never
176
- changes the target, so on a HiDPI display a 720-pixel scene is stretched
177
- across ~1100 device pixels and looks soft, and nothing warns (the
178
- examples' `SIZE = 720` is a verification convenience, not a sizing
179
- model). Render at the window's device size and lay the leaf out at its
180
- logical size:
275
+ Fill (the default): omit `width`/`height` and the built-in leaf is laid
276
+ out at 100% of its parent's box (give it a sized parent, as on the web)
277
+ while the target follows the leaf's on-screen size in DEVICE pixels -
278
+ display scale, `designSize` fits and ancestor transforms included
279
+ (getBoundingBoxViewport x displayScale, applied from onLayout, so no
280
+ frame draws at a stale size). A bare `<Scene>` in a pane renders at
281
+ native density on any display, mesh events are wired automatically
282
+ (event scaling reads the leaf's untransformed box back with
283
+ `getLayoutBox`), the `pointer` feed you hand in is spread on the leaf,
284
+ and the camera aspect follows the box. Fill
285
+ or fixed is decided at mount; `output` requires explicit sizes (the
286
+ target cannot follow a leaf it does not own), and giving exactly one of
287
+ width/height throws.
288
+
289
+ Fixed sizes still matter where the target is a measured quantity: probes
290
+ and checks that snapshot exact pixels, supersampling via `output`, or a
291
+ scene composited at a size unrelated to its layout. `width`/`height` are
292
+ DEVICE pixels and the leaf's layout is LOGICAL - a fixed 720-pixel scene
293
+ under a HiDPI `designSize` fit is stretched across ~1100 device pixels
294
+ and looks soft, and nothing warns; that trap is what fill removes (the
295
+ examples fill; scene-views and scene-post-effect keep fixed sizes to
296
+ show multi-view composition and supersampling). A custom `output` leaf
297
+ whose layout differs from the target
298
+ takes `handlersFor` (below), not `handlers`; `useScene()` works inside
299
+ `output` because it runs in the scene context. One spread carries
300
+ everything: the `pointer` feed listens at the scene's root, so
301
+ `{...useScene().scene.handlers}` feeds the nodes, the scene's own
302
+ handlers and the feed alike (a detached leaf gives its feed a `layout`,
303
+ it has no layout box to normalize by).
304
+
305
+ Input, the rule (ARCHITECTURE.md): a camera control consumes a
306
+ device-free abstraction and never handles events or reads a device
307
+ itself. The controls expose `axes` (core's createAxes contract - rates
308
+ sampled per frame, immediate deltas in device-free units) and pose verbs;
309
+ an input map (`createInputMap`, core AGENTS.md) drives the axes by action
310
+ name, and the APP binds devices to the map: the scene leaf's pointer feed
311
+ (`createPointerFeed()`, handed to `<Scene pointer>` / `<View3d pointer>`,
312
+ where it listens at the root of the pointer walk, so a mesh that claims
313
+ its press keeps the control out of that drag; an imperative scene or
314
+ view feeds it with `feedPointer(scene, feed)`), a pad (`gamepad(0)`, or `gamepad.next()`
315
+ for the next pad to press a button), the keyboard (`keyboard`, its key
316
+ events through `input.handlers` on the window).
317
+ Nothing binds by default: `<OrbitCamera />` without `input` moves only
318
+ through its handle. The standard wiring is a preset, plain bindings the
319
+ app applies and edits:
181
320
 
182
321
  ```tsx
183
- let target = createMemo(() => {
184
- let { width, height } = windowSize()
185
- let scale = displayScale()
186
- return { w: Math.round(width * scale), h: Math.round(height * scale) }
187
- })
188
- <Scene width={target().w} height={target().h}
189
- output={t => <texture src={t} width={windowSize().width}
190
- height={windowSize().height}
191
- {...useScene().scene.handlersFor(windowSize)} />}>
322
+ let pointer = createPointerFeed()
323
+ let input = createInputMap(orbitActions)
324
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
325
+ <Scene pointer={pointer}>
326
+ <OrbitCamera input={input} distance={7} />
192
327
  ```
193
328
 
194
- `windowSize` and `displayScale` come from `@solidrt/core`. The leaf's
195
- layout differs from the target, so it takes `handlersFor` (below), not
196
- `handlers`; `useScene()` works inside `output` because it runs in the
197
- scene context.
329
+ The vocabulary is shared with @solidrt/2d, one kind and unit per word:
330
+ `rotate` vec2 (orbit turns), `zoom` axis (octaves, positive in), `pan`
331
+ vec2 (element heights), `look` vec2 (turns), `move` vec2 ([right,
332
+ forward], forward = -y in the screen convention), `rise` axis. Keys and
333
+ sticks move the CAMERA where a drag moves the content, so the presets
334
+ bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
335
+ a drag and a stick both turn the eye.
198
336
 
199
337
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
200
338
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
201
- zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
202
- - drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
203
- auto-orbit. The first argument is anything with the scene's `setCamera`: a
204
- Scene, or a View to drive one view's camera independently (one orbit per
205
- view, each handed the handlers of its own viewport element). Input runs on
206
- core's `createTransform` recognizer, so drag and pinch arbitrate in the
207
- app-wide gesture arena (a viewport inside a scroller
208
- does not double-handle) and rotation starts after the recognizer's slop;
209
- `zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
210
- scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
211
- `viewport()` supplies `{ height, fov }` for the pixel-to-world mapping -
212
- without it, it rotates like one finger; `clampTarget(target)` bounds where
213
- a pan may put the pivot. Zoom aims
214
- at the target unless `zoomAnchor(x, y, {eye, target})` maps the pinch focal
215
- / wheel cursor to a world point (ground hit, target-depth plane, ...) - then
216
- that point stays pinned under the pointer and the target slides toward it;
217
- only the app can build that mapping, since fov, aspect and element placement
218
- are app state. Pair it with `rotateAnchor({eye, target})`: called at gesture
219
- start, its point is projected onto the view axis and re-seats the pivot
220
- without moving the picture, so a drag after an anchored zoom orbits what the
221
- camera looks at, not wherever the zoom left the target. Spread
222
- `orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
223
- from your onFrame (no frame loop of its own), and use its return - true
224
- when the pose changed - to gate per-frame dependents like reprojecting
225
- HUD overlays. `orbiting()` is reactive (HUD-safe); the pose is plain state via
226
- `pose()`/`set()` (also the debug-command shape). It drives position and
227
- target only; fov/near/far stay on scene.setCamera. In a component tree,
228
- reach the scene via `<Scene ref>` or useScene().
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})`
354
+ maps the gesture's focal point - a FRACTION of the element, [0..1, 0..1] -
355
+ to a world point (ground hit, target-depth plane, ...): then that point
356
+ stays pinned under the pointer and the target slides toward it; only the
357
+ app can build that mapping, since it needs the projection
358
+ (scene.unproject over the scene's size). A pinch holds one anchor for its
359
+ whole gesture (the fingers' interleaved events make the span oscillate,
360
+ and re-anchoring per delta turns that into a crawl); the wheel, arriving
361
+ unbracketed, anchors per notch. Pair it with `rotateAnchor({eye, target})`:
362
+ called when a rotate gesture begins, its point is projected onto the
363
+ view axis and re-seats the pivot without moving the picture, so a drag
364
+ after an anchored zoom orbits what the camera looks at, not wherever the
365
+ 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
373
+ drives position and target only; fov/near/far stay on scene.setCamera
374
+ (or the Scene `camera` prop). The auto-orbit pauses while a gesture is
375
+ open.
376
+
377
+ In a component tree, skip the wiring: `<OrbitCamera input={input}
378
+ azimuth={1.2} distance={7} />` as a Scene child reaches the scene through
379
+ context (as a `<View3d>` child, that view: the context's `viewport` is
380
+ the nearest owner's), drives from the map in `input` (its `rotate`,
381
+ `zoom`, `pan` actions, or the names in `actions`; live - a new map
382
+ reconnects) and nothing else, and runs a frame loop only while
383
+ `active()`, so a camera moved by drags alone keeps the app demand-driven
384
+ idle. The pose props are initial values: runtime pose changes (and the
385
+ 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.
390
+
391
+ First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
392
+ pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
393
+ a position plus yaw/pitch (yaw 0 faces -z, positive turns left; pitch
394
+ positive looks up), Unity's FirstPersonController shape (look AND move in
395
+ one control) where Three splits PointerLockControls from a hand-written
396
+ key loop. Its `axes`: `look` (a delta of one element height sweeps half a
397
+ turn - a drag across the screen turns the walker around - and the
398
+ pointer feed's `mouseDelta` delivers mouse motion under pointer lock in
399
+ the same unit; a rate turns at 0.4 turn/s), `move` ([right, forward],
400
+ forward = -y: a stick pushed up or W reads [0, -1]; a rate walks at
401
+ `moveSpeed`, a delta is a step in world units, diagonals clamped to unit
402
+ length so they walk no faster) and `rise` (world up at `moveSpeed`, fly
403
+ mode only). Walking (the default) flattens the heading onto the ground
404
+ plane at fixed height; `fly` moves along the view. The verbs:
405
+ `lookBy(yaw, pitch)` radians, `moveBy(right, forward, up?)` in the
406
+ walker's frame, `set(pose)`. Every option but the initial pose is read
407
+ where it applies (`fly` per step, `clampPosition` per move, the rates and
408
+ pitch clamps per input), so a field changed on the options object takes
409
+ effect on the next move: walk and fly are one control. The control NEVER
410
+ calls `lockPointer` - click-to-lock and Escape-to-release are the app's
411
+ window-level decisions (see `examples/first-person.tsx`) - and has no
412
+ collision of its own: `clampPosition(next, current)` is the whole hook -
413
+ bounds, a floor height, or `moveAndSlide` over `next - current` against
414
+ the collision layer (see `examples/collision.tsx`, whose `jump` is one
415
+ 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
418
+ tracks held keys from the down/up pair through `input.handlers`; spread
419
+ its `onBlur` too, since the up never arrives once focus has left.
420
+
421
+ `<FirstPersonCamera input={input}>` as a Scene child wires the control
422
+ to the scene and the map, nothing else: the app spreads
423
+ `input.handlers` on the window (keys bubble there from wherever focus
424
+ is, so a click on the scene needs no focus dance) and hands the scene
425
+ its `pointer` feed. The frame loop runs only while `active()`. Every prop
426
+ but the initial pose is live: `fly={flying()}` toggles walk/fly on the
427
+ running control (pose carries over, no remount, and `clampPosition` may
428
+ swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
429
+ clamp change re-clamps at once. checks/orbit-check.ts and
430
+ checks/first-person-check.ts pin both controls headless (they import
431
+ `@solidrt/core/input` only).
229
432
 
230
433
  Overlay projection: `scene.project(point)` maps a world point to scene
231
434
  pixels (top-left origin, y down - the output texture's own space; `w` is
232
- clip-space w, the camera-forward distance) and returns null for a point
233
- at or behind the camera plane. It reflects a pending `setCamera`
234
- immediately, so set-then-project in one tick is exact. `scene.viewProj(out?)`
235
- copies the view-projection matrix for batch work. Never rebuild the
236
- camera matrices by hand for a HUD.
435
+ the camera-forward distance in world units under either projection) and
436
+ returns null for a point at or behind a PERSPECTIVE camera's plane; an
437
+ ortho camera places every point (`w` may be <= 0 there - negative near
438
+ is legal ortho). It reflects a pending `setCamera` immediately, so
439
+ set-then-project in one tick is exact. `scene.unproject(x, y, w, out?)`
440
+ is its exact inverse: the world point at that pixel and camera-forward
441
+ distance `w` - project()'s `w` round-trips in both modes (the
442
+ drag-at-depth recipe: project the grabbed point once, keep its `w`,
443
+ unproject each move). `scene.viewProj(out?)` copies the view-projection
444
+ matrix for batch work. Never rebuild the camera matrices by hand for a
445
+ HUD.
237
446
 
238
447
  Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
239
448
  through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
240
449
  world units, nearest first; every hit along the ray, not just the front
241
- one). `scene.raycast(origin, direction)` is the world-space primitive
242
- under it.
450
+ one). `scene.raycast(origin, direction, opts?)` is the world-space
451
+ primitive under it, and `scene.screenRay(x, y)` the ray itself
452
+ (`{ origin, direction }`; direction's camera-forward component is 1, so
453
+ `origin + w * direction` = unproject) for intersection work pick cannot
454
+ do - drag planes, ground grids, filtered raycasts. All three cast
455
+ exactly the same ray. raycast's `opts` filters the query:
456
+ `{ layers }` (Unity's layerMask) replaces the scene's mask for this ray,
457
+ and `{ meshes }` (Three's intersectObjects) is an include-list; a
458
+ per-frame ground query passes one or the other instead of skipping
459
+ skyboxes and actors by hand.
243
460
  The index and the narrowphase live in the spatial core: every attached
244
461
  mesh's local box is a leaf in a dynamic AABB tree the flush refits from
245
462
  the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
246
463
  an ordinary mesh is then tested per triangle against its geometry's
247
464
  shape (one CPU copy per distinct geometry, created with its GPU buffers),
248
465
  so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
249
- a ray through a knot's hole misses. An instanced mesh is box-only (its
250
- explicit population bounds; records are opaque), so its hits have none of
251
- the three. Both methods
466
+ a ray through a knot's hole misses. A large geometry's triangles are
467
+ BVH-indexed too - built by the first ray that reaches the shape, log-cost
468
+ after - so raycasting a merged static scene stays cheap (see the batching
469
+ advice). An instanced mesh is box-only (its
470
+ explicit population bounds; records are opaque): it is tested by the
471
+ box's twelve triangles, so its hits carry the struck face's `normal` and
472
+ no `face`/`uv` - and a ray from inside it meets the far side, the
473
+ surface contract overlap/sweep share. Both methods
252
474
  flush pending writes first (the lookAt/project immediacy contract), and
253
475
  both skip invisible meshes.
254
476
 
255
- Mesh pointer events - the element vocabulary one tree deeper:
256
- `onPointerDown/Move/Up/Enter/Leave` as plain fields on any node (and as
257
- Mesh/Group props). The nearest hit mesh is the target; down/move/up
258
- bubble mesh -> ancestor groups (`stopPropagation()` stops the walk);
259
- enter/leave fire on the mesh alone, pairing on hover changes. A
260
- pointer-down CAPTURES its mesh until the up: moves and the up keep
261
- dispatching to it off-mesh (the platform's captured-drag rule), with
262
- `point`/`distance` null while the ray misses it. The event carries the
263
- element fields (pointerId, pointerType, button, modifiers) plus `mesh`,
264
- `currentTarget`, `point`, `distance`, and `x`/`y` in scene pixels.
265
- Wiring: the built-in `<Scene>` leaf carries `scene.handlers`
266
- automatically (opt out: `events={false}`); an `output` leaf or
267
- imperative composition spreads `{...scene.handlers}` onto the element
268
- showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
269
- the target size - true for the built-in leaf and a d-texture at natural
270
- size, under any ancestor transforms or design-size fits (the hit test
271
- undoes them; localX/localY arrive in the leaf's layout frame). A leaf
272
- laid out at a different size (the supersampling pattern) uses
477
+ Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
478
+ motion, opts?)` are the same index's other two questions - what a volume
479
+ touches, and where a moving one first touches: Unity's
480
+ OverlapSphere/Box/Capsule and SphereCast/CapsuleCast/BoxCast, Godot's
481
+ intersect_shape and cast_motion (Three has the raycaster alone; every
482
+ Three game adds a physics library or three-mesh-bvh for this). A
483
+ `Volume` is a sphere (`{ center, radius }`), a capsule (`{ a, b,
484
+ radius }`, the radius swept along the segment - a character) or an
485
+ oriented box (`{ center, halfExtents, rotation? }`). overlap returns
486
+ `Overlap[]` - `{ mesh, point, normal, depth }`, the deepest contact per
487
+ mesh: the point on the mesh, the unit direction out of it and the depth
488
+ along it that clears the contact (Godot's get_rest_info, Unity's
489
+ ComputePenetration, per hit; unordered) - and sweep `Impact[]` -
490
+ `{ mesh, time, point, normal }`, per mesh its first touch with `time`
491
+ the fraction of the motion, earliest first. Both take raycast's `opts`
492
+ (`{ layers, meshes }`) and test per triangle in WORLD space against the
493
+ same shapes, so any transform holds, an instanced mesh or sprite counts
494
+ by its box, and the physics-collider pattern is one `{ layers }` away.
495
+ Two contracts to know: the tests are SURFACES (a volume wholly inside a
496
+ closed mesh with no triangle in reach touches nothing - the trimesh rule
497
+ in every engine), and a sweep from a volume already in contact reports
498
+ time 0 only while the motion closes in; leaving or sliding along the
499
+ contact is no hit, which is what lets a slide along a wall proceed. A
500
+ zero motion touches nothing. The filters run in the core: every query
501
+ hands `flux:spatial` a `{ root, layers, nodes }` filter (the scene root,
502
+ the mask, the `meshes` include-list as node ids - an instanced mesh
503
+ contributing every instance node), and a mesh's `layers` is written to
504
+ its core node (`setLayers`), so a query never sees another scene's or a
505
+ 2d layer's nodes and JS only maps hits back to meshes.
506
+ `scene.moveAndSlide(volume, motion, opts?)` (and the free
507
+ `moveAndSlide(scene, ...)` spelling of it) is the controller over them:
508
+ Godot's CharacterBody3D.move_and_slide and Unity's
509
+ CharacterController.Move as one PURE call - no node, no velocity; the
510
+ first-person camera composes it in `clampPosition`, a node-driven body
511
+ applies `result.motion` with setTransform. The loop runs in the spatial
512
+ core (`alloy/src/spatial/mover.rs`, one FFI call per body per frame; the
513
+ 2d layer's mover is the same loop), the JS side only packs the volume
514
+ and maps hits back to meshes. It pushes the body out
515
+ of anything it starts inside, sweeps, stops a skin short, slides the
516
+ rest along the contact plane up to `maxSlides` times, then snaps down
517
+ onto a floor within `floorSnap` unless the motion rises - and the
518
+ `floor` it reports is the one it ENDS on (within `floorSnap` below), not
519
+ one it touched on the way. `MoveOptions`
520
+ adds `up`, `floorMaxAngle` (45 degrees: flatter is floor, steeper a wall
521
+ the body slides down), `maxSlides` (6), `skin` (0.01) and `floorSnap`
522
+ (0.1, 0 off) to the query filters; the result is `{ motion, floor, wall,
523
+ ceiling, hits }`. Gravity is the caller's - fold it into `motion` and
524
+ zero it while `floor` is set (the snap keeps reporting the floor while
525
+ the body stands still) - and a walkable floor absorbs the vertical part,
526
+ so a body never creeps down a slope it can stand on.
527
+ `examples/collision.tsx` is the whole pattern: a capsule walker through
528
+ `clampPosition`, gravity as a frame loop that runs only while airborne,
529
+ the level on a collider layer bit beside its drawn one, pickups lit by
530
+ one overlap per move. Deliberately absent and additive when asked: a
531
+ step offset (Unity's stepOffset; Godot has none either) and a cylinder
532
+ volume (Godot only).
533
+
534
+ Pointer events (scene-pointer.ts): the element event model one tree
535
+ deeper, with the SCENE as the root of the walk (under a `<View3d>` leaf:
536
+ the view). `onPointerDown/Move/Up/Enter/Leave/Wheel/Tap` are plain
537
+ fields on any node (and Mesh/Group/Instance props); the nearest hit is
538
+ the target - the struck instance of an instanced mesh, else the mesh -
539
+ and down/move/up/wheel dispatch there, bubble through the ancestors and
540
+ END AT THE SCENE's listeners (`scene.listen({...})`, the `<Scene>`
541
+ `onPointer*`/`onWheel`/`onTap` props); over empty space the walk is the
542
+ scene alone, with `event.mesh` null (a tap there is the deselect idiom).
543
+ `event.mesh` stays the hit (`NodePointerEvent` in the chain,
544
+ `ScenePointerEvent` at the root with `mesh` nullable), `currentTarget`
545
+ the node whose handler runs (the scene at the end), `stopPropagation()`
546
+ stops the walk, and a stopped DOWN claims the whole press: that
547
+ pointer's move, up and tap never reach the scene either (the chain still
548
+ bubbles). That one rule is how a mesh drags itself under an
549
+ `<OrbitCamera>` without the view turning: stop the down, own the
550
+ captured moves - the control's pointer feed listens at the root
551
+ (`<Scene pointer>` does it, `feedPointer(scene, feed)` by hand) and sees
552
+ only what the nodes let through. Capture is per pointerId to the press
553
+ target, the scene included (a drag from empty space keeps delivering to
554
+ the root as it crosses meshes; a drag from a mesh keeps naming it, with
555
+ `point`/`distance` null while the ray misses it). Enter/leave fire on
556
+ the struck node alone - a group never receives them - while the root
557
+ sees every move, so "hovering empty space" is a root move with `mesh`
558
+ null. Taps are synthesized by the dispatch (DOM click, Unity's click
559
+ handler): `onTap` fires after the up when the press released on the
560
+ target it pressed within the slop (8 window px, core's recognizer slop,
561
+ so a press is never both a tap and a drag), was the only pointer down
562
+ for its whole press (a pinch never taps), with `tapCount` counting
563
+ repeats within 300 ms and 20 px on the same target (a double tap is
564
+ `tapCount === 2`); a release on another instance of the same mesh is no
565
+ tap. Wheel walks like a move with `deltaX/deltaY` (a node stopping it
566
+ keeps the zoom out). Every event carries `native`, the leaf's element
567
+ event, for core's recognizers, and the element fields (pointerId,
568
+ pointerType, button, modifiers) plus `x`/`y` in scene pixels
569
+ (screenRay's input - a drag plane is one intersection away). Root
570
+ listeners all run, in registration order (the root is the last stop,
571
+ nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
572
+ `scene.handlers` automatically (opt out: `events={false}`); an `output`
573
+ leaf or imperative composition spreads `{...scene.handlers}` onto the
574
+ element showing the texture. `scene.handlers` assumes that leaf is LAID
575
+ OUT at the target size - true for the built-in leaf and a d-texture at
576
+ natural size, under any ancestor transforms or design-size fits (the hit
577
+ test undoes them; localX/localY arrive in the leaf's layout frame). A
578
+ leaf laid out at a different size (the supersampling pattern) uses
273
579
  `scene.handlersFor(() => ({ width, height }))` with its layout size.
580
+ checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
581
+ and tap rules headless; examples/pick.tsx is the live guard.
274
582
 
275
583
  Geometry generators take ONE options object, every field optional with
276
584
  a default, named as Three names them: `box({ width, height, depth })`
@@ -313,7 +621,10 @@ concatenates parts into one geometry with offset indices (uint32 past 64k
313
621
  vertices); parts must share one layout, a mixed list throws. Together
314
622
  they collapse a static scene to one mesh per material - transform each
315
623
  part into place, merge, draw once - so only what actually moves keeps a
316
- node, a draw entry and a per-frame `uModel` write of its own. Both are
624
+ node, a draw entry and a per-frame `uModel` write of its own. Merging
625
+ does not tax picking: a merged geometry's raycast narrowphase runs
626
+ through its triangle BVH (built on the first ray), so merge for draw
627
+ count without giving up ground queries. Both are
317
628
  pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
318
629
  call, and the source geometries are untouched. `geometryBounds(geometry)`
319
630
  returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
@@ -350,9 +661,10 @@ uint16/uint32 indices by vertex count automatically.
350
661
  Materials:
351
662
 
352
663
  - `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
353
- straight `[r, g, b, a?]` 0..1, premultiplied internally; `cull` and
664
+ straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
665
+ below), premultiplied internally; `cull` and
354
666
  `alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
355
- opts out of the scene's fog (all three standard materials take it).
667
+ opts out of the scene's fog (all four library materials take it).
356
668
  - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
357
669
  that turns to face the camera IN THE VERTEX STAGE (off the shared
358
670
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
@@ -377,7 +689,15 @@ Materials:
377
689
  `normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
378
690
  (the camera's world-space view axes, shared likewise - a billboard is
379
691
  `center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
380
- uViewProj rows, that carries the clip flip) and `uniform mat4 uNormal` (the world
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
381
701
  inverse-transpose, written beside uModel for this material's meshes;
382
702
  take `mat3(uNormal)` - correct under non-uniform scale, where
383
703
  mat3(uModel) bends normals off the surface). Attributes come from the
@@ -401,50 +721,283 @@ Materials:
401
721
  pipeline with its own values. `dispose()` lives on the class alone.
402
722
  `shaderMaterial(opts)` is exactly a class with one instance (its
403
723
  `dispose` forwards to the class).
404
- - `instanceAttributes: [{ name, format }]` on either shader-material form
405
- makes an INSTANCED material: the vertex stage reads them as `in`
724
+ - `instanceAttributes: [{ name, format, slot? }]` on either shader-material
725
+ form makes an INSTANCED material: the vertex stage reads them as `in`
406
726
  variables beside the layout's own, and each drawn instance gets one
407
- record of the mesh's instance buffer. Its meshes come from
408
- `createInstancedMesh` (below); a `createMesh` mesh is rejected at add().
409
-
410
- Instancing - one draw entry covering a population:
411
- `createInstancedMesh(geometry, material, records, count?, { bounds?,
412
- label? })` returns an ordinary Mesh whose entry draws the geometry once
413
- per record. `records` is the interleaved per-instance data (stride = the
414
- material's instanceAttributes summed, a mismatch throws), uploaded to a
415
- mesh-owned buffer whose capacity starts at the records given. `count` picks how
416
- many records draw (default all). Everything mesh works unchanged:
417
- setTransform moves the whole population through one uModel, setVisible
418
- zeroes the drawn count and restores the record count on unhide,
419
- renderOrder/params/geometry/material swaps apply. `setInstances(mesh,
420
- records, count?)` rewrites records from the start (count defaults to the
421
- records written; more than capacity GROWS: capacity doubles into a
422
- replacement buffer, the entry is re-pointed via `setDrawBuffers`, the old
423
- buffer is freed), `setInstanceCount(mesh, n)` is the population dial (clamped to capacity;
424
- frame-rate-safe), and `disposeInstances(mesh)` detaches and frees the
425
- record buffer - the one explicit free, geometry-buffer rule. Records are
426
- opaque data (position/yaw/tint/whatever your shader reads), NOT matrices:
427
- a per-instance mat4 would be four vec4 columns reassembled in the shader,
428
- but most fleets want a few floats. Picking: the library cannot know where
429
- records place instances, so an instanced mesh has NO picking leaf unless
430
- you pass `bounds` (local, covering the population) - then it picks and
431
- transparent-sorts conservatively as one box. `examples/instanced.tsx` is
432
- the live proof.
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.
737
+
738
+ Instancing - one draw entry covering a population, in two forms. The
739
+ axis between them is WHERE MOTION IS COMPUTED, the same split as
740
+ @solidrt/2d's sprite and record layers.
741
+
742
+ `createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
743
+ draws the geometry once per instance NODE: `addInstance(mesh,
744
+ transform?, parent?)` returns a scene node (kind "instance") placed
745
+ inside the mesh - setTransform/setTransition/setVisible, lookAt,
746
+ worldPosition, pointer handlers and children of its own all apply - whose
747
+ matrix RELATIVE to the mesh the spatial core writes into the record
748
+ buffer (`bindMatrixRecord` anchored on the mesh node: one coalesced
749
+ buffer write per flush however many instances moved, so native
750
+ 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
753
+ stage splices `INSTANCE_MATRIX` from `@solidrt/3d/glsl`: `uModel *
754
+ instanceMatrix() * vec4(aPos, 1.0)`, normals through
755
+ `instanceNormalMatrix()` (Three's derivation, exact for any rotation and
756
+ scale at no per-instance data; a sheared hierarchy - a non-uniformly
757
+ scaled group above a rotated instance - takes
758
+ `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
767
+ multiplies by the same color, so an instance faded below the cutoff
768
+ 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
770
+ @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
777
+ in a fleet): the record stays mesh-relative through it. Picking is per
778
+ instance: every instance node carries the geometry's bounds and
779
+ triangle shape, so `Hit`, `Overlap` and `Impact` name the `instance` and
780
+ pointer events bubble from it (`event.instance`) through the mesh to its
781
+ ancestors. `bounds` on the mesh is optional here: without it the mesh
782
+ culls by the union of its live instances' boxes (following them, hidden
783
+ ones included) and sorts by its node position; with it the explicit
784
+ box does both. Three's InstancedMesh count
785
+ constructor over Unity's one-transform-per-instance model; Godot's
786
+ MultiMesh is the record form. The components: `<InstancedMesh capacity>`
787
+ with `<Instance position style transition onPointerDown>` children
788
+ (`<Group>` children are squads, an `<Instance>` is a parent too);
789
+ `examples/fleet.tsx` is a thousand of them springing between formations
790
+ with tap-to-tint, `probes/3d-instance-probe.tsx` the function face.
791
+ Mount cost is the one difference between the faces: an `<Instance>` row
792
+ costs about eight times an `addInstance` call (the component machinery -
793
+ 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
796
+ mount in tens of milliseconds either way; populations spawned per frame,
797
+ or in five figures, belong to the function face.
798
+
799
+ `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).
805
+ `setRecords(mesh, records, count?)` rewrites from the start (count
806
+ 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
811
+ library cannot know where they place instances: a record mesh has NO
812
+ picking leaf unless you pass `bounds` (local, covering the population) -
813
+ then it picks and transparent-sorts conservatively as one box. The
814
+ escape hatch for motion only JS can compute at scale (a particle sim, a
815
+ crowd stepped in a worker).
816
+
817
+ Everything mesh works on both: setTransform moves the whole population
818
+ through one uModel, setVisible zeroes the drawn count and restores it on
819
+ unhide, renderOrder/params/geometry/material swaps apply, and
820
+ `disposeInstances(mesh)` detaches and frees the record buffers - the one
821
+ 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.
433
824
 
434
825
  Background: `scene.setBackground(source | null)`, the `background` option
435
- on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
436
- FIRST entry of the scene's own pass (attributeless fullscreen triangle,
437
- depth off) - one target instead of a backdrop texture stacked under the
438
- scene, with no separate resize plumbing. The source gets the
439
- shader-target fragment contract exactly (vUV 0..1 top-left origin,
440
- iResolution, fragColor; no `#version` line = the standard preamble), so
441
- a `createShaderTexture` backdrop ports verbatim. Three's
442
- `scene.background = color` is `clearColor` here; a texture-id form can
443
- widen the signature later (a branded TextureId is a number, so
444
- `string | TextureId` disambiguates at runtime). No app-driven uniforms:
445
- a background is static art - anything animated is a mesh's own
446
- shaderMaterial (or, until blend factors land, a separate shader texture
447
- underneath, which translucent grounds also still need).
826
+ on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
827
+ of the scene's own pass (attributeless fullscreen triangle, depth off) -
828
+ one target instead of a backdrop texture stacked under the scene, with
829
+ no separate resize plumbing. Two forms:
830
+
831
+ - Fragment GLSL. The source gets the shader-target fragment contract
832
+ (vUV 0..1 top-left origin, iResolution, fragColor; no `#version` line
833
+ = the standard preamble), so a `createShaderTexture` backdrop ports
834
+ verbatim, PLUS `in vec3 vRay`: the world-space view ray through the
835
+ pixel, unnormalized (the vertex stage carries its clip position back
836
+ through the shared uInvViewProj at the near and far planes). A
837
+ directional sky - horizon gradient, sun disc, stars - is a few lines
838
+ on `normalize(vRay)`. The background is an ordinary scene entry, so it
839
+ may declare `uniform vec3 uCamPos` (the ray's origin) and any name
840
+ written through `scene.setParams` (an app clock for an animated sky).
841
+ Godot's sky shader and Unity's skybox material are the same idea; the
842
+ 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.
848
+ - A skybox `{ cube, intensity?, rotation? }` (SkyboxOptions): a cube
849
+ map from createCubeTexture sampled along the same ray - Three's
850
+ `scene.background = cubeTexture` with `backgroundIntensity` and
851
+ `backgroundRotation`. `rotation` is a turn about world y in radians
852
+ (the sky turns as a node with that rotation would); `intensity` a
853
+ multiplier. Replacing a skybox with a skybox rewrites the entry's
854
+ params and cube in place (no recompile), so the reactive prop can
855
+ animate the rotation. Under an orthographic camera every pixel looks
856
+ the same way, so a skybox is one flat color there. A 2D texture id
857
+ throws at the samplerCube binding. `examples/skybox.tsx`.
858
+
859
+ The cube-map convention: a cube map holds what a GL lookup returns -
860
+ each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
861
+ and every library lookup is a plain `texture(cube, dir)` in world space.
862
+ No shader flip, as in Godot and Unity, which convert images at import;
863
+ Three instead flips x in the shader for image cubes (`flipEnvMap`) and
864
+ not for rendered ones, so a ported Three shader drops its flip, and a
865
+ Three-style six-face image set (px, nx, py, ny, pz, nz as seen from
866
+ inside) is mirrored per image at load. Every bake here (the tool,
867
+ `equirectToCube`, the examples' JS skies) writes GL's table directly,
868
+ and a cube the scene renders itself needs nothing. Three's `scene.background = color` is `clearColor` here; a 2D
869
+ image form can widen the signature later (a branded TextureId is a
870
+ number, so the object form keeps it unambiguous). Translucent grounds
871
+ over a background still need blend factors (a separate shader texture
872
+ underneath until then).
873
+
874
+ Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
875
+ null)`, the `environment` option on createScene and the reactive `Scene`
876
+ prop - the cube map every `standard` material is lit by (always: the
877
+ split sum `envRadiance` at its roughness times PBR's `envBrdf` for the
878
+ specular, `envIrradiance` - the fully rough sample along the normal, as
879
+ Three's getIBLIrradiance and Godot read it - added to the hemisphere
880
+ for the diffuse) and every `lit({ reflectivity })` material mirrors,
881
+ typically the skybox's own cube turned with it. The cube to use is a
882
+ BAKED one: `bunx srt tool 3d/environment sky.hdr -o assets/sky.srte`
883
+ turns an equirectangular Radiance .hdr (Poly Haven's are CC0) into the
884
+ six faces plus the GGX-prefiltered mip chain in linear float, and
885
+ `await loadEnvironment("assets/sky.srte")` uploads it as an explicit
886
+ "rgba16f" chain (createCubeTexture's array-of-levels form: no generated
887
+ mipmaps, so no half-float render support needed - it works on every
888
+ device; created after an await, so not auto-freed). Unity convolves at
889
+ import the same way; Three's PMREMGenerator and Godot's radiance map do
890
+ it at runtime. The roughness-to-level rule is `ENVIRONMENT`'s:
891
+ roughness r samples level `r * (log2(size) - 2)`, so a roughness of 1
892
+ lands on the 4x4 level (ENV_ROUGH_FACE in environment-bake.ts), the
893
+ last one the bake convolves; a `mipmap: true` cube from six faces
894
+ (equirectToCube, a JS-baked sky) merely box-filters that chain, so its
895
+ rough reflections are sharper than they should be and its diffuse term
896
+ is a coarse average - fine for a sky gradient, wrong for a photograph.
897
+ A 128 environment (2 MiB, the default) lights any surface; for a
898
+ mirror-finish showpiece bake at 256, and for a crisp backdrop pair it
899
+ with a separate hi-res LDR skybox (a 2k panorama through equirectToCube)
900
+ as `background` while the .srte stays the `environment`. Scene-level like Three's
901
+ `scene.environment`, Unity's environment reflections and Godot's
902
+ sky-lit reflections: ONE `uEnv` samplerCube bound on every target the
903
+ scene draws into (a 1x1 black placeholder while unset) and one
904
+ shared-params write (`uEnvIntensity`, `uEnvRotation`, `uEnvOn`), however
905
+ many meshes reflect; no per-material envMap (Three's Basic/Phong
906
+ `envMap`) - a custom material composes ENVIRONMENT from
907
+ `@solidrt/3d/glsl`. `reflectivity` 0..1 is the
908
+ face-on weight, rising to 1 at grazing angles (Schlick), mixed in as
909
+ `rgb = mix(rgb, reflection, weight)`: 1 is chrome, ~0.05 a glossy
910
+ dielectric with rim reflections; Three's Phong `reflectivity` under its
911
+ MixOperation with a fresnel weight (Three's default MultiplyOperation
912
+ tints instead; not offered). The reflection blurs with `shininess`:
913
+ roughness `sqrt(2 / (shininess + 2))` picks a mip level of the cube
914
+ (`textureLod`), so the environment cube wants `mipmap: true`; a cube
915
+ without mipmaps stays sharp. `specularMap`'s red scales it like
916
+ `specular`. For `lit` it is not an ambient light source: the hemisphere
917
+ light stays its only ambient term (`standard` adds envIrradiance; SH9
918
+ is a later, additive mode). A declared `reflectivity` with no
919
+ environment set contributes nothing (uEnvOn 0), not a black reflection.
920
+ `examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
921
+ baked HDRI lighting the scene alone).
922
+
923
+ Reflection probes: `scene.createReflectionProbe({ position, size?,
924
+ near?, far?, layers?, clearColor?, label? })` renders the scene into a
925
+ cube map from a point - Three's CubeCamera, Unity's and Godot's
926
+ realtime ReflectionProbe - and returns `{ cube, setPosition, update(),
927
+ dispose() }`; `cube` is what `environment={{ cube }}` (a chrome ball
928
+ mirroring its surroundings) or `background` takes. A view under the
929
+ hood: one entry list mirrored from the scene, the light set and scene
930
+ params fanned out, its own layer mask (keep the mirroring object out of
931
+ its own probe with `layers`), the scene's background drawn first on
932
+ every face (the GLSL sky or skybox behind the meshes, through the face
933
+ camera, in linear light - what Three, Unity and Godot probes see), a
934
+ cube draw target (`createCubeDrawTarget` in core, rendered face by face
935
+ with `renderTarget(cube, face)`).
936
+ Nothing renders it but `probe.update()`: six scene passes, from the
937
+ meshes as the last frame's flush placed them, so call it when the
938
+ surroundings moved (every frame for a moving scene, once for a still
939
+ one), then the PREFILTER: the faces convolved on the GPU into a second,
940
+ `mipmap: true` cube target level by level (`renderTarget(chain, face,
941
+ level)`, one small pass each - 48 at 128 - the bake tool's GGX
942
+ importance sampling as a fragment, `createPrefilter` in environment.ts,
943
+ with the same roughness-to-level rule as a .srte chain), so `standard`
944
+ blurs a probe by roughness exactly like a baked environment; `prefilter:
945
+ false` skips it and hands out the sharp faces (Three's CubeCamera: a
946
+ mirror at every roughness). COST: the chain's passes are tiny, but a
947
+ pass that samples mip levels of a cube map carries a fixed GPU cost
948
+ (about 0.3 ms each on an Intel/Mesa laptop, the same at 8 samples or at
949
+ 256 - measured 2026-09-05), so a prefiltered probe updated every frame
950
+ costs ~14 ms of GPU there against ~1 ms for the six face passes: fine at
951
+ 60 fps on its own, the largest single item in a frame budget. Realtime
952
+ probes are the expensive option in every engine (Unity time-slices
953
+ them): update a prefiltered probe when the surroundings changed, or
954
+ 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
958
+ renders it (`limits.halfFloatRenderable`, every GLES 3 device here: a
959
+ sun's or an emissive's range survives into the reflection, as in every
960
+ engine's HDR probe) and 8-bit clamped elsewhere - the renderer decides
961
+ for all probes (Godot), not a per-probe knob (`probeFormat()` in
962
+ environment.ts) - and the probe never samples its own cube while
963
+ rendering (a black environment stands in: one bounce). The face cameras are plain world-up cameras through an
964
+ x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
965
+ from outside; the engine inverts the front-face rule on cube target
966
+ passes so cull modes keep their meaning. `examples/probe.tsx`.
967
+
968
+ Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
969
+ 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
973
+ TextureId for `environment={{ cube }}`: Godot's sky-to-radiance bake, so
974
+ a procedural sky lights the scene with no light nodes, and the runtime
975
+ way to turn a hi-res LDR skybox into a properly convolved environment
976
+ (the `mipmap: true` box chain above is the sharper, cheaper
977
+ alternative). A snapshot at the probe format (half float where
978
+ renderable; default size 128): bake again when the sky changes, and
979
+ 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
982
+ bakes as 40.0 and shows as the broad bright highlight on rough metal
983
+ that HDR is for. A sky that reads
984
+ `uCamPos` bakes from the origin; scene params (an app clock) are seen as
985
+ of the call. `examples/sky-lit.tsx`.
986
+
987
+ Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
988
+ equirectangular 2D texture (createImage, createTexture) into a cube
989
+ TextureId on the GPU, synchronously (six face passes straight into a
990
+ cube draw target of the panorama's format - rgba8, rgba8-srgb or
991
+ rgba16f, no readback; `opts` are createCubeTexture's - `mipmap: true`
992
+ for an environment). The center column faces -Z and the top row is +Y,
993
+ as in Godot's PanoramaSkyMaterial and Unity's Skybox/Panoramic; Three
994
+ centers +X, a quarter turn away. Leave the source texture's wrap at
995
+ clamp (`repeat` also wraps vertically and bleeds the poles). An HDR
996
+ panorama uploaded as rgba16f converts into a half-float cube (sharp:
997
+ a skybox, or `mipmap: true` for the box chain); its PREFILTERED form is
998
+ the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
999
+ decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
1000
+ pure TypeScript and bun-tested - there is no runtime .hdr decoder.
448
1001
 
449
1002
  Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
450
1003
  the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
@@ -477,6 +1030,31 @@ horizon, and put `far` at or inside the camera's far plane to hide the
477
1030
  clip. `examples/fog.tsx` cycles the forms over a valley;
478
1031
  `examples/cascades.tsx` fogs its field to the sky.
479
1032
 
1033
+ Color: the scene shades in LINEAR light and outputs sRGB, like Three
1034
+ (ColorManagement), Godot and Unity's linear space - no gamma mode. Every
1035
+ `[r, g, b]` color option is sRGB, what a color picker shows: material
1036
+ `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.
1057
+
480
1058
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
481
1059
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
482
1060
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
@@ -485,14 +1063,22 @@ plus the colored layout's aColor forwarded raw as vColor - using it makes
485
1063
  the material need that channel) and the pure functions `HEMISPHERE`
486
1064
  (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
487
1065
  `BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
488
- (`fresnel(n, v, power)`), and the shadow trio composed IN ORDER:
1066
+ (`fresnel(n, v, power)`), `PBR` (the GGX metalness/roughness model
1067
+ `standard` shades with: `ggxSpecular(n, v, l, f0, roughness)` - one
1068
+ light's lobe, to weight by `lambert(n, l)` and the light color like the
1069
+ diffuse - `envBrdf(nv, roughness)` for the split sum's scale and bias on
1070
+ f0, `DIELECTRIC_F0`, and the D/V/F pieces; it defines `PBR_PI`, not
1071
+ `PI`), and the shadow trio composed IN ORDER:
489
1072
  `SHADOW_SLOTS` (the scene's shadow set: `uShadowAtlas`, per map slot
490
- `uShadowRect[M]`/`uShadowMatrix[M]`, per directional light
1073
+ `uShadowRect[M]`/`uShadowMatrix[M]`, per light index
491
1074
  `uShadowFirst[N]`/`uShadowCount[N]` (its slots; a cascaded light has
492
1075
  several, tightest first), `uShadowBias[N]`, `uShadowNormalBias[N]`),
493
1076
  `SHADOW` (`shadowPoint(coord)` - clip to map point, `shadowInside(p)` -
494
1077
  does the map have it, `shadowSample(map, rect, p, bias)` - one tile's
495
- 3x3 PCF factor, and `shadow(map, rect, coord, bias)` composing the
1078
+ factor as ONE hardware comparison tap (`uShadowAtlas` is a
1079
+ sampler2DShadow; the engine binds the comparison sampler for that
1080
+ declaration, so the 2x2 PCF weighting is the driver's), and
1081
+ `shadow(map, rect, coord, bias)` composing the
496
1082
  three) and `SHADOW_LOOKUP`
497
1083
  (`lightShadow(i, worldPos, n)` - light i's factor, 1 when it does not
498
1084
  cast; it walks the light's slots and samples the first map that covers
@@ -504,40 +1090,129 @@ samplers. Lights, colors and exponents are arguments, so
504
1090
  nothing is pinned but the function names; `lit` is composed from these
505
1091
  same constants - customizing never means leaving the system.
506
1092
 
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:
1096
+
1097
+ 1. STANDARD FRAGMENT, CUSTOM VERTEX. Any vertex stage that writes the lit
1098
+ varyings (vWorldPos, vNormal, vUv, plus vColor with `vertexColors`,
1099
+ vUv2 with `lightMap`) pairs with `litFragment(options)` /
1100
+ `standardFragment(options)` (`/glsl`): the exact fragment `lit` /
1101
+ `standard` compile, the same option names and defaults as their
1102
+ options with the texture options boolean. An instanced or displaced
1103
+ mesh keeps the stock shading whole (`examples/instanced.tsx`: the
1104
+ per-instance tint rides vColor). Instance it with the per-entry
1105
+ 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,
1108
+ prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
1109
+ it declares is an ordinary `instance()` param), `surface` declares
1110
+ `void surface(inout Surface s)`, called once the program has filled
1111
+ the Surface struct from its options (base from uColor, the map and
1112
+ the vertex color; the normal, bent by the normal map; emissive,
1113
+ ambient, the light model's fields) and before it shades. Rewrite any
1114
+ field or `discard`; it reads the varyings, the declared uniforms and
1115
+ prelude's names, and runs in the shadow twin too, so what it discards
1116
+ casts no shadow. The struct is the contract, no local of the
1117
+ generated program is; colors are linear light, premultiplied
1118
+ throughout, `Surface.base` included. The material describes the
1119
+ surface, the package shades it (Godot's fragment(), Filament's
1120
+ material()).
1121
+ 3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
1122
+ `sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
1123
+ 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,
1126
+ normal)`, set the fields you mean, call `shadeBlinn(s, position)` or
1127
+ `shadePbr(s, position)` (premultiplied rgb back: hemisphere, every
1128
+ light with its shadow, the environment term, the emissive), add your
1129
+ own terms times the alpha, and end with `sceneOutput(rgb, alpha,
1130
+ position)` (fog, exposure, tone mapping, encode). A custom LIGHT MODEL
1131
+ loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
1132
+ shade function: light i's direction and its color already attenuated,
1133
+ 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.
1136
+ 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
1140
+ minus `specularMap`/`env` (the environment is always composed) plus
1141
+ `metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
1142
+ `uMetalness`/`uRoughness` in place of `uSpecular`/`uShininess`.
1143
+ `litShadowFragment(options)` is the depth-pass twin (same base, cutout
1144
+ and surface function, nothing after them), so a discarding material
1145
+ casts what it draws: build it on `litVertex(options)` with the OPPOSITE cull, instance
1146
+ it with only the uniform values its source declares (per-entry params
1147
+ reject unknown names), and pass it as the main instance's `shadow`.
1148
+ It returns undefined when the options cannot discard - the scene's
1149
+ default depth override is then already right, carry no `shadow`.
1150
+ `UNLIT_VERTEX` / `unlitFragment` / `unlitShadowFragment` are the unlit
1151
+ twins (no lighting flags, no cull; varyings vUv/vWorldPos only). TRAP:
1152
+ a shadow program that never reads `n` (no triplanar, no surface function
1153
+ using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
1154
+ instance or every caster move warns about the skipped write.
1155
+
507
1156
  Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
508
1157
  direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
509
1158
  travelling along `direction` in the node's LOCAL space (default `[0, -1,
510
1159
  0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
511
1160
  and position/scale do not matter - deliberately a direction, not Three's
512
- position-minus-target. `createHemisphereLight({ sky?, ground?, intensity?
1161
+ position-minus-target. `createSpotLight({ direction?, color?, intensity?,
1162
+ distance?, angle?, penumbra?, decay? })` / `<SpotLight>` is a cone from
1163
+ the node's WORLD position along that same LOCAL `direction` (aim by
1164
+ `direction` or a parent's rotation; place by setTransform): `angle` is
1165
+ the cone half-angle in DEGREES ((0, 90], default 60 - degrees like
1166
+ camera fov and like Unity/Godot; Three's radians convert as
1167
+ `angle * 180 / PI`), `penumbra` the
1168
+ 0..1 fraction of it fading to the rim (default 0, a hard edge), and the
1169
+ strength falls off as `1 / d^decay` (default 2) windowed to zero at
1170
+ `distance` (0 = no cutoff) - Three's SpotLight semantics minus the
1171
+ target object. `createPointLight({ color?, intensity?, distance?,
1172
+ decay? })` / `<PointLight>` is the omnidirectional version: position
1173
+ only, same falloff, no cone. `createHemisphereLight({ sky?, ground?, intensity?
513
1174
  })` / `<HemisphereLight>` is the ambient term, a gradient by the WORLD
514
1175
  normal's tilt (fixed to world up, the node's transform is ignored); one per
515
1176
  scene, the last attached wins. Placement goes through setTransform, the
516
1177
  light's own fields through `setLight(light, { ... })` (frame-rate-safe,
517
- like setMeshParams). At most `MAX_LIGHTS` (4, exported from `/glsl`)
518
- directional lights per scene - the fifth throws at add(); it is a
519
- shader-source constant, fixed per app. `uLightDir` is core-driven: each
520
- directional light's slot is a spatial-core shared-slot sink following
521
- the node's world rotation, so a MOVING light costs no JS. The sync
522
- rewrites the rest whenever a light attaches, detaches or changes a
523
- field -
524
- `uHemiSky`/`uHemiGround` (vec3, intensity folded in), `uLightCount` (int),
525
- `uLightDir[MAX_LIGHTS]`/`uLightColor[MAX_LIGHTS]` (world-space vector
526
- TOWARD the light, normalized; intensity folded into the color) - so a
527
- custom fragment declaring those names reads the same list, and a light
528
- change costs one write however many meshes. Everything starts black: a
529
- lit scene with no light shows nothing, on purpose, like Three.
1178
+ like setMeshParams). At most `MAX_LIGHTS` (8, exported from `/glsl`)
1179
+ 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:
1182
+ each light's slots are spatial-core shared-slot sinks following the
1183
+ node's world rotation (direction, negated so the shader reads the
1184
+ vector TOWARD the light) and world position, so a MOVING light costs no
1185
+ JS. The sync rewrites the rest whenever a light attaches, detaches or
1186
+ changes a field - `uHemiSky`/`uHemiGround` (vec3, intensity folded in),
1187
+ `uLightCount` (int), `uLightType[N]` (LIGHT_DIRECTIONAL | LIGHT_SPOT |
1188
+ LIGHT_POINT), `uLightDir[N]`/`uLightPos[N]`/`uLightColor[N]` (intensity
1189
+ folded into the color) and `uLightParams[N]` (cosInner, cosOuter,
1190
+ distance, decay) - so a custom fragment composing `LIGHT_SLOTS` +
1191
+ `LIGHT_LOOKUP` from `/glsl` reads the same list through `lightVector(i,
1192
+ worldPos, out l)` (returns the attenuation, 0 = skip the light; `lit`
1193
+ is the shape), and a light change costs one write however many meshes.
1194
+ A custom fragment that declares only the old directional subset
1195
+ (`uLightCount`/`uLightDir`/`uLightColor`) still works - it just shades
1196
+ every light as directional, so keep such materials to directional-only
1197
+ scenes. Everything starts black: a lit scene with no light shows
1198
+ nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
1199
+ shape (soft vs hard cone, casting spots, an orbiting bulb).
530
1200
 
531
1201
  `lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
532
1202
  the directional list, Lambert diffuse, Blinn-Phong highlight when
533
- `specular` (0..1 strength) is set with `shininess` (default 30), the
534
- same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
1203
+ `specular` (0..1 strength) is set with `shininess` (default 30), a
1204
+ mirror of the scene's environment when `reflectivity` (0..1, the
1205
+ face-on weight; see Environment above) is set, blurred by the same
1206
+ `shininess`, the same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
535
1207
  multiply by the colored layout's aColor (so the geometry must carry it),
536
1208
  `triplanar: n` to sample `map` by world position at `n` repeats per
537
1209
  world unit, blended across the three axis planes by the normal, and
538
1210
  `alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
539
1211
  is discarded; Three's alphaTest, glTF MASK): opaque, depth-written, no
540
- sorting, usually with `cull: "none"` for cards. Triplanar
1212
+ sorting, usually with `cull: "none"` for cards, and `instanced: true` /
1213
+ `instanceColors: true` for an instanced mesh's population (see
1214
+ Instancing: the placement, the depth pass and a per-instance tint with
1215
+ no GLSL; the material then draws populated meshes only). Triplanar
541
1216
  is an OPTION, not the default: generators emit 0..1 UVs per face, so a
542
1217
  map on a plane is a decal (UV) while a map on generated scenery wants one
543
1218
  density across parts of any size (triplanar); the map must be created
@@ -546,45 +1221,132 @@ with `wrap: "repeat"`. Any `map` on a surface seen at distance also wants
546
1221
  surface seen at a grazing angle (a floor, a road) wants `anisotropy: 4`
547
1222
  or more beside it, or trilinear smears the far half into the mip its long
548
1223
  axis picked (`createModel` uploads its images with both; the device clamps
549
- the level, `limits.maxAnisotropy` reports it). Internally one `shaderMaterialClass` per option
550
- combination (map x vertexColors x triplanar x transparent x cull x
551
- alphaTest), cached for the app's lifetime, one pipeline per vertex layout
552
- - a thousand lit meshes share one program. The view vector comes from the
553
- shared uCamPos; `uTriplanar` and `uAlphaTest` are declared only by the
554
- classes that use them (the cutoff is a per-entry value, so every
555
- alphaTest material shares one class) so the other classes do not warn
556
- about an inactive uniform.
1224
+ the level, `limits.maxAnisotropy` reports it).
1225
+
1226
+ The surface maps, each an option beside `map` and sampled at its uv:
1227
+
1228
+ - `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
1229
+ Vector2 exists to flip DirectX-style green channels, and glTF mandates
1230
+ OpenGL-style +Y) bends the lit normal per texel. The tangent frame is
1231
+ built per fragment from screen-space derivatives (`NORMAL_MAP` in
1232
+ `/glsl`, Three's untangented path), so ANY UV-mapped geometry works
1233
+ with no tangent channel; the trade is mild seams on mirrored UVs. Not
1234
+ with `triplanar` (throws - triplanar samples by world position).
1235
+ - `emissive: [r, g, b]` (sRGB like `color`) times `emissiveIntensity`
1236
+ (linear, default 1; glTF's emissive strength) and `emissiveMap` add
1237
+ light the lights do not provide, after the lighting terms,
1238
+ shadow-proof, fogged. `emissive` defaults to WHITE when `emissiveMap`
1239
+ is given - the map is the emission - fixing Three's gotcha where an
1240
+ emissiveMap alone shows nothing against the black default.
1241
+ - `specularMap`: its RED channel scales `specular` per fragment (chrome
1242
+ and rubber on one mesh); with it `specular` defaults to 1.
1243
+ - `lightMap` (+ `lightMapIntensity`) adds a baked-light texture by the
1244
+ geometry's aUV2 channel (`withAttribute(g, { name: "aUV2", format:
1245
+ "vec2" }, fill)`) - ADDED to the light sum like the hemisphere term, so
1246
+ a fully baked scene runs with no lights at all. Three's material-slot
1247
+ form; Unity and Godot bake at scene level, but here the material picks
1248
+ the program.
1249
+ - `mapTransform: { offset?, repeat? }` samples every uv map of the
1250
+ material at `uv * repeat + offset` - ONE transform per MATERIAL
1251
+ (Godot's uv1_offset/uv1_scale, Unity's Tiling/Offset; deliberately not
1252
+ Three's per-texture transform, since a TextureId is a shared value
1253
+ whose sampling is creation-time state). aUV2 is exempt. Scroll it per
1254
+ frame with `setMeshParams(mesh, { uMapTransform: [ru, rv, ou, ov] })`.
1255
+ Also on `unlit`. Not with `triplanar` (throws - its repeat is the
1256
+ triplanar value). A cutout's shadow transforms the same way.
1257
+
1258
+ `examples/materials.tsx` shows all five.
1259
+
1260
+ `standard(opts)` is the metalness/roughness material, the look authored
1261
+ assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
1262
+ Unity's Standard): every lit option but the Blinn-Phong knobs
1263
+ (`specular`, `shininess`, `specularMap`, `reflectivity`), plus
1264
+ `metalness` (0..1, default 0: a metal has no diffuse and reflects tinted
1265
+ by `color`, a dielectric reflects 4% face-on), `roughness` (0..1
1266
+ perceptual, default 1: 0 a mirror, 1 matte; one value widens the
1267
+ highlight and blurs the environment alike; Unity's smoothness is its
1268
+ inverse) and the packed data maps `metalnessMap` (its BLUE channel) and
1269
+ `roughnessMap` (GREEN) - Three's two channel-select options over glTF's
1270
+ ONE metallicRoughnessTexture, so pass the same texture to both; with a
1271
+ map the factor defaults to 1 (the map is the value). Shading is GGX
1272
+ (`PBR` in `/glsl`: distribution, height-correlated Smith visibility,
1273
+ Schlick fresnel) per light in the same light and shadow loop, the
1274
+ hemisphere on the diffuse, and the scene's environment ALWAYS - the
1275
+ split sum, `envRadiance` at the roughness over the cube's mip chain
1276
+ times the analytic `envBrdf` for the specular, `envIrradiance` on the
1277
+ diffuse beside the hemisphere - with no `reflectivity` switch: the
1278
+ 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
1280
+ (1 lights a white matte surface to 1; Godot's and Unity's convention -
1281
+ a Three scene's intensities divide by pi). Without an environment a
1282
+ metal shows only its highlights: no diffuse, nothing to reflect (Three
1283
+ and Godot do the same), so give the scene one. `examples/standard.tsx`
1284
+ is the sphere grid. Internally one
1285
+ `shaderMaterialClass` per option combination (map x vertexColors x
1286
+ triplanar x transparent x cull x alphaTest x fog x the surface maps),
1287
+ cached for the app's lifetime, one pipeline per vertex layout - a
1288
+ thousand lit meshes share one program, and the key's width costs nothing
1289
+ by itself: classes are created lazily per combination USED, so the
1290
+ program count is the app's distinct material configurations. The view
1291
+ vector comes from the shared uCamPos; `uTriplanar` and `uAlphaTest` are
1292
+ declared only by the classes that use them (the cutoff is a per-entry
1293
+ value, so every alphaTest material shares one class) so the other
1294
+ classes do not warn about an inactive uniform.
557
1295
 
558
1296
  ## Models
559
1297
 
560
1298
  Authored models come in as glTF 2.0 (.gltf with its .bin and image files
561
- next to it, or single-file .glb) and become a Group of meshes, Three's
562
- `gltf.scene`. Three layers, use the lowest that fits:
1299
+ next to it, or single-file .glb) and become a Group carrying the file's
1300
+ node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
1301
+ fits:
563
1302
 
564
1303
  - `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
565
- bun and on flux): `ModelData` = `parts` (one per mesh node, its NAME
566
- kept, vertices in the standard layout with the node's WORLD transform
567
- baked in), `materials` (base color factor, `map` = index into `images`,
568
- `doubleSided`, `transparent` = alphaMode BLEND, `alphaMode` as written
569
- and `alphaCutoff`, spec default 0.5), `images` (the encoded
570
- PNG/JPEG bytes, undecoded) and `bounds`. A .gltf's external files come
571
- through `resolve(uri)` (uri as written, still percent-encoded;
572
- `gltfExternalUris(bytes)` lists them so an async caller can read them
573
- first); .glb and data: uris need none. Missing normals produce FLAT
574
- shading (the spec's rule): the primitive is un-indexed, one vertex per
575
- corner. A mirroring node flips the winding so `cull: "back"` still
576
- keeps the outside. Non-triangle primitives are skipped; a required
577
- extension the parser does not implement throws naming it, and Draco or
578
- meshopt compression throws "re-export without mesh compression" -
579
- Blender exports Draco by DEFAULT, so that is the first error a real
580
- file hits.
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.
581
1333
  - `createModel(data, { material?, label? })` - uploads the images (repeat
582
- wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `lit({
583
- color, map, transparent })`; pass `material(m, map)` for anything else,
584
- it is called once per material and shared), one mesh per part, all
585
- children of the returned `Model` (a Group): `add(scene.root, model)`,
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)`,
586
1343
  place it with `setTransform`, find parts by name in `model.parts`
587
- (`{ name, mesh }`), `model.bounds` for framing a camera. `dispose()`
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()`
588
1350
  detaches it and frees the geometry buffers and textures - the model owns
589
1351
  them, nothing else frees them.
590
1352
  - `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
@@ -598,20 +1360,173 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
598
1360
  "binary" }` then `createModel(parseGltf(bytes))`, see
599
1361
  `examples/model.tsx`); bake anything big.
600
1362
 
1363
+ Loading is async everywhere but the binary import: loadGltf/loadModel
1364
+ return promises, and the async value must be read the way Solid 2 async
1365
+ works - inside a tracking scope whose result the JSX reads back, under a
1366
+ `<Loading>` boundary. The worked shape is `examples/model-load.tsx`: the
1367
+ component keeps the async read in a memo (`let loaded = createMemo(() =>
1368
+ loadModel(path))`), derives everything - framing, mounting - in a second
1369
+ memo that reads `loaded()` FIRST and returns the scene JSX, and returns
1370
+ only that memo read; the window/view shell lives in the parent, above the
1371
+ boundary. Reading the value in the component body instead throws
1372
+ PENDING_ASYNC_UNTRACKED_READ, and any element the component builds before
1373
+ the suspending read is orphaned on the boundary's retry and never freed
1374
+ (the dev leak sentinel reports it) - so the suspending component creates
1375
+ no elements of its own. Async here means the file read: the parse and
1376
+ createModel run synchronously on main. Bake anything big to .srtm; when
1377
+ a source glTF must be parsed at runtime, do the parse in an isolate
1378
+ (parseGltf's result is plain data and copies across) and keep
1379
+ createModel on main.
1380
+
1381
+ Placement: pieces of one authored set (a body and its fitted cosmetics)
1382
+ export in one world space, so composing them is `add(group, model)` per
1383
+ piece and nothing else - no placement math. SOCKETED items (a weapon in
1384
+ a hand) are different: they bind to a joint, so they only land once a
1385
+ skeleton exists (a rig-less export cannot place them at all). The joint
1386
+ is an ordinary Group in `model.nodes` - find it by name and `add()` the
1387
+ 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
1390
+ origin needs the plain `add()` and nothing more; one authored in the
1391
+ RIG'S model space needs a socket Group between joint and item carrying
1392
+ the joint's rest-pose inverse (at rest the item then sits exactly where
1393
+ authored, posed it follows), since parenting stacks the joint's
1394
+ transform on top of the authored placement. Skinned PARTS are the one
1395
+ thing that never needs this: they hang off the model root and the
1396
+ palette places them.
1397
+
601
1398
  Applied: `doubleSided` (the default material draws it with `cull:
602
- "none"`) and alphaMode MASK (`alphaTest: alphaCutoff`). Not in the
603
- subset, dropped: vertex colors, tangents and further UV sets; samplers
604
- are ignored (every texture repeats); emissive/additive parts of a model
605
- draw as their base color (a model's "glow" cards come out as dark wedges).
606
- The follow-ups are filed in okf/backlog/3d-model-loader.md.
1399
+ "none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
1400
+ (+ scale; the derivative frame needs no tangents), `emissiveFactor` x
1401
+ `emissiveTexture` with KHR_materials_emissive_strength folded into the
1402
+ factor (a zero factor skips the map too - glTF's product rule, emission
1403
+ off), `pbrMetallicRoughness` factors as `m.metalness`/`m.roughness` and
1404
+ 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.
1410
+ 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`.
1413
+ Animation: `createMixer(model)` plays `model.clips` by name -
1414
+ `mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
1415
+ named clip fades in, everything else fades out - Unity's CrossFade,
1416
+ Godot's play-with-blend), `mixer.stop({ fadeMs? })`, `mixer.playing()`,
1417
+ `mixer.onFinish` for `loop: false` clips (the pose holds at the end).
1418
+ Playback is CORE-DRIVEN: there is no update() and no frame loop to
1419
+ register - clips are registered with the spatial core once and players
1420
+ sample, weight-blend and write joint TRS natively each frame, so a
1421
+ playing character costs zero JS per frame (gate other work on
1422
+ `mixer.playing()`). play() requires the model to be IN a scene (players
1423
+ bind live arena nodes; removing the model drops them - a re-added model
1424
+ plays again from play()). Three traps that follow from core ownership:
1425
+ (1) players advance BEFORE your onFrame, which is therefore the
1426
+ post-animation hook - read a freshly posed joint and overwrite it
1427
+ (root-motion strips, skeleton copies) in plain setTransform, last write
1428
+ wins, all published by the same flush; (2) the JS
1429
+ `position`/`quaternion` fields of player-animated joints (and of the
1430
+ model node under root motion "apply") go STALE (they hold the last JS
1431
+ write) - read poses with `getTransform(node)`, which reads the core;
1432
+ writes to such a node always go through (setTransform skips its
1433
+ usual equal-value short-circuit there, so a teleport back to the last
1434
+ JS-written spot is not lost); (3) a channel nothing plays leaves the node's pose
1435
+ alone; (4) known gap: native pose writes bypass the scene's moved list,
1436
+ so a TRANSPARENT mesh parented under a player-animated joint does not
1437
+ re-trigger the back-to-front re-sort while it animates (opaque meshes,
1438
+ palettes and picking are unaffected) - nudge the scene with any
1439
+ setTransform if it shows, until the core-side transparent sort lands.
1440
+ Root motion: `play(name, { inPlace? })` strips a clip's root travel
1441
+ (Unity's applyRootMotion off, Godot's root_motion_track): the root's
1442
+ x/z hold at the clip's first key and its height rebases onto the root's
1443
+ rest position, the vertical bob intact. `createMixer(model, {
1444
+ rootHeight })` rebases EVERY clip onto that height, pinned or not, for
1445
+ an export whose clips ride above its rest pose. Unset, it is decided per clip by NET DRIFT of the
1446
+ root's position track - last key minus first, past a fraction of the
1447
+ model's height - so run cycles play in place and a taunt that roams and
1448
+ returns does not (pinning THOSE pushes the slide into the feet);
1449
+ `mixer.travels(name)` reads the verdict. The root is the topmost node
1450
+ any position channel of the clip targets. The strip is baked into a
1451
+ second core clip on first in-place play, so it costs no per-frame JS and
1452
+ crossfades like any other clip. A GAME wants the travel kept and moved
1453
+ onto the character: `createMixer(model, { rootMotion: "apply" |
1454
+ "report" })` plays every clip fully pinned (all three root axes held)
1455
+ and its yaw held too (the turn about +y stripped key by key, so the
1456
+ lean and pitch of the pose survive), while the core samples the
1457
+ authored root tracks at each player's time and hands the per-frame
1458
+ delta on - a translation in the model's local frame and a yaw in
1459
+ radians. "apply" adds both to the model node itself (Unity's
1460
+ applyRootMotion: the character walks and turns where its clip says),
1461
+ "report" accumulates them until `mixer.rootDelta()` takes them
1462
+ (`{ position, yaw }`), for a controller to spend through its own
1463
+ movement (Godot's get_root_motion_position). Zero per-frame JS in
1464
+ "apply"; one read per frame in "report". Loop wraps are continuous
1465
+ (the clip's net drift is added across the wrap), crossfades weight the
1466
+ deltas like the poses, and a clip's travel is given in the root's
1467
+ CURRENT facing (its own turn so far undone), so a turn that wanders
1468
+ out and back ends where the clip says and two blending clips agree on
1469
+ the frame. The object form `{ mode, up?, vertical? }` names the root's
1470
+ parent-space up axis (default +y; the turn and height axis) and
1471
+ `vertical: "pose"` keeps the height in the pose, delivering only the
1472
+ horizontal travel (Unity's bake-into-pose Y - for a controller that
1473
+ owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
1474
+ the swing-twist about up (exact under any lean); a cubic rotation
1475
+ 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
1477
+ (model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
1478
+ float texture, 4 texels wide, one row per joint, sampled in the vertex
1479
+ stage via texelFetch, so there is no joint cap) is composed by the
1480
+ spatial core at the frame's flush from the joint nodes themselves, in
1481
+ any write order, and identical skins (a body/legs split, LODs) share one
1482
+ computed-once texture. `sampleChannel` (pure, from the root) stays the
1483
+ JS sampling core for checks and custom drivers;
1484
+ okf/done/animation-core.md records the design.
1485
+
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`.
607
1492
 
608
1493
  ## Traps
609
1494
 
610
- - A model's vertices are in WORLD space at parse time (node transforms
611
- baked), so `model.bounds` and each part's geometry already include the
612
- file's placement; the Model group starts at identity and `setTransform`
613
- on it moves the whole thing. Parts cannot be moved relative to their
614
- glTF parent - that is the retained-hierarchy follow-up, not a bug.
1495
+ - A model's vertices are LOCAL to their node; the file's placement lives
1496
+ in the node-table TRS, composed by the scene like any Group chain. So
1497
+ a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
1498
+ world) is what frames a camera, and moving a named node moves its
1499
+ subtree. The winding flip for a mirroring node chain is baked into the
1500
+ index order from the REST pose - re-scaling a node across zero at
1501
+ runtime shows mesh interiors, so do not do that.
1502
+ - Skinning is a VERTEX-STAGE effect: everything that runs off the
1503
+ retained tree sees the bind pose. A skinned mesh picks by its
1504
+ bind-pose triangles at the model root and its transparent sort key is
1505
+ the bind-pose box; moving the JOINTS never moves either, moving the
1506
+ MODEL moves both. Shadows are the exception: the shadow variants
1507
+ (depth and cutout) skin by the same uBones palette, so a caster casts
1508
+ its pose.
1509
+ - A `standard` metal in a scene with no environment renders near black:
1510
+ its diffuse is zero and the black placeholder cube is all there is to
1511
+ reflect (Three and Godot render the same; Unity falls back to an
1512
+ ambient probe, this does not). glTF's default metallic factor is 1,
1513
+ so an untextured asset is all metal, and createModel's default is
1514
+ `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
1528
+ lights a white matte surface to 1 face-on. A Three scene's intensities
1529
+ are a factor pi larger for the same look; divide when porting.
615
1530
  - The y-down clip flip is baked into `perspective()`; scene code and
616
1531
  geometry are plain y-up right-handed, and CCW-outward winding culls
617
1532
  correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
@@ -626,19 +1541,46 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
626
1541
  live: it has no world matrix yet, and drawn before the sync microtask it
627
1542
  flashes at the world origin for a frame.
628
1543
  - Instancing pairs strictly at add(), like layout: an instanced material
629
- needs a createInstancedMesh mesh (records included) and vice versa, and
630
- the record stride must match the material's attributes - each mismatch
631
- throws there. The instance buffer is MESH-owned (unlike shared geometry
632
- buffers): `disposeInstances` is its one free, and the mesh cannot be
633
- re-added afterwards. Capacity grows by REPLACEMENT, never resize:
634
- `setInstances` past capacity doubles (at least to the records written)
635
- into a new buffer and swaps it in - amortized like a dynamic array, same
636
- policy as @solidrt/2d; size the initial records to skip the copies.
637
- - An instanced mesh without explicit `bounds` has no BVH leaf: it never
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
638
1570
  picks, pointer events never target it, and its transparent sort key
639
1571
  falls back to the node's world position. That is deliberate - records
640
1572
  are opaque to the library, so any inferred box would be a guess. Supply
641
- `bounds` for anything pickable or transparent.
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.
642
1584
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
643
1585
  [r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
644
1586
  classes write alpha 1 when not `transparent` (the scene target is
@@ -762,9 +1704,14 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
762
1704
  and supplies its own cutout variant as the `shadow` instance option).
763
1705
  Opting out of receiving is on the
764
1706
  MATERIAL here (`receiveShadow: false`), not the object (Three's
765
- `mesh.receiveShadow`) - Godot's split, and URP's - and instanced
766
- meshes never cast (the depth override cannot know their records) - the
767
- additive follow-up is a per-class `shadowVertex`. Every casting light
1707
+ `mesh.receiveShadow`) - Godot's split, and URP's. An instanced mesh
1708
+ casts when its class declares `shadowVertex` (the vertex stage reduced
1709
+ to position, instance placement included; the class builds one depth
1710
+ program from it plus the shared depth fragment, culling the shadow
1711
+ side and binding only the instance slots the stage reads, and every
1712
+ instance shares it) - without one the shadow views
1713
+ SKIP instanced meshes, since the plain depth override cannot know
1714
+ their records. `examples/instanced.tsx` casts. Every casting light
768
1715
  is a full extra pass over the casters plus a sampler unit on every
769
1716
  receiving program (MAX_LIGHTS of those are always bound, placeholders
770
1717
  included), so cast from the lights that matter, not all of them.
@@ -778,7 +1725,9 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
778
1725
  - SCENE-WIDE uniforms go through that same shared channel via
779
1726
  `scene.setParams({ uTime })`, and this is the single highest-leverage
780
1727
  pattern in the library. It merges an app-owned name in beside
781
- uViewProj/uCamPos/uCamRight/uCamUp - names merge, a target tolerates
1728
+ uViewProj/uInvViewProj/uCamPos/uCamRight/uCamUp and the scene's own
1729
+ fog, environment and output sets (uFog*, uEnv*, uExposure,
1730
+ uToneMapping) - names merge, a target tolerates
782
1731
  zero coverage, neither side clobbers the other. One write per frame
783
1732
  however many meshes read it, with the motion itself in vertex shaders
784
1733
  off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
@@ -839,9 +1788,9 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
839
1788
  aColor you do not read.
840
1789
  - Picking is triangle-accurate for ordinary meshes (`point` is a surface
841
1790
  point, hits carry `face`/`uv`/`normal`) but box-only for instanced
842
- meshes: there `point` is the entry point of the population `bounds`
843
- box, and `face`/`uv`/`normal` are absent. Never present an instanced
844
- hit as a surface hit. Both tiers run in the spatial core (Rust); never
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
1793
+ instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
845
1794
  add a per-triangle path in JS - rays at mesh scale are
846
1795
  interpreter-hostile, and the core already does it.
847
1796
  - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
@@ -852,13 +1801,16 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
852
1801
  leaf under a design size. Only a leaf whose layout size deliberately
853
1802
  differs from the target (supersampling) needs `handlersFor`, fed the
854
1803
  layout size the app itself set.
855
- - Scene-wide effects reach a custom material ONLY by composition: a
856
- `shaderMaterial` that does not compose `FOG` is unfogged, one that does
857
- not compose the `SHADOW_*` trio is unshadowed, and since every
858
- instanced mesh has a custom material, an instanced forest stays crisp
859
- in a fogged scene until its fragment calls `fog()`. The engine cannot
860
- inject it (what you declare is what runs); check both when a custom
861
- look sits beside standard ones and reads wrong at distance.
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.
862
1814
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
863
1815
  under a still pointer fires nothing until the next move - the same
864
1816
  limit the element hit test has (hit-test-per-frame is an open platform
@@ -871,6 +1823,36 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
871
1823
  REPLACES the clearColor visually (the clear still runs; you just never
872
1824
  see it), and a `transparent: true` mesh blends over it in-pass since the
873
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.
874
1838
  - The background pipeline/program are SCENE-OWNED (unlike shared
875
1839
  material pipelines): setBackground(null), replacement, and dispose()
876
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
+ - overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
1849
+ volume wholly inside a closed mesh with no triangle in reach touches
1850
+ nothing, and a body whose center has passed through a wall reports
1851
+ the push-out on the side its center is on. Keep a skin (moveAndSlide
1852
+ does) and never teleport a body into geometry expecting it to come
1853
+ out the far side.
1854
+ - moveAndSlide owns no gravity and no velocity: fold the fall into
1855
+ `motion` every frame and zero it while `floor` is set. A walkable
1856
+ floor absorbs the vertical part (a body never creeps down a slope it
1857
+ can stand on), and with `floorSnap: 0` a standing body reports no
1858
+ floor unless its motion presses into one.