@solidrt/3d 0.0.60 → 0.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -167,6 +167,13 @@ pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
167
167
  invisible ones - unless the query passes its own `{ layers }`, which is
168
168
  how a low-poly collision mesh lives undrawn in the scene yet answers
169
169
  ground and collision queries (the physics-collider pattern).
170
+ SEALED INTERIORS are the other manual use: from outside, a closed shell
171
+ hides its whole interior, but frustum culling cannot know that and
172
+ there is no occlusion or portal culling. Put the interior, the shell
173
+ and an opening "plug" on their own layers and switch the scene mask by
174
+ the camera's zone (inside, outside, in the doorway) - the reactive
175
+ `<Scene layers>` makes it one signal, and a light's reach is masked
176
+ the same way once lights carry layers.
170
177
  Per-view fog: `fog: FogOptions | null` on createView overrides the
171
178
  scene's fog for that view (null = unfogged - the clear minimap over a
172
179
  fogged scene); absent follows the scene. `overrideMaterial` (Three's
@@ -225,12 +232,13 @@ a zoomed camera or a scaled-up tree). `createLod([{ node, size }, ...],
225
232
  levels nearest first, `size` the projected size BELOW which a level
226
233
  hands over to the next, the last one's the cull threshold (0 = never
227
234
  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
235
+ `setLod`, one parented elsewhere is refused, never moved). Every camera
236
+ write is also its target's view for the core, which derives the eye,
237
+ focal and ortho flag it measures with (under `lodBias`): the scene and
238
+ each view pick by their own camera (a minimap sees the far level of the
239
+ same tree), shadow tiles by the SCENE camera (a caster draws the level
240
+ the camera sees, so its shadow matches - Unity's and Godot's rule),
241
+ probe faces by each face's camera. The gate composes with the frustum and `visible` into the same
234
242
  instance-count switch, so a still camera re-tests nothing and a thousand
235
243
  groups cost no JS per frame; a child of the group that carries no
236
244
  `lodSize` is drawn always. Without a `fade` the switch is hard with a
@@ -264,8 +272,14 @@ device. Picking and collision see the level the queried target draws
264
272
  (`scene.pick`/overlap/sweep/moveAndSlide the scene's, `view.pick` the
265
273
  view's); a collider that must not follow the camera is its own undrawn
266
274
  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.
275
+ a bake job, not a runtime one, and a far level for EMISSIVE DETAIL is a
276
+ design job: geometry simplifies, point lights do not. Fine lights
277
+ aggregated into bigger dots read as a uniform point cloud from afar;
278
+ what works is folding them into a dim emissive floor under the coarse
279
+ blocks, brightness proportional to the local light density squared,
280
+ visible only through the gaps - and tiny lights kept in the MIDDLE
281
+ level bring the speckle back one level closer. `examples/lod.tsx` is
282
+ the shape; `probes/3d-lod-bench.tsx` the cost.
269
283
 
270
284
  ### Shadows
271
285
 
@@ -317,7 +331,9 @@ PCF taps clamp at face-tile edges, so a face seam hardens slightly
317
331
  instead of bleeding into the neighbour.
318
332
  `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
319
333
  N maps fitted to slices of the SCENE camera's frustum (near ..
320
- `shadow.distance`, default the camera far; the practical split; each
334
+ `shadow.distance`, default the camera far; the practical split, or
335
+ `shadow.splits` - `N - 1` ascending fractions of that range, Godot's
336
+ shadow_split_1..3 - to push resolution outward or pull it in; each
321
337
  slice's bounding sphere as an ortho box along the light, its centre
322
338
  snapped to the map's texel grid so edges do not swim; re-fitted
323
339
  whenever the scene camera or the light moves) - a receiver samples the
@@ -521,13 +537,16 @@ faces); an instance is just per-entry uniforms (`uColor`) and bindings
521
537
 
522
538
  ### Pure pieces and checks
523
539
 
524
- The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
540
+ The pure pieces (`math.ts`, `color.ts`, `geometry.ts`,
525
541
  `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
526
- GPU-free BY DESIGN so they can be checked headless (and, for the two
527
- model modules, run under bun in `tools/model.ts`); keep them that way.
542
+ GPU-free BY DESIGN so they can be checked headless and run under bun:
543
+ `src/model-data.ts` re-exports exactly this set as the published
544
+ `@solidrt/3d/model` entry (the bake tool and app bake scripts), and
545
+ `tests/model-data.test.ts` imports it under `bun test`, so a runtime
546
+ import creeping into any of them fails there; keep them that way.
528
547
  The rigs under `checks/`
529
548
  (`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
530
- `order-check`, `gltf-check`) run on
549
+ `gltf-check`) run on
531
550
  flux from the repo root: `bunx srt bundle -f --stdout
532
551
  packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
533
552
  touching what you changed. `raycast-check.tsx` and
@@ -547,15 +566,15 @@ collision claims - two copies of this contract have drifted before.
547
566
 
548
567
  | Component | Props |
549
568
  | --- | --- |
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) |
569
+ | `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?` (FogOptions: linear `{ color, near, far }` or exp2 `{ color, density }`, either plus `height`/`heightFalloff` - see Fog), `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
570
  | `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
571
  | `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
572
  | `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
573
  | `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 |
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)` |
574
+ | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `renderOrder?`, `castShadow?`, `layers?` (membership bitmask, default 1), `frustumCulled?` (default true; false for geometry a vertex stage moves beyond the node's box - a billboard, a fullscreen quad; see Culling), `cullMargin?` (world units of slack around the box for bounded displacement), pointer events (below), `ref?(mesh)` |
556
575
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
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 |
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) |
576
+ | `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?` (an optional cull box, never a hit: instances pick by themselves, and without it 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, and `anchor?` (a SceneNode, fixed at creation: the ancestor the records are relative to, so `<Instance mesh>` children may sit anywhere under it - see createInstancedMesh) |
577
+ | `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), and `mesh?` (the population, fixed at creation, when the instance is placed outside its `<InstancedMesh>` - inside that mesh's `anchor` subtree) |
559
578
  | `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 |
560
579
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` - or the Scene `camera` prop, the same state (last write wins) |
561
580
  | `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)` |
@@ -637,17 +656,27 @@ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
637
656
 
638
657
  The vocabulary is shared with @solidrt/2d, one kind and unit per word:
639
658
  `rotate` vec2 (orbit turns), `zoom` axis (octaves, positive in), `pan`
640
- vec2 (element heights), `look` vec2 (turns), `move` vec2 ([right,
641
- forward], forward = -y in the screen convention), `rise` axis. Keys and
659
+ vec2 (element heights), `focus` axis (a nudge with a focal: a double
660
+ tap's point), `look` vec2 (turns), `move` vec2 ([right, forward],
661
+ forward = -y in the screen convention), `rise` axis. Keys and
642
662
  sticks move the CAMERA where a drag moves the content, so the presets
643
663
  bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
644
664
  a drag and a stick both turn the eye.
645
665
 
646
666
  ### Orbit camera
647
667
 
668
+ The camera controls run one pipeline (okf/design/camera-controls.md):
669
+ a SOURCE (an input nudge, a verb, the follow) writes the pose, the
670
+ FRAMING eases a followed point into its zones, the LANES (a screen
671
+ offset, shakes) are summed on top of the pose, the CONSTRAINTS (the
672
+ clamps, `clampPose`, occlusion) apply, and the PUSH writes the final
673
+ camera. `pose()` is the pose alone; `camera()` the final camera as
674
+ pushed.
675
+
648
676
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
649
677
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
650
- zoomSpeed?, panSpeed?, damping?, clampPose?, zoomAnchor?, rotateAnchor? })`
678
+ zoomSpeed?, panSpeed?, damping?, push?, panPlane?, follow?, offset?,
679
+ clampPose?, zoomAnchor?, rotateAnchor?, occluder? })`
651
680
  - azimuth/elevation/distance around a target with optional auto-orbit. The
652
681
  first argument is anything with the scene's `setCamera`, `camera()` (the
653
682
  fov maps pan travel to world) and `size()` (the aspect `fit` frames
@@ -656,78 +685,186 @@ Its `axes` are `rotate` (a delta of one element height sweeps one full
656
685
  turn, Three's OrbitControls convention, so a drag feels the same on a
657
686
  phone and a 4k window; a rate turns at 0.5 turn/s at full deflection),
658
687
  `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
688
+ per second - a DOLLY, bounded by the target), `pan` (the target slides so
689
+ the scene tracks the fingers 1:1 at the target's depth, three.js
690
+ DOLLY_PAN, weighted by `panSpeed`; `panPlane: "ground"` slides in the
691
+ plane orthogonal to world up instead, Three's screenSpacePanning off,
692
+ the map's choice) and `focus` (an axis: a nudge with a focal - a double
693
+ tap - glides the target to the point under it through `zoomAnchor`;
694
+ without a focal, to the point under the view centre). A delta bracketed
695
+ by a gesture (a drag, a pinch, two fingers) applies at once, the content
696
+ staying under the fingers; an unbracketed one (a wheel notch, a key
697
+ step) is an impulse and glides in - `damping` scales that settle time, 0
698
+ applies it at once - notches compounding on the pending value so a fast
699
+ scroll is one push, the 2d camera's rule and the same knob there. The
700
+ verbs: `rotateBy(azimuth, elevation)` radians, `zoomBy(factor, anchor?)`
701
+ (factor > 1 in, as the 2d camera's zoomAt), `panBy(right, up)` world
702
+ units, `setPivot(point)`, `setOrbitPoint(point)`, `set(pose)`,
703
+ `shake(strength, duration, { frequency?, direction? })`; every one
669
704
  pushes the pose at once. Two commanded moves ease instead, inside
670
705
  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})`
686
- maps the gesture's focal point - a FRACTION of the element, [0..1, 0..1] -
687
- to a world point (ground hit, target-depth plane, ...): then that point
688
- stays pinned under the pointer and the target slides toward it; only the
689
- app can build that mapping, since it needs the projection
690
- (scene.unproject over the scene's size). A pinch holds one anchor for its
691
- whole gesture (the fingers' interleaved events make the span oscillate,
692
- and re-anchoring per delta turns that into a crawl); the wheel, arriving
693
- unbracketed, anchors per notch. Pair it with `rotateAnchor({eye, target})`:
694
- called when a rotate gesture begins, its point is projected onto the
695
- view axis and re-seats the pivot without moving the picture, so a drag
696
- after an anchored zoom orbits what the camera looks at, not wherever the
697
- zoom left the target. Call `orbit.update(dt)` from your onFrame to
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
706
- drives position and target only; fov/near/far stay on scene.setCamera
707
- (or the Scene `camera` prop). The auto-orbit pauses while a gesture is
708
- open.
706
+ `fit(bounds, { glide? })`, which frames a `[minX, minY, minZ, maxX,
707
+ maxY, maxZ]` box (geometryBounds, a model's `bounds`) - target to its
708
+ centre, distance to where the bounding sphere fills the tighter of the
709
+ vertical and horizontal fov at the target's size, azimuth and elevation
710
+ kept, clamps applied; a snap unless `glide`, so a park-then-snapshot
711
+ repeats (under ortho only the target moves). Any input drops a glide (a
712
+ finger landing holds the view), and a `set()` that writes a pose field
713
+ snaps and drops any motion; `set({})` and `set({ orbiting })` leave it
714
+ running. Every write - input, verb, glide frame, set() - goes through
715
+ the range clamps and then `clampPose(pose)`, which sees the whole pose
716
+ and returns the fields to change: bound where a pan may put the target,
717
+ or hold the eye above a floor (an elevation floor that depends on the
718
+ distance, which a fixed `minElevation` cannot say); a glide's goal is
719
+ clamped when set and its frames as they land, so it never shows an
720
+ illegal pose.
721
+
722
+ **Push.** `push: true` lets a zoom step past `minDistance` (or
723
+ `maxDistance`) move eye and target together by the overflow - along the
724
+ view axis, or the anchor's ray when the step is anchored, so the point
725
+ under the fingers keeps its pixel - instead of stopping at the floor
726
+ (camera-controls' infinityDolly): the eye's speed is continuous across
727
+ the floor, so a pinch moves THROUGH a model at a speed that scales with
728
+ the distance. Off by default: a dolly that ends at the subject is the
729
+ viewer's default.
730
+
731
+ **Anchors.** Zoom aims at the target unless `zoomAnchor(focal, {eye,
732
+ target})` maps the gesture's focal point - a FRACTION of the element,
733
+ [0..1, 0..1] - to a world point: then that point stays pinned under the
734
+ pointer and the target slides toward it. A pinch holds one anchor for
735
+ its whole gesture (the fingers' interleaved events make the span
736
+ oscillate, and re-anchoring per delta turns that into a crawl); the
737
+ wheel, arriving unbracketed, anchors per notch. `rotateAnchor({eye,
738
+ target})`, called when a rotate gesture begins, names the point its
739
+ drag should pivot about: projected onto the view axis, it re-seats the
740
+ pivot without moving the picture (`setPivot`), so a drag after an
741
+ anchored zoom orbits what the camera looks at, not wherever the zoom
742
+ left the target. `<OrbitCamera>` builds both from its scene (`anchor`
743
+ below); a function-face caller supplies them (`scene.pick` at the
744
+ focal, `scene.unproject` at the target's depth as the fallback).
745
+
746
+ **Orbit point and the lanes.** `setOrbitPoint(point)` re-seats the
747
+ pivot EXACTLY: the target becomes the point, the distance its depth, and
748
+ the offset lane takes up the point's sideways offset, so the camera's
749
+ position and orientation do not change and the next drag orbits that
750
+ point (camera-controls' setOrbitPoint with a focal offset) - a viewer
751
+ pushed through a model keeps turning about the model's centre. The
752
+ `offset` option is the lane's persistent half, in view HEIGHTS from the
753
+ centre (x right, y down: `[0.25, 0]` shows the target a quarter of the
754
+ height right of centre - Babylon's targetScreenOffset); `shake` adds
755
+ decaying oscillations to it. Lanes are added on top of the pose at push
756
+ and never enter `pose()`; a verb that writes the target (`set`,
757
+ `glideTo`, `fit`, `focus`) clears setOrbitPoint's share.
758
+
759
+ **Follow.** `follow(point)` makes the target chase a world point
760
+ through the framing in `follow: { damping?, deadZone?, hardLimits?,
761
+ lookahead?, heading? }` (core's camera-control, shared with the 2d
762
+ camera): damping per view axis (`{ x, y, z }`: right, up, forward - a
763
+ lazy vertical, a tight horizontal), the dead zone and hard limits as
764
+ fractions of the view centred on the target (inside the dead zone the
765
+ point roams free; in the band up to the hard limits it is eased back;
766
+ beyond them it is clamped inside at once, whatever the damping - a fast
767
+ point never leaves the view), lookahead by `time` seconds of the
768
+ point's velocity smoothed over `smoothing` seconds. Cinemachine's
769
+ Orbital Follow: the orbit's own input keeps working around the followed
770
+ point - a rotate orbits it, a zoom dollies toward it, a pan moves the
771
+ target and the follow eases it back. `follow(point, heading)` also
772
+ recentres the azimuth on `heading`, the followed thing's yaw in the
773
+ first-person convention (0 faces -z, positive turns left: a walker's
774
+ `pose().yaw` feeds a chase camera directly), by the shortest turn,
775
+ after `follow.heading.wait` seconds (default 1) without rotate input at
776
+ `follow.heading.damping` - Cinemachine's recentering: a drag looks
777
+ around, the camera settles back behind the walker. Call `follow` every
778
+ frame for a moving point; the control settles once the point rests
779
+ inside the zones and the heading is reached. `unfollow()`, or a verb
780
+ that writes the target, ends it.
781
+
782
+ **Occlusion.** `occluder(target, eye)` reports the free distance from
783
+ the target toward the eye when something stands between them (null
784
+ otherwise); a free distance under the pose's pulls the FINAL eye in to
785
+ it at once, and the return eases as the obstacle clears (Cinemachine's
786
+ deoccluder damping; Godot's SpringArm3D snaps both ways). The pose's
787
+ distance is untouched: a zoom out from behind a wall still goes where
788
+ the pose says once the wall is gone. `<OrbitCamera occlusion>` builds
789
+ the hook from a scene raycast.
790
+
791
+ Call `orbit.update(dt)` from your onFrame to integrate the rates, the
792
+ auto-orbit, any glide, the follow, the lanes and the occlusion return
793
+ (no frame loop of its own), and use its return - true when the camera
794
+ changed since the previous update, nudges included - to gate per-frame
795
+ dependents like reprojecting HUD overlays. `orbiting()` (the auto-orbit
796
+ switch) and `active()` (the frame-loop gate: orbiting with a non-zero
797
+ rate, a glide, follow, shake or occlusion return in flight, or any rate
798
+ driving - the predicate every camera control shares) are reactive
799
+ (HUD-safe); the pose is plain state via `pose()`/`set()` (also the
800
+ debug-command shape). It drives position and target only; fov/near/far
801
+ stay on scene.setCamera (or the Scene `camera` prop). The auto-orbit
802
+ pauses while a gesture is open. checks/orbit-check.ts pins every stage
803
+ headless.
709
804
 
710
805
  In a component tree, skip the wiring: `<OrbitCamera input={input}
711
806
  azimuth={1.2} distance={7} />` as a Scene child reaches the scene through
712
807
  context (as a `<View3d>` child, that view: the context's `viewport` is
713
808
  the nearest owner's), drives from the map in `input` (its `rotate`,
714
- `zoom`, `pan` actions, or the names in `actions`; live - a new map
715
- reconnects) and nothing else, and runs a frame loop only while
809
+ `zoom`, `pan`, `focus` actions, or the names in `actions`; live - a new
810
+ map reconnects) and nothing else, and runs a frame loop only while
716
811
  `active()`, so a camera moved by drags alone keeps the app demand-driven
717
- idle. The pose props are initial values: runtime pose changes (and the
718
- debug-command hookup) go through `ref`'s handle, whose set() and verbs
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).
812
+ idle. The projection-dependent hooks are built here, since the
813
+ component sits where the projection is: `anchor` ("pick", the default:
814
+ the scene under the focal, else the plane at the target's depth;
815
+ "plane": that plane only; false: zoom toward the target, no pivot
816
+ re-seat) fills `zoomAnchor` and `rotateAnchor`, `occlusion={{ radius?,
817
+ layers?, meshes? }}` fills `occluder` from a raycast between target and
818
+ eye (mask the followed character out with `layers`, or name the level's
819
+ meshes); a hook prop overrides its half. The pose props are initial
820
+ values: runtime pose changes (and the debug-command hookup) go through
821
+ `ref`'s handle, whose set() and verbs push the pose and whose
822
+ glideTo/fit ease it. Every other prop is live - forwarded to the
823
+ control as a getter and read where it applies, never snapshotted - so
824
+ clamps, rates, `damping`, the follow's zones, `offset` and anchors
825
+ follow their props without a remount, and a clamp or offset change
826
+ re-clamps the pose at once. A `fit` from `ref` at mount reads the
827
+ scene's size of that moment: a fill-mode `<Scene>` has its creation
828
+ size until its first layout, so fit after the first frame (or hand a
829
+ fixed-size scene). `orbitBindings` is the viewer's wiring (a drag
830
+ rotates, Ctrl-drag, right-drag and two fingers pan, pinch and wheel
831
+ zoom, a double tap focuses); `mapBindings` the map's (a drag pans,
832
+ Ctrl-drag, right-drag and two fingers rotate), paired with `panPlane:
833
+ "ground"`.
834
+
835
+ ### Shots and blends
836
+
837
+ `createShots(scene | view, { blend? })` (core's blender over the
838
+ scene's CameraState, Cinemachine's virtual cameras): `shot(name, {
839
+ priority? })` returns a target an orbit or first-person control drives
840
+ instead of the scene (its `camera()` is the shot's own, its `size()` the
841
+ owner's) - a chase camera and a cinematic one, each with its own
842
+ control - and `activate(name, { blend? })` makes the highest-priority
843
+ enabled shot live, blending the scene from the current output over
844
+ `blend` seconds (default 0.5; 0 cuts; position, target, up, fov, near
845
+ and far linear, two ortho extents linear, a perspective-to-ortho switch
846
+ cuts at the midpoint; `mixCamera`); `deactivate` falls back by
847
+ priority. A live shot's pushes reach the scene at once; a blend needs
848
+ `update(dt)` while `active()`, the controls' rule. `live()` and
849
+ `camera()` read the state. A switch mid-blend starts from the output of
850
+ that moment, so a quick back-and-forth never jumps. Every shot starts
851
+ from the owner's camera at creation. The component form: `<Shots
852
+ blend? ref?>` as a Scene (or View3d) child with `<Shot name priority?
853
+ active? blend?>` children, each providing a context whose `viewport` is
854
+ the shot's recording target, so an `<OrbitCamera>` or
855
+ `<FirstPersonCamera>` inside a `<Shot>` drives that shot; `active`
856
+ (default true, live) withdraws and restores a shot, an unmount removes
857
+ it, the container runs the blend on frames while one is in flight. A
858
+ shot's picks and raycasts (`anchor`, `occlusion`) go through the
859
+ owner's actual camera, which is the shot's own only while it is live
860
+ and at rest. Pinned in checks/orbit-check.ts; probes/shots-3d-probe.tsx
861
+ is the live guard.
726
862
 
727
863
  ### First-person camera
728
864
 
729
865
  First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
730
- pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
866
+ pitch?, min/maxPitch?, moveSpeed?, boostSpeed?, lookSpeed?, fly?,
867
+ clampPosition? })` -
731
868
  a position plus yaw/pitch (yaw 0 faces -z, positive turns left; pitch
732
869
  positive looks up), Unity's FirstPersonController shape (look AND move in
733
870
  one control) where Three splits PointerLockControls from a hand-written
@@ -737,15 +874,24 @@ pointer feed's `mouseDelta` delivers mouse motion under pointer lock in
737
874
  the same unit; a rate turns at 0.4 turn/s), `move` ([right, forward],
738
875
  forward = -y: a stick pushed up or W reads [0, -1]; a rate walks at
739
876
  `moveSpeed`, a delta is a step in world units, diagonals clamped to unit
740
- length so they walk no faster) and `rise` (world up at `moveSpeed`, fly
741
- mode only). Walking (the default) flattens the heading onto the ground
877
+ length so they walk no faster), `rise` (world up at `moveSpeed`, fly
878
+ mode only) and `boost` (an axis, since the control has no button kind:
879
+ a bound button reads 1 while held, and any non-zero read multiplies the
880
+ move and rise RATES by `boostSpeed`, default 2 - the sprint; a delta
881
+ stays a one-unit step; `firstPersonBindings` puts it on Shift and the
882
+ left stick's press). Walking (the default) flattens the heading onto the ground
742
883
  plane at fixed height; `fly` moves along the view. The verbs:
743
884
  `lookBy(yaw, pitch)` radians, `moveBy(right, forward, up?)` in the
744
885
  walker's frame, `set(pose)`, and `glideTo({ position?, yaw?, pitch? })`,
745
886
  which eases there inside update(dt) (yaw to the number given, not the
746
887
  shortest turn), dropped by any input and by a set() of a pose field -
747
888
  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
889
+ delta is mouse motion under pointer lock, and that is never eased.
890
+ `shake(strength, duration, { frequency?, direction? })` is the lane
891
+ every control has (okf/design/camera-controls.md), here a view KICK in
892
+ TURNS of yaw and pitch (the look axis's unit; 0.01 is 3.6 degrees), the
893
+ first-person convention: a hit, a recoil, a footstep; the pose and the
894
+ eye are untouched. Every
749
895
  option but the initial pose is read
750
896
  where it applies (`fly` per step, `clampPosition` per move, the rates and
751
897
  pitch clamps per input), so a field changed on the options object takes
@@ -820,7 +966,9 @@ so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
820
966
  a ray through a knot's hole misses. A large geometry's triangles are
821
967
  BVH-indexed too - built by the first ray that reaches the shape, log-cost
822
968
  after - so raycasting a merged static scene stays cheap (see the batching
823
- advice). An instanced mesh is box-only (its
969
+ advice). An instanced mesh picks per instance (each a leaf with the
970
+ geometry's shape; its own node is never a hit, its `bounds` cull
971
+ only). A record mesh is box-only (its
824
972
  explicit population bounds; records are opaque), and so is a mesh whose
825
973
  geometry is not a triangle list (lines, points, strips have no triangle
826
974
  narrowphase): it is tested by the
@@ -1150,7 +1298,8 @@ opts out of the scene's fog (all four library materials take it).
1150
1298
 
1151
1299
  #### sprite
1152
1300
 
1153
- `sprite({ color?, map?, transparent?, blend?, billboard? })` - unlit on a quad
1301
+ `sprite({ color?, map?, transparent?, blend?, billboard?, shape?,
1302
+ falloff? })` - unlit on a quad
1154
1303
  that turns to face the camera IN THE VERTEX STAGE (off the shared
1155
1304
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
1156
1305
  only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
@@ -1160,7 +1309,11 @@ defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
1160
1309
  off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
1161
1310
  shared unit plane, no geometry argument, `scale` = world size, rotation
1162
1311
  ignored. Picks by a unit box around its center (its reach at any
1163
- facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
1312
+ facing), so hits carry no normal/face/uv. `shape: "radial"` is a
1313
+ procedural falloff over the quad's inscribed disc, `(1 - 2|uv -
1314
+ 0.5|)^falloff` (default 1; 2 a soft puff) multiplying color and alpha,
1315
+ so a glow, flare or puff needs no texture; it composes with a `map`
1316
+ and with `blend: "add"`. `examples/sprites.tsx`.
1164
1317
 
1165
1318
  #### shaderMaterial
1166
1319
 
@@ -1205,6 +1358,12 @@ Scene-wide values (a clock, a sun direction, fog) go through
1205
1358
  class/instance split for your own GLSL: compiles once, and
1206
1359
  `cls.instance({ params?, textures? })` returns a Material sharing that
1207
1360
  pipeline with its own values. `dispose()` lives on the class alone.
1361
+ Seed only the names the material owns: a per-entry param is applied
1362
+ AFTER the target's shared params, so a scene-wide name seeded in
1363
+ `instance({ params })` (uTime, a sun direction) pins that mesh to the
1364
+ seeded value and `scene.setParams` never reaches it again. Declare the
1365
+ uniform in the source, leave it out of the instance, and the scene's
1366
+ write lands.
1208
1367
  `shaderMaterial(opts)` is exactly a class with one instance (its
1209
1368
  `dispose` forwards to the class).
1210
1369
 
@@ -1259,7 +1418,7 @@ tree, a crowd member), not to submit a lot of vertices. The tell in
1259
1418
 
1260
1419
  #### createInstancedMesh
1261
1420
 
1262
- `createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
1421
+ `createInstancedMesh(geometry, material, { capacity?, bounds?, anchor?, label? })`
1263
1422
  draws the geometry once per instance NODE: `addInstance(mesh,
1264
1423
  transform?, parent?)` returns a scene node (kind "instance") placed
1265
1424
  inside the mesh - setTransform/setTransition/setVisible, lookAt,
@@ -1268,6 +1427,15 @@ matrix RELATIVE to the mesh the spatial core writes into the record
1268
1427
  buffer (`bindMatrixRecord` anchored on the mesh node: one coalesced
1269
1428
  buffer write per flush however many instances moved, so native
1270
1429
  transitions and clip players move instances with zero per-frame JS).
1430
+ `anchor` moves that frame to an ANCESTOR the mesh sits under at
1431
+ identity: the records are then relative to the anchor and instances may
1432
+ be placed under any node of the anchor's subtree, not only under the
1433
+ mesh - a population whose copies ride a hierarchy the mesh is not the
1434
+ root of (createModel's shared parts: the mesh at identity under the
1435
+ model root, an instance under every placement node). The anchor must be
1436
+ an ancestor when the mesh is added (it throws otherwise); keeping the
1437
+ chain between them identity is the caller's contract, the same one an
1438
+ instanced LOD's levels live under.
1271
1439
  The material declares `INSTANCE_MATRIX_ATTRIBUTES` as its first instance
1272
1440
  buffer (four vec4 columns, 16 floats; anything else throws at creation)
1273
1441
  and its vertex
@@ -1378,7 +1546,12 @@ no separate resize plumbing. Two forms:
1378
1546
  later, so a procedural sky written here lights the scene then. A sky
1379
1547
  writes LINEAR light to fragColor (the skybox form does) and the
1380
1548
  scene's resolve exposes, tone maps and encodes it with everything
1381
- else.
1549
+ else. Derivatives in uniform control flow only: `fwidth()`/`dFdx()`
1550
+ after an early `return` or inside a data-dependent branch are
1551
+ undefined in the 2x2 quads that straddle it, and on Mesa Intel a
1552
+ hash-grid sky with an "empty cell" early out drew stray 1-px lines
1553
+ and L-shapes along every cell edge. Take the derivatives at the top
1554
+ of main, before any branch.
1382
1555
  - A skybox `{ cube, intensity?, rotation? }` (SkyboxOptions): a cube
1383
1556
  map from createCubeTexture sampled along the same ray - Three's
1384
1557
  `scene.background = cubeTexture` with `backgroundIntensity` and
@@ -1703,25 +1876,32 @@ resolve - at one of three tiers, top first:
1703
1876
  params: { uColor: [1, 1, 1, 1], uSpecular: 0.12, uShininess: 24 } })`
1704
1877
  is the white starting point (the stock materials seed exactly this
1705
1878
  from their `color` option).
1706
- 2. A SURFACE FUNCTION inside the stock fragment. `phongFragment({ surface,
1707
- prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
1708
- it declares is an ordinary `instance()` param), `surface` declares
1709
- `void surface(inout Surface s)`, called once the program has filled
1710
- the Surface struct from its options (base from uColor, the map and
1711
- the vertex color; the normal, bent by the normal map; emissive,
1712
- ambient, the light model's fields) and before it shades. Rewrite any
1713
- field or `discard`; it reads the varyings, the declared uniforms and
1714
- prelude's names, and runs in the shadow twin too, so what it discards
1715
- casts no shadow. The struct is the contract, no local of the
1716
- generated program is; colors are linear light, premultiplied
1717
- throughout, `Surface.base` included. The material describes the
1718
- surface, the package shades it (Godot's fragment(), Filament's
1719
- material()). This is the tier for anything whose geometry is not a
1720
- surface: a point splat discarding outside `gl_PointCoord`'s inscribed
1721
- circle and flipping a fitted normal towards the viewer keeps the
1722
- scene's whole light model in about fifteen lines, where hand-rolling
1723
- the light loop (tier 3) would have to match every light's falloff,
1724
- cone and shadow by hand.
1879
+ 2. A SURFACE FUNCTION inside the stock material. `phong({ ..., prelude,
1880
+ surface })` / `standard({ ..., prelude, surface })`: every other
1881
+ option seeds as usual (`color`, the maps, metalness and roughness),
1882
+ `prelude` is file scope (uniforms and helpers; a uniform it declares
1883
+ is an ordinary per-entry param, `params` on the mesh or
1884
+ `setMeshParams`), `surface` declares `void surface(inout Surface s)`,
1885
+ called once the program has filled the Surface struct from the
1886
+ options (base from the color, the map and the vertex color; the
1887
+ normal, bent by the normal map; emissive, ambient, the light model's
1888
+ fields) and before it shades. Rewrite any field or `discard`; it
1889
+ reads the varyings, the declared uniforms and prelude's names, and
1890
+ runs in the shadow twin too (attached whenever a surface is given),
1891
+ so what it discards casts no shadow - with the twin seeing a prelude
1892
+ uniform at its zero value, since mesh params reach the main entry
1893
+ only. One program per distinct source, keyed like every option. The
1894
+ struct is the contract, no local of the generated program is; colors
1895
+ are linear light, premultiplied throughout, `Surface.base` included.
1896
+ The material describes the surface, the package shades it (Godot's
1897
+ fragment(), Filament's material()). This is the tier for anything
1898
+ whose geometry is not a surface: a point splat discarding outside
1899
+ `gl_PointCoord`'s inscribed circle and flipping a fitted normal
1900
+ towards the viewer keeps the scene's whole light model in about
1901
+ fifteen lines, where hand-rolling the light loop (tier 3) would have
1902
+ to match every light's falloff, cone and shadow by hand. The same
1903
+ two slots on `phongFragment` / `standardFragment` are the class form
1904
+ for a custom vertex stage (tier 1 and 2 together).
1725
1905
  3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
1726
1906
  `sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
1727
1907
  declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
@@ -1820,7 +2000,9 @@ is discarded; Three's alphaTest, glTF MASK): opaque, depth-written, no
1820
2000
  sorting, usually with `cull: "none"` for cards, and `instanced: true` /
1821
2001
  `instanceColors: true` for an instanced mesh's population (see
1822
2002
  Instancing: the placement, the depth pass and a per-instance tint with
1823
- no GLSL; the material then draws populated meshes only). Triplanar
2003
+ no GLSL; the material then draws populated meshes only), and `prelude`
2004
+ / `surface` for one procedural term inside the stock shading (tier 2
2005
+ of Custom looks below; also on `standard`). Triplanar
1824
2006
  is an OPTION, not the default: generators emit 0..1 UVs per face, so a
1825
2007
  map on a plane is a decal (UV) while a map on generated scenery wants one
1826
2008
  density across parts of any size (triplanar); the map must be created
@@ -1892,7 +2074,11 @@ lights at all lights a scene (`examples/environment.tsx`). Light intensities rea
1892
2074
  (1 lights a white matte surface to 1; Godot's and Unity's convention -
1893
2075
  a Three scene's intensities divide by pi). Without an environment a
1894
2076
  metal shows only its highlights: no diffuse, nothing to reflect (Three
1895
- and Godot do the same), so give the scene one. `examples/standard.tsx`
2077
+ and Godot do the same), so give the scene one. The same inside a closed
2078
+ volume: a dark, mostly-metal surface there gets little diffuse light
2079
+ and has little to reflect, so a hemisphere fill barely registers on it
2080
+ (a 5x fill and an interior reflection probe both did nothing visible);
2081
+ keep directional key and fill lights on inside. `examples/standard.tsx`
1896
2082
  is the sphere grid. Internally one
1897
2083
  `shaderMaterialClass` per option combination (map x vertexColors x
1898
2084
  triplanar x transparent x cull x alphaTest x fog x the surface maps),
@@ -1949,6 +2135,33 @@ naming it, and Draco or meshopt compression throws "re-export without
1949
2135
  mesh compression" - Blender exports Draco by DEFAULT, so that is the
1950
2136
  first error a real file hits.
1951
2137
 
2138
+ Reuse is kept, not unrolled. A mesh several nodes reference (Blender's
2139
+ linked duplicates export that way) becomes ONE part per primitive with
2140
+ `node` = the first node and `placements` = the further ones, and
2141
+ `EXT_mesh_gpu_instancing` lands the same way: its per-instance TRS
2142
+ become synthesized child nodes of the referencing node, named
2143
+ `<node>[i]`, so the hierarchy stays one table and an instance moves
2144
+ like any node (Three builds an InstancedMesh, Godot a MultiMesh;
2145
+ Unity's importer shares the mesh and lets its batcher instance).
2146
+ What cannot share one geometry stays a part per node: a skinned use
2147
+ (bind-pose model space, placed by its skin), a morphed one (its
2148
+ weights are the node's), and a placement whose rest-pose world
2149
+ MIRRORS the first's (opposite determinant sign - the winding flip is
2150
+ baked into the indices, so one index order cannot serve both). The
2151
+ file's `extras` ride through where glTF puts them, the way Three fills
2152
+ `userData` and Godot node metadata: `data.extras` (root), `node.extras`,
2153
+ `part.extras` (the glTF MESH's) and `material.extras`, each present
2154
+ only when non-empty - so a collider box authored as a Blender custom
2155
+ property arrives with the model. Parts come out in WALK order (each
2156
+ where its first placing node was visited, a node's primitives in
2157
+ order), and the parts a primitive cannot fold still share its built
2158
+ geometry: two skinned placements are two parts over ONE `Geometry`
2159
+ object, a mirrored copy shares the vertex buffer with its own index
2160
+ order - and the container writes each object and each buffer once
2161
+ (`decodeModel` restores the identities, so the runtime uploads them
2162
+ once too). A document with no `scene`/`scenes` draws everything from
2163
+ its true roots; a hierarchy reaching a node twice (a cycle) throws.
2164
+
1952
2165
  #### createModel
1953
2166
 
1954
2167
  `createModel(data, { material?, label?, autoFree? })` - uploads the images (repeat
@@ -1962,14 +2175,32 @@ Blinn-Phong look, or any other material; it is called once per material and shar
1962
2175
  Groups with the file's local TRS, and one mesh per part under its node,
1963
2176
  all inside the returned `Model` (a Group): `add(scene.root, model)`,
1964
2177
  place it with `setTransform`, find parts by name in `model.parts`
1965
- (`{ name, mesh }`), spin a wheel relative to its axle through
1966
- `model.nodes` (`{ name, node }` in table order, parents first; names
2178
+ (`{ name, mesh, extras? }`), spin a wheel relative to its axle through
2179
+ `model.nodes` (`{ name, node, extras? }` in table order, parents first; names
1967
2180
  repeat when the file's do - `.find()` yours), `model.bounds` for
1968
- framing a camera. Skinned parts get the `skinned: true` material
2181
+ framing a camera, `model.extras` and `model.blobs` for the app data a
2182
+ file or a bake carried. Skinned parts get the `skinned: true` material
1969
2183
  variant and hang off the model ROOT (the spec ignores their node's
1970
- transform; the palette places them - see the mixer below). `dispose()`
1971
- detaches it and frees the geometry buffers and textures - the model owns
1972
- them, nothing else frees them.
2184
+ transform; the palette places them - see the mixer below). A part with
2185
+ `placements` is a POPULATION: one InstancedMesh at identity under the
2186
+ model root (its `anchor`, see Instancing), `instanced: true` material
2187
+ variant, with an instance node at identity under every placement node
2188
+ - `part.instances`, in placement order, the part's own node first - so
2189
+ the copies are one draw entry (`/gpu` shows one entry labelled with the
2190
+ part's name, `instanceCount` = placements + 1; every mesh's entry
2191
+ carries its geometry's label there) and still ride the hierarchy: move,
2192
+ hide or animate the placement node in `model.nodes` and its copy
2193
+ follows; a pick lands on the copy (`hit.mesh` the part's mesh,
2194
+ `hit.instance` the instance, whose `parent` is the placement node);
2195
+ culling is per copy through the mesh's instance union. The one thing a
2196
+ population cannot do per copy is a material swap - `setMaterial` on the
2197
+ part's mesh restyles every copy; per-copy tint is `setInstanceStyle`
2198
+ under a material with `instanceColors`. The `material` callback's
2199
+ last flag is `instanced` (after `skinned`, `vertexColors`, `morphed`):
2200
+ pass it through, or the instanced draw has no matrix to read.
2201
+ `dispose()` detaches it and frees the geometry buffers, a population's
2202
+ record buffers and the textures - the model owns them, nothing else
2203
+ frees them.
1973
2204
 
1974
2205
  #### loadGltf and loadModel
1975
2206
 
@@ -1990,6 +2221,32 @@ models and a binary import (`import bytes from "./x.glb" with { type:
1990
2221
  "binary" }` then `createModel(parseGltf(bytes))`, see
1991
2222
  `examples/model.tsx`); bake anything big.
1992
2223
 
2224
+ #### Baking your own geometry
2225
+
2226
+ Geometry an app GENERATES (a terrain, a city block, a hundred thousand
2227
+ vertices of anything) pays the same interpreter cost every start; when
2228
+ it does not depend on runtime input, bake it once too. The whole data
2229
+ side of the package - the parser, the container and the pure geometry
2230
+ kit (generators, `withAttribute`, `mergeGeometries`, `transformGeometry`,
2231
+ the sweeps and profiles, the math) - is published without the runtime
2232
+ as `@solidrt/3d/model`, so a bake script runs under `bun` with no
2233
+ `flux:*` shim:
2234
+
2235
+ ```ts
2236
+ import { box, encodeModel, transformGeometry } from "@solidrt/3d/model"
2237
+ import type { ModelData } from "@solidrt/3d/model"
2238
+ // nodes in pre-order, one part per drawable (a part several nodes place
2239
+ // takes `placements`), materials as glTF-shaped records, app data in
2240
+ // `extras` (JSON) and `blobs` (named bytes, each an aligned block).
2241
+ let data: ModelData = { nodes, parts, skins: [], clips: [], materials, images: [], bounds, extras: { spawn }, blobs: { grid } }
2242
+ await Bun.write("assets/level.srtm", encodeModel(data))
2243
+ ```
2244
+
2245
+ `loadModel("assets/level.srtm")` then builds it like any authored model;
2246
+ `model.extras` and `model.blobs` hand the app data back (blobs are views
2247
+ onto the loaded bytes). `srt tool 3d/model` is the same entry applied
2248
+ to a glTF, so the two bakes share one code path.
2249
+
1993
2250
  ### Async loading
1994
2251
 
1995
2252
  Loading is async everywhere but the binary import: loadGltf/loadModel
@@ -2036,7 +2293,10 @@ WITH a skin over the body's joints and WITHOUT clips, so beside an
2036
2293
  animated body they hold their bind pose. `bindSkeleton(body, piece)`
2037
2294
  drives them from the body's skeleton - Three's SkinnedMesh.bind, Unity's
2038
2295
  `bones =`, Godot's shared Skeleton3D. The piece's palette rows re-bind
2039
- onto the body's joint nodes (matched by name, case-insensitive) with
2296
+ onto the body's joint nodes (matched by name, case-insensitive; a
2297
+ pipeline whose names differ by a prefix or suffix - Mixamo's
2298
+ `mixamorig:`, a one-sided `_JNT` - passes `{ match: pieceName =>
2299
+ bodyName }`, Unity's leave-it-to-the-app in one option) with
2040
2300
  the piece's own inverse binds, so the flush writes the body's pose into
2041
2301
  the piece's skin and no per-frame code exists; joints with no body
2042
2302
  counterpart (a hat's internal bones) and rigid parts hanging off a
@@ -2106,11 +2366,9 @@ write) - read poses with `getTransform(node)`, which reads the core;
2106
2366
  writes to such a node always go through (setTransform skips its
2107
2367
  usual equal-value short-circuit there, so a teleport back to the last
2108
2368
  JS-written spot is not lost); (3) a channel nothing plays leaves the node's pose
2109
- alone; (4) known gap: native pose writes bypass the scene's moved list,
2110
- so a TRANSPARENT mesh parented under a player-animated joint does not
2111
- re-trigger the back-to-front re-sort while it animates (opaque meshes,
2112
- palettes and picking are unaffected) - nudge the scene with any
2113
- setTransform if it shows, until the core-side transparent sort lands.
2369
+ alone; the draw sort follows native pose writes like any move (the core
2370
+ re-keys what it recomputed), so an animated transparent sorts as it
2371
+ animates.
2114
2372
 
2115
2373
  ### Root motion
2116
2374
 
@@ -2256,6 +2514,21 @@ with `srt tool 3d/model`.
2256
2514
  half-blend of EXTREME targets can show a ragged self-shadow
2257
2515
  terminator where the lit and shadowed halves disagree; a modeller's
2258
2516
  blend shapes are far too small for it to show.
2517
+ - A shared part (`part.instances` set) has ONE mesh for every copy, at
2518
+ identity under the model root: hiding it hides all copies, swapping its
2519
+ material restyles all, moving it is wrong (its records are anchored on
2520
+ the root - see the instancing traps). Per-copy identity lives on the
2521
+ placement nodes in `model.nodes` and on `part.instances` - hide, move,
2522
+ animate and pick those. A file that references one mesh from nodes on
2523
+ both sides of a mirror yields two parts (one per winding sign), so
2524
+ `model.parts.find` by name may find the unmirrored one only; the
2525
+ mirrored copy carries the mirroring node's name.
2526
+ - `createModel` refuses a skinned or morphed part with `placements`
2527
+ (thrown, not drawn wrong): parseGltf never folds those, so this only
2528
+ comes from a hand-built ModelData - give each copy its own part.
2529
+ - `extras` fields exist only where the file HAD data (an empty object
2530
+ is dropped), so read them with `?.` - `model.extras?.spawn`, not
2531
+ `model.extras.spawn`.
2259
2532
 
2260
2533
  ### Materials and color
2261
2534
 
@@ -2282,15 +2555,32 @@ with `srt tool 3d/model`.
2282
2555
  behind opaques without occluding other translucents). The one inference:
2283
2556
  a stock material or `shaderMaterial` with any `blend` but "none" is transparent
2284
2557
  unless told `transparent: false` - every blended draw belongs after the opaques, and
2285
- back-to-front is harmless for add/multiply. The scene owns the
2286
- order: background, opaque meshes by `renderOrder` then add order,
2287
- transparent meshes by `renderOrder` then back-to-front by the CENTER of
2288
- the mesh's world bounds in view space (not the origin: off-origin geometry
2289
- sorts by where it is; not the nearest bounds point: a big translucent
2290
- ground plane would cover the small translucents on it) - one `setDrawOrder` from sync() whenever the list changed, a
2291
- renderOrder changed, or (with two or more transparent meshes) the camera
2292
- or a transparent mesh moved, and skipped when the resort lands on the
2293
- permutation already issued. Per-mesh sort only: one non-convex translucent
2558
+ back-to-front is harmless for add/multiply. The spatial core owns the
2559
+ order of every scene and view target (`setDrawSort`; each mesh's bind
2560
+ carries its queue and renderOrder): four queues - the background,
2561
+ bound to the scene root and pinned first whatever the camera does,
2562
+ opaque meshes front-to-back by their distance to the camera, cutout
2563
+ meshes (any `alphaTest`, a `shaderMaterialClass({ cutout: true })`)
2564
+ after them the same way because their discard defeats early-z, and
2565
+ transparent meshes back-to-front by their view-space depth -
2566
+ `renderOrder` above depth inside each queue, never across queues,
2567
+ all measured at the CENTER of the mesh's world bounds (not the origin:
2568
+ off-origin geometry sorts by where it is; not the nearest bounds point:
2569
+ a big translucent ground plane would cover the small translucents on
2570
+ it), add order breaking ties. The opaque and cutout key is coarse on
2571
+ purpose - a logarithmic distance bucket, four per doubling - because
2572
+ early-z only needs near layers before far ones, and a coarse key keeps
2573
+ the order (and the meshes' add-order material grouping within a bucket)
2574
+ stable while the camera moves: a look-around changes no distance, and a
2575
+ step shorter than 16% of the nearest bucketed center's distance can
2576
+ cross no bucket edge, so neither re-sorts. Dense geometry added
2577
+ base-first therefore no longer needs the app to sort it (Godot and
2578
+ Unity sort opaques the same way, Three exactly). The core issues one
2579
+ `setDrawOrder` per target from the flush whenever a mesh was added,
2580
+ removed or re-keyed, a node moved, or the camera moved (with a
2581
+ transparent bound, or past that bucketed step), and skips it when the
2582
+ resort lands on the permutation already issued. Per-mesh sort only:
2583
+ one non-convex translucent
2294
2584
  mesh still overlaps itself in vertex order, and two large interpenetrating
2295
2585
  translucents can sort wrong (center distance, not per-pixel) - that is the
2296
2586
  engine contract, no OIT. A `shaderMaterial({ transparent: true })`
@@ -2493,6 +2783,13 @@ with `srt tool 3d/model`.
2493
2783
  core before freeing the buffer, so the destroyed instances' hiding
2494
2784
  writes land in a live buffer; keep that order if you ever free a
2495
2785
  record buffer by hand.
2786
+ - An anchored population (`createInstancedMesh` with `anchor`) draws
2787
+ `uModel * record`, and the record is relative to the ANCHOR: the
2788
+ copies land right only while the mesh's world equals the anchor's.
2789
+ Moving the mesh itself (or any group between it and the anchor) shifts
2790
+ every copy twice; move the anchor, the instances or their parents
2791
+ instead. A model's shared parts are anchored this way - `setTransform`
2792
+ on `model.parts[i].mesh` of a part with `instances` is the mistake.
2496
2793
 
2497
2794
  ### Shadows
2498
2795
 
@@ -2602,11 +2899,13 @@ with `srt tool 3d/model`.
2602
2899
 
2603
2900
  ### Picking, pointer and collision
2604
2901
 
2605
- - Picking is triangle-accurate for ordinary meshes (`point` is a surface
2606
- point, hits carry `face`/`uv`/`normal`) but box-only for instanced
2607
- meshes and for lines/points geometry: there `point` is where the ray
2608
- meets the population `bounds` box (the geometry's box for lines),
2609
- `normal` that face's, and `face`/`uv` are absent. Never present an
2902
+ - Picking is triangle-accurate for ordinary meshes and for instances
2903
+ (`point` is a surface point, hits carry `face`/`uv`/`normal`; an
2904
+ instanced mesh's own node is never struck, its `bounds` only cull)
2905
+ but box-only for record meshes and for lines/points geometry: there
2906
+ `point` is where the ray meets the population `bounds` box (the
2907
+ geometry's box for lines), `normal` that face's, and `face`/`uv` are
2908
+ absent. Never present an
2610
2909
  instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
2611
2910
  add a per-triangle path in JS - rays at mesh scale are
2612
2911
  interpreter-hostile, and the core already does it.
@@ -2642,10 +2941,8 @@ with `srt tool 3d/model`.
2642
2941
  lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
2643
2942
  map inscribed in the unit square) but the doc comment is the source.
2644
2943
  - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
2645
- list END and dirty the order, so the next sync() re-sorts and the mesh
2646
- keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
2647
- (setMaterial swaps `mesh.material` before the rebuild, so _detach must
2648
- not read the new material's flag).
2944
+ list END and rebind it with the new material's key, so the core's next
2945
+ flush re-sorts and the mesh keeps its place.
2649
2946
  - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
2650
2947
  to the axis at x = 0) - it is a solid of revolution, NOT Three's open
2651
2948
  polyline shell. An "open" outline must be closed by the author;