@solidrt/3d 0.0.55 → 0.0.56

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 (54) hide show
  1. package/AGENTS.md +1083 -183
  2. package/README.md +15 -7
  3. package/examples/README.md +79 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -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 +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -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 +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +763 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1343 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +667 -232
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +204 -35
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1242 -1557
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
package/AGENTS.md CHANGED
@@ -24,6 +24,16 @@ blendMode and pointer events like any element.
24
24
  in JS and why. The component face (`Scene`/`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
@@ -37,24 +47,99 @@ blendMode and pointer events like any element.
37
47
  its CORE node, so the one flush writes every target - the app writes
38
48
  nothing per view. Geometry buffers and (without an override) materials
39
49
  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
50
+ view, `view.setParams` is the view's own channel - and names a view
51
+ sets itself (or its `fog` option, below) become VIEW-OWNED: the
52
+ scene's setParams/setFog fan-out skips them from then on, so a view
53
+ override survives scene-wide writes. A view's backdrop is its
54
+ clearColor (the scene background draws on PROBES, not views); a view
55
+ has no picking. LAYERS select what a target
56
+ draws, Three's model exactly: `layers` on a mesh is its membership
57
+ bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
58
+ Groups), and each target carries a mask (default 1) - `layers` on
59
+ createScene/createView, live via `setLayers` on the scene handle and
60
+ each view. A mesh draws where mask & layers is non-zero, so a minimap's
61
+ marker meshes live on bit 2: invisible in the main render, drawn by
62
+ the map view whose mask admits them. Shadow views follow the SCENE's
63
+ mask (what the scene cannot see must not darken it), and
64
+ pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
65
+ invisible ones - unless the query passes its own `{ layers }`, which is
66
+ how a low-poly collision mesh lives undrawn in the scene yet answers
67
+ ground and collision queries (the physics-collider pattern).
68
+ Per-view fog: `fog: FogOptions | null` on createView overrides the
69
+ scene's fog for that view (null = unfogged - the clear minimap over a
70
+ fogged scene); absent follows the scene. `overrideMaterial` (Three's
42
71
  `scene.overrideMaterial`, scoped to the view) draws every mesh with one
43
72
  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
73
+ meshes (unless the override itself declares their exact
74
+ `instanceAttributes` record layout) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
75
+ input; the same option on createScene exposes `scene.depthTexture`,
76
+ the input for a depth-reading post effect in `output` (not combinable
77
+ with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
47
78
  perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
48
79
  the scene's own camera takes it too, and pick() follows.
49
80
  `examples/scene-views.tsx` is the shape.
81
+ - CULLING is the core's, per target, on by default: every camera write
82
+ (the scene's, a view's, a shadow tile's) also sets that target's
83
+ frustum in the spatial core, and the flush switches an entry whose
84
+ world box falls wholly outside it to instance count 0 - the same
85
+ switch as `visible`, so a culled mesh costs nothing per frame and a
86
+ still camera re-tests nothing (a camera move re-tests every sink on
87
+ that target, in Rust; a node move re-tests its own). The box is the
88
+ picking box (the local bounds through the world matrix, the
89
+ Godot/Unity AABB test; Three uses spheres); an instanced mesh without
90
+ explicit `bounds` is culled by the union of its live instances' boxes
91
+ (the joint group below over the instance nodes, so the box follows
92
+ them: Godot's MultiMesh AABB, with `bounds` as its custom_aabb; Three
93
+ computes an InstancedMesh sphere once and leaves it stale, Unity wants
94
+ worldBounds), and a record mesh without `bounds` is never culled. Per
95
+ mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
96
+ a vertex stage moves beyond its box - a fullscreen quad, a custom
97
+ displacement - and `cullMargin` (world units, Godot's
98
+ `extra_cull_margin`) for bounded displacement such as wind. Sprites
99
+ cull by their quad's reach at any facing. A SKINNED part is culled by
100
+ the union of its joints' boxes (the bake computes each joint's
101
+ influence box in joint space, `ModelSkin.jointBounds`, .srtm VERSION
102
+ 5; the joint nodes carry them as culling-only bounds, outside the
103
+ picking index), so the box follows the pose with no per-frame JS -
104
+ Unity's bone bounds, Godot's per-bone AABBs; no `updateWhenOffscreen`
105
+ knob is needed. Probe faces set no frustum (six cameras, one target).
106
+ Shadow tiles cull casters against their light frustum, which is what
107
+ makes `shadow.distance` and cascades cheaper.
50
108
  - SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
51
- bias?, normalBias?, camera? }}>` (`createDirectionalLight({ castShadow,
109
+ bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
52
110
  shadow })`, `setLight`) makes the scene own an internal
53
111
  `createView({ depth: "texture", overrideMaterial: depth pass })` drawing
54
112
  the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
55
113
  orthographic camera at the light's WORLD position along its world
56
114
  direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
57
- Any directional light may cast (capped by MAX_LIGHTS = MAX_SHADOWS).
115
+ Any light may cast, bounded by the shadow-slot budget
116
+ (MAX_SHADOW_MAPS = 8, its own constant: a directional light claims
117
+ `shadow.cascades` consecutive slots, a point light six, a spot one,
118
+ and a caster past the budget throws at attach). `<SpotLight castShadow
119
+ shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
120
+ machinery with a PERSPECTIVE camera: at the light's world position
121
+ along its world direction, fov = its cone (2 * angle), near from
122
+ `shadow.near` (default 0.5), far from the light's `distance` (or the
123
+ directional default 500 when 0) - one map, one slot, the same atlas
124
+ and lookup. A perspective map's depth is nonlinear, so `normalBias`
125
+ (world units) is the acne knob to reach for; `bias` acts in that
126
+ nonlinear depth. `<PointLight castShadow>` casts in every direction
127
+ with the same option set: six 90-degree face maps (world-axis
128
+ aligned, slot order +X, -X, +Y, -Y, +Z, -Z) as six consecutive tiles
129
+ of the same atlas, far from `distance` like a spot - so give a
130
+ casting bulb a distance. No cube map: a receiver picks the face by
131
+ the dominant axis of the light-to-point vector (SHADOW_LOOKUP), one
132
+ projection, one hardware-compare tap - the Three/Godot/Unity-URP
133
+ atlas route. `shadow.radius` (every casting light, default 1 = that
134
+ one tap, a 2x2 bilinear compare) softens the edge: above 1 a 3x3 grid
135
+ of hardware taps `radius` texels apart, Three's `shadow.radius` /
136
+ Godot's `shadow_blur`, nine taps per receiver fragment. A first-person
137
+ eye over a floor shows the texel stairs at any map size; 2 is the
138
+ usual figure, past ~3 the taps separate into bands. Each face map renders a few degrees wider than its face
139
+ (URP's fovBias) so a seam fragment's occluder is inside the map it
140
+ samples - without the guard band every seam shows a lit slit - and
141
+ PCF taps clamp at face-tile edges, so a face seam hardens slightly
142
+ instead of bleeding into the neighbour.
58
143
  `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
59
144
  N maps fitted to slices of the SCENE camera's frustum (near ..
60
145
  `shadow.distance`, default the camera far; the practical split; each
@@ -82,7 +167,8 @@ blendMode and pointer events like any element.
82
167
  (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
83
168
  material out and drops the map from its program - a material option,
84
169
  as with vertexColors/triplanar, because the material picks the program
85
- (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s 3x3 PCF
170
+ (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
171
+ hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
86
172
  on each casting light's own term. `examples/shadows.tsx` (three
87
173
  casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
88
174
  - RETARGETED motion is native: `setTransition(node, { position:
@@ -146,18 +232,31 @@ blendMode and pointer events like any element.
146
232
  `gltf-check`) run on
147
233
  flux from the repo root: `bunx srt bundle -f --stdout
148
234
  packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
149
- touching what you changed.
235
+ touching what you changed. `raycast-check.tsx` and
236
+ `collision-check.tsx` are the exceptions: they assert the documented
237
+ picking contract (triangle accuracy, the box tier, pick/raycast
238
+ parity, layer masks, the `{ meshes }` filter) and collision contract
239
+ (exact sweep times, the surface rule, the slide filter, layers and
240
+ meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
241
+ so they run on the playback client instead:
242
+ `bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
243
+ --size 128x128`. Run them whenever a doc edit touches picking or
244
+ collision claims - two copies of this contract have drifted before.
150
245
 
151
246
  ## Components
152
247
 
153
248
  | Component | Props |
154
249
  | --- | --- |
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) |
250
+ | `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
156
251
  | `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)` |
252
+ | `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
253
  | `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?` |
254
+ | `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 |
255
+ | `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) |
256
+ | `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 |
257
+ | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` - or the Scene `camera` prop, the same state (last write wins) |
258
+ | `SpotLight` | transforms as Group, `direction?` (local aim, default [0, -1, 0]), `color?`, `intensity?`, `distance?` (falloff cutoff, 0 = none), `angle?` (cone half-angle DEGREES, default 60), `penumbra?` (0..1 rim fade, default 0), `decay?` (falloff exponent, default 2), `castShadow?`, `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
259
+ | `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
260
 
162
261
  Output composition: without `output`, `Scene` emits a minimal
163
262
  `<texture width height>` leaf and nothing else is forwarded - anything
@@ -171,30 +270,33 @@ elsewhere. Called once, untracked, inside the scene context. Scene
171
270
  layout, so render and display size separate - render at 2x and display
172
271
  smaller for supersampling.
173
272
 
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:
181
-
182
- ```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)} />}>
192
- ```
193
-
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.
273
+ Fill (the default): omit `width`/`height` and the built-in leaf is laid
274
+ out at 100% of its parent's box (give it a sized parent, as on the web)
275
+ while the target follows the leaf's on-screen size in DEVICE pixels -
276
+ display scale, `designSize` fits and ancestor transforms included
277
+ (getBoundingBoxViewport x displayScale, applied from onLayout, so no
278
+ frame draws at a stale size). A bare `<Scene>` in a pane renders at
279
+ native density on any display, mesh events and `<OrbitCamera>` input are
280
+ wired automatically (event scaling reads the leaf's untransformed box
281
+ back with `getLayoutBox`), and the camera aspect follows the box. Fill
282
+ or fixed is decided at mount; `output` requires explicit sizes (the
283
+ target cannot follow a leaf it does not own), and giving exactly one of
284
+ width/height throws.
285
+
286
+ Fixed sizes still matter where the target is a measured quantity: probes
287
+ and checks that snapshot exact pixels, supersampling via `output`, or a
288
+ scene composited at a size unrelated to its layout. `width`/`height` are
289
+ DEVICE pixels and the leaf's layout is LOGICAL - a fixed 720-pixel scene
290
+ under a HiDPI `designSize` fit is stretched across ~1100 device pixels
291
+ and looks soft, and nothing warns; that trap is what fill removes (the
292
+ examples fill; scene-views and scene-post-effect keep fixed sizes to
293
+ show multi-view composition and supersampling). A custom `output` leaf
294
+ whose layout differs from the target
295
+ takes `handlersFor` (below), not `handlers`; `useScene()` works inside
296
+ `output` because it runs in the scene context. With an `<OrbitCamera>`
297
+ (or any SceneInput listener) in the scene, also spread
298
+ `{...useScene().input.handlersFor(layout)}` on the leaf - the mesh-event
299
+ and control-input channels are separate spreads with the same layout.
198
300
 
199
301
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
200
302
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
@@ -214,44 +316,168 @@ a pan may put the pivot. Zoom aims
214
316
  at the target unless `zoomAnchor(x, y, {eye, target})` maps the pinch focal
215
317
  / wheel cursor to a world point (ground hit, target-depth plane, ...) - then
216
318
  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
319
+ only the app can build that mapping, since fov and aspect are app state
320
+ (the point arrives in the input element's own pixels). Pair it with `rotateAnchor({eye, target})`: called at gesture
219
321
  start, its point is projected onto the view axis and re-seats the pivot
220
322
  without moving the picture, so a drag after an anchored zoom orbits what the
221
323
  camera looks at, not wherever the zoom left the target. Spread
222
324
  `orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
223
325
  from your onFrame (no frame loop of its own), and use its return - true
224
326
  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().
327
+ HUD overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
328
+ frame-loop gate: orbiting with a non-zero rate - the predicate the 2d
329
+ camera and the first-person camera share) are reactive (HUD-safe); the
330
+ pose is plain state via `pose()`/`set()` (also the debug-command shape). It drives position and
331
+ target only; fov/near/far stay on scene.setCamera (or the Scene `camera`
332
+ prop).
333
+
334
+ In a component tree, skip the wiring: `<OrbitCamera azimuth={1.2}
335
+ distance={7} />` as a Scene child reaches the scene through context,
336
+ receives input from the scene's leaf (the built-in one automatically; a
337
+ custom `output` leaf spreads `{...useScene().input.handlersFor(layout)}`
338
+ beside its scene.handlersFor spread, same `layout`), defaults `viewport`
339
+ to the leaf's laid-out size plus the scene camera's fov, and pushes input
340
+ poses synchronously - no ref plumbing, no onFrame. Auto-orbit runs a
341
+ frame loop only while `active()`, so a paused camera keeps the app
342
+ demand-driven idle. The pose props are
343
+ initial values: runtime pose changes (and the debug-command hookup) go
344
+ through `ref`'s handle, whose set() also pushes the pose. Every other
345
+ prop is live - forwarded to the control as a getter and read where it
346
+ applies, never snapshotted - so clamps, rates, anchors and `viewport`
347
+ follow their props without a remount, and a clamp change re-clamps the
348
+ pose at once.
349
+
350
+ First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
351
+ pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, viewport?,
352
+ clampPosition? })` - a position plus yaw/pitch (yaw 0 faces -z, positive
353
+ turns left; pitch positive looks up), Unity's FirstPersonController shape
354
+ (look AND move in one control) where Three splits PointerLockControls
355
+ from a hand-written key loop. Look comes from pointer-move
356
+ movementX/movementY while `pointerLocked()`, from a one-finger drag
357
+ (arena-arbitrated through createTransform, viewport-relative with
358
+ `viewport`) while not, and from the right stick; move from WASD/arrows
359
+ (physical codes and logical keys both), the left stick, and Q/E for
360
+ down/up - bound always, inert unless `fly` is on. Walking (the default)
361
+ flattens the heading onto the ground plane at fixed height. Every option
362
+ but the initial pose is read where it applies (`fly` per update,
363
+ `clampPosition` per move, the rates and pitch clamps per input), so a
364
+ field changed on the options object takes effect on the next move: walk
365
+ and fly are one control. The control NEVER calls `lockPointer`
366
+ - click-to-lock and Escape-to-release are the app's window-level
367
+ decisions (see `examples/first-person.tsx`) - and has no collision of
368
+ its own: `clampPosition(next, current)` is the whole hook - bounds, a
369
+ floor height, or `moveAndSlide` over `next - current` against the
370
+ collision layer (see `examples/collision.tsx`). Spread `handlers` (pointer + key +
371
+ onBlur; keys reach only the FOCUSED node, so that element must hold
372
+ focus or be the window), call `update(dt)` from onFrame; `active()` is
373
+ reactive - a key held or a stick deflected - and gates the loop. Keys
374
+ cannot be polled (core has no key-state accessor), so held keys are
375
+ tracked from the down/up pair and `onBlur` drops them.
376
+
377
+ `<FirstPersonCamera>` as a Scene child wires all of it: with a
378
+ key-driven control registered, the built-in leaf becomes `focusable`
379
+ and takes focus on pointer down (the web canvas gesture), which routes
380
+ the keys to the control; a pointer-only scene (`<OrbitCamera>`) never
381
+ steals focus. A custom `output` leaf spreads
382
+ `useScene().input.handlersFor(layout, () => node)` and declares
383
+ `focusable` itself. The frame loop runs only while `active()`. Every prop
384
+ but the initial pose is live: `fly={flying()}` toggles walk/fly on the
385
+ running control (pose and held keys carry over, no remount, and
386
+ `clampPosition` may swap with it), `moveSpeed`/`lookSpeed` follow their
387
+ props, and a pitch clamp change re-clamps at once.
229
388
 
230
389
  Overlay projection: `scene.project(point)` maps a world point to scene
231
390
  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.
391
+ the camera-forward distance in world units under either projection) and
392
+ returns null for a point at or behind a PERSPECTIVE camera's plane; an
393
+ ortho camera places every point (`w` may be <= 0 there - negative near
394
+ is legal ortho). It reflects a pending `setCamera` immediately, so
395
+ set-then-project in one tick is exact. `scene.unproject(x, y, w, out?)`
396
+ is its exact inverse: the world point at that pixel and camera-forward
397
+ distance `w` - project()'s `w` round-trips in both modes (the
398
+ drag-at-depth recipe: project the grabbed point once, keep its `w`,
399
+ unproject each move). `scene.viewProj(out?)` copies the view-projection
400
+ matrix for batch work. Never rebuild the camera matrices by hand for a
401
+ HUD.
237
402
 
238
403
  Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
239
404
  through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
240
405
  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.
406
+ one). `scene.raycast(origin, direction, opts?)` is the world-space
407
+ primitive under it, and `scene.screenRay(x, y)` the ray itself
408
+ (`{ origin, direction }`; direction's camera-forward component is 1, so
409
+ `origin + w * direction` = unproject) for intersection work pick cannot
410
+ do - drag planes, ground grids, filtered raycasts. All three cast
411
+ exactly the same ray. raycast's `opts` filters the query:
412
+ `{ layers }` (Unity's layerMask) replaces the scene's mask for this ray,
413
+ and `{ meshes }` (Three's intersectObjects) is an include-list; a
414
+ per-frame ground query passes one or the other instead of skipping
415
+ skyboxes and actors by hand.
243
416
  The index and the narrowphase live in the spatial core: every attached
244
417
  mesh's local box is a leaf in a dynamic AABB tree the flush refits from
245
418
  the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
246
419
  an ordinary mesh is then tested per triangle against its geometry's
247
420
  shape (one CPU copy per distinct geometry, created with its GPU buffers),
248
421
  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
422
+ a ray through a knot's hole misses. A large geometry's triangles are
423
+ BVH-indexed too - built by the first ray that reaches the shape, log-cost
424
+ after - so raycasting a merged static scene stays cheap (see the batching
425
+ advice). An instanced mesh is box-only (its
426
+ explicit population bounds; records are opaque): it is tested by the
427
+ box's twelve triangles, so its hits carry the struck face's `normal` and
428
+ no `face`/`uv` - and a ray from inside it meets the far side, the
429
+ surface contract overlap/sweep share. Both methods
252
430
  flush pending writes first (the lookAt/project immediacy contract), and
253
431
  both skip invisible meshes.
254
432
 
433
+ Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
434
+ motion, opts?)` are the same index's other two questions - what a volume
435
+ touches, and where a moving one first touches: Unity's
436
+ OverlapSphere/Box/Capsule and SphereCast/CapsuleCast/BoxCast, Godot's
437
+ intersect_shape and cast_motion (Three has the raycaster alone; every
438
+ Three game adds a physics library or three-mesh-bvh for this). A
439
+ `Volume` is a sphere (`{ center, radius }`), a capsule (`{ a, b,
440
+ radius }`, the radius swept along the segment - a character) or an
441
+ oriented box (`{ center, halfExtents, rotation? }`). overlap returns
442
+ `Overlap[]` - `{ mesh, point, normal, depth }`, the deepest contact per
443
+ mesh: the point on the mesh, the unit direction out of it and the depth
444
+ along it that clears the contact (Godot's get_rest_info, Unity's
445
+ ComputePenetration, per hit; unordered) - and sweep `Impact[]` -
446
+ `{ mesh, time, point, normal }`, per mesh its first touch with `time`
447
+ the fraction of the motion, earliest first. Both take raycast's `opts`
448
+ (`{ layers, meshes }`) and test per triangle in WORLD space against the
449
+ same shapes, so any transform holds, an instanced mesh or sprite counts
450
+ by its box, and the physics-collider pattern is one `{ layers }` away.
451
+ Two contracts to know: the tests are SURFACES (a volume wholly inside a
452
+ closed mesh with no triangle in reach touches nothing - the trimesh rule
453
+ in every engine), and a sweep from a volume already in contact reports
454
+ time 0 only while the motion closes in; leaving or sliding along the
455
+ contact is no hit, which is what lets a slide along a wall proceed. A
456
+ zero motion touches nothing.
457
+ `moveAndSlide(scene, volume, motion, opts?)` is the controller over
458
+ them: Godot's CharacterBody3D.move_and_slide and Unity's
459
+ CharacterController.Move as one PURE function - no node, no velocity;
460
+ the first-person camera composes it in `clampPosition`, a node-driven
461
+ body applies `result.motion` with setTransform. It pushes the body out
462
+ of anything it starts inside, sweeps, stops a skin short, slides the
463
+ rest along the contact plane up to `maxSlides` times, then snaps down
464
+ onto a floor within `floorSnap` unless the motion rises - and the
465
+ `floor` it reports is the one it ENDS on (within `floorSnap` below), not
466
+ one it touched on the way. `MoveOptions`
467
+ adds `up`, `floorMaxAngle` (45 degrees: flatter is floor, steeper a wall
468
+ the body slides down), `maxSlides` (6), `skin` (0.01) and `floorSnap`
469
+ (0.1, 0 off) to the query filters; the result is `{ motion, floor, wall,
470
+ ceiling, hits }`. Gravity is the caller's - fold it into `motion` and
471
+ zero it while `floor` is set (the snap keeps reporting the floor while
472
+ the body stands still) - and a walkable floor absorbs the vertical part,
473
+ so a body never creeps down a slope it can stand on.
474
+ `examples/collision.tsx` is the whole pattern: a capsule walker through
475
+ `clampPosition`, gravity as a frame loop that runs only while airborne,
476
+ the level on a collider layer bit beside its drawn one, pickups lit by
477
+ one overlap per move. Deliberately absent and additive when asked: a
478
+ step offset (Unity's stepOffset; Godot has none either) and a cylinder
479
+ volume (Godot only).
480
+
255
481
  Mesh pointer events - the element vocabulary one tree deeper:
256
482
  `onPointerDown/Move/Up/Enter/Leave` as plain fields on any node (and as
257
483
  Mesh/Group props). The nearest hit mesh is the target; down/move/up
@@ -313,7 +539,10 @@ concatenates parts into one geometry with offset indices (uint32 past 64k
313
539
  vertices); parts must share one layout, a mixed list throws. Together
314
540
  they collapse a static scene to one mesh per material - transform each
315
541
  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
542
+ node, a draw entry and a per-frame `uModel` write of its own. Merging
543
+ does not tax picking: a merged geometry's raycast narrowphase runs
544
+ through its triangle BVH (built on the first ray), so merge for draw
545
+ count without giving up ground queries. Both are
317
546
  pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
318
547
  call, and the source geometries are untouched. `geometryBounds(geometry)`
319
548
  returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
@@ -350,9 +579,10 @@ uint16/uint32 indices by vertex count automatically.
350
579
  Materials:
351
580
 
352
581
  - `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
353
- straight `[r, g, b, a?]` 0..1, premultiplied internally; `cull` and
582
+ straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
583
+ below), premultiplied internally; `cull` and
354
584
  `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).
585
+ opts out of the scene's fog (all four library materials take it).
356
586
  - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
357
587
  that turns to face the camera IN THE VERTEX STAGE (off the shared
358
588
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
@@ -377,7 +607,15 @@ Materials:
377
607
  `normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
378
608
  (the camera's world-space view axes, shared likewise - a billboard is
379
609
  `center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
380
- uViewProj rows, that carries the clip flip) and `uniform mat4 uNormal` (the world
610
+ uViewProj rows, that carries the clip flip), `uniform mat4
611
+ uInvViewProj` (the camera's inverse view-projection, shared likewise -
612
+ a clip position back to world, the world-space ray through a pixel
613
+ without knowing the projection), the output stage's `uniform float
614
+ uExposure` / `uToneMapping` (compose `OUTPUT` from `/glsl` and end with
615
+ `fragColor = outputColor(rgb, alpha)` to take the scene's exposure and
616
+ tone mapping and encode like the library materials do; a fragment
617
+ writing fragColor directly writes final encoded pixels) and `uniform
618
+ mat4 uNormal` (the world
381
619
  inverse-transpose, written beside uModel for this material's meshes;
382
620
  take `mat3(uNormal)` - correct under non-uniform scale, where
383
621
  mat3(uModel) bends normals off the surface). Attributes come from the
@@ -401,50 +639,283 @@ Materials:
401
639
  pipeline with its own values. `dispose()` lives on the class alone.
402
640
  `shaderMaterial(opts)` is exactly a class with one instance (its
403
641
  `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`
642
+ - `instanceAttributes: [{ name, format, slot? }]` on either shader-material
643
+ form makes an INSTANCED material: the vertex stage reads them as `in`
406
644
  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.
645
+ record per slot from the mesh's instance buffers - slot 0 (default)
646
+ is the record buffer, slot 1 an instanced mesh's STYLE buffer (below).
647
+ Its meshes come from `createInstancedMesh` or `createRecordMesh`; a
648
+ `createMesh` mesh is rejected at add(). `instanceStyle: [..]` is the
649
+ slot-1 record a fresh instance starts with. The stock materials do
650
+ the same with one flag: `lit/standard/unlit({ instanced: true })`
651
+ places by the instance matrix (the shadow pass too, so the fleet
652
+ casts), and `{ instanceColors: true }` adds a per-instance
653
+ premultiplied `[r, g, b, a]` style record starting white - Unity's
654
+ per-material instancing switch with Three's setColorAt.
655
+
656
+ Instancing - one draw entry covering a population, in two forms. The
657
+ axis between them is WHERE MOTION IS COMPUTED, the same split as
658
+ @solidrt/2d's sprite and record layers.
659
+
660
+ `createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
661
+ draws the geometry once per instance NODE: `addInstance(mesh,
662
+ transform?, parent?)` returns a scene node (kind "instance") placed
663
+ inside the mesh - setTransform/setTransition/setVisible, lookAt,
664
+ worldPosition, pointer handlers and children of its own all apply - whose
665
+ matrix RELATIVE to the mesh the spatial core writes into the record
666
+ buffer (`bindMatrixRecord` anchored on the mesh node: one coalesced
667
+ buffer write per flush however many instances moved, so native
668
+ transitions and clip players move instances with zero per-frame JS).
669
+ The material declares `INSTANCE_MATRIX_ATTRIBUTES` in slot 0 (four vec4
670
+ columns, 16 floats; anything else throws at creation) and its vertex
671
+ stage splices `INSTANCE_MATRIX` from `@solidrt/3d/glsl`: `uModel *
672
+ instanceMatrix() * vec4(aPos, 1.0)`, normals through
673
+ `instanceNormalMatrix()` (Three's derivation, exact for any rotation and
674
+ scale at no per-instance data; a sheared hierarchy - a non-uniformly
675
+ scaled group above a rotated instance - takes
676
+ `transpose(inverse(mat3(instanceMatrix())))` instead) - or is a stock
677
+ material with `instanced`. Slot-1 attributes, when the material declares
678
+ any, give every instance a STYLE record beside its matrix
679
+ (`MeshInstances.style`): app-owned floats in a second, mesh-owned
680
+ buffer, written per instance with `setInstanceStyle(instance, floats)`
681
+ into a JS mirror and published as ONE coalesced buffer write per mesh at
682
+ the scene's sync (a frame-rate path, like setTransform); a fresh or
683
+ recycled slot starts from the material's `instanceStyle` (white under
684
+ `instanceColors`, zeros otherwise); under `alphaTest` the cutout shadow
685
+ multiplies by the same color, so an instance faded below the cutoff
686
+ casts nothing, like its pixels (Godot's alpha scissor holds in its
687
+ shadow pass too). The core's pose slot and the app's style slot are
688
+ @solidrt/2d's split one dimension up. Slots are fixed
689
+ for an instance's life and recycle on `removeInstance` (remove means
690
+ destroy: an instance cannot exist outside its mesh, and the generic
691
+ add/remove throw on one), the drawn count is the slot high-water mark,
692
+ and past `capacity` (default 64) both buffers double into replacements
693
+ with the live records retargeted in one core call and the style mirror
694
+ republished. `parent` may be a group inside the mesh's subtree (a squad
695
+ in a fleet): the record stays mesh-relative through it. Picking is per
696
+ instance: every instance node carries the geometry's bounds and
697
+ triangle shape, so `Hit`, `Overlap` and `Impact` name the `instance` and
698
+ pointer events bubble from it (`event.instance`) through the mesh to its
699
+ ancestors. `bounds` on the mesh is optional here: without it the mesh
700
+ culls by the union of its live instances' boxes (following them, hidden
701
+ ones included) and sorts by its node position; with it the explicit
702
+ box does both. Three's InstancedMesh count
703
+ constructor over Unity's one-transform-per-instance model; Godot's
704
+ MultiMesh is the record form. The components: `<InstancedMesh capacity>`
705
+ with `<Instance position style transition onPointerDown>` children
706
+ (`<Group>` children are squads, an `<Instance>` is a parent too);
707
+ `examples/fleet.tsx` is a thousand of them springing between formations
708
+ with tap-to-tint, `probes/3d-instance-probe.tsx` the function face.
709
+ Mount cost is the one difference between the faces: an `<Instance>` row
710
+ costs about eight times an `addInstance` call (the component machinery -
711
+ a Solid row, two effects, the style effect; a node component's context
712
+ provider is built only when it has children, the biggest single saving -
713
+ `probes/3d-instance-mount-bench.tsx` measures it), so a few thousand
714
+ mount in tens of milliseconds either way; populations spawned per frame,
715
+ or in five figures, belong to the function face.
716
+
717
+ `createRecordMesh(geometry, material, records, count?, { bounds?,
718
+ label? })` is the raw form: `records` is the interleaved per-instance
719
+ data (stride = the material's slot-0 instanceAttributes summed, a
720
+ mismatch throws; slot 1 is an instanced mesh's and throws here),
721
+ uploaded to a mesh-owned buffer whose capacity starts at the
722
+ records given, and `count` picks how many draw (default all).
723
+ `setRecords(mesh, records, count?)` rewrites from the start (count
724
+ defaults to the records written; more than capacity GROWS: capacity
725
+ doubles into a replacement buffer, the entry is re-pointed via
726
+ `setDrawBuffers`, the old buffer is freed) and `setRecordCount(mesh, n)`
727
+ is the population dial (clamped to capacity; frame-rate-safe). Records
728
+ are opaque data (position/yaw/tint/whatever your shader reads), so the
729
+ library cannot know where they place instances: a record mesh has NO
730
+ picking leaf unless you pass `bounds` (local, covering the population) -
731
+ then it picks and transparent-sorts conservatively as one box. The
732
+ escape hatch for motion only JS can compute at scale (a particle sim, a
733
+ crowd stepped in a worker).
734
+
735
+ Everything mesh works on both: setTransform moves the whole population
736
+ through one uModel, setVisible zeroes the drawn count and restores it on
737
+ unhide, renderOrder/params/geometry/material swaps apply, and
738
+ `disposeInstances(mesh)` detaches and frees the record buffers - the one
739
+ explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
740
+ components), `probes/3d-instance-probe.tsx` (instances, functions) and
741
+ `examples/instanced.tsx` (records) are the live proofs.
433
742
 
434
743
  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).
744
+ on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
745
+ of the scene's own pass (attributeless fullscreen triangle, depth off) -
746
+ one target instead of a backdrop texture stacked under the scene, with
747
+ no separate resize plumbing. Two forms:
748
+
749
+ - Fragment GLSL. The source gets the shader-target fragment contract
750
+ (vUV 0..1 top-left origin, iResolution, fragColor; no `#version` line
751
+ = the standard preamble), so a `createShaderTexture` backdrop ports
752
+ verbatim, PLUS `in vec3 vRay`: the world-space view ray through the
753
+ pixel, unnormalized (the vertex stage carries its clip position back
754
+ through the shared uInvViewProj at the near and far planes). A
755
+ directional sky - horizon gradient, sun disc, stars - is a few lines
756
+ on `normalize(vRay)`. The background is an ordinary scene entry, so it
757
+ may declare `uniform vec3 uCamPos` (the ray's origin) and any name
758
+ written through `scene.setParams` (an app clock for an animated sky).
759
+ Godot's sky shader and Unity's skybox material are the same idea; the
760
+ radiance bake for environment lighting will consume this same source
761
+ later, so a procedural sky written here lights the scene then. The
762
+ preamble declares the OUTPUT set: end with `fragColor =
763
+ outputColor(rgb, 1.0)` for a sky that takes the scene's exposure and
764
+ tone mapping (the skybox form does); a direct fragColor write is final
765
+ encoded pixels.
766
+ - A skybox `{ cube, intensity?, rotation? }` (SkyboxOptions): a cube
767
+ map from createCubeTexture sampled along the same ray - Three's
768
+ `scene.background = cubeTexture` with `backgroundIntensity` and
769
+ `backgroundRotation`. `rotation` is a turn about world y in radians
770
+ (the sky turns as a node with that rotation would); `intensity` a
771
+ multiplier. Replacing a skybox with a skybox rewrites the entry's
772
+ params and cube in place (no recompile), so the reactive prop can
773
+ animate the rotation. Under an orthographic camera every pixel looks
774
+ the same way, so a skybox is one flat color there. A 2D texture id
775
+ throws at the samplerCube binding. `examples/skybox.tsx`.
776
+
777
+ The cube-map convention: a cube map holds what a GL lookup returns -
778
+ each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
779
+ and every library lookup is a plain `texture(cube, dir)` in world space.
780
+ No shader flip, as in Godot and Unity, which convert images at import;
781
+ Three instead flips x in the shader for image cubes (`flipEnvMap`) and
782
+ not for rendered ones, so a ported Three shader drops its flip, and a
783
+ Three-style six-face image set (px, nx, py, ny, pz, nz as seen from
784
+ inside) is mirrored per image at load. Every bake here (the tool,
785
+ `equirectToCube`, the examples' JS skies) writes GL's table directly,
786
+ and a cube the scene renders itself needs nothing. Three's `scene.background = color` is `clearColor` here; a 2D
787
+ image form can widen the signature later (a branded TextureId is a
788
+ number, so the object form keeps it unambiguous). Translucent grounds
789
+ over a background still need blend factors (a separate shader texture
790
+ underneath until then).
791
+
792
+ Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
793
+ null)`, the `environment` option on createScene and the reactive `Scene`
794
+ prop - the cube map every `standard` material is lit by (always: the
795
+ split sum `envRadiance` at its roughness times PBR's `envBrdf` for the
796
+ specular, `envIrradiance` - the fully rough sample along the normal, as
797
+ Three's getIBLIrradiance and Godot read it - added to the hemisphere
798
+ for the diffuse) and every `lit({ reflectivity })` material mirrors,
799
+ typically the skybox's own cube turned with it. The cube to use is a
800
+ BAKED one: `bunx srt tool 3d/environment sky.hdr -o assets/sky.srte`
801
+ turns an equirectangular Radiance .hdr (Poly Haven's are CC0) into the
802
+ six faces plus the GGX-prefiltered mip chain in linear float, and
803
+ `await loadEnvironment("assets/sky.srte")` uploads it as an explicit
804
+ "rgba16f" chain (createCubeTexture's array-of-levels form: no generated
805
+ mipmaps, so no half-float render support needed - it works on every
806
+ device; created after an await, so not auto-freed). Unity convolves at
807
+ import the same way; Three's PMREMGenerator and Godot's radiance map do
808
+ it at runtime. The roughness-to-level rule is `ENVIRONMENT`'s:
809
+ roughness r samples level `r * (log2(size) - 2)`, so a roughness of 1
810
+ lands on the 4x4 level (ENV_ROUGH_FACE in environment-bake.ts), the
811
+ last one the bake convolves; a `mipmap: true` cube from six faces
812
+ (equirectToCube, a JS-baked sky) merely box-filters that chain, so its
813
+ rough reflections are sharper than they should be and its diffuse term
814
+ is a coarse average - fine for a sky gradient, wrong for a photograph.
815
+ A 128 environment (2 MiB, the default) lights any surface; for a
816
+ mirror-finish showpiece bake at 256, and for a crisp backdrop pair it
817
+ with a separate hi-res LDR skybox (a 2k panorama through equirectToCube)
818
+ as `background` while the .srte stays the `environment`. Scene-level like Three's
819
+ `scene.environment`, Unity's environment reflections and Godot's
820
+ sky-lit reflections: ONE `uEnv` samplerCube bound on every target the
821
+ scene draws into (a 1x1 black placeholder while unset) and one
822
+ shared-params write (`uEnvIntensity`, `uEnvRotation`, `uEnvOn`), however
823
+ many meshes reflect; no per-material envMap (Three's Basic/Phong
824
+ `envMap`) - a custom material composes ENVIRONMENT from
825
+ `@solidrt/3d/glsl`. `reflectivity` 0..1 is the
826
+ face-on weight, rising to 1 at grazing angles (Schlick), mixed in as
827
+ `rgb = mix(rgb, reflection, weight)`: 1 is chrome, ~0.05 a glossy
828
+ dielectric with rim reflections; Three's Phong `reflectivity` under its
829
+ MixOperation with a fresnel weight (Three's default MultiplyOperation
830
+ tints instead; not offered). The reflection blurs with `shininess`:
831
+ roughness `sqrt(2 / (shininess + 2))` picks a mip level of the cube
832
+ (`textureLod`), so the environment cube wants `mipmap: true`; a cube
833
+ without mipmaps stays sharp. `specularMap`'s red scales it like
834
+ `specular`. For `lit` it is not an ambient light source: the hemisphere
835
+ light stays its only ambient term (`standard` adds envIrradiance; SH9
836
+ is a later, additive mode). A declared `reflectivity` with no
837
+ environment set contributes nothing (uEnvOn 0), not a black reflection.
838
+ `examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
839
+ baked HDRI lighting the scene alone).
840
+
841
+ Reflection probes: `scene.createReflectionProbe({ position, size?,
842
+ near?, far?, layers?, clearColor?, label? })` renders the scene into a
843
+ cube map from a point - Three's CubeCamera, Unity's and Godot's
844
+ realtime ReflectionProbe - and returns `{ cube, setPosition, update(),
845
+ dispose() }`; `cube` is what `environment={{ cube }}` (a chrome ball
846
+ mirroring its surroundings) or `background` takes. A view under the
847
+ hood: one entry list mirrored from the scene, the light set and scene
848
+ params fanned out, its own layer mask (keep the mirroring object out of
849
+ its own probe with `layers`), the scene's background drawn first on
850
+ every face (the GLSL sky or skybox behind the meshes, through the face
851
+ camera, in linear light - what Three, Unity and Godot probes see), a
852
+ cube draw target (`createCubeDrawTarget` in core, rendered face by face
853
+ with `renderTarget(cube, face)`).
854
+ Nothing renders it but `probe.update()`: six scene passes, from the
855
+ meshes as the last frame's flush placed them, so call it when the
856
+ surroundings moved (every frame for a moving scene, once for a still
857
+ one), then the PREFILTER: the faces convolved on the GPU into a second,
858
+ `mipmap: true` cube target level by level (`renderTarget(chain, face,
859
+ level)`, one small pass each - 48 at 128 - the bake tool's GGX
860
+ importance sampling as a fragment, `createPrefilter` in environment.ts,
861
+ with the same roughness-to-level rule as a .srte chain), so `standard`
862
+ blurs a probe by roughness exactly like a baked environment; `prefilter:
863
+ false` skips it and hands out the sharp faces (Three's CubeCamera: a
864
+ mirror at every roughness). COST: the chain's passes are tiny, but a
865
+ pass that samples mip levels of a cube map carries a fixed GPU cost
866
+ (about 0.3 ms each on an Intel/Mesa laptop, the same at 8 samples or at
867
+ 256 - measured 2026-09-05), so a prefiltered probe updated every frame
868
+ costs ~14 ms of GPU there against ~1 ms for the six face passes: fine at
869
+ 60 fps on its own, the largest single item in a frame budget. Realtime
870
+ probes are the expensive option in every engine (Unity time-slices
871
+ them): update a prefiltered probe when the surroundings changed, or
872
+ every few frames, and keep `prefilter: false` for a probe that must
873
+ refresh every frame on a tight budget. The faces hold LINEAR light (the probe owns
874
+ `uOutputEncode` 0, `uToneMapping` 0 and `uExposure` 1 on its target,
875
+ names the scene's fan-out then skips), HALF FLOAT where the device
876
+ renders it (`limits.halfFloatRenderable`, every GLES 3 device here: a
877
+ sun's or an emissive's range survives into the reflection, as in every
878
+ engine's HDR probe) and 8-bit clamped elsewhere - the renderer decides
879
+ for all probes (Godot), not a per-probe knob (`probeFormat()` in
880
+ environment.ts) - and the probe never samples its own cube while
881
+ rendering (a black environment stands in: one bounce). The face cameras are plain world-up cameras through an
882
+ x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
883
+ from outside; the engine inverts the front-face rule on cube target
884
+ passes so cull modes keep their meaning. `examples/probe.tsx`.
885
+
886
+ Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
887
+ origin that sees no mesh (layer mask 0): the scene's background - the
888
+ GLSL sky or the skybox - alone on its six faces, LINEAR (a sky ending in
889
+ `outputColor` bakes its light; one writing fragColor raw bakes those
890
+ bytes as light), prefilters it like a probe and returns the chain's
891
+ TextureId for `environment={{ cube }}`: Godot's sky-to-radiance bake, so
892
+ a procedural sky lights the scene with no light nodes, and the runtime
893
+ way to turn a hi-res LDR skybox into a properly convolved environment
894
+ (the `mipmap: true` box chain above is the sharper, cheaper
895
+ alternative). A snapshot at the probe format (half float where
896
+ renderable; default size 128): bake again when the sky changes, and
897
+ destroy the old cube - it is not auto-freed (an environment normally
898
+ lives as long as the app). `outputColor` leaves linear output UNCLAMPED
899
+ (the clamp sits in the display encode), so a sky's sun disc of 40.0
900
+ bakes as 40.0 and shows as the broad bright highlight on rough metal
901
+ that HDR is for. A sky that reads
902
+ `uCamPos` bakes from the origin; scene params (an app clock) are seen as
903
+ of the call. `examples/sky-lit.tsx`.
904
+
905
+ Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
906
+ equirectangular 2D texture (createImage, createTexture) into a cube
907
+ TextureId on the GPU, synchronously (six face passes straight into a
908
+ cube draw target of the panorama's format - rgba8, rgba8-srgb or
909
+ rgba16f, no readback; `opts` are createCubeTexture's - `mipmap: true`
910
+ for an environment). The center column faces -Z and the top row is +Y,
911
+ as in Godot's PanoramaSkyMaterial and Unity's Skybox/Panoramic; Three
912
+ centers +X, a quarter turn away. Leave the source texture's wrap at
913
+ clamp (`repeat` also wraps vertically and bleeds the poles). An HDR
914
+ panorama uploaded as rgba16f converts into a half-float cube (sharp:
915
+ a skybox, or `mipmap: true` for the box chain); its PREFILTERED form is
916
+ the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
917
+ decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
918
+ pure TypeScript and bun-tested - there is no runtime .hdr decoder.
448
919
 
449
920
  Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
450
921
  the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
@@ -477,6 +948,31 @@ horizon, and put `far` at or inside the camera's far plane to hide the
477
948
  clip. `examples/fog.tsx` cycles the forms over a valley;
478
949
  `examples/cascades.tsx` fogs its field to the sky.
479
950
 
951
+ Color: the scene shades in LINEAR light and outputs sRGB, like Three
952
+ (ColorManagement), Godot and Unity's linear space - no gamma mode. Every
953
+ `[r, g, b]` color option is sRGB, what a color picker shows: material
954
+ `color` and `emissive`, light `color`, the hemisphere's `sky`/`ground`,
955
+ fog `color`; the library decodes it when it writes the uniform
956
+ (`srgbToLinear`/`linearColor` are exported for values you write straight
957
+ to a uniform yourself). Color MAPS decode through their format: create a
958
+ base color, emissive or sky image with `format: "rgba8-srgb"`
959
+ (createTexture, createCubeTexture; createModel does it for glTF's base
960
+ color and emissive images) - a plain rgba8 map reads as linear data and
961
+ renders washed out; data maps (normal, specular, roughness, light maps)
962
+ stay rgba8, and an HDR image is "rgba16f". Vertex colors are linear, as
963
+ glTF stores them. Every library fragment ends in the OUTPUT set's
964
+ `outputColor(rgb, alpha)`: exposure (`scene.setExposure`, default 1),
965
+ tone mapping (`scene.setToneMapping("none" | "aces")`, the reactive
966
+ `toneMapping`/`exposure` props) and the sRGB encode, premultiplied. The
967
+ scene target therefore holds encoded pixels like every texture the
968
+ runtime displays; `clearColor` is written as given and is NOT tone
969
+ mapped (with a curve on, draw the backdrop as a background), and a
970
+ transparent mesh blends in encoded space (Three's compromise; the
971
+ hardware-encode alternative would display wrong through the runtime's
972
+ raw sampling). What changed for a scene tuned before this: terminators
973
+ soften, mid-tones brighten, highlights widen - drop ambient rather than
974
+ lights. `emissiveIntensity` scales the emissive in linear light.
975
+
480
976
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
481
977
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
482
978
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
@@ -485,14 +981,22 @@ plus the colored layout's aColor forwarded raw as vColor - using it makes
485
981
  the material need that channel) and the pure functions `HEMISPHERE`
486
982
  (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
487
983
  `BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
488
- (`fresnel(n, v, power)`), and the shadow trio composed IN ORDER:
984
+ (`fresnel(n, v, power)`), `PBR` (the GGX metalness/roughness model
985
+ `standard` shades with: `ggxSpecular(n, v, l, f0, roughness)` - one
986
+ light's lobe, to weight by `lambert(n, l)` and the light color like the
987
+ diffuse - `envBrdf(nv, roughness)` for the split sum's scale and bias on
988
+ f0, `DIELECTRIC_F0`, and the D/V/F pieces; it defines `PBR_PI`, not
989
+ `PI`), and the shadow trio composed IN ORDER:
489
990
  `SHADOW_SLOTS` (the scene's shadow set: `uShadowAtlas`, per map slot
490
- `uShadowRect[M]`/`uShadowMatrix[M]`, per directional light
991
+ `uShadowRect[M]`/`uShadowMatrix[M]`, per light index
491
992
  `uShadowFirst[N]`/`uShadowCount[N]` (its slots; a cascaded light has
492
993
  several, tightest first), `uShadowBias[N]`, `uShadowNormalBias[N]`),
493
994
  `SHADOW` (`shadowPoint(coord)` - clip to map point, `shadowInside(p)` -
494
995
  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
996
+ factor as ONE hardware comparison tap (`uShadowAtlas` is a
997
+ sampler2DShadow; the engine binds the comparison sampler for that
998
+ declaration, so the 2x2 PCF weighting is the driver's), and
999
+ `shadow(map, rect, coord, bias)` composing the
496
1000
  three) and `SHADOW_LOOKUP`
497
1001
  (`lightShadow(i, worldPos, n)` - light i's factor, 1 when it does not
498
1002
  cast; it walks the light's slots and samples the first map that covers
@@ -504,40 +1008,129 @@ samplers. Lights, colors and exponents are arguments, so
504
1008
  nothing is pinned but the function names; `lit` is composed from these
505
1009
  same constants - customizing never means leaving the system.
506
1010
 
1011
+ A custom look is a citizen of the scene - lit by its lights, shadowed,
1012
+ fogged, exposed and tone mapped like the stock materials - at one of
1013
+ three tiers, top first:
1014
+
1015
+ 1. STANDARD FRAGMENT, CUSTOM VERTEX. Any vertex stage that writes the lit
1016
+ varyings (vWorldPos, vNormal, vUv, plus vColor with `vertexColors`,
1017
+ vUv2 with `lightMap`) pairs with `litFragment(options)` /
1018
+ `standardFragment(options)` (`/glsl`): the exact fragment `lit` /
1019
+ `standard` compile, the same option names and defaults as their
1020
+ options with the texture options boolean. An instanced or displaced
1021
+ mesh keeps the stock shading whole (`examples/instanced.tsx`: the
1022
+ per-instance tint rides vColor). Instance it with the per-entry
1023
+ uniforms the source declares (uColor, uSpecular/uShininess or
1024
+ uMetalness/uRoughness, the maps opted into).
1025
+ 2. A SURFACE FUNCTION inside the stock fragment. `litFragment({ surface,
1026
+ prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
1027
+ it declares is an ordinary `instance()` param), `surface` declares
1028
+ `void surface(inout Surface s)`, called once the program has filled
1029
+ the Surface struct from its options (base from uColor, the map and
1030
+ the vertex color; the normal, bent by the normal map; emissive,
1031
+ ambient, the light model's fields) and before it shades. Rewrite any
1032
+ field or `discard`; it reads the varyings, the declared uniforms and
1033
+ prelude's names, and runs in the shadow twin too, so what it discards
1034
+ casts no shadow. The struct is the contract, no local of the
1035
+ generated program is; colors are linear light, premultiplied
1036
+ throughout, `Surface.base` included. The material describes the
1037
+ surface, the package shades it (Godot's fragment(), Filament's
1038
+ material()).
1039
+ 3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
1040
+ `sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
1041
+ declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
1042
+ light list, the shadow set, the environment, fog and OUTPUT - declare
1043
+ none of them yourself. Build a `Surface` with `surfaceOf(base,
1044
+ normal)`, set the fields you mean, call `shadeBlinn(s, position)` or
1045
+ `shadePbr(s, position)` (premultiplied rgb back: hemisphere, every
1046
+ light with its shadow, the environment term, the emissive), add your
1047
+ own terms times the alpha, and end with `sceneOutput(rgb, alpha,
1048
+ position)` (fog, exposure, tone mapping, encode). A custom LIGHT MODEL
1049
+ loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
1050
+ shade function: light i's direction and its color already attenuated,
1051
+ cone-faded and shadowed (zero when it cannot reach). The stock
1052
+ materials are built from this same set, so the tiers cannot drift;
1053
+ `probes/scene-set-probe.tsx` is the byte-identity rig that checks it.
1054
+ The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
1055
+ (the knot's rim term); `probes/spot-custom-material-probe.tsx` a
1056
+ sceneLight loop beside a lit() floor.
1057
+ `standardFragment(options)` is the same for `standard`: lit's options
1058
+ minus `specularMap`/`env` (the environment is always composed) plus
1059
+ `metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
1060
+ `uMetalness`/`uRoughness` in place of `uSpecular`/`uShininess`.
1061
+ `litShadowFragment(options)` is the depth-pass twin (same base, cutout
1062
+ and surface function, nothing after them), so a discarding material
1063
+ casts what it draws: build it on `litVertex(options)` with the OPPOSITE cull, instance
1064
+ it with only the uniform values its source declares (per-entry params
1065
+ reject unknown names), and pass it as the main instance's `shadow`.
1066
+ It returns undefined when the options cannot discard - the scene's
1067
+ default depth override is then already right, carry no `shadow`.
1068
+ `UNLIT_VERTEX` / `unlitFragment` / `unlitShadowFragment` are the unlit
1069
+ twins (no lighting flags, no cull; varyings vUv/vWorldPos only). TRAP:
1070
+ a shadow program that never reads `n` (no triplanar, no surface function
1071
+ using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
1072
+ instance or every caster move warns about the skipped write.
1073
+
507
1074
  Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
508
1075
  direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
509
1076
  travelling along `direction` in the node's LOCAL space (default `[0, -1,
510
1077
  0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
511
1078
  and position/scale do not matter - deliberately a direction, not Three's
512
- position-minus-target. `createHemisphereLight({ sky?, ground?, intensity?
1079
+ position-minus-target. `createSpotLight({ direction?, color?, intensity?,
1080
+ distance?, angle?, penumbra?, decay? })` / `<SpotLight>` is a cone from
1081
+ the node's WORLD position along that same LOCAL `direction` (aim by
1082
+ `direction` or a parent's rotation; place by setTransform): `angle` is
1083
+ the cone half-angle in DEGREES ((0, 90], default 60 - degrees like
1084
+ camera fov and like Unity/Godot; Three's radians convert as
1085
+ `angle * 180 / PI`), `penumbra` the
1086
+ 0..1 fraction of it fading to the rim (default 0, a hard edge), and the
1087
+ strength falls off as `1 / d^decay` (default 2) windowed to zero at
1088
+ `distance` (0 = no cutoff) - Three's SpotLight semantics minus the
1089
+ target object. `createPointLight({ color?, intensity?, distance?,
1090
+ decay? })` / `<PointLight>` is the omnidirectional version: position
1091
+ only, same falloff, no cone. `createHemisphereLight({ sky?, ground?, intensity?
513
1092
  })` / `<HemisphereLight>` is the ambient term, a gradient by the WORLD
514
1093
  normal's tilt (fixed to world up, the node's transform is ignored); one per
515
1094
  scene, the last attached wins. Placement goes through setTransform, the
516
1095
  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.
1096
+ like setMeshParams). At most `MAX_LIGHTS` (8, exported from `/glsl`)
1097
+ lights per scene, directional, spot and point together (the hemisphere
1098
+ is not in the list) - the ninth throws at add(); it is a shader-source
1099
+ constant, fixed per app. `uLightDir` and `uLightPos` are core-driven:
1100
+ each light's slots are spatial-core shared-slot sinks following the
1101
+ node's world rotation (direction, negated so the shader reads the
1102
+ vector TOWARD the light) and world position, so a MOVING light costs no
1103
+ JS. The sync rewrites the rest whenever a light attaches, detaches or
1104
+ changes a field - `uHemiSky`/`uHemiGround` (vec3, intensity folded in),
1105
+ `uLightCount` (int), `uLightType[N]` (LIGHT_DIRECTIONAL | LIGHT_SPOT |
1106
+ LIGHT_POINT), `uLightDir[N]`/`uLightPos[N]`/`uLightColor[N]` (intensity
1107
+ folded into the color) and `uLightParams[N]` (cosInner, cosOuter,
1108
+ distance, decay) - so a custom fragment composing `LIGHT_SLOTS` +
1109
+ `LIGHT_LOOKUP` from `/glsl` reads the same list through `lightVector(i,
1110
+ worldPos, out l)` (returns the attenuation, 0 = skip the light; `lit`
1111
+ is the shape), and a light change costs one write however many meshes.
1112
+ A custom fragment that declares only the old directional subset
1113
+ (`uLightCount`/`uLightDir`/`uLightColor`) still works - it just shades
1114
+ every light as directional, so keep such materials to directional-only
1115
+ scenes. Everything starts black: a lit scene with no light shows
1116
+ nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
1117
+ shape (soft vs hard cone, casting spots, an orbiting bulb).
530
1118
 
531
1119
  `lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
532
1120
  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
1121
+ `specular` (0..1 strength) is set with `shininess` (default 30), a
1122
+ mirror of the scene's environment when `reflectivity` (0..1, the
1123
+ face-on weight; see Environment above) is set, blurred by the same
1124
+ `shininess`, the same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
535
1125
  multiply by the colored layout's aColor (so the geometry must carry it),
536
1126
  `triplanar: n` to sample `map` by world position at `n` repeats per
537
1127
  world unit, blended across the three axis planes by the normal, and
538
1128
  `alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
539
1129
  is discarded; Three's alphaTest, glTF MASK): opaque, depth-written, no
540
- sorting, usually with `cull: "none"` for cards. Triplanar
1130
+ sorting, usually with `cull: "none"` for cards, and `instanced: true` /
1131
+ `instanceColors: true` for an instanced mesh's population (see
1132
+ Instancing: the placement, the depth pass and a per-instance tint with
1133
+ no GLSL; the material then draws populated meshes only). Triplanar
541
1134
  is an OPTION, not the default: generators emit 0..1 UVs per face, so a
542
1135
  map on a plane is a decal (UV) while a map on generated scenery wants one
543
1136
  density across parts of any size (triplanar); the map must be created
@@ -546,45 +1139,132 @@ with `wrap: "repeat"`. Any `map` on a surface seen at distance also wants
546
1139
  surface seen at a grazing angle (a floor, a road) wants `anisotropy: 4`
547
1140
  or more beside it, or trilinear smears the far half into the mip its long
548
1141
  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.
1142
+ the level, `limits.maxAnisotropy` reports it).
1143
+
1144
+ The surface maps, each an option beside `map` and sampled at its uv:
1145
+
1146
+ - `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
1147
+ Vector2 exists to flip DirectX-style green channels, and glTF mandates
1148
+ OpenGL-style +Y) bends the lit normal per texel. The tangent frame is
1149
+ built per fragment from screen-space derivatives (`NORMAL_MAP` in
1150
+ `/glsl`, Three's untangented path), so ANY UV-mapped geometry works
1151
+ with no tangent channel; the trade is mild seams on mirrored UVs. Not
1152
+ with `triplanar` (throws - triplanar samples by world position).
1153
+ - `emissive: [r, g, b]` (sRGB like `color`) times `emissiveIntensity`
1154
+ (linear, default 1; glTF's emissive strength) and `emissiveMap` add
1155
+ light the lights do not provide, after the lighting terms,
1156
+ shadow-proof, fogged. `emissive` defaults to WHITE when `emissiveMap`
1157
+ is given - the map is the emission - fixing Three's gotcha where an
1158
+ emissiveMap alone shows nothing against the black default.
1159
+ - `specularMap`: its RED channel scales `specular` per fragment (chrome
1160
+ and rubber on one mesh); with it `specular` defaults to 1.
1161
+ - `lightMap` (+ `lightMapIntensity`) adds a baked-light texture by the
1162
+ geometry's aUV2 channel (`withAttribute(g, { name: "aUV2", format:
1163
+ "vec2" }, fill)`) - ADDED to the light sum like the hemisphere term, so
1164
+ a fully baked scene runs with no lights at all. Three's material-slot
1165
+ form; Unity and Godot bake at scene level, but here the material picks
1166
+ the program.
1167
+ - `mapTransform: { offset?, repeat? }` samples every uv map of the
1168
+ material at `uv * repeat + offset` - ONE transform per MATERIAL
1169
+ (Godot's uv1_offset/uv1_scale, Unity's Tiling/Offset; deliberately not
1170
+ Three's per-texture transform, since a TextureId is a shared value
1171
+ whose sampling is creation-time state). aUV2 is exempt. Scroll it per
1172
+ frame with `setMeshParams(mesh, { uMapTransform: [ru, rv, ou, ov] })`.
1173
+ Also on `unlit`. Not with `triplanar` (throws - its repeat is the
1174
+ triplanar value). A cutout's shadow transforms the same way.
1175
+
1176
+ `examples/materials.tsx` shows all five.
1177
+
1178
+ `standard(opts)` is the metalness/roughness material, the look authored
1179
+ assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
1180
+ Unity's Standard): every lit option but the Blinn-Phong knobs
1181
+ (`specular`, `shininess`, `specularMap`, `reflectivity`), plus
1182
+ `metalness` (0..1, default 0: a metal has no diffuse and reflects tinted
1183
+ by `color`, a dielectric reflects 4% face-on), `roughness` (0..1
1184
+ perceptual, default 1: 0 a mirror, 1 matte; one value widens the
1185
+ highlight and blurs the environment alike; Unity's smoothness is its
1186
+ inverse) and the packed data maps `metalnessMap` (its BLUE channel) and
1187
+ `roughnessMap` (GREEN) - Three's two channel-select options over glTF's
1188
+ ONE metallicRoughnessTexture, so pass the same texture to both; with a
1189
+ map the factor defaults to 1 (the map is the value). Shading is GGX
1190
+ (`PBR` in `/glsl`: distribution, height-correlated Smith visibility,
1191
+ Schlick fresnel) per light in the same light and shadow loop, the
1192
+ hemisphere on the diffuse, and the scene's environment ALWAYS - the
1193
+ split sum, `envRadiance` at the roughness over the cube's mip chain
1194
+ times the analytic `envBrdf` for the specular, `envIrradiance` on the
1195
+ diffuse beside the hemisphere - with no `reflectivity` switch: the
1196
+ environment is intrinsic to the model, and a baked environment with no
1197
+ lights at all lights a scene (`examples/environment.tsx`). Light intensities read as lit's
1198
+ (1 lights a white matte surface to 1; Godot's and Unity's convention -
1199
+ a Three scene's intensities divide by pi). Without an environment a
1200
+ metal shows only its highlights: no diffuse, nothing to reflect (Three
1201
+ and Godot do the same), so give the scene one. `examples/standard.tsx`
1202
+ is the sphere grid. Internally one
1203
+ `shaderMaterialClass` per option combination (map x vertexColors x
1204
+ triplanar x transparent x cull x alphaTest x fog x the surface maps),
1205
+ cached for the app's lifetime, one pipeline per vertex layout - a
1206
+ thousand lit meshes share one program, and the key's width costs nothing
1207
+ by itself: classes are created lazily per combination USED, so the
1208
+ program count is the app's distinct material configurations. The view
1209
+ vector comes from the shared uCamPos; `uTriplanar` and `uAlphaTest` are
1210
+ declared only by the classes that use them (the cutoff is a per-entry
1211
+ value, so every alphaTest material shares one class) so the other
1212
+ classes do not warn about an inactive uniform.
557
1213
 
558
1214
  ## Models
559
1215
 
560
1216
  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:
1217
+ next to it, or single-file .glb) and become a Group carrying the file's
1218
+ node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
1219
+ fits:
563
1220
 
564
1221
  - `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.
1222
+ bun and on flux): `ModelData` = `nodes` (the retained hierarchy in
1223
+ pre-order - name, parent index, local TRS; matrix-form nodes are
1224
+ TRS-decomposed, shear dropped; nodes that carry no part, joint or
1225
+ animation target anywhere - cameras, lights, unused empties - are
1226
+ pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
1227
+ index, vertices in the standard layout LOCAL to the node - except
1228
+ skinned parts: "skinned" layout, model-space bind pose, `skin` index),
1229
+ `skins` (joint node indices + inverse binds), `clips` (the animations
1230
+ as baked channel buffers: node/path/interpolation, times, values),
1231
+ `materials` (base color factor, `map` =
1232
+ index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
1233
+ `alphaMode` as written and `alphaCutoff`, spec default 0.5, the
1234
+ normal and emissive slots, `metalness`/`roughness` factors and the
1235
+ packed `metalnessRoughnessMap` - standard's inputs), `images`
1236
+ (the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
1237
+ pose, conservative for parts under rotated nodes). External
1238
+ files come through `resolve(uri)` (uri as written, still
1239
+ percent-encoded; `gltfExternalUris(bytes)` lists them so an async
1240
+ caller can read them first) - for a .gltf AND for a .glb, which is
1241
+ usually self-contained but may legally reference external images
1242
+ (real exporters do); data: uris need no resolver. Missing
1243
+ normals produce FLAT shading (the spec's rule): the primitive is
1244
+ un-indexed, one vertex per corner. A mirroring node chain (negative
1245
+ rest-pose world determinant) flips the part's index winding so
1246
+ `cull: "back"` still keeps the outside. Non-triangle primitives are
1247
+ skipped; a required extension the parser does not implement throws
1248
+ naming it, and Draco or meshopt compression throws "re-export without
1249
+ mesh compression" - Blender exports Draco by DEFAULT, so that is the
1250
+ first error a real file hits.
581
1251
  - `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)`,
1252
+ wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
1253
+ with the file's color, maps, normal scale, metalness/roughness and
1254
+ packed map, emissive and transparency - the glTF material model, so a
1255
+ scene showing a model wants an `environment` (a glTF metal in a scene
1256
+ with none renders near black); `material: (m, maps, skinned) =>
1257
+ lit({ color: m.color, map: maps.map ?? undefined, skinned })` for the
1258
+ Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
1259
+ Groups with the file's local TRS, and one mesh per part under its node,
1260
+ all inside the returned `Model` (a Group): `add(scene.root, model)`,
586
1261
  place it with `setTransform`, find parts by name in `model.parts`
587
- (`{ name, mesh }`), `model.bounds` for framing a camera. `dispose()`
1262
+ (`{ name, mesh }`), spin a wheel relative to its axle through
1263
+ `model.nodes` (`{ name, node }` in table order, parents first; names
1264
+ repeat when the file's do - `.find()` yours), `model.bounds` for
1265
+ framing a camera. Skinned parts get the `skinned: true` material
1266
+ variant and hang off the model ROOT (the spec ignores their node's
1267
+ transform; the palette places them - see the mixer below). `dispose()`
588
1268
  detaches it and frees the geometry buffers and textures - the model owns
589
1269
  them, nothing else frees them.
590
1270
  - `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
@@ -598,20 +1278,173 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
598
1278
  "binary" }` then `createModel(parseGltf(bytes))`, see
599
1279
  `examples/model.tsx`); bake anything big.
600
1280
 
1281
+ Loading is async everywhere but the binary import: loadGltf/loadModel
1282
+ return promises, and the async value must be read the way Solid 2 async
1283
+ works - inside a tracking scope whose result the JSX reads back, under a
1284
+ `<Loading>` boundary. The worked shape is `examples/model-load.tsx`: the
1285
+ component keeps the async read in a memo (`let loaded = createMemo(() =>
1286
+ loadModel(path))`), derives everything - framing, mounting - in a second
1287
+ memo that reads `loaded()` FIRST and returns the scene JSX, and returns
1288
+ only that memo read; the window/view shell lives in the parent, above the
1289
+ boundary. Reading the value in the component body instead throws
1290
+ PENDING_ASYNC_UNTRACKED_READ, and any element the component builds before
1291
+ the suspending read is orphaned on the boundary's retry and never freed
1292
+ (the dev leak sentinel reports it) - so the suspending component creates
1293
+ no elements of its own. Async here means the file read: the parse and
1294
+ createModel run synchronously on main. Bake anything big to .srtm; when
1295
+ a source glTF must be parsed at runtime, do the parse in an isolate
1296
+ (parseGltf's result is plain data and copies across) and keep
1297
+ createModel on main.
1298
+
1299
+ Placement: pieces of one authored set (a body and its fitted cosmetics)
1300
+ export in one world space, so composing them is `add(group, model)` per
1301
+ piece and nothing else - no placement math. SOCKETED items (a weapon in
1302
+ a hand) are different: they bind to a joint, so they only land once a
1303
+ skeleton exists (a rig-less export cannot place them at all). The joint
1304
+ is an ordinary Group in `model.nodes` - find it by name and `add()` the
1305
+ item under it; it then follows the pose, mixer-driven or hand-posed,
1306
+ like any child transform. Two authoring cases (verified in
1307
+ `probes/joint-cap-probe.tsx`): an item authored about its own socket
1308
+ origin needs the plain `add()` and nothing more; one authored in the
1309
+ RIG'S model space needs a socket Group between joint and item carrying
1310
+ the joint's rest-pose inverse (at rest the item then sits exactly where
1311
+ authored, posed it follows), since parenting stacks the joint's
1312
+ transform on top of the authored placement. Skinned PARTS are the one
1313
+ thing that never needs this: they hang off the model root and the
1314
+ palette places them.
1315
+
601
1316
  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.
1317
+ "none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
1318
+ (+ scale; the derivative frame needs no tangents), `emissiveFactor` x
1319
+ `emissiveTexture` with KHR_materials_emissive_strength folded into the
1320
+ factor (a zero factor skips the map too - glTF's product rule, emission
1321
+ off), `pbrMetallicRoughness` factors as `m.metalness`/`m.roughness` and
1322
+ its packed texture as BOTH `maps.metalnessMap` and `maps.roughnessMap`
1323
+ (standard's channel-select options; the default `lit` ignores them).
1324
+ The `material(m, maps)` callback receives every uploaded texture
1325
+ by lit()/standard() option name (`maps.map`/`maps.normalMap`/
1326
+ `maps.emissiveMap`/`maps.metalnessMap`/`maps.roughnessMap`);
1327
+ `data.materials` is in file order, so the calls arrive in file order.
1328
+ A `.srtm` baked before the material records carried the PBR fields
1329
+ (file version 3) is rejected by loadModel - re-bake with `srt tool
1330
+ 3d/model`.
1331
+ Animation: `createMixer(model)` plays `model.clips` by name -
1332
+ `mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
1333
+ named clip fades in, everything else fades out - Unity's CrossFade,
1334
+ Godot's play-with-blend), `mixer.stop({ fadeMs? })`, `mixer.playing()`,
1335
+ `mixer.onFinish` for `loop: false` clips (the pose holds at the end).
1336
+ Playback is CORE-DRIVEN: there is no update() and no frame loop to
1337
+ register - clips are registered with the spatial core once and players
1338
+ sample, weight-blend and write joint TRS natively each frame, so a
1339
+ playing character costs zero JS per frame (gate other work on
1340
+ `mixer.playing()`). play() requires the model to be IN a scene (players
1341
+ bind live arena nodes; removing the model drops them - a re-added model
1342
+ plays again from play()). Three traps that follow from core ownership:
1343
+ (1) players advance BEFORE your onFrame, which is therefore the
1344
+ post-animation hook - read a freshly posed joint and overwrite it
1345
+ (root-motion strips, skeleton copies) in plain setTransform, last write
1346
+ wins, all published by the same flush; (2) the JS
1347
+ `position`/`quaternion` fields of player-animated joints (and of the
1348
+ model node under root motion "apply") go STALE (they hold the last JS
1349
+ write) - read poses with `getTransform(node)`, which reads the core;
1350
+ writes to such a node always go through (setTransform skips its
1351
+ usual equal-value short-circuit there, so a teleport back to the last
1352
+ JS-written spot is not lost); (3) a channel nothing plays leaves the node's pose
1353
+ alone; (4) known gap: native pose writes bypass the scene's moved list,
1354
+ so a TRANSPARENT mesh parented under a player-animated joint does not
1355
+ re-trigger the back-to-front re-sort while it animates (opaque meshes,
1356
+ palettes and picking are unaffected) - nudge the scene with any
1357
+ setTransform if it shows, until the core-side transparent sort lands.
1358
+ Root motion: `play(name, { inPlace? })` strips a clip's root travel
1359
+ (Unity's applyRootMotion off, Godot's root_motion_track): the root's
1360
+ x/z hold at the clip's first key and its height rebases onto the root's
1361
+ rest position, the vertical bob intact. `createMixer(model, {
1362
+ rootHeight })` rebases EVERY clip onto that height, pinned or not, for
1363
+ an export whose clips ride above its rest pose. Unset, it is decided per clip by NET DRIFT of the
1364
+ root's position track - last key minus first, past a fraction of the
1365
+ model's height - so run cycles play in place and a taunt that roams and
1366
+ returns does not (pinning THOSE pushes the slide into the feet);
1367
+ `mixer.travels(name)` reads the verdict. The root is the topmost node
1368
+ any position channel of the clip targets. The strip is baked into a
1369
+ second core clip on first in-place play, so it costs no per-frame JS and
1370
+ crossfades like any other clip. A GAME wants the travel kept and moved
1371
+ onto the character: `createMixer(model, { rootMotion: "apply" |
1372
+ "report" })` plays every clip fully pinned (all three root axes held)
1373
+ and its yaw held too (the turn about +y stripped key by key, so the
1374
+ lean and pitch of the pose survive), while the core samples the
1375
+ authored root tracks at each player's time and hands the per-frame
1376
+ delta on - a translation in the model's local frame and a yaw in
1377
+ radians. "apply" adds both to the model node itself (Unity's
1378
+ applyRootMotion: the character walks and turns where its clip says),
1379
+ "report" accumulates them until `mixer.rootDelta()` takes them
1380
+ (`{ position, yaw }`), for a controller to spend through its own
1381
+ movement (Godot's get_root_motion_position). Zero per-frame JS in
1382
+ "apply"; one read per frame in "report". Loop wraps are continuous
1383
+ (the clip's net drift is added across the wrap), crossfades weight the
1384
+ deltas like the poses, and a clip's travel is given in the root's
1385
+ CURRENT facing (its own turn so far undone), so a turn that wanders
1386
+ out and back ends where the clip says and two blending clips agree on
1387
+ the frame. The object form `{ mode, up?, vertical? }` names the root's
1388
+ parent-space up axis (default +y; the turn and height axis) and
1389
+ `vertical: "pose"` keeps the height in the pose, delivering only the
1390
+ horizontal travel (Unity's bake-into-pose Y - for a controller that
1391
+ owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
1392
+ the swing-twist about up (exact under any lean); a cubic rotation
1393
+ track is linearized at 60 keys/s before its yaw is held. Verified on
1394
+ Mixamo's standing turns (external/mixamo-turn). Skins need nothing further: each skin's uBones palette
1395
+ (model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
1396
+ float texture, 4 texels wide, one row per joint, sampled in the vertex
1397
+ stage via texelFetch, so there is no joint cap) is composed by the
1398
+ spatial core at the frame's flush from the joint nodes themselves, in
1399
+ any write order, and identical skins (a body/legs split, LODs) share one
1400
+ computed-once texture. `sampleChannel` (pure, from the root) stays the
1401
+ JS sampling core for checks and custom drivers;
1402
+ okf/done/animation-core.md records the design.
1403
+
1404
+ Not in the subset, dropped: vertex colors, tangents and further UV sets;
1405
+ morph targets (the "weights" channel path); samplers are ignored (every
1406
+ texture repeats); additive blending draws as base color. The follow-ups
1407
+ are filed in okf/backlog/3d-model-loader.md. The `.srtm` container is
1408
+ VERSION 3 (node table in the header, node-local vertices, skins, clips);
1409
+ older bakes are rejected - re-bake with `srt tool 3d/model`.
607
1410
 
608
1411
  ## Traps
609
1412
 
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.
1413
+ - A model's vertices are LOCAL to their node; the file's placement lives
1414
+ in the node-table TRS, composed by the scene like any Group chain. So
1415
+ a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
1416
+ world) is what frames a camera, and moving a named node moves its
1417
+ subtree. The winding flip for a mirroring node chain is baked into the
1418
+ index order from the REST pose - re-scaling a node across zero at
1419
+ runtime shows mesh interiors, so do not do that.
1420
+ - Skinning is a VERTEX-STAGE effect: everything that runs off the
1421
+ retained tree sees the bind pose. A skinned mesh picks by its
1422
+ bind-pose triangles at the model root and its transparent sort key is
1423
+ the bind-pose box; moving the JOINTS never moves either, moving the
1424
+ MODEL moves both. Shadows are the exception: the shadow variants
1425
+ (depth and cutout) skin by the same uBones palette, so a caster casts
1426
+ its pose.
1427
+ - A `standard` metal in a scene with no environment renders near black:
1428
+ its diffuse is zero and the black placeholder cube is all there is to
1429
+ reflect (Three and Godot render the same; Unity falls back to an
1430
+ ambient probe, this does not). glTF's default metallic factor is 1,
1431
+ so an untextured asset is all metal, and createModel's default is
1432
+ `standard`: give a model scene an `environment` (loadEnvironment's
1433
+ baked .srte, or the skybox's cube), or pass `lit` as the material.
1434
+ - A reflection probe is a mirrored render (x-flipped projection, winding
1435
+ inverted by the engine): anything built from screen-space derivatives
1436
+ flips with it, so a normal-mapped surface shows its bumps INVERTED in
1437
+ a probe's reflection. Known and shared with every engine's mirrored
1438
+ views; a `uMirror` sign on the derivative frame is the fix when it
1439
+ matters.
1440
+ - A generated cube chain (`mipmap: true` from six faces) is a box
1441
+ filter, not the GGX convolution the roughness-to-level rule assumes:
1442
+ rough reflections read too sharp and the diffuse `envIrradiance` is a
1443
+ 4x4 average. Bake with `srt tool 3d/environment` for anything
1444
+ photographed; the JS sky gradients in the examples get away with it.
1445
+ - Light intensities are the same numbers for `lit` and `standard`: 1
1446
+ lights a white matte surface to 1 face-on. A Three scene's intensities
1447
+ are a factor pi larger for the same look; divide when porting.
615
1448
  - The y-down clip flip is baked into `perspective()`; scene code and
616
1449
  geometry are plain y-up right-handed, and CCW-outward winding culls
617
1450
  correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
@@ -626,19 +1459,46 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
626
1459
  live: it has no world matrix yet, and drawn before the sync microtask it
627
1460
  flashes at the world origin for a frame.
628
1461
  - 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
1462
+ needs a createInstancedMesh or createRecordMesh mesh and vice versa,
1463
+ and the record strides must match the material's attributes per slot
1464
+ (the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
1465
+ mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
1466
+ mismatch throws there, at creation for the mesh's own material and at
1467
+ add() for a swapped one. The instance buffers are MESH-owned (unlike
1468
+ shared geometry buffers): `disposeInstances` is their one free, and
1469
+ the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
1470
+ never resize: an addInstance or setRecords past capacity doubles into
1471
+ new buffers and swaps them in (an instanced mesh's live records move
1472
+ in one core `retargetRecords`, its style mirror republishes) -
1473
+ amortized like a dynamic array, same policy as @solidrt/2d; size
1474
+ `capacity` or the initial records to skip the copies. An instanced
1475
+ mesh's style stride comes from its material AT CREATION: a later
1476
+ setMaterial to a class with another slot-1 layout throws at the
1477
+ rebuild.
1478
+ - Style writes are mirrored, not immediate: `setInstanceStyle` lands in
1479
+ `MeshInstances.style.data` and the scene's sync publishes the dirty
1480
+ range - so a write on a mesh outside any scene shows once it is added
1481
+ (the attach republishes the whole mirror), and reading the GPU buffer
1482
+ back mid-frame can lag the mirror by one sync.
1483
+ - Instanced casters: `castShadow` on a populated mesh needs a depth pass
1484
+ with the instance placement in it - the stock materials' `instanced`
1485
+ carries one, a custom class needs `shadowVertex` (see shadows below);
1486
+ a class without one is skipped by shadow views, silently.
1487
+ - A record mesh without explicit `bounds` has no BVH leaf: it never
638
1488
  picks, pointer events never target it, and its transparent sort key
639
1489
  falls back to the node's world position. That is deliberate - records
640
1490
  are opaque to the library, so any inferred box would be a guess. Supply
641
- `bounds` for anything pickable or transparent.
1491
+ `bounds` for anything pickable or transparent. An instanced mesh picks
1492
+ per instance regardless and culls by its instances' union; its
1493
+ `bounds` replace that union and give the transparent sort a center.
1494
+ - An instance node's own fields hold its LOCAL pose (relative to its
1495
+ parent, as for any node); the record the core writes is its pose
1496
+ relative to the MESH, composed through any group between them. A
1497
+ removed instance's children stay parented to the dead node: remove()
1498
+ one to re-use it. `disposeInstances` on an instanced mesh flushes the
1499
+ core before freeing the buffer, so the destroyed instances' hiding
1500
+ writes land in a live buffer; keep that order if you ever free a
1501
+ record buffer by hand.
642
1502
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
643
1503
  [r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
644
1504
  classes write alpha 1 when not `transparent` (the scene target is
@@ -762,9 +1622,14 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
762
1622
  and supplies its own cutout variant as the `shadow` instance option).
763
1623
  Opting out of receiving is on the
764
1624
  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
1625
+ `mesh.receiveShadow`) - Godot's split, and URP's. An instanced mesh
1626
+ casts when its class declares `shadowVertex` (the vertex stage reduced
1627
+ to position, instance placement included; the class builds one depth
1628
+ program from it plus the shared depth fragment, culling the shadow
1629
+ side and binding only the instance slots the stage reads, and every
1630
+ instance shares it) - without one the shadow views
1631
+ SKIP instanced meshes, since the plain depth override cannot know
1632
+ their records. `examples/instanced.tsx` casts. Every casting light
768
1633
  is a full extra pass over the casters plus a sampler unit on every
769
1634
  receiving program (MAX_LIGHTS of those are always bound, placeholders
770
1635
  included), so cast from the lights that matter, not all of them.
@@ -778,7 +1643,9 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
778
1643
  - SCENE-WIDE uniforms go through that same shared channel via
779
1644
  `scene.setParams({ uTime })`, and this is the single highest-leverage
780
1645
  pattern in the library. It merges an app-owned name in beside
781
- uViewProj/uCamPos/uCamRight/uCamUp - names merge, a target tolerates
1646
+ uViewProj/uInvViewProj/uCamPos/uCamRight/uCamUp and the scene's own
1647
+ fog, environment and output sets (uFog*, uEnv*, uExposure,
1648
+ uToneMapping) - names merge, a target tolerates
782
1649
  zero coverage, neither side clobbers the other. One write per frame
783
1650
  however many meshes read it, with the motion itself in vertex shaders
784
1651
  off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
@@ -839,9 +1706,9 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
839
1706
  aColor you do not read.
840
1707
  - Picking is triangle-accurate for ordinary meshes (`point` is a surface
841
1708
  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
1709
+ meshes: there `point` is where the ray meets the population `bounds`
1710
+ box, `normal` that face's, and `face`/`uv` are absent. Never present an
1711
+ instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
845
1712
  add a per-triangle path in JS - rays at mesh scale are
846
1713
  interpreter-hostile, and the core already does it.
847
1714
  - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
@@ -852,13 +1719,16 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
852
1719
  leaf under a design size. Only a leaf whose layout size deliberately
853
1720
  differs from the target (supersampling) needs `handlersFor`, fed the
854
1721
  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.
1722
+ - A custom fragment that ends in `fragColor = vec4(...)` bypasses the
1723
+ scene: no fog, no exposure or tone mapping, no encode - and a
1724
+ hand-rolled loop over `uLightDir` renders a spot light as a
1725
+ directional one (a lit rectangle on the floor, no cone). What you
1726
+ declare is what runs, and the engine injects nothing, so pick a tier
1727
+ (the three after the GLSL exports under "The model"): a stock fragment
1728
+ on your vertex stage,
1729
+ a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
1730
+ `sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
1731
+ `SHADOW_*` trio by hand still works and is no longer the shape.
862
1732
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
863
1733
  under a still pointer fires nothing until the next move - the same
864
1734
  limit the element hit test has (hit-test-per-frame is an open platform
@@ -871,6 +1741,36 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
871
1741
  REPLACES the clearColor visually (the clear still runs; you just never
872
1742
  see it), and a `transparent: true` mesh blends over it in-pass since the
873
1743
  background is always entry zero.
1744
+ - A color map created as plain rgba8 renders WASHED OUT: the fragment
1745
+ reads its encoded bytes as linear light and encodes them again. Create
1746
+ color images (base color, emissive, a sky's faces or panorama) with
1747
+ `format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
1748
+ (a scene view, a shader target, a UI capture) holds encoded pixels and
1749
+ cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
1750
+ custom fragment (no material option yet); a draw target you create
1751
+ yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
1752
+ is sampler-only: no display, readback or copy).
1753
+ - Reading the scene texture back (a probe's readTexture, a snapshot)
1754
+ gives ENCODED pixels: an expected linear value v shows as
1755
+ `linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
874
1756
  - The background pipeline/program are SCENE-OWNED (unlike shared
875
1757
  material pipelines): setBackground(null), replacement, and dispose()
876
1758
  destroy them. Do not hand the background's pipeline to anything else.
1759
+ A skybox is the same slot with the library's fragment; only a
1760
+ skybox-to-skybox replace keeps the entry (params and cube rewritten).
1761
+ - The environment binds through the light rewrite's map set (uEnv
1762
+ beside uShadowAtlas on every receiving target, new views included) and
1763
+ directly on setEnvironment; the placeholder cube is app-lifetime like
1764
+ the shadow placeholder. A `lit` without `reflectivity` declares no
1765
+ environment sampler - the flag is part of the class key.
1766
+ - overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
1767
+ volume wholly inside a closed mesh with no triangle in reach touches
1768
+ nothing, and a body whose center has passed through a wall reports
1769
+ the push-out on the side its center is on. Keep a skin (moveAndSlide
1770
+ does) and never teleport a body into geometry expecting it to come
1771
+ out the far side.
1772
+ - moveAndSlide owns no gravity and no velocity: fold the fall into
1773
+ `motion` every frame and zero it while `floor` is set. A walkable
1774
+ floor absorbs the vertical part (a body never creeps down a slope it
1775
+ can stand on), and with `floorSnap: 0` a standing body reports no
1776
+ floor unless its motion presses into one.