@solidrt/3d 0.0.58 → 0.0.60

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 (70) hide show
  1. package/AGENTS.md +734 -264
  2. package/README.md +4 -2
  3. package/examples/README.md +50 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +24 -2
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +172 -39
  51. package/src/geometry.ts +1573 -239
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +363 -141
  54. package/src/index.ts +31 -21
  55. package/src/light.ts +7 -8
  56. package/src/lod.ts +148 -0
  57. package/src/material.ts +316 -202
  58. package/src/mesh.ts +490 -256
  59. package/src/mixer.ts +27 -14
  60. package/src/model-file.ts +54 -15
  61. package/src/model.ts +86 -21
  62. package/src/motion.ts +17 -0
  63. package/src/node.ts +421 -22
  64. package/src/orbit.ts +378 -116
  65. package/src/profile.ts +4 -25
  66. package/src/resolve.ts +181 -0
  67. package/src/scene-shadows.ts +94 -93
  68. package/src/scene.ts +605 -185
  69. package/src/sweep.ts +27 -8
  70. package/tools/model.ts +3 -1
package/AGENTS.md CHANGED
@@ -24,6 +24,7 @@ Contents:
24
24
  - [Rendering is the runtime's](#rendering-is-the-runtimes)
25
25
  - [Views and layers](#views-and-layers)
26
26
  - [Culling](#culling)
27
+ - [Level of detail](#level-of-detail)
27
28
  - [Shadows](#shadows)
28
29
  - [Retargeted motion](#retargeted-motion)
29
30
  - [Geometry layout, indices and topology](#geometry-layout-indices-and-topology)
@@ -56,7 +57,7 @@ Contents:
56
57
  - [Lighting GLSL](#lighting-glsl)
57
58
  - [Custom looks: three tiers](#custom-looks-three-tiers)
58
59
  - [Lights](#lights)
59
- - [lit](#lit)
60
+ - [phong](#phong)
60
61
  - [standard](#standard)
61
62
  - [Models](#models)
62
63
  - [Three layers](#three-layers)
@@ -103,16 +104,24 @@ nothing itself.
103
104
 
104
105
  ### Node lifecycle
105
106
 
106
- Node lifecycle: `add(parent, child)` attaches (re-parenting detaches
107
- first), `remove(child)` DETACHES an intact subtree - children stay
108
- under the removed node, core nodes free on leave and recreate on
109
- re-enter, so a removed subtree re-adds cleanly. Nothing is destroyed
110
- except instance record buffers (`disposeInstances`) and the scene
111
- itself. (@solidrt/2d's `removeGroup` DESTROYS its subtree instead - a
112
- sprite cannot exist outside its layer, so there remove means destroy.)
113
- An instance node (addInstance on an instanced mesh) follows the 2d
114
- rule: slot-bound to its mesh, `removeInstance` destroys it, and the
115
- generic add/remove throw on it.
107
+ Two removal verbs, one meaning each, the same words with the same
108
+ meanings in @solidrt/2d. `remove(child)` DETACHES an intact subtree -
109
+ Three's `parent.remove`, Godot's remove_child: children stay under the
110
+ removed node, core nodes free on leave and recreate on re-enter, so a
111
+ removed subtree re-adds cleanly (`add(parent, child)` re-parents through
112
+ it); it SNAPS, since a detached node is coming back. `destroy(node)` is
113
+ Unity's Destroy, Godot's queue_free: the subtree is gone for good, every
114
+ handle in it inert (writes are no-ops, add throws, remove skips), and it
115
+ is the removal an `exit` rides on (see Retargeted motion). The components
116
+ destroy on unmount. Nothing else is destroyed: GPU resources stay on
117
+ their own disposers (`disposeGeometry`, `disposeInstances`,
118
+ `model.dispose`, the scene's `dispose`), each of which frees a node still
119
+ animating out on the spot. An instance node (addInstance on an instanced
120
+ mesh) is slot-bound to its mesh: `destroy` frees it (the slot recycles at
121
+ the free) and the generic add/remove throw on it. (@solidrt/2d has no
122
+ detach - a sprite cannot exist outside its layer - so `destroySprite` /
123
+ `destroyGroup` are its only removal, and a sprite that should come back
124
+ is hidden.)
116
125
 
117
126
  ### Rendering is the runtime's
118
127
 
@@ -138,7 +147,14 @@ sets itself (or its `fog` option, below) become VIEW-OWNED: the
138
147
  scene's setParams/setFog fan-out skips them from then on, so a view
139
148
  override survives scene-wide writes. A view's backdrop is its
140
149
  clearColor (the scene background draws on PROBES, not views); a view
141
- has no picking. LAYERS select what a target
150
+ has no picking. A view is a buffer plus a resolve like the scene
151
+ (`view.hdrTexture`, `view.texture`, the `resolve` option and
152
+ `view.setResolve` - see Color); a tiled view (`into`) renders LINEAR
153
+ light into the atlas and the app resolves the atlas once: a
154
+ `createShaderTexture` over `resolveFragment()` from `/glsl` (the stock
155
+ source with `uScene` and the RESOLVE set declared) with the atlas bound
156
+ as `uScene` and `resolveParams({ toneMapping, exposure })` as its params
157
+ - `examples/scene-atlas.tsx`. LAYERS select what a target
142
158
  draws, Three's model exactly: `layers` on a mesh is its membership
143
159
  bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
144
160
  Groups), and each target carries a mask (default 1) - `layers` on
@@ -157,7 +173,7 @@ fogged scene); absent follows the scene. `overrideMaterial` (Three's
157
173
  `scene.overrideMaterial`, scoped to the view) draws every mesh with one
158
174
  material - a depth pass, a normal/id visualizer - skips instanced
159
175
  meshes (unless the override itself declares their exact
160
- `instanceAttributes` record layout) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
176
+ `instanceBuffers` record layouts) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
161
177
  input; the same option on createScene exposes `scene.depthTexture`,
162
178
  the input for a depth-reading post effect in `output` (not combinable
163
179
  with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
@@ -195,6 +211,62 @@ knob is needed. Probe faces set no frustum (six cameras, one target).
195
211
  Shadow tiles cull casters against their light frustum, which is what
196
212
  makes `shadow.distance` and cascades cheaper.
197
213
 
214
+ ### Level of detail
215
+
216
+ LOD is the core's too, beside culling: a group's level is picked per
217
+ target after each flush by PROJECTED SIZE - the sphere around the
218
+ group's boxes (radius = half the box diagonal, conservative like the
219
+ frustum box), its diameter as a fraction of the viewport height,
220
+ `radius / (distance * tan(fov / 2))` under a perspective camera and
221
+ `radius * 2 / (top - bottom)` under an orthographic one (Unity's
222
+ screen-relative model; Three's distance is wrong for an ortho minimap,
223
+ a zoomed camera or a scaled-up tree). `createLod([{ node, size }, ...],
224
+ { fade? })` / `<Lod fade?>` with `lodSize` on its direct children: the
225
+ levels nearest first, `size` the projected size BELOW which a level
226
+ hands over to the next, the last one's the cull threshold (0 = never
227
+ culled); a level is a direct child (a parentless node is adopted by
228
+ `setLod`, one parented elsewhere is refused, never moved). Every camera write also sets its target's LOD view (eye,
229
+ focal, ortho flag, `lodBias`): the scene and each view pick by their
230
+ own camera (a minimap sees the far level of the same tree), shadow
231
+ tiles by the SCENE camera (a caster draws the level the camera sees, so
232
+ its shadow matches - Unity's and Godot's rule), probe faces pick
233
+ nothing. The gate composes with the frustum and `visible` into the same
234
+ instance-count switch, so a still camera re-tests nothing and a thousand
235
+ groups cost no JS per frame; a child of the group that carries no
236
+ `lodSize` is drawn always. Without a `fade` the switch is hard with a
237
+ one-sided hysteresis band (a level is left at its threshold and
238
+ re-entered only once the size climbs a tenth above it), so a boundary
239
+ never flickers; with one, each threshold `s` widens into `[s, s * (1 +
240
+ fade))` where both levels draw with COMPLEMENTARY screen-hash dithers
241
+ (the near one keeps the pixels below the band position, the far one
242
+ the rest - a partition, never a double draw) through the `uLodFade`
243
+ vec2 every stock fragment composes, its band position in 64 steps so an
244
+ entry is rewritten when its step changes, not every frame, and written
245
+ solid again when the entry switches off; a custom class opts in by composing
246
+ `LOD_FADE` from `@solidrt/3d/glsl` and calling `lodFade()` first in
247
+ main. Shadow tiles never fade: their depth pass switches hard at the
248
+ band's midpoint, invisible for two variants of one shape.
249
+ `createInstancedLod([{ geometry, material, size }, ...], opts)` /
250
+ `<InstancedLod levels capacity?>` is the population form: one instance
251
+ set, one matrix buffer PER LEVEL, and the core stages each instance's
252
+ record into the level ITS OWN size picks - a spread forest as three
253
+ entries, near trees full and far ones cards. Returned as the first
254
+ level's mesh (the other levels are its children at identity, sharing
255
+ its instance slots; `setLayers`/`setCastShadow` on it reach them,
256
+ `setInstanceStyle` writes every level's style stream; a pick or pointer
257
+ event on an instance names that first mesh whatever level the instance
258
+ is drawn at). Records are
259
+ target-agnostic, so instances pick by the SCENE camera and every view
260
+ draws that choice; instanced levels switch hard (`fade` is refused).
261
+ `scene.setLodBias` (Unity's lodBias) multiplies every measured size:
262
+ below 1 hands over sooner, the one-line quality knob for a low-end
263
+ device. Picking and collision see the level the queried target draws
264
+ (`scene.pick`/overlap/sweep/moveAndSlide the scene's, `view.pick` the
265
+ view's); a collider that must not follow the camera is its own undrawn
266
+ mesh on a collision layer. Nested groups chain. Mesh simplification is
267
+ a bake job, not a runtime one. `examples/lod.tsx` is the shape;
268
+ `probes/3d-lod-bench.tsx` the cost.
269
+
198
270
  ### Shadows
199
271
 
200
272
  SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
@@ -205,9 +277,20 @@ the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
205
277
  orthographic camera at the light's WORLD position along its world
206
278
  direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
207
279
  Any light may cast, bounded by the shadow-slot budget
208
- (MAX_SHADOW_MAPS = 8, its own constant: a directional light claims
209
- `shadow.cascades` consecutive slots, a point light six, a spot one,
210
- and a caster past the budget throws at attach). `<SpotLight castShadow
280
+ (`MAX_SHADOW_MAPS` = 8, its own constant, exported from the root and
281
+ `/glsl` with `MAX_LIGHTS` and `MAX_CASCADES`: a directional light claims
282
+ `shadow.cascades` consecutive slots, a point light six, a spot one).
283
+ The budget is checked when the set settles (the scene's sync), never
284
+ at attach, so a `<Show>`/`<Switch>` swap whose branches each fit is
285
+ fine even though both are attached for an instant. A set over budget
286
+ once settled is reported once per change of the set: `<Scene>`
287
+ rethrows it inside the tree, so the app's error boundary (or an
288
+ `<Errored>` closer in) shows it, and Reset returns to the scene, which
289
+ kept rendering - the casters that fit in attach order keep their maps,
290
+ the rest light the scene unshadowed. A bare `createScene` throws it
291
+ from its sync unless given `onError`. A deliberate overlap must fit
292
+ alongside its successor: a casting light with its own exit transition
293
+ keeps casting until it frees. `<SpotLight castShadow
211
294
  shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
212
295
  machinery with a PERSPECTIVE camera: at the light's world position
213
296
  along its world direction, fov = its cone (2 * angle), near from
@@ -255,8 +338,8 @@ its view's own view-projection (the whole array is one write per
255
338
  shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
256
339
  name its slots (count 0 = it does not cast) with
257
340
  `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
258
- glsl declares the set. Every `lit` material RECEIVES by default
259
- (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
341
+ glsl declares the set. Every `phong` material RECEIVES by default
342
+ (Godot's and Three's default); `phong({ receiveShadow: false })` opts a
260
343
  material out and drops the map from its program - a material option,
261
344
  as with vertexColors/triplanar, because the material picks the program
262
345
  (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
@@ -273,28 +356,88 @@ the quaternion geodesic - a spring keeps its velocity through
273
356
  retargets), so a mesh gliding to a slot or a camera rig easing costs
274
357
  one JS write per target change, zero per frame. The declaration lives
275
358
  on the SceneNode and re-applies on every scene enter; the pose a node
276
- enters with always snaps. Each natural settle calls the node's
277
- `onTransitionEnd` (plain field like the pointer handlers) with
278
- `{ component }`; the raw "spatialTransitionEnd" engine event
279
- (srt:events, carrying the CORE node id `_node`) stays for flux:spatial
280
- consumers.
359
+ enters with snaps, unless a component's `from` (`position: { duration,
360
+ from: [x, y, z] }`, a quaternion for `rotation`) animates it in from
361
+ there - at every scene enter, since each enter creates the core node
362
+ anew. A component's `exit` is where it animates to when `destroy` lets
363
+ go of the node: it stays drawn while it leaves and is a GHOST meanwhile -
364
+ no pick, raycast, overlap, sweep or pointer event sees it - then frees
365
+ when the last exit settles; children go first and the parent stays their
366
+ frame until the last of them has settled, so a dying character's parts
367
+ leave in its frame (a mixer driving its joints keeps driving them). Either
368
+ endpoint takes `{ value, duration?, curve?, bounce?, delay? }` to own its
369
+ direction's motion (an ease-out enter, an ease-in exit), and `delay` on
370
+ an entry holds its writes on the animation clock (a late frame catches
371
+ up). `stagger` (ms) goes on an ANCESTOR's declaration (a `<Group
372
+ transition={{ stagger: 40 }}>`), never on the animating nodes: it spaces
373
+ the enters and exits of its descendants that begin in one frame by
374
+ `index * stagger` (add order in, tree order out whatever order the
375
+ unmount lets them go in; nearest declaring ancestor wins) and cascades
376
+ nothing unless they declare `from`/`exit` -
377
+ a squad of instances spawning in is one `stagger` on their group, and
378
+ for the nodes straight under the scene root it is `<Scene stagger>` /
379
+ `createScene({ stagger })` (a declaration on `scene.root`). A populated
380
+ mesh unmounts as `destroy(mesh)` then `disposeInstances(mesh)`, and the
381
+ dispose waits for a mesh still animating out, so an `<Instance>`'s exit
382
+ plays through its `<InstancedMesh>`'s unmount (`model.dispose` waits the
383
+ same way); the scene's and a layer's `dispose` cut a corpse short - the
384
+ whole target is going. `examples/exits.tsx` is the live example: crates
385
+ that pop in and shrink away on tap, the shelf cascading out and back
386
+ through `<Show>`. Each natural settle calls the node's `onTransitionEnd`
387
+ (plain field like the pointer handlers) with `{ component }` - never on
388
+ a cancel, snap, leave or exit; the raw "spatialTransitionEnd" engine
389
+ event (srt:events, carrying the CORE node id `_node`) stays for
390
+ flux:spatial consumers.
281
391
 
282
392
  ### Geometry layout, indices and topology
283
393
 
284
394
  One interleaved vertex buffer per geometry, described by an open layout
285
- (`Geometry.layout`, absent = "standard"): an ordered attribute list that
286
- always starts with the standard prefix `aPos` vec3 + `aNormal` vec3 +
287
- `aUV` vec2 (what every generator emits) and may carry any named channels
288
- after it. `withAttribute(geometry, { name, format }, fill)` appends one
289
- (Three's `setAttribute` for an interleave); "colored" names the common
290
- case, the prefix plus `aColor` vec4 - the per-vertex data channel (a
291
- tint, baked AO, any four scalars; standard name, your contents) - and
292
- `withColors(geometry, fill)` is its spelling. Fill is a flat
293
- size-per-vertex array or a per-vertex callback receiving `(index, pos,
294
- normal, uv)`. Materials read attributes BY NAME: a material's vertex
295
- stage may declare any subset of its geometry's channels, and a channel
296
- the program reads that the geometry lacks (name + format) throws at
297
- add(). What a program reads is the ENGINE's word (`material.attributes()`
395
+ (`Geometry.layout`, absent = "base"): an ordered attribute list that
396
+ starts with `aPos` float32x3 and may carry any named channels after it,
397
+ each in a vertex format. Formats are WebGPU's spelling of (component
398
+ type, count, normalized): the float32 family (`float32` .. `float32x4`),
399
+ `float16x2/x4`, the normalized integers `unorm8x4`, `snorm8x4`,
400
+ `unorm16x2/x4`, `snorm16x2/x4`, the unsigned integers `uint8x4`,
401
+ `uint16x2/x4`, `uint32` .. `uint32x4` and the signed integers `sint8x4`,
402
+ `sint16x2/x4`, `sint32` .. `sint32x4`; `VERTEX_FORMATS` is the table.
403
+ Every format is a multiple of 4 bytes, so offsets and strides are
404
+ 4-aligned by construction. A format feeds the shader `in` of its KIND
405
+ and component count, WebGPU's rule: float and normalized formats feed
406
+ `in vec4` (the fetch converts: `in vec4 aColor` reads a `unorm8x4` as
407
+ 0..1), `uint*` formats feed `in uvec4` and `sint*` formats `in ivec4`,
408
+ exact at every width - so a per-vertex id or an index into a wide
409
+ table rides as `uint32` into `in uint aId`, and the "skinned" layout's
410
+ `aJoints` is `uint8x4` into `in uvec4 aJoints`. A layout that crosses
411
+ kinds (`uint8x4` into `in vec4`) is the missing-attribute error at
412
+ add() (`formatFeeds` is the rule). The base prefix `aPos` float32x3 + `aNormal`
413
+ float32x3 + `aUV` float32x2 is what every generator emits and what the
414
+ stock materials read, not a rule: a hand-built geometry declares what
415
+ it has, so a point cloud may carry `[aPos float32x3, aData float32]` at
416
+ 16 bytes a point instead of 36, or a color as `unorm8x4` at 4 bytes
417
+ instead of 16. Pair a prefixless layout with a material that reads only
418
+ what it carries: `unlit` (aPos only) or a shaderMaterial; a stock lit
419
+ material over it throws the ordinary missing-attribute error at add().
420
+ A layout handed to a generator must start with the prefix and stay
421
+ float32-family (the generator writes floats); packing is a pass over
422
+ the result. `withAttribute(geometry, { name, format }, fill)` appends
423
+ one channel in any format (Three's `setAttribute` for an interleave;
424
+ the fill is given in float and encoded on write); "colored" names the
425
+ common case, the prefix plus `aColor` float32x4 - the per-vertex data
426
+ channel (a tint, baked AO, any four scalars; standard name, your
427
+ contents) - and `withColors(geometry, fill)` is its spelling. Fill is a
428
+ flat size-per-vertex array or a per-vertex callback receiving `(index,
429
+ pos, normal, uv)` (zeros for a channel the layout lacks). The fill is
430
+ raw floats: a tint the stock materials read under `vertexColors` is
431
+ premultiplied linear like every shader color, so encode an sRGB pick
432
+ with `premultipliedColor(color)`. `Geometry.vertices` is an
433
+ `ArrayBufferView` (a Float32Array for an all-float layout, a Uint8Array
434
+ when a channel is packed; never the contract): read and write a channel
435
+ through `geometryAttribute(geometry, name)`, an accessor whose
436
+ `get(i, k)`/`set(i, k, v)` speak floats whatever the format, and count
437
+ vertices with `vertexCount`. Materials read attributes BY NAME: a
438
+ material's vertex stage may declare any subset of its geometry's
439
+ channels, and a channel the program reads that the geometry lacks (by
440
+ name, or with a different component count) throws at add(). What a program reads is the ENGINE's word (`material.attributes()`
298
441
  = `programAttributes` reflection of the linked program, instance
299
442
  attributes excluded), not a parse of the GLSL: an `in` the compiler
300
443
  dropped does not count, and the engine also rejects a pipeline whose
@@ -302,15 +445,39 @@ attribute lists leave a read attribute uncovered. The material
302
445
  keeps one program and builds one pipeline per layout its meshes bring,
303
446
  so a geometry may carry more than a material reads. The whole layout
304
447
  ships whether a material reads every attribute or not (inactive
305
- attributes only keep the stride), so extra channels cost their floats on
448
+ attributes only keep the stride), so extra channels cost their bytes on
306
449
  every draw of that geometry - keep data-light passes (a wireframe
307
- reading only aPos) on standard geometry. The prefix has no opt-out, so
308
- geometry that is not a surface pays for it anyway: a lidar point
309
- carrying a position and one packed channel still costs the 8-float
310
- prefix, doubling a large cloud's vertex buffer
311
- (okf/backlog/non-surface-vertex-layouts.md). `layoutStride`/`layoutSlot`/
312
- `layoutKey`/`layoutAttributes` are the layout arithmetic; two layouts
313
- with equal keys interleave identically (merge requires that).
450
+ reading only aPos) on base-layout geometry. `layoutStride` (bytes)/
451
+ `layoutSlot` (byte offset, format, components)/`layoutKey`/
452
+ `layoutAttributes` are the layout arithmetic; two layouts with equal
453
+ keys interleave identically (merge requires that).
454
+ STREAMS: a geometry may carry more than one vertex buffer.
455
+ `Geometry.vertices` under `layout` is stream 0 (where aPos lives) and
456
+ `Geometry.streams` holds extra `{ layout, vertices }` buffers, each with
457
+ its own attribute list and the same vertex count (Unity's vertex
458
+ streams; Three's one buffer per channel when a stream carries one
459
+ attribute). `withAttribute(geometry, attr, fill, { stream })` appends a
460
+ channel to stream 1..n or opens stream n + 1; `geometryAttribute`,
461
+ `geometrySlot`, `geometryKey`, `geometryLayouts`, `geometryStreams`
462
+ and `geometryVertexCount` read across streams, and a material's
463
+ pipeline is one per `geometryKey` (every stream's layout, `|` between
464
+ them). Streams exist for data on its own schedule: `updateVertices(
465
+ geometry, { stream?, first?, count? })` re-uploads that vertex range of
466
+ that stream from the geometry's own array in place (write the array
467
+ through the accessor first; Three's `needsUpdate` with an update
468
+ range), reaching every mesh, view and wireframe over the geometry
469
+ while the other streams never move; a stream-0 update drops the cached
470
+ bounds and rewrites the same range of the core's copy of the positions,
471
+ so the mesh picks and culls where it is now drawn (the box follows at
472
+ the next flush, the triangle index is rebuilt by the next query - a
473
+ deforming mesh needs no re-attach). transformGeometry and
474
+ the edge builders share extra streams with their source; merge
475
+ concatenates every stream; the `.srtm` container refuses streams (run
476
+ time data). `setDrawRange(mesh, first, count?)` draws indices `[first,
477
+ first + count)` of the mesh's geometry (Three's `setDrawRange`, on the
478
+ mesh because the scene owns the entries), applied to every entry of the
479
+ mesh and reset by setGeometry. The worked example is
480
+ `examples/streams.tsx`.
314
481
  Indices are uint16 or uint32 - the `Geometry.indices` array type picks
315
482
  the draw's index format, so hand-built geometry past 64k vertices just
316
483
  uses a Uint32Array (generators emit uint16). What the indices LIST is
@@ -321,8 +488,9 @@ SetIndices topology), a material builds one pipeline per (layout,
321
488
  topology) pair its meshes bring, and validateGeometry enforces the
322
489
  count rule at add() (a whole number of triangles or lines, one
323
490
  primitive's worth for a strip). Materials have no topology of their
324
- own. Only a triangle list gets a picking shape; lines and points pick
325
- and collide by their bounds box, and cast no shadow. `geometryTopology`
491
+ own. Only a triangle list gets a triangle narrowphase; lines and points
492
+ pick and collide by their bounds box (which follows `updateVertices` all
493
+ the same), and cast no shadow. `geometryTopology`
326
494
  reads the field with the default applied. POINTS: a point cloud is
327
495
  ordinary indexed geometry - one vertex per point, the index buffer
328
496
  listing them, `topology: "points"`, no constraint on the index count.
@@ -336,7 +504,7 @@ cost model). Geometry GPU buffers are lazy, shared, and
336
504
  reference-counted by draw entries: removing the last
337
505
  entry frees them at the end of the microtask (a same-tick rebuild keeps
338
506
  the upload), so swapping `<Mesh geometry>` reactively never accumulates
339
- old generations; the vertex upload is keyed on the `Float32Array`
507
+ old generations; the vertex upload is keyed on the vertices view
340
508
  itself, so geometries sharing a vertex array (a wireframe over its
341
509
  source) share one buffer. `disposeGeometry` is the immediate explicit
342
510
  free.
@@ -345,9 +513,9 @@ free.
345
513
 
346
514
  Materials dedupe hard: one program + one pipeline per material CLASS
347
515
  (a `shaderMaterialClass` per option combination for unlit, lit and
348
- sprite alike: map x transparent x cull x alphaTest, lit's extras on
516
+ sprite alike: map x transparent x cull x alphaTest, phong's extras on
349
517
  top), `depth: true` + `cull: "back"` unless the material says otherwise
350
- (`cull: "none"` for double-sided geometry; lit flips the normal on back
518
+ (`cull: "none"` for double-sided geometry; phong flips the normal on back
351
519
  faces); an instance is just per-entry uniforms (`uColor`) and bindings
352
520
  (`uMap`).
353
521
 
@@ -379,14 +547,16 @@ collision claims - two copies of this contract have drifted before.
379
547
 
380
548
  | Component | Props |
381
549
  | --- | --- |
382
- | `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form; the camera CONTROLS are not a third form - `OrbitCamera`/`FirstPersonCamera` drive position and target only, so `fov`/`near`/`far` come from here even while a control moves the camera, and the default `far` of 100 is what clips a scene in metres), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (pointer events, default on), `pointer?` (the leaf's pointer feed, fed from the scene's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the scene's own handlers, the last stop of the walk - `event.mesh` null over empty space) |
383
- | `View3d` | a Scene child rendering the scene again from a camera of its own (scene.createView as a component): `width`, `height` (target pixels, live; fixed-size only for now), `x?`, `y?` (the tile's top-left in `into`, live), `into?` (tile an app-owned draw target - one pass for every view into it; fixed at creation), `camera?` (partial CameraUpdate on the view's camera, live; same state as a `PerspectiveCamera` child), `layers?` (the view's mask, live), `clearColor?`, `label?`, `overrideMaterial?`, `fog?` (FogOptions, or null for none), `depth?`, `samples?`, `filter?`, `wrap?` (createView's, fixed), `ref?(view)`, `output?(texture)` (else a built-in `<texture>` leaf at the target size, a tile shown through srcX/srcY), `events?`, `pointer?` (the view leaf's feed, fed from the view's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the view's own handlers); camera-control children drive the VIEW (inside, `useScene()` reports the view as `viewport` and the view's feed as `pointer`); node children mount to the scene as outside, and under the view's leaf get their ordinary pointer handlers, picked through the view's camera (`view.pick`), the view as the root of that walk |
384
- | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
550
+ | `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form; the camera CONTROLS are not a third form - `OrbitCamera`/`FirstPersonCamera` drive position and target only, so `fov`/`near`/`far` come from here even while a control moves the camera, and the default `far` of 100 is what clips a scene in metres), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default, `"aces"`, `"agx"` or `"neutral"`), `exposure?` (default 1), `bloom?` (`{ threshold?, intensity?, radius? }`, the stock bloom on the resolve, reactive), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `resolve?` (the source, `{ source, textures }`, or a function of the buffer id returning either; fixed at creation - see Color), `events?` (pointer events, default on), `pointer?` (the leaf's pointer feed, fed from the scene's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the scene's own handlers, the last stop of the walk - `event.mesh` null over empty space) |
551
+ | `View3d` | a Scene child rendering the scene again from a camera of its own (scene.createView as a component): `width`, `height` (target pixels, live; fixed-size only for now), `x?`, `y?` (the tile's top-left in `into`, live), `into?` (tile an app-owned draw target - one pass for every view into it; fixed at creation), `camera?` (partial CameraUpdate on the view's camera, live; same state as a `PerspectiveCamera` child), `layers?` (the view's mask, live), `clearColor?`, `label?`, `overrideMaterial?`, `fog?` (FogOptions, or null for none), `depth?`, `samples?`, `filter?`, `wrap?` (createView's, fixed), `ref?(view)`, `output?(texture)` (else a built-in `<texture>` leaf at the target size, a tile shown through srcX/srcY), `resolve?` (as Scene's; not with `into`), `bloom?` (BloomOptions overrides the scene's, null turns it off in this view, absent follows the scene; not with `into`), `events?`, `pointer?` (the view leaf's feed, fed from the view's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the view's own handlers); camera-control children drive the VIEW (inside, `useScene()` reports the view as `viewport` and the view's feed as `pointer`); node children mount to the scene as outside, and under the view's leaf get their ordinary pointer handlers, picked through the view's camera (`view.pick`), the view as the root of that walk |
552
+ | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, the bubbling pointer events (below: down/move/up/wheel/tap from a hit descendant; a group is never the struck node, so it takes no `onPointerEnter`/`onPointerLeave` - `Lod` likewise), `ref?(node)` |
553
+ | `Lod` | a Group whose direct children carrying `lodSize` (a prop every node component takes: the projected size below which that child hands over, see Level of detail) are its levels, sorted by size, never by JSX position; plus `fade?` (the cross-fade band fraction, default 0); a child without `lodSize` is drawn always |
554
+ | `InstancedLod` | as InstancedMesh minus `material`, plus `levels` (`[{ geometry, material, size }]` nearest first, fixed at creation; instanced materials as InstancedMesh's), `castShadow?` (every level); `<Instance>` children populate it as under `InstancedMesh`, each drawing the level its own projected size picks |
385
555
  | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `renderOrder?`, `castShadow?`, `layers?` (membership bitmask, default 1), pointer events (below), `ref?(mesh)` |
386
556
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
387
557
  | `InstancedMesh` | as Mesh (an instanced `material`: a stock one with `instanced`/`instanceColors`, or a class declaring INSTANCE_MATRIX_ATTRIBUTES), plus `capacity?` (instance slots, default 64; the buffers double past it), `bounds?` (optional: instances pick by themselves and the mesh culls by their union), `label?`; a PARENT: `<Instance>` children populate it, `<Group>` children are squads; the record buffers are component-owned and freed on unmount |
388
- | `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) |
389
- | `RecordMesh` | as Mesh, plus `records` (interleaved per-instance floats; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffer is component-owned and freed on unmount |
558
+ | `Instance` | one instance of the enclosing `InstancedMesh`: transforms, `transition`, pointer events as Group, plus `style?` (the material's style record, one value per component - `[r, g, b, a]` under `instanceColors`), `ref?(instance)`; a parent too (a `<Mesh>` under an instance rides with it) |
559
+ | `RecordMesh` | as Mesh, plus `records` (the per-instance records in the material's first instance layout, an ArrayBufferView; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffers are component-owned and freed on unmount |
390
560
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` - or the Scene `camera` prop, the same state (last write wins) |
391
561
  | `SpotLight` | transforms as Group, `direction?` (local aim, default [0, -1, 0]), `color?`, `intensity?`, `distance?` (falloff cutoff, 0 = none), `angle?` (cone half-angle DEGREES, default 60), `penumbra?` (0..1 rim fade, default 0), `decay?` (falloff exponent, default 2), `castShadow?`, `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
392
562
  | `PointLight` | transforms as Group (position is what matters), `color?`, `intensity?`, `distance?`, `decay?`, `castShadow?` (six face maps, six shadow slots), `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
@@ -400,7 +570,9 @@ a `<d-texture>`, a leaf with blendMode/fit/pointer/layout props, or a
400
570
  post-effect chain (`createShaderTarget` sampling the id with a
401
571
  covering-triangle pass; created in the callback it disposes with the
402
572
  Scene). Return null for no leaf at all and compose `scene.texture`
403
- elsewhere. Called once, untracked, inside the scene context. Scene
573
+ elsewhere. Called once, untracked, inside the scene context. `output`
574
+ composes the DISPLAYED texture, the resolve's output: an LDR effect
575
+ lives here; one that needs the scene's radiance is a `resolve` (Color). Scene
404
576
  `width`/`height` are target pixels and the leaf's own width/height are
405
577
  layout, so render and display size separate - render at 2x and display
406
578
  smaller for supersampling.
@@ -475,21 +647,42 @@ a drag and a stick both turn the eye.
475
647
 
476
648
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
477
649
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
478
- zoomSpeed?, panSpeed?, clampTarget?, zoomAnchor?, rotateAnchor? })` -
479
- azimuth/elevation/distance around a target with optional auto-orbit. The
480
- first argument is anything with the scene's `setCamera` and `camera()`
481
- (the fov maps pan travel to world): a Scene, or a View to drive one view's
482
- camera independently. Its `axes` are `rotate` (a delta of one element
483
- height sweeps one full turn, Three's OrbitControls convention, so a drag
484
- feels the same on a phone and a 4k window; a rate turns at 0.5 turn/s at
485
- full deflection), `zoom` (octaves: a delta of 1 halves the distance, a
486
- rate of 1 halves it per second) and `pan` (the target slides so the scene
487
- tracks the fingers 1:1 at the target's depth, three.js DOLLY_PAN, weighted
488
- by `panSpeed`; `clampTarget(target)` bounds where a pan may put the
489
- pivot). The verbs: `rotateBy(azimuth, elevation)` radians, `zoomBy(factor,
490
- anchor?)` (factor > 1 in, as the 2d camera's zoomAt), `panBy(right, up)`
491
- world units, `setPivot(point)`, `set(pose)`; every one pushes the pose at
492
- once. Zoom aims at the target unless `zoomAnchor(focal, {eye, target})`
650
+ zoomSpeed?, panSpeed?, damping?, clampPose?, zoomAnchor?, rotateAnchor? })`
651
+ - azimuth/elevation/distance around a target with optional auto-orbit. The
652
+ first argument is anything with the scene's `setCamera`, `camera()` (the
653
+ fov maps pan travel to world) and `size()` (the aspect `fit` frames
654
+ against): a Scene, or a View to drive one view's camera independently.
655
+ Its `axes` are `rotate` (a delta of one element height sweeps one full
656
+ turn, Three's OrbitControls convention, so a drag feels the same on a
657
+ phone and a 4k window; a rate turns at 0.5 turn/s at full deflection),
658
+ `zoom` (octaves: a delta of 1 halves the distance, a rate of 1 halves it
659
+ per second) and `pan` (the target slides so the scene tracks the fingers
660
+ 1:1 at the target's depth, three.js DOLLY_PAN, weighted by `panSpeed`). A
661
+ delta bracketed by a gesture (a drag, a pinch, two fingers) applies at
662
+ once, the content staying under the fingers; an unbracketed one (a wheel
663
+ notch, a key step) is an impulse and glides in - `damping` scales that
664
+ settle time, 0 applies it at once - notches compounding on the pending
665
+ value so a fast scroll is one push, the 2d camera's rule and the same
666
+ knob there. The verbs: `rotateBy(azimuth, elevation)` radians,
667
+ `zoomBy(factor, anchor?)` (factor > 1 in, as the 2d camera's zoomAt),
668
+ `panBy(right, up)` world units, `setPivot(point)`, `set(pose)`; every one
669
+ pushes the pose at once. Two commanded moves ease instead, inside
670
+ update(dt): `glideTo({ azimuth?, elevation?, distance?, target? })`, and
671
+ `fit(bounds, { glide? })`, which frames a `[minX, minY, minZ, maxX, maxY,
672
+ maxZ]` box (geometryBounds, a model's `bounds`) - target to its centre,
673
+ distance to where the bounding sphere fills the tighter of the vertical
674
+ and horizontal fov at the target's size, azimuth and elevation kept,
675
+ clamps applied; a snap unless `glide`, so a park-then-snapshot repeats
676
+ (under ortho only the target moves). Any input drops a glide (a finger
677
+ landing holds the view), and a `set()` that writes a pose field snaps
678
+ and drops any motion; `set({})` and `set({ orbiting })` leave it running.
679
+ Every write - input, verb, glide frame, set() - goes through the range
680
+ clamps and then `clampPose(pose)`, which sees the whole pose and returns
681
+ the fields to change: bound where a pan may put the target, or hold the
682
+ eye above a floor (an elevation floor that depends on the distance, which
683
+ a fixed `minElevation` cannot say); a glide's goal is clamped when set
684
+ and its frames as they land, so it never shows an illegal pose.
685
+ Zoom aims at the target unless `zoomAnchor(focal, {eye, target})`
493
686
  maps the gesture's focal point - a FRACTION of the element, [0..1, 0..1] -
494
687
  to a world point (ground hit, target-depth plane, ...): then that point
495
688
  stays pinned under the pointer and the target slides toward it; only the
@@ -502,13 +695,14 @@ called when a rotate gesture begins, its point is projected onto the
502
695
  view axis and re-seats the pivot without moving the picture, so a drag
503
696
  after an anchored zoom orbits what the camera looks at, not wherever the
504
697
  zoom left the target. Call `orbit.update(dt)` from your onFrame to
505
- integrate the rates and the auto-orbit (no frame loop of its own), and
506
- use its return - true when the pose changed since the previous update,
507
- nudges included - to gate per-frame dependents like reprojecting HUD
508
- overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
509
- frame-loop gate: orbiting with a non-zero rate, or any rate driving - the
510
- predicate every camera control shares) are reactive (HUD-safe); the pose
511
- is plain state via `pose()`/`set()` (also the debug-command shape). It
698
+ integrate the rates, the auto-orbit and any glide (no frame loop of its
699
+ own), and use its return - true when the pose changed since the previous
700
+ update, nudges included - to gate per-frame dependents like reprojecting
701
+ HUD overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
702
+ frame-loop gate: orbiting with a non-zero rate, a glide or damped notch
703
+ in flight, or any rate driving - the predicate every camera control
704
+ shares) are reactive (HUD-safe); the pose is plain state via
705
+ `pose()`/`set()` (also the debug-command shape). It
512
706
  drives position and target only; fov/near/far stay on scene.setCamera
513
707
  (or the Scene `camera` prop). The auto-orbit pauses while a gesture is
514
708
  open.
@@ -522,10 +716,13 @@ reconnects) and nothing else, and runs a frame loop only while
522
716
  `active()`, so a camera moved by drags alone keeps the app demand-driven
523
717
  idle. The pose props are initial values: runtime pose changes (and the
524
718
  debug-command hookup) go through `ref`'s handle, whose set() and verbs
525
- push the pose. Every other prop is live - forwarded to the control as a
526
- getter and read where it applies, never snapshotted - so clamps, rates
527
- and anchors follow their props without a remount, and a clamp change
528
- re-clamps the pose at once.
719
+ push the pose and whose glideTo/fit ease it. Every other prop is live -
720
+ forwarded to the control as a getter and read where it applies, never
721
+ snapshotted - so clamps, rates, `damping` and anchors follow their props
722
+ without a remount, and a clamp change (`clampPose` included) re-clamps
723
+ the pose at once. A `fit` from `ref` at mount reads the scene's size of
724
+ that moment: a fill-mode `<Scene>` has its creation size until its first
725
+ layout, so fit after the first frame (or hand a fixed-size scene).
529
726
 
530
727
  ### First-person camera
531
728
 
@@ -544,7 +741,12 @@ length so they walk no faster) and `rise` (world up at `moveSpeed`, fly
544
741
  mode only). Walking (the default) flattens the heading onto the ground
545
742
  plane at fixed height; `fly` moves along the view. The verbs:
546
743
  `lookBy(yaw, pitch)` radians, `moveBy(right, forward, up?)` in the
547
- walker's frame, `set(pose)`. Every option but the initial pose is read
744
+ walker's frame, `set(pose)`, and `glideTo({ position?, yaw?, pitch? })`,
745
+ which eases there inside update(dt) (yaw to the number given, not the
746
+ shortest turn), dropped by any input and by a set() of a pose field -
747
+ the orbit camera's glide, without its damping: this control's unbracketed
748
+ delta is mouse motion under pointer lock, and that is never eased. Every
749
+ option but the initial pose is read
548
750
  where it applies (`fly` per step, `clampPosition` per move, the rates and
549
751
  pitch clamps per input), so a field changed on the options object takes
550
752
  effect on the next move: walk and fly are one control. The control NEVER
@@ -554,8 +756,10 @@ collision of its own: `clampPosition(next, current)` is the whole hook -
554
756
  bounds, a floor height, or `moveAndSlide` over `next - current` against
555
757
  the collision layer (see `examples/collision.tsx`, whose `jump` is one
556
758
  more action on the same map: Space or the pad's south button, read by
557
- name). Call `update(dt)` from onFrame; `active()` is reactive - a rate
558
- driving (a held key, a deflected stick) - and gates the loop. The map
759
+ name); a glide consults it every frame as a walk does every step. Call
760
+ `update(dt)` from onFrame; `active()` is reactive - a rate driving (a
761
+ held key, a deflected stick), or a glide in flight - and gates the loop.
762
+ The map
559
763
  tracks held keys from the down/up pair through `input.handlers`; spread
560
764
  its `onBlur` too, since the up never arrives once focus has left.
561
765
 
@@ -609,7 +813,9 @@ The index and the narrowphase live in the spatial core: every attached
609
813
  mesh's local box is a leaf in a dynamic AABB tree the flush refits from
610
814
  the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
611
815
  an ordinary mesh is then tested per triangle against its geometry's
612
- shape (one CPU copy per distinct geometry, created with its GPU buffers),
816
+ shape (one CPU copy of the positions per distinct geometry, created with
817
+ its GPU buffers and rewritten by `updateVertices`; the mesh's box comes
818
+ from the same copy, so a deforming mesh is found where it is drawn),
613
819
  so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
614
820
  a ray through a knot's hole misses. A large geometry's triangles are
615
821
  BVH-indexed too - built by the first ray that reaches the shape, log-cost
@@ -723,7 +929,9 @@ pointerType, button, modifiers) plus `x`/`y` in scene pixels
723
929
  (screenRay's input - a drag plane is one intersection away). Root
724
930
  listeners all run, in registration order (the root is the last stop,
725
931
  nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
726
- `scene.handlers` automatically (opt out: `events={false}`); an `output`
932
+ `scene.handlers` automatically (opt out: `events={false}`, which throws
933
+ at mount together with a `pointer` feed - the feed would listen at a root
934
+ no event reaches; the same on `<View3d>`); an `output`
727
935
  leaf or imperative composition spreads `{...scene.handlers}` onto the
728
936
  element showing the texture. `scene.handlers` assumes that leaf is LAID
729
937
  OUT at the target size - true for the built-in leaf and a d-texture at
@@ -742,15 +950,36 @@ a default, named as Three names them: `box({ width, height, depth })`
742
950
  `ring({ innerRadius, outerRadius, segments })` (XY, facing +z - rotate
743
951
  `[-Math.PI/2, 0, 0]` for a floor); `sphere({ radius, widthSegments,
744
952
  heightSegments })`; `cylinder({ radiusTop, radiusBottom, height,
745
- radialSegments })` (y axis, capped; unequal radii taper it) and
746
- `cone({ radius, height, radialSegments })`; `torus({ radius, tube,
953
+ radialSegments, heightSegments })` (y axis, capped; unequal radii taper
954
+ it) and `cone({ radius, height, radialSegments, heightSegments })`;
955
+ `capsule({ radius, height, capSegments, radialSegments, heightSegments
956
+ })` (y axis, `height` the TOTAL extent like
957
+ cylinder's and Godot's/Unity's where Three's is the middle section
958
+ only, so `height: 2 * radius` is a sphere and less throws; its collision
959
+ volume is `{ a: [0, -(height / 2 - radius), 0], b: [0, height / 2 -
960
+ radius, 0], radius }`); `torus({ radius, tube,
747
961
  radialSegments, tubularSegments })` (lying flat, hole on the y axis) and
748
962
  `torusKnot({ radius, tube, tubularSegments, radialSegments, p, q })`
749
963
  (standing y-up) - both oriented for the y-up world, unlike Three's z-up.
750
964
  No positional form: `box()` is the default cube, `box({ label: "rock" })`
751
- names it. Every options object (the profile kit's `extrude`/`lathe`/
965
+ names it. The polyhedra: `tetrahedron`, `octahedron`, `icosahedron` and
966
+ `dodecahedron` (`{ radius, detail }`, radius 0.5 like sphere where
967
+ Three's is 1) and the generic `polyhedron(vertices, indices, { radius,
968
+ detail })` over Three's data form (flat xyz list, CCW triangle list, the
969
+ data first like `box3Helper`), each solid projected onto its
970
+ circumsphere. `detail` splits every edge `detail + 1` ways first, so
971
+ `detail: 0` is the flat-shaded solid (face normals) and anything above a
972
+ sphere of uniform triangles with radial normals: `icosahedron({ detail:
973
+ 3 })` is the icosphere, no pole pinch. Non-indexed like Three, every
974
+ triangle owning its three vertices; `edgesGeometry` welds by position,
975
+ so `edgesGeometry(dodecahedron())` is the twelve pentagons (the fan
976
+ diagonals are coplanar and drop out) where `wireframeGeometry` shows the
977
+ triangulation. UVs are the spherical map with Three's seam patch,
978
+ stretched toward the poles: the textured sphere stays `sphere()`, the
979
+ polyhedra are for flat-shaded, low-poly and procedurally shaded looks
980
+ (examples/polyhedra.tsx). Every options object (the profile kit's `extrude`/`lathe`/
752
981
  `sweep`/`tube` too) also takes `label` and `layout` - `layout` makes the
753
- generator emit that layout in one pass (standard channels written, the
982
+ generator emit that layout in one pass (base channels written, the
754
983
  extra slots zero), so `box({ layout: "colored" })` then
755
984
  `fillColors(g, fill)` builds colored geometry without the
756
985
  generate-then-repack copy; the result is byte-identical to
@@ -759,7 +988,8 @@ the tail every generator ends in, for your own generators.
759
988
  `withAttribute(geometry, attr, fill, label?)` derives a copy of any
760
989
  geometry (generator or hand-built) with one more channel after its
761
990
  current layout; the source is untouched. `withColors(geometry, fill,
762
- label?)` is the aColor vec4 case, keeping the "colored" preset name.
991
+ label?)` is the aColor float32x4 case, keeping the "colored" preset
992
+ name (`withAttribute` with `unorm8x4` is the 4-byte color).
763
993
  `fillAttribute(geometry, name, fill, first?, count?)` is the in-place
764
994
  primitive under both: overwrites one channel the geometry's layout
765
995
  already carries (withAttribute ADDS one), reading pos/normal/uv from the
@@ -796,13 +1026,86 @@ until the threshold passes 360 / radialSegments. The wireframe of a
796
1026
  "skinned" part draws in its pose under `unlit({ skinned: true })` with
797
1027
  the mesh's palette, and the swap onto a live mesh is
798
1028
  `setGeometry` + `setMaterial` (`examples/wireframe.tsx`). Lines are one
799
- pixel wide on GL ES; thick lines are quad geometry. `geometryBounds(geometry)`
1029
+ pixel wide on GL ES; thick lines are quad geometry. The debug helpers are
1030
+ `"lines"` builders too, Three's helper classes camelCased:
1031
+ `gridHelper({ size?, divisions?, color?, centerColor? })` is the XZ grid
1032
+ at y 0 (defaults 10 by 10, Three's grays; the two lines through the
1033
+ origin take `centerColor` and exist only for an even `divisions`),
1034
+ `axesHelper({ size? })` the X red, Y green, Z blue triad from the origin,
1035
+ `box3Helper(bounds, options?)` the twelve edges of a
1036
+ `[minX, minY, minZ, maxX, maxY, maxZ]` box in local space, and
1037
+ `planeHelper({ size? })` a square outline with its diagonals and a unit
1038
+ normal tick in the XY plane facing +z, placed like `plane()`, and
1039
+ `arrowHelper({ length?, headLength?, headWidth? })` an arrow along +y
1040
+ with a pyramid-outline head, aimed by the node's rotation
1041
+ (`quatFromTo(out, [0, 1, 0], direction)`), and `capsuleHelper(volume,
1042
+ { segments? })` the outline of a `{ a, b, radius }` collision capsule
1043
+ (rings at both ends, four lines, two half circles per cap; `a == b`
1044
+ draws a sphere's three great circles) in the volume's own space, the
1045
+ gizmo Unity and Godot draw for a capsule collider. Godot and
1046
+ Unity keep these in the editor and Three makes them scene objects; here
1047
+ they are plain geometry a node places and a material draws. The grid and
1048
+ the triad are "colored" layout (sRGB colors in, premultiplied linear
1049
+ aColor out, the material contract) drawn by `unlit({ vertexColors:
1050
+ true })`; a material that reads no aColor draws them in its own color.
1051
+ The box, the plane, the arrow and the capsule are base layout. For a bounds box that moves
1052
+ every frame, draw `edgesGeometry(box())` on a node whose position is the
1053
+ box center and whose scale is its size and update the transform (Three's
1054
+ Box3Helper does exactly that) instead of rebuilding.
1055
+ `examples/wireframe.tsx` draws a grid, the triad and the rover's bounds
1056
+ box. `geometryBounds(geometry)`
800
1057
  returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
801
1058
  `rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
802
1059
  slab test (entry t >= 0 in units of the direction's length, 0 from
803
1060
  inside, -1 for a miss) - for ray-testing boxes you keep yourself
804
1061
  (triggers, collision volumes) without meshes you do not want to draw.
805
1062
 
1063
+ Normals as data: two ops, split by whether the vertex count survives.
1064
+ `computeVertexNormals(geometry)` recomputes a triangle geometry's
1065
+ normals IN PLACE from the faces that name each vertex by index (Three's
1066
+ `computeVertexNormals`, Unity's `RecalculateNormals`, Godot's
1067
+ `generate_normals`, all in place), each face weighted by its corner
1068
+ angle so the result does not depend on how a surface was triangulated.
1069
+ The math runs in the core in one call over the vertex floats (so aNormal
1070
+ must be float32x3; a packed normal is an authoring format, pack after
1071
+ withNormals), and it returns the stream carrying aNormal, which
1072
+ `updateVertices` re-uploads: the CPU deformation loop is
1073
+ `fillAttribute(g, "aPos", ...)`, `computeVertexNormals(g)`,
1074
+ `updateVertices(g)`. That loop is for data that genuinely changes on the
1075
+ CPU - a streamed cloud, a cloth solved in JS, an editor edit. A
1076
+ per-frame ripple, sway or wave belongs in a vertex shader: displace and
1077
+ derive the normal in the vertex stage (`shaderMaterial`), and no
1078
+ per-vertex work touches the CPU at all; the fillAttribute callback alone
1079
+ is an interpreter call per vertex per frame. A vertex shared across faces
1080
+ shades smooth and a split one per face, so a merged or deformed
1081
+ generator geometry comes out right; a uv seam's copies each see one side
1082
+ and the seam shows (Three's artifact too). `withNormals(
1083
+ geometry, creaseAngle = 60, label?)` is the authoring path and a COPY:
1084
+ faces are matched by position, so a triangle soup, a hand-written face
1085
+ list, a merged result or a geometry with no aNormal channel at all (one
1086
+ is added, float32x3) all work and a seam shades smooth across it; 0 is
1087
+ flat shading, 180 smooths everything, 60 (Three's `toCreasedNormals`
1088
+ and Unity's import smoothing angle) keeps hard edges hard. It is
1089
+ `toNonIndexed` + per-corner normals + `mergeVertices`, so the result is
1090
+ indexed and split only where a crease or a seam needs it (a box stays
1091
+ 24 vertices under 90 degrees). Both pieces are exported:
1092
+ `toNonIndexed(geometry, label?)` gives every index its own vertex
1093
+ (identity indices; per-face colors via fillAttribute, flat shading via
1094
+ computeVertexNormals), `mergeVertices(geometry, tolerance = 1e-4,
1095
+ label?)` welds vertices equal in every channel back together, the exact
1096
+ inverse (a normal or uv that differs keeps them apart). Neither takes a
1097
+ morphed geometry: split or weld before `withMorphTargets`.
1098
+ `normalsHelper(geometry, { size?, color? })` draws one line per vertex
1099
+ along its normal (Three's VertexNormalsHelper as a static builder,
1100
+ "colored" layout, red by default, drawn by `unlit({ vertexColors: true
1101
+ })` under the mesh's node), the way to look at any of this;
1102
+ `examples/normals.tsx` shows the three crease angles on one soup, a
1103
+ rippling sphere on the per-frame loop, and the helper refreshed in place
1104
+ with the same loop. Three's `center()` is `transformGeometry(g, {
1105
+ position: [-(b[0] + b[3]) / 2, -(b[1] + b[4]) / 2, -(b[2] + b[5]) / 2]
1106
+ })` over `geometryBounds`; there is no bounding sphere op, the box is
1107
+ what every query and the LOD read.
1108
+
806
1109
  ### Profile kit
807
1110
 
808
1111
  Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
@@ -825,8 +1128,8 @@ Closed loops are NOT supported yet - overlap the ends by a segment to
825
1128
  fake one. `tube(path, { radius, radialSegments })` is the round-profile
826
1129
  shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
827
1130
  per-segment frames (tangents, cross-section axes, arc lengths) for
828
- custom work along a path. `shape(profile, options?)` fills one flat (facing +z,
829
- like circle); `triangulate(points)` is the ear-clipping core (fan
1131
+ custom work along a path. `polygon(profile, options?)` fills one flat (facing
1132
+ +z, like circle); `triangulate(points)` is the ear-clipping core (fan
830
1133
  fallback, never drops a cap), exported for custom flat work. These pick
831
1134
  uint16/uint32 indices by vertex count automatically.
832
1135
 
@@ -834,15 +1137,20 @@ uint16/uint32 indices by vertex count automatically.
834
1137
 
835
1138
  #### unlit
836
1139
 
837
- `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
1140
+ `unlit({ color?, map?, vertexColors?, transparent?, blend?, cull?, alphaTest?, fog? })` -
838
1141
  straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
839
- below), premultiplied internally; `cull` and
840
- `alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
1142
+ below), premultiplied internally; `vertexColors: true` multiplies by the
1143
+ "colored" layout's aColor (the geometry must carry it: withColors, a
1144
+ gridHelper or axesHelper); `blend` the factors of a transparent draw,
1145
+ "alpha" when absent, `"add"` for a glow (any mode but "none" implies
1146
+ `transparent` unless told `transparent: false`, the shaderMaterial
1147
+ rule); `cull` and
1148
+ `alphaTest` as on phong (a mapped cutout casts its cutout); `fog: false`
841
1149
  opts out of the scene's fog (all four library materials take it).
842
1150
 
843
1151
  #### sprite
844
1152
 
845
- `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
1153
+ `sprite({ color?, map?, transparent?, blend?, billboard? })` - unlit on a quad
846
1154
  that turns to face the camera IN THE VERTEX STAGE (off the shared
847
1155
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
848
1156
  only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
@@ -872,12 +1180,7 @@ with uViewProj - the specular/fresnel view vector is
872
1180
  uViewProj rows, that carries the clip flip), `uniform mat4
873
1181
  uInvViewProj` (the camera's inverse view-projection, shared likewise -
874
1182
  a clip position back to world, the world-space ray through a pixel
875
- without knowing the projection), the output stage's `uniform float
876
- uExposure` / `uToneMapping` (compose `OUTPUT` from `/glsl` and end with
877
- `fragColor = outputColor(rgb, alpha)` to take the scene's exposure and
878
- tone mapping and encode like the library materials do; a fragment
879
- writing fragColor directly writes final encoded pixels) and `uniform
880
- mat4 uNormal` (the world
1183
+ without knowing the projection) and `uniform mat4 uNormal` (the world
881
1184
  inverse-transpose, written beside uModel for this material's meshes;
882
1185
  take `mat3(uNormal)` - correct under non-uniform scale, where
883
1186
  mat3(uModel) bends normals off the surface). Attributes come from the
@@ -907,21 +1210,33 @@ pipeline with its own values. `dispose()` lives on the class alone.
907
1210
 
908
1211
  #### Instanced materials
909
1212
 
910
- `instanceAttributes: [{ name, format, slot? }]` (`format` is the vertex
911
- vocabulary, `"f32" | "vec2" | "vec3" | "vec4"`, NOT WebGPU's
912
- `float32x3` spelling) on either shader-material form makes an INSTANCED
913
- material: the vertex stage reads them as `in` variables beside the
914
- layout's own, and each drawn instance gets one
915
- record per slot from the mesh's instance buffers - slot 0 (default)
916
- is the record buffer, slot 1 an instanced mesh's STYLE buffer (below).
917
- Its meshes come from `createInstancedMesh` or `createRecordMesh`; a
918
- `createMesh` mesh is rejected at add(). `instanceStyle: [..]` is the
919
- slot-1 record a fresh instance starts with. The stock materials do
920
- the same with one flag: `lit/standard/unlit({ instanced: true })`
921
- places by the instance matrix (the shadow pass too, so the fleet
922
- casts), and `{ instanceColors: true }` adds a per-instance
923
- premultiplied `[r, g, b, a]` style record starting white - Unity's
924
- per-material instancing switch with Three's setColorAt.
1213
+ `instanceBuffers: [{ attributes: [{ name, format }] }]` (one layout per
1214
+ per-instance buffer, instance-step by definition and tightly packed in
1215
+ list order like a geometry layout; `format` is any of the vertex
1216
+ vocabulary, so a per-instance tint is a `unorm8x4` at four bytes and a
1217
+ frame index a `uint16x2`; explicit strides and offsets stay the
1218
+ engine's, through core's pipeline API) on either shader-material form
1219
+ makes an INSTANCED material: the vertex stage reads the attributes as
1220
+ `in` variables beside the layout's own, and each drawn instance gets one
1221
+ record from each of the mesh's instance buffers. An instance buffer IS a
1222
+ vertex stream stepped per instance: on the mesh it is an `InstanceStream`
1223
+ (the layout, a byte mirror, a mesh-owned GPU buffer), written through
1224
+ `instanceAttribute(mesh, name)` - the accessor `geometryAttribute`
1225
+ returns, record index in, values as the shader sees them, the codec
1226
+ packing the bytes - and published with `updateRecords(mesh, { stream?,
1227
+ first?, count? })`, which is `updateVertices` for records: one coalesced
1228
+ buffer write per dirty stream at the scene's sync, so ten moved records
1229
+ of ten thousand cost ten. Its meshes come from `createInstancedMesh`
1230
+ (the first buffer is the core-written matrix, the rest are streams; the
1231
+ second is the STYLE record, below) or `createRecordMesh` (every buffer a
1232
+ stream); a `createMesh` mesh is rejected at add(). `instanceStyle: [..]`
1233
+ is the style record a fresh instance starts with, one value per
1234
+ component. The stock materials do the same with one flag:
1235
+ `lit/standard/unlit({ instanced: true })` places by the instance matrix
1236
+ (the shadow pass too, so the fleet casts), and `{ instanceColors: true
1237
+ }` adds a per-instance premultiplied `[r, g, b, a]` style record
1238
+ starting white, stored as `float16x4` (eight bytes, no banding in the
1239
+ darks) - Unity's per-material instancing switch with Three's setColorAt.
925
1240
 
926
1241
  ### Instancing
927
1242
 
@@ -953,32 +1268,36 @@ matrix RELATIVE to the mesh the spatial core writes into the record
953
1268
  buffer (`bindMatrixRecord` anchored on the mesh node: one coalesced
954
1269
  buffer write per flush however many instances moved, so native
955
1270
  transitions and clip players move instances with zero per-frame JS).
956
- The material declares `INSTANCE_MATRIX_ATTRIBUTES` in slot 0 (four vec4
957
- columns, 16 floats; anything else throws at creation) and its vertex
1271
+ The material declares `INSTANCE_MATRIX_ATTRIBUTES` as its first instance
1272
+ buffer (four vec4 columns, 16 floats; anything else throws at creation)
1273
+ and its vertex
958
1274
  stage splices `INSTANCE_MATRIX` from `@solidrt/3d/glsl`: `uModel *
959
1275
  instanceMatrix() * vec4(aPos, 1.0)`, normals through
960
1276
  `instanceNormalMatrix()` (Three's derivation, exact for any rotation and
961
1277
  scale at no per-instance data; a sheared hierarchy - a non-uniformly
962
1278
  scaled group above a rotated instance - takes
963
1279
  `transpose(inverse(mat3(instanceMatrix())))` instead) - or is a stock
964
- material with `instanced`. Slot-1 attributes, when the material declares
965
- any, give every instance a STYLE record beside its matrix
966
- (`MeshInstances.style`): app-owned floats in a second, mesh-owned
967
- buffer, written per instance with `setInstanceStyle(instance, floats)`
968
- into a JS mirror and published as ONE coalesced buffer write per mesh at
969
- the scene's sync (a frame-rate path, like setTransform); a fresh or
970
- recycled slot starts from the material's `instanceStyle` (white under
971
- `instanceColors`, zeros otherwise); under `alphaTest` the cutout shadow
1280
+ material with `instanced`. A second instance buffer, when the material
1281
+ declares one, gives every instance a STYLE record beside its matrix
1282
+ (`MeshInstances.streams[0]`): an app-owned stream in the material's
1283
+ layout, written per instance with `setInstanceStyle(instance, values)`
1284
+ (one value per attribute component, encoded by the codecs) or by name
1285
+ through `instanceAttribute` + `updateRecords`, into a JS mirror and
1286
+ published as ONE coalesced buffer write per stream at the scene's sync
1287
+ (a frame-rate path, like setTransform); a fresh or recycled slot starts
1288
+ from the material's `instanceStyle` (white under `instanceColors`, zeros
1289
+ otherwise); a third buffer is a further stream, reached by name only;
1290
+ under `alphaTest` the cutout shadow
972
1291
  multiplies by the same color, so an instance faded below the cutoff
973
1292
  casts nothing, like its pixels (Godot's alpha scissor holds in its
974
- shadow pass too). The core's pose slot and the app's style slot are
1293
+ shadow pass too). The core's pose buffer and the app's style buffer are
975
1294
  @solidrt/2d's split one dimension up. Slots are fixed
976
- for an instance's life and recycle on `removeInstance` (remove means
977
- destroy: an instance cannot exist outside its mesh, and the generic
978
- add/remove throw on one), the drawn count is the slot high-water mark,
979
- and past `capacity` (default 64) both buffers double into replacements
980
- with the live records retargeted in one core call and the style mirror
981
- republished. `parent` may be a group inside the mesh's subtree (a squad
1295
+ for an instance's life and recycle on `destroy` (at the free: an
1296
+ instance cannot exist outside its mesh, so the generic add/remove throw
1297
+ on one), the drawn count is the slot high-water mark,
1298
+ and past `capacity` (default 64) every buffer doubles into a
1299
+ replacement, with the live matrix records retargeted in one core call
1300
+ and each stream's mirror republished. `parent` may be a group inside the mesh's subtree (a squad
982
1301
  in a fleet): the record stays mesh-relative through it. Picking is per
983
1302
  instance: every instance node carries the geometry's bounds and
984
1303
  triangle shape, so `Hit`, `Overlap` and `Impact` name the `instance` and
@@ -1004,17 +1323,23 @@ or in five figures, belong to the function face.
1004
1323
  #### createRecordMesh
1005
1324
 
1006
1325
  `createRecordMesh(geometry, material, records, count?, { bounds?,
1007
- label? })` is the raw form: `records` is the interleaved per-instance
1008
- data (stride = the material's slot-0 instanceAttributes summed, a
1009
- mismatch throws; slot 1 is an instanced mesh's and throws here),
1010
- uploaded to a mesh-owned buffer whose capacity starts at the
1011
- records given, and `count` picks how many draw (default all).
1326
+ label? })` is the raw form: `records` is the per-instance data laid out
1327
+ in the material's first instance layout - a Float32Array over an
1328
+ all-float layout, otherwise bytes built with `vertexView(layout, buffer)`
1329
+ and `attributeAccess` like a geometry stream (a length that is not whole
1330
+ records throws) - copied into the first stream's mirror, whose capacity
1331
+ starts at the records given; every further instance buffer the material
1332
+ declares is a zeroed stream of the same capacity, reached by name
1333
+ through `instanceAttribute`. `count` picks how many draw (default all).
1012
1334
  `setRecords(mesh, records, count?)` rewrites from the start (count
1013
1335
  defaults to the records written; more than capacity GROWS: capacity
1014
- doubles into a replacement buffer, the entry is re-pointed via
1015
- `setDrawBuffers`, the old buffer is freed) and `setRecordCount(mesh, n)`
1016
- is the population dial (clamped to capacity; frame-rate-safe). Records
1017
- are opaque data (position/yaw/tint/whatever your shader reads), so the
1336
+ doubles into replacement buffers, the entry is re-pointed via
1337
+ `setDrawBuffers`, the old ones are freed), `instanceAttribute` +
1338
+ `updateRecords(mesh, { first, count })` rewrites a few (the range is
1339
+ against capacity, so write ahead and dial after), and
1340
+ `setRecordCount(mesh, n)` is the population dial (clamped to capacity;
1341
+ frame-rate-safe). Records are opaque data (position/yaw/tint/whatever
1342
+ your shader reads), so the
1018
1343
  library cannot know where they place instances: a record mesh has NO
1019
1344
  picking leaf unless you pass `bounds` (local, covering the population) -
1020
1345
  then it picks and transparent-sorts conservatively as one box. The
@@ -1050,11 +1375,10 @@ no separate resize plumbing. Two forms:
1050
1375
  written through `scene.setParams` (an app clock for an animated sky).
1051
1376
  Godot's sky shader and Unity's skybox material are the same idea; the
1052
1377
  radiance bake for environment lighting will consume this same source
1053
- later, so a procedural sky written here lights the scene then. The
1054
- preamble declares the OUTPUT set: end with `fragColor =
1055
- outputColor(rgb, 1.0)` for a sky that takes the scene's exposure and
1056
- tone mapping (the skybox form does); a direct fragColor write is final
1057
- encoded pixels.
1378
+ later, so a procedural sky written here lights the scene then. A sky
1379
+ writes LINEAR light to fragColor (the skybox form does) and the
1380
+ scene's resolve exposes, tone maps and encodes it with everything
1381
+ else.
1058
1382
  - A skybox `{ cube, intensity?, rotation? }` (SkyboxOptions): a cube
1059
1383
  map from createCubeTexture sampled along the same ray - Three's
1060
1384
  `scene.background = cubeTexture` with `backgroundIntensity` and
@@ -1091,7 +1415,7 @@ prop - the cube map every `standard` material is lit by (always: the
1091
1415
  split sum `envRadiance` at its roughness times PBR's `envBrdf` for the
1092
1416
  specular, `envIrradiance` - the fully rough sample along the normal, as
1093
1417
  Three's getIBLIrradiance and Godot read it - added to the hemisphere
1094
- for the diffuse) and every `lit({ reflectivity })` material mirrors,
1418
+ for the diffuse) and every `phong({ reflectivity })` material mirrors,
1095
1419
  typically the skybox's own cube turned with it. The cube to use is a
1096
1420
  BAKED one: `bunx srt tool 3d/environment sky.hdr -o assets/sky.srte`
1097
1421
  turns an equirectangular Radiance .hdr (Poly Haven's are CC0) into the
@@ -1127,7 +1451,7 @@ tints instead; not offered). The reflection blurs with `shininess`:
1127
1451
  roughness `sqrt(2 / (shininess + 2))` picks a mip level of the cube
1128
1452
  (`textureLod`), so the environment cube wants `mipmap: true`; a cube
1129
1453
  without mipmaps stays sharp. `specularMap`'s red scales it like
1130
- `specular`. For `lit` it is not an ambient light source: the hemisphere
1454
+ `specular`. For `phong` it is not an ambient light source: the hemisphere
1131
1455
  light stays its only ambient term (`standard` adds envIrradiance; SH9
1132
1456
  is a later, additive mode). A declared `reflectivity` with no
1133
1457
  environment set contributes nothing (uEnvOn 0), not a black reflection.
@@ -1141,7 +1465,11 @@ near?, far?, layers?, clearColor?, label? })` renders the scene into a
1141
1465
  cube map from a point - Three's CubeCamera, Unity's and Godot's
1142
1466
  realtime ReflectionProbe - and returns `{ cube, setPosition, update(),
1143
1467
  dispose() }`; `cube` is what `environment={{ cube }}` (a chrome ball
1144
- mirroring its surroundings) or `background` takes. A view under the
1468
+ mirroring its surroundings) or `background` takes. `dispose()` destroys
1469
+ the cube, and a scene whose environment or background names it drops
1470
+ that first (setEnvironment(null) / setBackground(null)), so a subtree
1471
+ that owned the environment leaves the scene unlit by it rather than
1472
+ sampling a destroyed texture; set another when it goes. A view under the
1145
1473
  hood: one entry list mirrored from the scene, the light set and scene
1146
1474
  params fanned out, its own layer mask (keep the mirroring object out of
1147
1475
  its own probe with `layers`), the scene's background drawn first on
@@ -1168,13 +1496,12 @@ costs ~14 ms of GPU there against ~1 ms for the six face passes: fine at
1168
1496
  probes are the expensive option in every engine (Unity time-slices
1169
1497
  them): update a prefiltered probe when the surroundings changed, or
1170
1498
  every few frames, and keep `prefilter: false` for a probe that must
1171
- refresh every frame on a tight budget. The faces hold LINEAR light (the probe owns
1172
- `uOutputEncode` 0, `uToneMapping` 0 and `uExposure` 1 on its target,
1173
- names the scene's fan-out then skips), HALF FLOAT where the device
1499
+ refresh every frame on a tight budget. The faces hold LINEAR light like every buffer (a
1500
+ probe is a buffer without a resolve), HALF FLOAT where the device
1174
1501
  renders it (`limits.halfFloatRenderable`, every GLES 3 device here: a
1175
1502
  sun's or an emissive's range survives into the reflection, as in every
1176
1503
  engine's HDR probe) and 8-bit clamped elsewhere - the renderer decides
1177
- for all probes (Godot), not a per-probe knob (`probeFormat()` in
1504
+ for every target (Godot), not a per-probe knob (`bufferFormat()` in
1178
1505
  environment.ts) - and the probe never samples its own cube while
1179
1506
  rendering (a black environment stands in: one bounce). The face cameras are plain world-up cameras through an
1180
1507
  x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
@@ -1185,9 +1512,8 @@ passes so cull modes keep their meaning. `examples/probe.tsx`.
1185
1512
 
1186
1513
  Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
1187
1514
  origin that sees no mesh (layer mask 0): the scene's background - the
1188
- GLSL sky or the skybox - alone on its six faces, LINEAR (a sky ending in
1189
- `outputColor` bakes its light; one writing fragColor raw bakes those
1190
- bytes as light), prefilters it like a probe and returns the chain's
1515
+ GLSL sky or the skybox - alone on its six faces, LINEAR (a sky fragment writes light, so the bake stores exactly
1516
+ what the backdrop draws), prefilters it like a probe and returns the chain's
1191
1517
  TextureId for `environment={{ cube }}`: Godot's sky-to-radiance bake, so
1192
1518
  a procedural sky lights the scene with no light nodes, and the runtime
1193
1519
  way to turn a hi-res LDR skybox into a properly convolved environment
@@ -1195,8 +1521,8 @@ way to turn a hi-res LDR skybox into a properly convolved environment
1195
1521
  alternative). A snapshot at the probe format (half float where
1196
1522
  renderable; default size 128): bake again when the sky changes, and
1197
1523
  destroy the old cube - it is not auto-freed (an environment normally
1198
- lives as long as the app). `outputColor` leaves linear output UNCLAMPED
1199
- (the clamp sits in the display encode), so a sky's sun disc of 40.0
1524
+ lives as long as the app). A buffer is UNCLAMPED (the clamp sits in the
1525
+ resolve's encode), so a sky's sun disc of 40.0
1200
1526
  bakes as 40.0 and shows as the broad bright highlight on rough metal
1201
1527
  that HDR is for. A sky that reads
1202
1528
  `uCamPos` bakes from the origin; scene params (an app clock) are seen as
@@ -1245,7 +1571,8 @@ material drops the code from the program (Three's `material.fog`) - a
1245
1571
  sky sphere, a far backdrop. A shaderMaterial opts in by composing `FOG`
1246
1572
  from `/glsl` (declares the set; `fog(rgb, alpha, worldPos, camPos)`, or
1247
1573
  `fogAdditive(rgb, worldPos, camPos)` for a `blend: "add"` look, which
1248
- fades toward black instead of the fog color).
1574
+ fades toward black instead of the fog color; a stock material with
1575
+ `blend: "add"` composes that form by itself).
1249
1576
  The BACKGROUND is not fogged: it is entry zero with no depth or
1250
1577
  distance, so match the fog color to `clearColor` or the background's
1251
1578
  horizon, and put `far` at or inside the camera's far plane to hide the
@@ -1258,26 +1585,65 @@ Color: the scene shades in LINEAR light and outputs sRGB, like Three
1258
1585
  (ColorManagement), Godot and Unity's linear space - no gamma mode. Every
1259
1586
  `[r, g, b]` color option is sRGB, what a color picker shows: material
1260
1587
  `color` and `emissive`, light `color`, the hemisphere's `sky`/`ground`,
1261
- fog `color`; the library decodes it when it writes the uniform
1262
- (`srgbToLinear`/`linearColor` are exported for values you write straight
1263
- to a uniform yourself). Color MAPS decode through their format: create a
1264
- base color, emissive or sky image with `format: "rgba8-srgb"`
1265
- (createTexture, createCubeTexture; createModel does it for glTF's base
1266
- color and emissive images) - a plain rgba8 map reads as linear data and
1267
- renders washed out; data maps (normal, specular, roughness, light maps)
1268
- stay rgba8, and an HDR image is "rgba16f". Vertex colors are linear, as
1269
- glTF stores them. Every library fragment ends in the OUTPUT set's
1270
- `outputColor(rgb, alpha)`: exposure (`scene.setExposure`, default 1),
1271
- tone mapping (`scene.setToneMapping("none" | "aces")`, the reactive
1272
- `toneMapping`/`exposure` props) and the sRGB encode, premultiplied. The
1273
- scene target therefore holds encoded pixels like every texture the
1274
- runtime displays; `clearColor` is written as given and is NOT tone
1275
- mapped (with a curve on, draw the backdrop as a background), and a
1276
- transparent mesh blends in encoded space (Three's compromise; the
1277
- hardware-encode alternative would display wrong through the runtime's
1278
- raw sampling). What changed for a scene tuned before this: terminators
1279
- soften, mid-tones brighten, highlights widen - drop ambient rather than
1280
- lights. `emissiveIntensity` scales the emissive in linear light.
1588
+ fog `color`, the scene's and a view's `clearColor`; the library decodes
1589
+ it when it writes the uniform or the clear (`srgbToLinear`/`linearColor`
1590
+ are exported for values you write straight to a uniform yourself). Color
1591
+ MAPS decode through their format: create a base color, emissive or sky
1592
+ image with `format: "rgba8-srgb"` (createTexture, createCubeTexture;
1593
+ createModel does it for glTF's base color and emissive images) - a plain
1594
+ rgba8 map reads as linear data and renders washed out; data maps
1595
+ (normal, specular, roughness, light maps) stay rgba8, and an HDR image
1596
+ is "rgba16f". Vertex colors are linear, as glTF stores them.
1597
+
1598
+ Every target is a BUFFER plus a RESOLVE, Godot's and Unity's pipeline:
1599
+ the meshes draw into a linear buffer (`scene.hdrTexture` /
1600
+ `view.hdrTexture`: premultiplied linear light, half float where the
1601
+ device renders it - `limits.halfFloatRenderable`, every GLES 3 device
1602
+ here - else rgba8 linear and clamped; the renderer decides, no knob),
1603
+ and one full-screen pass, the resolve, writes the displayed rgba8
1604
+ `scene.texture`: exposure (`scene.setExposure`, default 1), tone mapping
1605
+ (`scene.setToneMapping("none" | "aces" | "agx" | "neutral")` - ACES the
1606
+ filmic curve every engine ships, AgX Blender's (Three, Godot 4.3+),
1607
+ Neutral the Khronos PBR curve (Three, Unity) that keeps product colors
1608
+ where a filmic curve shifts them; the reactive `toneMapping`/`exposure`
1609
+ props), then the sRGB encode with an ordered dither against banding,
1610
+ premultiplied. So a fragment - stock or custom -
1611
+ writes LINEAR light and never encodes (`sceneOutput` is fog only; a
1612
+ material has no output stage), a `transparent` mesh blends in linear
1613
+ space, the clearColor is tone mapped like a background is, and the
1614
+ resolve is an auto target, so a static scene still costs zero passes.
1615
+ The resolve is the post-effect slot: `resolve` on createScene/createView
1616
+ and the `<Scene>`/`<View3d>` prop - a source, `{ source, textures }`,
1617
+ or a function of the buffer id returning either, so a chain over
1618
+ `hdrTexture` is built before the resolve compiles - and
1619
+ `scene.setResolve` live with the same input. The
1620
+ source gets the shader-target contract (vUV, iResolution, fragColor),
1621
+ `uniform sampler2D uScene` (the buffer) and the RESOLVE set from `/glsl`
1622
+ declared: end with `fragColor = resolveColor(rgb, alpha)`; any
1623
+ `scene.setParams` name is readable; `DEFAULT_RESOLVE` is the stock one
1624
+ sample. BLOOM is stock: `bloom: { threshold?, intensity?, radius? }` on
1625
+ createScene, `scene.setBloom(opts | null)`, the reactive `bloom` prop -
1626
+ radiance above `threshold` (default 1, what a fully lit white surface
1627
+ reaches) blurred over `radius` rounds (default 2) of a separable blur
1628
+ at a quarter of the target's size and added back at `intensity`
1629
+ (default 0.5): Godot's glow, Unity's Bloom, Three's UnrealBloomPass. A
1630
+ chain of small auto passes per resolving target, re-rendered when the
1631
+ buffer is; views follow the scene's bloom unless they carry a `bloom`
1632
+ of their own (null = off, a clean minimap), the fog model. The glow is
1633
+ light: over a transparent backdrop (a view with no clearColor over UI)
1634
+ it still shows, its alpha its own brightness, and an opaque pixel is the
1635
+ plain sum. The default resolve composes it; a custom resolve does by declaring `uniform
1636
+ sampler2D uBloom; uniform float uBloomIntensity;` and adding the term
1637
+ (start from `BLOOM_RESOLVE` in `/glsl`; `examples/bloom.tsx` adds a
1638
+ vignette that way). `output` composes the DISPLAYED texture after the
1639
+ resolve, the place for an LDR effect (`examples/scene-post-effect.tsx`). Cost: one full-screen pass and
1640
+ double the color bandwidth per target, about 0.2 ms per 1080p target on
1641
+ an Intel iGPU (21 full-HD targets at once still ran at 36 fps); a
1642
+ device that cannot afford it renders a smaller buffer and the leaf
1643
+ scales it up. What changed for a scene tuned before linear light:
1644
+ terminators soften, mid-tones brighten, highlights widen - drop ambient
1645
+ rather than lights. `emissiveIntensity` scales the emissive in linear
1646
+ light.
1281
1647
 
1282
1648
  ### Lighting GLSL
1283
1649
 
@@ -1310,22 +1676,22 @@ three) and `SHADOW_LOOKUP`
1310
1676
  cast; it walks the light's slots and samples the first map that covers
1311
1677
  the point, which is the cascade select, blended into the next map over
1312
1678
  the outer `SHADOW_BLEND` of the map). A receiving fragment
1313
- multiplies light i's term by `lightShadow(i, ...)`, exactly what `lit`
1679
+ multiplies light i's term by `lightShadow(i, ...)`, exactly what `phong`
1314
1680
  composes; a non-receiving one composes none of the three and declares no
1315
1681
  samplers. Lights, colors and exponents are arguments, so
1316
- nothing is pinned but the function names; `lit` is composed from these
1682
+ nothing is pinned but the function names; `phong` is composed from these
1317
1683
  same constants - customizing never means leaving the system.
1318
1684
 
1319
1685
  ### Custom looks: three tiers
1320
1686
 
1321
- A custom look is a citizen of the scene - lit by its lights, shadowed,
1322
- fogged, exposed and tone mapped like the stock materials - at one of
1323
- three tiers, top first:
1687
+ A custom look is a citizen of the scene - lit by its lights, shadowed
1688
+ and fogged like the stock materials, exposed and tone mapped by the same
1689
+ resolve - at one of three tiers, top first:
1324
1690
 
1325
1691
  1. STANDARD FRAGMENT, CUSTOM VERTEX. Any vertex stage that writes the lit
1326
1692
  varyings (vWorldPos, vNormal, vUv, plus vColor with `vertexColors`,
1327
- vUv2 with `lightMap`) pairs with `litFragment(options)` /
1328
- `standardFragment(options)` (`/glsl`): the exact fragment `lit` /
1693
+ vUv2 with `lightMap`) pairs with `phongFragment(options)` /
1694
+ `standardFragment(options)` (`/glsl`): the exact fragment `phong` /
1329
1695
  `standard` compile, the same option names and defaults as their
1330
1696
  options with the texture options boolean. An instanced or displaced
1331
1697
  mesh keeps the stock shading whole (`examples/instanced.tsx`: the
@@ -1337,7 +1703,7 @@ three tiers, top first:
1337
1703
  params: { uColor: [1, 1, 1, 1], uSpecular: 0.12, uShininess: 24 } })`
1338
1704
  is the white starting point (the stock materials seed exactly this
1339
1705
  from their `color` option).
1340
- 2. A SURFACE FUNCTION inside the stock fragment. `litFragment({ surface,
1706
+ 2. A SURFACE FUNCTION inside the stock fragment. `phongFragment({ surface,
1341
1707
  prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
1342
1708
  it declares is an ordinary `instance()` param), `surface` declares
1343
1709
  `void surface(inout Surface s)`, called once the program has filled
@@ -1354,25 +1720,26 @@ three tiers, top first:
1354
1720
  surface: a point splat discarding outside `gl_PointCoord`'s inscribed
1355
1721
  circle and flipping a fitted normal towards the viewer keeps the
1356
1722
  scene's whole light model in about fifteen lines, where hand-rolling
1357
- the light loop (tier 3) would have to match the tone mapping and
1358
- output encoding by hand.
1723
+ the light loop (tier 3) would have to match every light's falloff,
1724
+ cone and shadow by hand.
1359
1725
  3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
1360
1726
  `sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
1361
1727
  declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
1362
- light list, the shadow set, the environment, fog and OUTPUT - declare
1363
- none of them yourself. Build a `Surface` with `surfaceOf(base,
1728
+ light list, the shadow set, the environment and fog - declare none
1729
+ of them yourself. Build a `Surface` with `surfaceOf(base,
1364
1730
  normal)`, set the fields you mean, call `shadeBlinn(s, position)` or
1365
1731
  `shadePbr(s, position)` (premultiplied rgb back: hemisphere, every
1366
1732
  light with its shadow, the environment term, the emissive), add your
1367
1733
  own terms times the alpha, and end with `sceneOutput(rgb, alpha,
1368
- position)` (fog, exposure, tone mapping, encode). A custom LIGHT MODEL
1734
+ position)` (fog; the pixel is linear light, the resolve exposes, tone
1735
+ maps and encodes it). A custom LIGHT MODEL
1369
1736
  loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
1370
1737
  shade function: light i's direction and its color already attenuated,
1371
1738
  cone-faded and shadowed (zero when it cannot reach). The stock
1372
1739
  materials are built from this same set, so the tiers cannot drift.
1373
1740
  The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
1374
1741
  (the knot's rim term).
1375
- `standardFragment(options)` is the same for `standard`: lit's options
1742
+ `standardFragment(options)` is the same for `standard`: phong's options
1376
1743
  minus `specularMap`/`env` (the environment is always composed) plus
1377
1744
  `metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
1378
1745
  `uMetalness`/`uRoughness` in place of `uSpecular`/`uShininess`.
@@ -1391,7 +1758,7 @@ instance or every caster move warns about the skipped write.
1391
1758
 
1392
1759
  ### Lights
1393
1760
 
1394
- Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
1761
+ Lights and `phong`: lights are graph NODES, like Three. `createDirectionalLight({
1395
1762
  direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
1396
1763
  travelling along `direction` in the node's LOCAL space (default `[0, -1,
1397
1764
  0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
@@ -1413,10 +1780,12 @@ only, same falloff, no cone. `createHemisphereLight({ sky?, ground?, intensity?
1413
1780
  normal's tilt (fixed to world up, the node's transform is ignored); one per
1414
1781
  scene, the last attached wins. Placement goes through setTransform, the
1415
1782
  light's own fields through `setLight(light, { ... })` (frame-rate-safe,
1416
- like setMeshParams). At most `MAX_LIGHTS` (8, exported from `/glsl`)
1783
+ like setMeshParams). At most `MAX_LIGHTS` (8, exported from the root and `/glsl`)
1417
1784
  lights per scene, directional, spot and point together (the hemisphere
1418
- is not in the list) - the ninth throws at add(); it is a shader-source
1419
- constant, fixed per app. `uLightDir` and `uLightPos` are core-driven:
1785
+ is not in the list) - a ninth is reported at the sync that settles the
1786
+ set, not at add() (a declarative swap's transient overlap is fine),
1787
+ the way a shadow-budget overflow is (see there), and the lights past
1788
+ the cap are not lit; it is a shader-source constant, fixed per app. `uLightDir` and `uLightPos` are core-driven:
1420
1789
  each light's slots are spatial-core shared-slot sinks following the
1421
1790
  node's world rotation (direction, negated so the shader reads the
1422
1791
  vector TOWARD the light) and world position, so a MOVING light costs no
@@ -1427,7 +1796,7 @@ LIGHT_POINT), `uLightDir[N]`/`uLightPos[N]`/`uLightColor[N]` (intensity
1427
1796
  folded into the color) and `uLightParams[N]` (cosInner, cosOuter,
1428
1797
  distance, decay) - so a custom fragment composing `LIGHT_SLOTS` +
1429
1798
  `LIGHT_LOOKUP` from `/glsl` reads the same list through `lightVector(i,
1430
- worldPos, out l)` (returns the attenuation, 0 = skip the light; `lit`
1799
+ worldPos, out l)` (returns the attenuation, 0 = skip the light; `phong`
1431
1800
  is the shape), and a light change costs one write however many meshes.
1432
1801
  A custom fragment that declares only the old directional subset
1433
1802
  (`uLightCount`/`uLightDir`/`uLightColor`) still works - it just shades
@@ -1436,15 +1805,14 @@ scenes. Everything starts black: a lit scene with no light shows
1436
1805
  nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
1437
1806
  shape (soft vs hard cone, casting spots, an orbiting bulb).
1438
1807
 
1439
- ### lit
1808
+ ### phong
1440
1809
 
1441
- `lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
1442
- the directional list, Lambert diffuse, Blinn-Phong highlight when
1810
+ `phong(opts)` is the Blinn-Phong material (Three's MeshPhongMaterial, Unity
1811
+ URP's Simple Lit) beside `unlit`: hemisphere ambient plus the light list, Lambert diffuse, Blinn-Phong highlight when
1443
1812
  `specular` (0..1 strength) is set with `shininess` (default 30), a
1444
1813
  mirror of the scene's environment when `reflectivity` (0..1, the
1445
1814
  face-on weight; see Environment above) is set, blurred by the same
1446
- `shininess`, the same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
1447
- multiply by the colored layout's aColor (so the geometry must carry it),
1815
+ `shininess`, the same `color`/`map`/`transparent`/`blend`/`vertexColors` as unlit,
1448
1816
  `triplanar: n` to sample `map` by world position at `n` repeats per
1449
1817
  world unit, blended across the three axis planes by the normal, and
1450
1818
  `alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
@@ -1520,7 +1888,7 @@ split sum, `envRadiance` at the roughness over the cube's mip chain
1520
1888
  times the analytic `envBrdf` for the specular, `envIrradiance` on the
1521
1889
  diffuse beside the hemisphere - with no `reflectivity` switch: the
1522
1890
  environment is intrinsic to the model, and a baked environment with no
1523
- lights at all lights a scene (`examples/environment.tsx`). Light intensities read as lit's
1891
+ lights at all lights a scene (`examples/environment.tsx`). Light intensities read as phong's
1524
1892
  (1 lights a white matte surface to 1; Godot's and Unity's convention -
1525
1893
  a Three scene's intensities divide by pi). Without an environment a
1526
1894
  metal shows only its highlights: no diffuse, nothing to reflect (Three
@@ -1554,7 +1922,7 @@ pre-order - name, parent index, local TRS; matrix-form nodes are
1554
1922
  TRS-decomposed, shear dropped; nodes that carry no part, joint or
1555
1923
  animation target anywhere - cameras, lights, unused empties - are
1556
1924
  pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
1557
- index, vertices in the standard layout LOCAL to the node - except
1925
+ index, vertices in the base layout LOCAL to the node - except
1558
1926
  skinned parts: "skinned" layout, model-space bind pose, `skin` index),
1559
1927
  `skins` (joint node indices + inverse binds), `clips` (the animations
1560
1928
  as baked channel buffers: node/path/interpolation, times, values),
@@ -1564,7 +1932,9 @@ index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
1564
1932
  normal and emissive slots, `metalness`/`roughness` factors and the
1565
1933
  packed `metalnessRoughnessMap` - standard's inputs), `images`
1566
1934
  (the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
1567
- pose, conservative for parts under rotated nodes). External
1935
+ pose: a part's box through its node, a skinned part's per-joint boxes
1936
+ through the joints' rest transforms - where the skin places it, armature
1937
+ scale included; conservative under rotation). External
1568
1938
  files come through `resolve(uri)` (uri as written, still
1569
1939
  percent-encoded; `gltfExternalUris(bytes)` lists them so an async
1570
1940
  caller can read them first) - for a .gltf AND for a .glb, which is
@@ -1581,13 +1951,13 @@ first error a real file hits.
1581
1951
 
1582
1952
  #### createModel
1583
1953
 
1584
- `createModel(data, { material?, label? })` - uploads the images (repeat
1954
+ `createModel(data, { material?, label?, autoFree? })` - uploads the images (repeat
1585
1955
  wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
1586
1956
  with the file's color, maps, normal scale, metalness/roughness and
1587
1957
  packed map, emissive and transparency - the glTF material model, so a
1588
1958
  scene showing a model wants an `environment` (a glTF metal in a scene
1589
1959
  with none renders near black); `material: (m, maps, skinned) =>
1590
- lit({ color: m.color, map: maps.map ?? undefined, skinned })` for the
1960
+ phong({ color: m.color, map: maps.map ?? undefined, skinned })` for the
1591
1961
  Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
1592
1962
  Groups with the file's local TRS, and one mesh per part under its node,
1593
1963
  all inside the returned `Model` (a Group): `add(scene.root, model)`,
@@ -1604,7 +1974,13 @@ them, nothing else frees them.
1604
1974
  #### loadGltf and loadModel
1605
1975
 
1606
1976
  `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
1607
- and build. `loadModel` reads the baked `.srtm` written by `srt tool
1977
+ and build. Like every resource creator, a model frees with the owning
1978
+ reactive scope (`dispose()` at its cleanup; `autoFree: false` opts out):
1979
+ for the loaders that scope is the caller's at the call, captured before
1980
+ the await, so a model loaded inside a memo goes with the memo's rerun
1981
+ and one loaded in a component with its unmount. A load that settles
1982
+ after its scope is gone (unmounted mid-load) is freed on arrival and
1983
+ resolves already disposed - nothing owns it any more. `loadModel` reads the baked `.srtm` written by `srt tool
1608
1984
  3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
1609
1985
  under bun, stored in the GPU layout, so loading is views onto the file's
1610
1986
  bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
@@ -1688,14 +2064,23 @@ Applied: `doubleSided` (the default material draws it with `cull:
1688
2064
  factor (a zero factor skips the map too - glTF's product rule, emission
1689
2065
  off), `pbrMetallicRoughness` factors as `m.metalness`/`m.roughness` and
1690
2066
  its packed texture as BOTH `maps.metalnessMap` and `maps.roughnessMap`
1691
- (standard's channel-select options; the default `lit` ignores them).
1692
- The `material(m, maps)` callback receives every uploaded texture
1693
- by lit()/standard() option name (`maps.map`/`maps.normalMap`/
1694
- `maps.emissiveMap`/`maps.metalnessMap`/`maps.roughnessMap`);
1695
- `data.materials` is in file order, so the calls arrive in file order.
2067
+ (standard's channel-select options; the default `phong` ignores them).
2068
+ The `material(m, maps, skinned, vertexColors)` callback receives every
2069
+ uploaded texture by phong()/standard() option name (`maps.map`/
2070
+ `maps.normalMap`/`maps.emissiveMap`/`maps.metalnessMap`/
2071
+ `maps.roughnessMap`); `data.materials` is in file order, so the calls
2072
+ arrive in file order. A primitive's `COLOR_0` lands in its geometry's
2073
+ aColor (the "colored" layout, or the skinned list plus aColor for a
2074
+ rigged one; linear and premultiplied as glTF stores it) and the default
2075
+ material takes `vertexColors: true` for such parts - the callback's
2076
+ fourth argument says so, and a material shared by painted and unpainted
2077
+ parts is made once per variant, like the skinned split.
1696
2078
  A `.srtm` baked before the material records carried the PBR fields
1697
- (file version 3) is rejected by loadModel - re-bake with `srt tool
1698
- 3d/model`.
2079
+ (file version 3), or before the vertex formats took the WebGPU spelling
2080
+ and byte counts (file version 5), is rejected by loadModel - re-bake
2081
+ with `srt tool 3d/model`. A baked part keeps a quantized export's
2082
+ bytes: u8 colors, u8/u16 joints and normalized weights, u16 uvs land in
2083
+ their own formats, positions and normals as floats.
1699
2084
 
1700
2085
  ### Animation
1701
2086
 
@@ -1779,14 +2164,66 @@ computed-once texture. `sampleChannel` (pure, from the root) stays the
1779
2164
  JS sampling core for checks and custom drivers;
1780
2165
  okf/done/animation-core.md records the design.
1781
2166
 
2167
+ ### Morph targets
2168
+
2169
+ Morph targets (blend shapes): a geometry carries named per-vertex
2170
+ deltas - `withMorphTargets(geometry, [{ name, position, normal? }])`
2171
+ for a hand-built shape, the loader's primitive targets for a glTF (its
2172
+ `extras.targetNames`, else `target<i>`; sparse accessors read) - packed
2173
+ SPARSE BY VERTEX on `geometry.morphs` (a header texel per vertex, then
2174
+ only the entries of the targets that move it; memory follows the
2175
+ authored deltas, never vertices x targets) and uploaded once per
2176
+ geometry as one rgba32f texture. A `morph: true` material (`phong`,
2177
+ `unlit`, `standard`; a custom class splices MORPH_DECLS and MORPH_APPLY
2178
+ from `@solidrt/3d/glsl` before its skin math) walks each vertex's
2179
+ entries in the vertex stage, before any skinning; a mesh under it
2180
+ whose geometry carries no targets is rejected at add(), and a plain
2181
+ material over morphed geometry draws the base shape. WEIGHTS belong to
2182
+ a NODE, glTF's `node.weights` model: a standalone mesh owns its own; a
2183
+ model's parts follow their glTF node (`model.nodes`), so a face split
2184
+ into skin, eyes and teeth parts takes one write. `setMorphWeights(node,
2185
+ { smile: 0.7 })` writes by name (the others untouched), an array writes
2186
+ every target in order, the `<Mesh morphWeights>` prop is the
2187
+ declarative form; `getMorphWeights(node)` reads them back (from the
2188
+ core while in a scene), `getMorphNames(node)` lists the targets. The
2189
+ write lands in the spatial core's register (a row of a small weights
2190
+ texture, published at the flush like a skin palette - Three's
2191
+ morphTargetInfluences, Godot's set_blend_shape_value); a file's
2192
+ `mesh.weights` seed it. Weights ANIMATE two ways, both native: a glTF
2193
+ `weights` animation channel plays through the mixer like a TRS
2194
+ channel (into the owning node's register, crossfading across players
2195
+ by the same weighted average), and a write is a TARGET like a
2196
+ transform write - `setTransition(node, { weights: { duration: 300 } })`
2197
+ makes every setMorphWeights animate (springs by default; `from`/`exit`
2198
+ endpoints are one number per target, `delay` and `stagger` apply), and
2199
+ `setMorphWeights(node, { smile: 1 }, { duration: 300, bounce: 0.2 })`
2200
+ animates that one write on its own motion, declaration or not; a
2201
+ settle calls `onTransitionEnd` with component "weights". A playing
2202
+ weights track and a JS write are both producers of the register: the
2203
+ players advance before the frame's JS, so a write from onFrame wins
2204
+ that frame. `getMorphWeights` reads the core, so it shows a playing
2205
+ track and a mid-flight transition; the by-name partial write merges
2206
+ into the last JS-WRITTEN set, not the core's - write every target
2207
+ while a clip plays. An INSTANCED mesh morphs per instance: the
2208
+ population owns one weights texture with a row per record slot, each
2209
+ `<Instance>` (or addInstance node) owns a register published into its
2210
+ slot's row, and the shader reads the row by `gl_InstanceID` - so
2211
+ `setMorphWeights(instance, { smile: 1 })`, the `<Instance morphWeights>`
2212
+ prop, a `transition.weights` spring and a clip's weights track all work
2213
+ per copy, the shadow variants included, with no per-record data
2214
+ (Three's InstancedMesh morphs). Skinned plus morphed plus instanced
2215
+ composes. A custom class splicing MORPH_DECLS reads the row as
2216
+ `uMorphRow + gl_InstanceID`, which the stock decls already do. A record
2217
+ mesh (JS-written records, no nodes) cannot morph: rejected at add().
2218
+
1782
2219
  ### Not in the subset
1783
2220
 
1784
- Not in the subset, dropped: vertex colors, tangents and further UV sets;
1785
- morph targets (the "weights" channel path); samplers are ignored (every
1786
- texture repeats); additive blending draws as base color. The follow-ups
1787
- are filed in okf/backlog/3d-model-loader.md. The `.srtm` container is
1788
- VERSION 3 (node table in the header, node-local vertices, skins, clips);
1789
- older bakes are rejected - re-bake with `srt tool 3d/model`.
2221
+ Not in the subset, dropped: tangents and further UV sets; samplers are
2222
+ ignored (every texture repeats); additive blending draws as base color.
2223
+ The follow-ups are filed in okf/backlog/3d-model-loader.md. The `.srtm`
2224
+ container is VERSION 8 (node table in the header, node-local vertices,
2225
+ skins, clips, packed morph targets); older bakes are rejected - re-bake
2226
+ with `srt tool 3d/model`.
1790
2227
 
1791
2228
  ## Traps
1792
2229
 
@@ -1809,6 +2246,16 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1809
2246
  MODEL moves both. Shadows are the exception: the shadow variants
1810
2247
  (depth and cutout) skin by the same uBones palette, so a caster casts
1811
2248
  its pose.
2249
+ - Morphing is the same kind of effect: picking and the transparent sort
2250
+ see the base shape, the shadow variants morph. The one thing the
2251
+ retained side knows is the box: `geometryBounds` (and a model's
2252
+ bounds) grow by the targets' extent, so a morphed shape is never
2253
+ culled while its weights stay in 0..1 - a weight past 1 can leave it.
2254
+ Normal deltas blend linearly like positions (Three's, Godot's and
2255
+ glTF's rule): exact at a target, an approximation between two, so a
2256
+ half-blend of EXTREME targets can show a ragged self-shadow
2257
+ terminator where the lit and shadowed halves disagree; a modeller's
2258
+ blend shapes are far too small for it to show.
1812
2259
 
1813
2260
  ### Materials and color
1814
2261
 
@@ -1818,8 +2265,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1818
2265
  ambient probe, this does not). glTF's default metallic factor is 1,
1819
2266
  so an untextured asset is all metal, and createModel's default is
1820
2267
  `standard`: give a model scene an `environment` (loadEnvironment's
1821
- baked .srte, or the skybox's cube), or pass `lit` as the material.
1822
- - Light intensities are the same numbers for `lit` and `standard`: 1
2268
+ baked .srte, or the skybox's cube), or pass `phong` as the material.
2269
+ - Light intensities are the same numbers for `phong` and `standard`: 1
1823
2270
  lights a white matte surface to 1 face-on. A Three scene's intensities
1824
2271
  are a factor pi larger for the same look; divide when porting.
1825
2272
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
@@ -1833,8 +2280,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1833
2280
  (or `shaderMaterial({ transparent: true })`) builds the pipeline with
1834
2281
  `blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
1835
2282
  behind opaques without occluding other translucents). The one inference:
1836
- a `shaderMaterial` with any `blend` but "none" is transparent unless told
1837
- `transparent: false` - every blended draw belongs after the opaques, and
2283
+ a stock material or `shaderMaterial` with any `blend` but "none" is transparent
2284
+ unless told `transparent: false` - every blended draw belongs after the opaques, and
1838
2285
  back-to-front is harmless for add/multiply. The scene owns the
1839
2286
  order: background, opaque meshes by `renderOrder` then add order,
1840
2287
  transparent meshes by `renderOrder` then back-to-front by the CENTER of
@@ -1857,9 +2304,11 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1857
2304
  custom fragment (no material option yet); a draw target you create
1858
2305
  yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
1859
2306
  is sampler-only: no display, readback or copy).
1860
- - Reading the scene texture back (a probe's readTexture, a snapshot)
1861
- gives ENCODED pixels: an expected linear value v shows as
1862
- `linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
2307
+ - Reading `scene.texture` back (readTexture, a snapshot) gives ENCODED
2308
+ pixels: an expected linear value v shows as `linearToSrgb(v) * 255` -
2309
+ intensity 0.5 reads 188, not 128. The buffer, `scene.hdrTexture`, is
2310
+ sampler-only (no readback, copy or display): read radiance through a
2311
+ `createShaderTexture` pass over it, scaled into 0..1.
1863
2312
 
1864
2313
  ### Environment and background
1865
2314
 
@@ -1886,7 +2335,7 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1886
2335
  - The environment binds through the light rewrite's map set (uEnv
1887
2336
  beside uShadowAtlas on every receiving target, new views included) and
1888
2337
  directly on setEnvironment; the placeholder cube is app-lifetime like
1889
- the shadow placeholder. A `lit` without `reflectivity` declares no
2338
+ the shadow placeholder. A `phong` without `reflectivity` declares no
1890
2339
  environment sampler - the flag is part of the class key.
1891
2340
 
1892
2341
  ### Coordinates and rotation
@@ -1989,26 +2438,42 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
1989
2438
  flashes at the world origin for a frame.
1990
2439
  - Instancing pairs strictly at add(), like layout: an instanced material
1991
2440
  needs a createInstancedMesh or createRecordMesh mesh and vice versa,
1992
- and the record strides must match the material's attributes per slot
1993
- (the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
1994
- mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
1995
- mismatch throws there, at creation for the mesh's own material and at
1996
- add() for a swapped one. The instance buffers are MESH-owned (unlike
1997
- shared geometry buffers): `disposeInstances` is their one free, and
1998
- the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
1999
- never resize: an addInstance or setRecords past capacity doubles into
2000
- new buffers and swaps them in (an instanced mesh's live records move
2001
- in one core `retargetRecords`, its style mirror republishes) -
2002
- amortized like a dynamic array, same policy as @solidrt/2d; size
2003
- `capacity` or the initial records to skip the copies. An instanced
2004
- mesh's style stride comes from its material AT CREATION: a later
2005
- setMaterial to a class with another slot-1 layout throws at the
2441
+ and every instance buffer the material declares must be a layout the
2442
+ mesh carries - the matrix (INSTANCE_MATRIX_ATTRIBUTES, the first
2443
+ buffer of an instanced mesh) or a stream with the same attribute names
2444
+ AND formats (a byte-equal layout in other formats decodes differently)
2445
+ - each mismatch throws there, at creation for the mesh's own material
2446
+ and at add() for a swapped one. The instance buffers are MESH-owned
2447
+ (unlike shared geometry buffers): `disposeInstances` is their one
2448
+ free, and the mesh cannot be re-added afterwards. Capacity grows by
2449
+ REPLACEMENT, never resize: an addInstance or setRecords past capacity
2450
+ doubles into new buffers and swaps them in (an instanced mesh's live
2451
+ matrix records move in one core `retargetRecords`, every stream's
2452
+ mirror republishes) - amortized like a dynamic array, same policy as
2453
+ @solidrt/2d; size `capacity` or the initial records to skip the
2454
+ copies. A mesh's stream layouts come from its material AT CREATION: a
2455
+ later setMaterial to a class with another layout throws at the
2006
2456
  rebuild.
2007
- - Style writes are mirrored, not immediate: `setInstanceStyle` lands in
2008
- `MeshInstances.style.data` and the scene's sync publishes the dirty
2009
- range - so a write on a mesh outside any scene shows once it is added
2010
- (the attach republishes the whole mirror), and reading the GPU buffer
2011
- back mid-frame can lag the mirror by one sync.
2457
+ - Record writes are mirrored, not immediate: `setInstanceStyle`,
2458
+ `setRecords` and an `instanceAttribute` write land in the stream's
2459
+ `data` and the scene's sync publishes the `dirty` range (`updateRecords`
2460
+ marks it for accessor writes) - so a write on a mesh outside any scene
2461
+ shows once it is added (the dirty range waits), and reading the GPU
2462
+ buffer back mid-frame can lag the mirror by one sync. Growth replaces
2463
+ `data`: hold the accessor, not the view.
2464
+ - The sugar is for FEW records, the mirror is for MANY. `setInstanceStyle`
2465
+ over an all-float layout is indexed stores; over a packed layout (the
2466
+ stock `float16x4` color included) it is a codec call per component,
2467
+ and so is every accessor write. Measured at ten thousand instances
2468
+ restyled every frame on a laptop under QuickJS: about 11 ms of JS per
2469
+ frame through setInstanceStyle on a float layout, 19 ms on the half
2470
+ color, 9 ms through the accessor, 5 ms through the mirror - and that
2471
+ 5 ms is the app's own per-instance tint math, the write itself is
2472
+ free. A population restyled per frame writes the mirror in bulk:
2473
+ a typed view over `stream.data` (a Float32Array as handed out, a
2474
+ `Float16Array` over the stock color, indexed by slot times components)
2475
+ filled in a plain loop, then ONE `updateRecords(mesh, { first, count
2476
+ })` - zero per-component calls, the same shape as `setRecords`.
2012
2477
  - Instanced casters: `castShadow` on a populated mesh needs a depth pass
2013
2478
  with the instance placement in it - the stock materials' `instanced`
2014
2479
  carries one, a custom class needs `shadowVertex` (see shadows below);
@@ -2023,8 +2488,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
2023
2488
  - An instance node's own fields hold its LOCAL pose (relative to its
2024
2489
  parent, as for any node); the record the core writes is its pose
2025
2490
  relative to the MESH, composed through any group between them. A
2026
- removed instance's children stay parented to the dead node: remove()
2027
- one to re-use it. `disposeInstances` on an instanced mesh flushes the
2491
+ destroyed instance takes its children with it, like any destroyed
2492
+ node. `disposeInstances` on an instanced mesh flushes the
2028
2493
  core before freeing the buffer, so the destroyed instances' hiding
2029
2494
  writes land in a live buffer; keep that order if you ever free a
2030
2495
  record buffer by hand.
@@ -2076,7 +2541,10 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
2076
2541
  `overrideMaterial` is validated against every mesh's layout (at
2077
2542
  createView for the meshes present, at add() for later ones) exactly like
2078
2543
  a mesh's own material, so an override reading `aColor` throws for a
2079
- standard-layout mesh. Views are disposed by the scene; `view.dispose()`
2544
+ base-layout mesh. An instanced mesh the override cannot place (it
2545
+ declares no `instanceBuffers`) is skipped, which the view's output
2546
+ cannot show, so the scene warns once per view naming it and the count
2547
+ (over the settled set, at the flush after the attach). Views are disposed by the scene; `view.dispose()`
2080
2548
  only for dropping one early.
2081
2549
  - SCENE-WIDE uniforms go through that same shared channel via
2082
2550
  `scene.setParams({ uTime })`, and this is the single highest-leverage
@@ -2088,9 +2556,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
2088
2556
  however many meshes read it, with the motion itself in vertex shaders
2089
2557
  off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
2090
2558
  is O(meshes) per frame; reach for it only when the value genuinely
2091
- differs per mesh. (`scene.texture` IS the draw target id, so
2092
- `setTargetParams(scene.texture, ...)` is the same write - setParams is
2093
- the sanctioned spelling.)
2559
+ differs per mesh. (`scene.texture` is the RESOLVE's id, not the buffer the meshes
2560
+ draw into, so `setTargetParams(scene.texture, ...)` reaches the
2561
+ resolve alone - setParams is the only spelling.)
2094
2562
 
2095
2563
  ### Shader materials
2096
2564
 
@@ -2121,7 +2589,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
2121
2589
  the material then rejects standard geometry at add(). Do not mention
2122
2590
  aColor you do not read.
2123
2591
  - A custom fragment that ends in `fragColor = vec4(...)` bypasses the
2124
- scene: no fog, no exposure or tone mapping, no encode - and a
2592
+ scene: no fog (exposure, tone mapping and the encode still happen, in
2593
+ the resolve, so it must write LINEAR premultiplied light) - and a
2125
2594
  hand-rolled loop over `uLightDir` renders a spot light as a
2126
2595
  directional one (a lit rectangle on the floor, no cone). What you
2127
2596
  declare is what runs, and the engine injects nothing, so pick a tier
@@ -2183,7 +2652,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
2183
2652
  otherwise the shape is simply wrong, there is no open-profile mode.
2184
2653
  - `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
2185
2654
  context).
2186
- - Geometry local bounds cache on the Geometry (like its GPU buffers):
2187
- geometry is immutable after creation. Mutating `vertices` after a mesh
2188
- used them leaves stale bounds AND a stale GPU buffer - make a new
2189
- Geometry instead.
2655
+ - Geometry local bounds cache on the Geometry (like its GPU buffers and
2656
+ the core's picking copy): mutating `vertices` after a mesh used them
2657
+ leaves stale bounds, a stale GPU buffer AND a stale picking shape
2658
+ until `updateVertices` publishes the range - write through the
2659
+ accessor and call it, or make a new Geometry.