@solidrt/3d 0.0.51 → 0.0.53

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
@@ -4,38 +4,125 @@ A retained 3D scene graph above `@solidrt/core/gpu`. Meshes, materials and
4
4
  a camera compile to ONE depth-buffered draw target (`createDrawTarget` +
5
5
  one `addDraw` entry per mesh); the scene's output is an ordinary texture
6
6
  id composited as a `<texture>` leaf, so it takes layout, transforms,
7
- blendMode and pointer events like any element. Design rationale:
8
- `okf/research/scene-graph-3d.md` in the repo.
7
+ blendMode and pointer events like any element.
9
8
 
10
9
  ## The model
11
10
 
12
11
  - Two layers. The imperative core is Solid-free: `createScene`,
13
12
  `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
14
- `lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects with dirty flags, batched to a
15
- microtask,
16
- one `setDrawParams` (uModel, plus uNormal for materials declaring it)
17
- per changed mesh and ONE `setTargetParams` (the shared uViewProj +
18
- uCamPos) per camera change, however many meshes. The component
19
- face (`Scene`/`Group`/`Mesh`/`PerspectiveCamera`) syncs props into that
20
- core over context and renders nothing itself.
13
+ `lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects
14
+ over the spatial core (`flux:spatial`, `alloy/src/spatial/`): every node
15
+ in a scene has a core node, JS keeps the LOCAL position/quaternion/scale
16
+ as the readable truth and forwards each write, and the core's flush
17
+ (one call per microtask) recomputes only the moved subtrees and writes
18
+ each entry's uModel (plus uNormal for materials declaring it) and its
19
+ visibility switch itself - a move costs its subtree, never the scene.
20
+ ONE `setTargetParams` (the shared uViewProj + uCamPos) per camera
21
+ change, however many meshes. World matrices live in the core only:
22
+ `worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
23
+ writes included). See okf/backlog/spatial-core.md for what still runs
24
+ in JS and why. The component face (`Scene`/`Group`/`Mesh`/
25
+ `PerspectiveCamera`) syncs props into that core over context and renders
26
+ nothing itself.
21
27
  - Rendering is the runtime's. The target is `render: "auto"`: it
22
28
  re-renders when entries change, so a STATIC scene costs zero passes and
23
29
  the library registers no frame loop. Continuous animation is the app's
24
30
  own `onFrame` writing a signal (declarative) or `setTransform` on a
25
31
  `ref`-grabbed node (the frame-rate escape hatch - signals carry
26
32
  structure, per-frame motion goes straight to the scene).
27
- - Two named vertex layouts (`Geometry.layout`, absent = "standard"):
28
- "standard" is `aPos` vec3 + `aNormal` vec3 + `aUV` vec2 - what every
29
- generator emits - and "colored" appends `aColor` vec4, the per-vertex
30
- data channel (a tint, baked AO, any four scalars; standard name, your
31
- contents). Derive colored geometry with `withColors(geometry, fill)` -
32
- fill is a flat 4-per-vertex array or a per-vertex callback receiving
33
- `(index, pos, normal, uv)`. Geometry and material layouts must match
34
- (layout is stride); a mismatched pair throws at add(). The whole layout
33
+ - VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
34
+ clearColor?, ... })` renders the same scene into a second target from
35
+ its own camera (`view.setCamera`, the scene's CameraUpdate shape). Each
36
+ mesh gets one entry in the view's target bound as one more draw sink of
37
+ its CORE node, so the one flush writes every target - the app writes
38
+ nothing per view. Geometry buffers and (without an override) materials
39
+ are shared; the light set and `scene.setParams` names fan out to every
40
+ view, `view.setParams` is the view's own channel; the scene background
41
+ is not mirrored; a view has no picking. `overrideMaterial` (Three's
42
+ `scene.overrideMaterial`, scoped to the view) draws every mesh with one
43
+ material - a depth pass, a normal/id visualizer - skips instanced
44
+ meshes (the override cannot know their record layout) and draws in add
45
+ order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
46
+ input. `ortho: { left, right, top, bottom }` on any camera swaps
47
+ perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
48
+ the scene's own camera takes it too, and pick() follows.
49
+ `examples/scene-views.tsx` is the shape.
50
+ - SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
51
+ bias?, normalBias?, camera? }}>` (`createDirectionalLight({ castShadow,
52
+ shadow })`, `setLight`) makes the scene own an internal
53
+ `createView({ depth: "texture", overrideMaterial: depth pass })` drawing
54
+ the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
55
+ orthographic camera at the light's WORLD position along its world
56
+ direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
57
+ Any directional light may cast (capped by MAX_LIGHTS = MAX_SHADOWS).
58
+ `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
59
+ N maps fitted to slices of the SCENE camera's frustum (near ..
60
+ `shadow.distance`, default the camera far; the practical split; each
61
+ slice's bounding sphere as an ortho box along the light, its centre
62
+ snapped to the map's texel grid so edges do not swim; re-fitted
63
+ whenever the scene camera or the light moves) - a receiver samples the
64
+ tightest map that covers the point, fading into the next over the
65
+ map's outer 10% (`SHADOW_BLEND`) so the hand-over is a band, not a
66
+ seam; contact shadows stay sharp near the camera while the horizon
67
+ still has coarse ones, and pulling `distance` in sharpens all of them.
68
+ The box is the honest tier for a bounded scene; cascades are for a
69
+ scene that outgrows it, at N times the shadow fill. Every map is a
70
+ TILE of the scene's one shadow atlas (a `depth: "texture"` draw target,
71
+ a grid of cells the largest `mapSize` wide, scaled down uniformly
72
+ against `limits.maxTextureSize`), so N maps are ONE pass: the atlas
73
+ depth binds as the target-level `uShadowAtlas` of the scene and every
74
+ non-shadow view (a white texel when nothing casts); maps are MAP slots
75
+ dealt in light order, a light's cascades consecutive and tightest
76
+ first - `uShadowRect[j]` slot j's tile in atlas UV, `uShadowMatrix[j]`
77
+ its view's own view-projection (the whole array is one write per
78
+ shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
79
+ name its slots (count 0 = it does not cast) with
80
+ `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
81
+ glsl declares the set. Every `lit` material RECEIVES by default
82
+ (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
83
+ material out and drops the map from its program - a material option,
84
+ as with vertexColors/triplanar, because the material picks the program
85
+ (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s 3x3 PCF
86
+ on each casting light's own term. `examples/shadows.tsx` (three
87
+ casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
88
+ - RETARGETED motion is native: `setTransition(node, { position:
89
+ { duration: 400 }, ... })` makes setTransform writes TARGETS the core
90
+ animates toward every frame (position/scale per lane, rotation along
91
+ the quaternion geodesic - a spring keeps its velocity through
92
+ retargets), so a mesh gliding to a slot or a camera rig easing costs
93
+ one JS write per target change, zero per frame. The declaration lives
94
+ on the SceneNode and re-applies on every scene enter; the pose a node
95
+ enters with always snaps. Each natural settle calls the node's
96
+ `onTransitionEnd` (plain field like the pointer handlers) with
97
+ `{ component }`; the raw "spatialTransitionEnd" engine event
98
+ (srt:events, carrying the CORE node id `_node`) stays for flux:spatial
99
+ consumers.
100
+ - One interleaved vertex buffer per geometry, described by an open layout
101
+ (`Geometry.layout`, absent = "standard"): an ordered attribute list that
102
+ always starts with the standard prefix `aPos` vec3 + `aNormal` vec3 +
103
+ `aUV` vec2 (what every generator emits) and may carry any named channels
104
+ after it. `withAttribute(geometry, { name, format }, fill)` appends one
105
+ (Three's `setAttribute` for an interleave); "colored" names the common
106
+ case, the prefix plus `aColor` vec4 - the per-vertex data channel (a
107
+ tint, baked AO, any four scalars; standard name, your contents) - and
108
+ `withColors(geometry, fill)` is its spelling. Fill is a flat
109
+ size-per-vertex array or a per-vertex callback receiving `(index, pos,
110
+ normal, uv)`. Materials read attributes BY NAME: a material's vertex
111
+ stage may declare any subset of its geometry's channels, and a channel
112
+ the program reads that the geometry lacks (name + format) throws at
113
+ add(). What a program reads is the ENGINE's word (`material.attributes()`
114
+ = `programAttributes` reflection of the linked program, instance
115
+ attributes excluded), not a parse of the GLSL: an `in` the compiler
116
+ dropped does not count, and the engine also rejects a pipeline whose
117
+ attribute lists leave a read attribute uncovered. The material
118
+ keeps one program and builds one pipeline per layout its meshes bring,
119
+ so a geometry may carry more than a material reads. The whole layout
35
120
  ships whether a material reads every attribute or not (inactive
36
- attributes only keep the stride), so colored vertices cost 12 floats
37
- regardless - keep data-light passes (a wireframe reading only aPos) on
38
- standard geometry.
121
+ attributes only keep the stride), so extra channels cost their floats on
122
+ every draw of that geometry - keep data-light passes (a wireframe
123
+ reading only aPos) on standard geometry. `layoutStride`/`layoutSlot`/
124
+ `layoutKey`/`layoutAttributes` are the layout arithmetic; two layouts
125
+ with equal keys interleave identically (merge requires that).
39
126
  Indices are uint16 or uint32 - the `Geometry.indices` array type picks
40
127
  the draw's index format, so hand-built geometry past 64k vertices just
41
128
  uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
@@ -47,20 +134,26 @@ blendMode and pointer events like any element. Design rationale:
47
134
  (unlit color, unlit map, each opaque or transparent), `depth: true` +
48
135
  `cull: "back"`; an instance is
49
136
  just per-entry uniforms (`uColor`) and bindings (`uMap`).
50
- - The pure pieces (`math.ts`, `bvh.ts`, `order.ts`, `geometry.ts`) have check rigs in
51
- `checks/`, run headless on flux from the repo root:
52
- `bunx srt bundle -f --stdout packages/3d/checks/<name>-check.ts | target/release/flux - [seed]`.
53
- They print PASS or FAIL lines and throw on failure, which exits nonzero.
54
- Extend the rig when you change the module.
137
+ - The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
138
+ `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
139
+ GPU-free BY DESIGN so they can be checked headless (and, for the two
140
+ model modules, run under bun in `tools/model.ts`); keep them that way.
141
+ The rigs under `checks/`
142
+ (`geometry-check`, `sweep-check`, `pick-check`, `order-check`,
143
+ `gltf-check`) run on
144
+ flux from the repo root: `bunx srt bundle -f --stdout
145
+ packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
146
+ touching what you changed.
55
147
 
56
148
  ## Components
57
149
 
58
150
  | Component | Props |
59
151
  | --- | --- |
60
- | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
152
+ | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
61
153
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
62
154
  | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
63
- | `InstancedMesh` | as Mesh, plus `records` (interleaved per-instance floats; buffer capacity fixed by the first value), `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 |
155
+ | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
156
+ | `InstancedMesh` | as Mesh, plus `records` (interleaved per-instance floats; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffer is component-owned and freed on unmount |
64
157
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
65
158
 
66
159
  Output composition: without `output`, `Scene` emits a minimal
@@ -79,8 +172,11 @@ Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
79
172
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
80
173
  zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
81
174
  - drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
82
- auto-orbit. Input runs on core's `createTransform` recognizer, so drag and
83
- pinch arbitrate in the app-wide gesture arena (a viewport inside a scroller
175
+ auto-orbit. The first argument is anything with the scene's `setCamera`: a
176
+ Scene, or a View to drive one view's camera independently (one orbit per
177
+ view, each handed the handlers of its own viewport element). Input runs on
178
+ core's `createTransform` recognizer, so drag and pinch arbitrate in the
179
+ app-wide gesture arena (a viewport inside a scroller
84
180
  does not double-handle) and rotation starts after the recognizer's slop;
85
181
  `zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
86
182
  scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
@@ -115,13 +211,16 @@ Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
115
211
  through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
116
212
  world units, nearest first; every hit along the ray, not just the front
117
213
  one). `scene.raycast(origin, direction)` is the world-space primitive
118
- under it. The volume tier: hits test each mesh's local bounding box,
119
- transformed exactly under any node transform (non-uniform scale
120
- included), so results are conservative - a ray through a knot's hole
121
- still hits (no `face`/`uv` fields until a triangle tier exists).
122
- Broadphase is a dynamic AABB tree (BVH) the sync walk keeps current from
123
- its own dirty set - maintenance is O(changed), a query O(log meshes) -
124
- so per-pointer-move picking puts no ceiling on scene size. Both methods
214
+ under it.
215
+ The index and the narrowphase live in the spatial core: every attached
216
+ mesh's local box is a leaf in a dynamic AABB tree the flush refits from
217
+ the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
218
+ an ordinary mesh is then tested per triangle against its geometry's
219
+ shape (one CPU copy per distinct geometry, created with its GPU buffers),
220
+ so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
221
+ a ray through a knot's hole misses. An instanced mesh is box-only (its
222
+ explicit population bounds; records are opaque), so its hits have none of
223
+ the three. Both methods
125
224
  flush pending writes first (the lookAt/project immediacy contract), and
126
225
  both skip invisible meshes.
127
226
 
@@ -140,29 +239,41 @@ automatically (opt out: `events={false}`); an `output` leaf or
140
239
  imperative composition spreads `{...scene.handlers}` onto the element
141
240
  showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
142
241
  the target size - true for the built-in leaf and a d-texture at natural
143
- size, under any ancestor transforms or viewBox fits (the hit test
242
+ size, under any ancestor transforms or design-size fits (the hit test
144
243
  undoes them; localX/localY arrive in the leaf's layout frame). A leaf
145
244
  laid out at a different size (the supersampling pattern) uses
146
245
  `scene.handlersFor(() => ({ width, height }))` with its layout size.
147
246
 
148
- Geometry: `box(w?, h?, d?)`; `plane(w?, h?)`, `circle(radius?, seg?)` and
149
- `ring(inner?, outer?, seg?)` (XY, facing +z - rotate `[-Math.PI/2, 0, 0]`
150
- for a floor); `sphere(radius?, wSeg?, hSeg?)`;
151
- `cylinder(rTop?, rBottom?, height?, radialSeg?)` (y axis, capped; unequal
152
- radii taper it) and `cone(radius?, height?, radialSeg?)`;
153
- `torus(radius?, tube?, radialSeg?, tubularSeg?)` (lying flat, hole on the
154
- y axis) and `torusKnot(radius?, tube?, tubularSeg?, radialSeg?, p?, q?)`
247
+ Geometry generators take ONE options object, every field optional with
248
+ a default, named as Three names them: `box({ width, height, depth })`
249
+ (1x1x1); `plane({ width, height })`, `circle({ radius, segments })` and
250
+ `ring({ innerRadius, outerRadius, segments })` (XY, facing +z - rotate
251
+ `[-Math.PI/2, 0, 0]` for a floor); `sphere({ radius, widthSegments,
252
+ heightSegments })`; `cylinder({ radiusTop, radiusBottom, height,
253
+ radialSegments })` (y axis, capped; unequal radii taper it) and
254
+ `cone({ radius, height, radialSegments })`; `torus({ radius, tube,
255
+ radialSegments, tubularSegments })` (lying flat, hole on the y axis) and
256
+ `torusKnot({ radius, tube, tubularSegments, radialSegments, p, q })`
155
257
  (standing y-up) - both oriented for the y-up world, unlike Three's z-up.
156
- `withColors(geometry, fill, label?)` derives a "colored"-layout copy of
157
- any standard-layout geometry (generator or hand-built), adding the
158
- `aColor` vec4 channel; the source is untouched.
159
- `fillColors(vertices, fill, first?, count?)` is the in-place primitive
160
- under it: writes the aColor slots of a colored-layout interleave you
161
- already own (a merging builder's packed buffer), reading pos/normal/uv
162
- from the buffer itself - so a packer that bakes transforms while writing
163
- hands the baker world-space vertices. `fill` indexes relative to
164
- `first`. It trusts the buffer's layout (no tag to check); withColors is
165
- the checked path.
258
+ No positional form: `box()` is the default cube, `box({ label: "rock" })`
259
+ names it. Every options object (the profile kit's `extrude`/`lathe`/
260
+ `sweep`/`tube` too) also takes `label` and `layout` - `layout` makes the
261
+ generator emit that layout in one pass (standard channels written, the
262
+ extra slots zero), so `box({ layout: "colored" })` then
263
+ `fillColors(g, fill)` builds colored geometry without the
264
+ generate-then-repack copy; the result is byte-identical to
265
+ `withColors(box(), fill)`. `packGeometry(verts, indices, options?)` is
266
+ the tail every generator ends in, for your own generators.
267
+ `withAttribute(geometry, attr, fill, label?)` derives a copy of any
268
+ geometry (generator or hand-built) with one more channel after its
269
+ current layout; the source is untouched. `withColors(geometry, fill,
270
+ label?)` is the aColor vec4 case, keeping the "colored" preset name.
271
+ `fillAttribute(geometry, name, fill, first?, count?)` is the in-place
272
+ primitive under both: overwrites one channel the geometry's layout
273
+ already carries (withAttribute ADDS one), reading pos/normal/uv from the
274
+ buffer itself - so a builder that bakes transforms while writing hands
275
+ the baker world-space vertices. `fill` indexes relative to `first`.
276
+ `fillColors(geometry, fill, first?, count?)` is its aColor spelling.
166
277
 
167
278
  Geometry as data: `transformGeometry(geometry, { position?, rotation?,
168
279
  quaternion?, scale? }, label?)` bakes a placement into a copy (the
@@ -188,11 +299,11 @@ closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
188
299
  share an averaged normal - `fillet(points, radius, segs?)` and
189
300
  `roundRect(w?, h?, radius?, segs?)` emit those (arc corners smooth).
190
301
  Winding is normalized, so either authoring direction works.
191
- `extrude(profile, depth?, bevel?, bevelSegs?)` sweeps along z, centered,
192
- with a quarter-round bevel at both rims; `lathe(profile, segs?, angle?,
193
- start?)` revolves a CLOSED (x = radius, y = height) profile about the y
302
+ `extrude(profile, { depth, bevel, bevelSegments })` sweeps along z,
303
+ centered, with a quarter-round bevel at both rims; `lathe(profile, {
304
+ segments, angle, start })` revolves a CLOSED (x = radius, y = height) profile about the y
194
305
  axis - watertight by construction, flat caps on partial sweeps;
195
- `sweep(profile, path)` runs the profile along an open 3D polyline with
306
+ `sweep(profile, path, options?)` runs the profile along an open 3D polyline with
196
307
  MITRED joints (each cross-section sits on its bend's bisector plane, so
197
308
  bends never gape or overlap) and flat caps at both ends. The path
198
309
  mirrors the profile convention: bare `[x, y, z]` points crease (a strap
@@ -200,10 +311,10 @@ folding over an edge), `{ p, smooth }` points shade continuous (tag a
200
311
  sampled curve's points); the profile's y starts as close to world up as
201
312
  the first segment allows, then parallel-transports without spinning.
202
313
  Closed loops are NOT supported yet - overlap the ends by a segment to
203
- fake one. `tube(path, radius?, radialSegs?)` is the round-profile
314
+ fake one. `tube(path, { radius, radialSegments })` is the round-profile
204
315
  shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
205
316
  per-segment frames (tangents, cross-section axes, arc lengths) for
206
- custom work along a path. `shape(profile)` fills one flat (facing +z,
317
+ custom work along a path. `shape(profile, options?)` fills one flat (facing +z,
207
318
  like circle); `triangulate(points)` is the ear-clipping core (fan
208
319
  fallback, never drops a cap), exported for custom flat work. These pick
209
320
  uint16/uint32 indices by vertex count automatically.
@@ -212,6 +323,17 @@ Materials:
212
323
 
213
324
  - `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
214
325
  internally.
326
+ - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
327
+ that turns to face the camera IN THE VERTEX STAGE (off the shared
328
+ uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
329
+ only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
330
+ tree/character sprite; the default `"full"` is Three's Sprite, flat to
331
+ the screen). No per-frame JS however many sprites. `transparent`
332
+ defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
333
+ off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
334
+ shared unit plane, no geometry argument, `scale` = world size, rotation
335
+ ignored. Picks by a unit box around its center (its reach at any
336
+ facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
215
337
  - `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
216
338
  depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
217
339
  custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
@@ -229,9 +351,11 @@ Materials:
229
351
  inverse-transpose, written beside uModel for this material's meshes;
230
352
  take `mat3(uNormal)` - correct under non-uniform scale, where
231
353
  mat3(uModel) bends normals off the surface). Attributes come from the
232
- geometry's layout by name; a vertex stage reading `in vec4 aColor` opts
233
- the material into the "colored" layout, and its meshes then need
234
- `withColors()` geometry. Sources without `#version` get the standard
354
+ geometry's layout by name; the ones the linked program actually reads
355
+ (engine reflection, instance attributes excluded) must all be in the
356
+ mesh's geometry layout or add() throws - so a used `in vec4 aColor`
357
+ needs `withColors()` geometry and a custom channel needs
358
+ `withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
235
359
  pipeline preamble. App-driven uniforms beyond the standard set: seed
236
360
  via `params`, then write per mesh with
237
361
  `setMeshParams(mesh, { name: value })` (validated names; values persist
@@ -258,14 +382,15 @@ Instancing - one draw entry covering a population:
258
382
  label? })` returns an ordinary Mesh whose entry draws the geometry once
259
383
  per record. `records` is the interleaved per-instance data (stride = the
260
384
  material's instanceAttributes summed, a mismatch throws), uploaded to a
261
- mesh-owned buffer whose CAPACITY is fixed at creation. `count` picks how
385
+ mesh-owned buffer whose capacity starts at the records given. `count` picks how
262
386
  many records draw (default all). Everything mesh works unchanged:
263
387
  setTransform moves the whole population through one uModel, setVisible
264
388
  zeroes the drawn count and restores the record count on unhide,
265
389
  renderOrder/params/geometry/material swaps apply. `setInstances(mesh,
266
390
  records, count?)` rewrites records from the start (count defaults to the
267
- records written; more than capacity throws - make a new mesh to grow),
268
- `setInstanceCount(mesh, n)` is the population dial (clamped to capacity;
391
+ records written; more than capacity GROWS: capacity doubles into a
392
+ replacement buffer, the entry is re-pointed via `setDrawBuffers`, the old
393
+ buffer is freed), `setInstanceCount(mesh, n)` is the population dial (clamped to capacity;
269
394
  frame-rate-safe), and `disposeInstances(mesh)` detaches and frees the
270
395
  record buffer - the one explicit free, geometry-buffer rule. Records are
271
396
  opaque data (position/yaw/tint/whatever your shader reads), NOT matrices:
@@ -295,17 +420,126 @@ Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
295
420
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
296
421
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
297
422
  varyings, normals via mat3(uNormal)), `LIT_VERTEX_COLORED` (the same
298
- plus the colored layout's aColor forwarded raw as vColor - using it opts
299
- the material into that layout) and the pure functions `HEMISPHERE`
423
+ plus the colored layout's aColor forwarded raw as vColor - using it makes
424
+ the material need that channel) and the pure functions `HEMISPHERE`
300
425
  (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
301
426
  `BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
302
- (`fresnel(n, v, power)`). Lights, colors and exponents are arguments, so
303
- nothing is pinned but the function names; future lit material classes
304
- compose from these same constants - customizing never means leaving the
305
- system.
427
+ (`fresnel(n, v, power)`), and the shadow trio composed IN ORDER:
428
+ `SHADOW_SLOTS` (the scene's shadow set: `uShadowAtlas`, per map slot
429
+ `uShadowRect[M]`/`uShadowMatrix[M]`, per directional light
430
+ `uShadowFirst[N]`/`uShadowCount[N]` (its slots; a cascaded light has
431
+ several, tightest first), `uShadowBias[N]`, `uShadowNormalBias[N]`),
432
+ `SHADOW` (`shadowPoint(coord)` - clip to map point, `shadowInside(p)` -
433
+ does the map have it, `shadowSample(map, rect, p, bias)` - one tile's
434
+ 3x3 PCF factor, and `shadow(map, rect, coord, bias)` composing the
435
+ three) and `SHADOW_LOOKUP`
436
+ (`lightShadow(i, worldPos, n)` - light i's factor, 1 when it does not
437
+ cast; it walks the light's slots and samples the first map that covers
438
+ the point, which is the cascade select, blended into the next map over
439
+ the outer `SHADOW_BLEND` of the map). A receiving fragment
440
+ multiplies light i's term by `lightShadow(i, ...)`, exactly what `lit`
441
+ composes; a non-receiving one composes none of the three and declares no
442
+ samplers. Lights, colors and exponents are arguments, so
443
+ nothing is pinned but the function names; `lit` is composed from these
444
+ same constants - customizing never means leaving the system.
445
+
446
+ Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
447
+ direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
448
+ travelling along `direction` in the node's LOCAL space (default `[0, -1,
449
+ 0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
450
+ and position/scale do not matter - deliberately a direction, not Three's
451
+ position-minus-target. `createHemisphereLight({ sky?, ground?, intensity?
452
+ })` / `<HemisphereLight>` is the ambient term, a gradient by the WORLD
453
+ normal's tilt (fixed to world up, the node's transform is ignored); one per
454
+ scene, the last attached wins. Placement goes through setTransform, the
455
+ light's own fields through `setLight(light, { ... })` (frame-rate-safe,
456
+ like setMeshParams). At most `MAX_LIGHTS` (4, exported from `/glsl`)
457
+ directional lights per scene - the fifth throws at add(); it is a
458
+ shader-source constant, fixed per app. `uLightDir` is core-driven: each
459
+ directional light's slot is a spatial-core shared-slot sink following
460
+ the node's world rotation, so a MOVING light costs no JS. The sync
461
+ rewrites the rest whenever a light attaches, detaches or changes a
462
+ field -
463
+ `uHemiSky`/`uHemiGround` (vec3, intensity folded in), `uLightCount` (int),
464
+ `uLightDir[MAX_LIGHTS]`/`uLightColor[MAX_LIGHTS]` (world-space vector
465
+ TOWARD the light, normalized; intensity folded into the color) - so a
466
+ custom fragment declaring those names reads the same list, and a light
467
+ change costs one write however many meshes. Everything starts black: a
468
+ lit scene with no light shows nothing, on purpose, like Three.
469
+
470
+ `lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
471
+ the directional list, Lambert diffuse, Blinn-Phong highlight when
472
+ `specular` (0..1 strength) is set with `shininess` (default 30), the
473
+ same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
474
+ multiply by the colored layout's aColor (so the geometry must carry it),
475
+ and `triplanar: n` to sample `map` by world position at `n` repeats per
476
+ world unit, blended across the three axis planes by the normal. Triplanar
477
+ is an OPTION, not the default: generators emit 0..1 UVs per face, so a
478
+ map on a plane is a decal (UV) while a map on generated scenery wants one
479
+ density across parts of any size (triplanar); the map must be created
480
+ with `wrap: "repeat"`. Internally one `shaderMaterialClass` per option
481
+ combination (map x vertexColors x triplanar x transparent), cached for
482
+ the app's lifetime, one pipeline per vertex layout - a thousand lit
483
+ meshes share one program. The view vector comes from the shared uCamPos;
484
+ `uTriplanar` is declared only by the triplanar classes so the other
485
+ classes do not warn about an inactive uniform.
486
+
487
+ ## Models
488
+
489
+ Authored models come in as glTF 2.0 (.gltf with its .bin and image files
490
+ next to it, or single-file .glb) and become a Group of meshes, Three's
491
+ `gltf.scene`. Three layers, use the lowest that fits:
492
+
493
+ - `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
494
+ bun and on flux): `ModelData` = `parts` (one per mesh node, its NAME
495
+ kept, vertices in the standard layout with the node's WORLD transform
496
+ baked in), `materials` (base color factor, `map` = index into `images`,
497
+ `doubleSided`, `transparent` = alphaMode BLEND), `images` (the encoded
498
+ PNG/JPEG bytes, undecoded) and `bounds`. A .gltf's external files come
499
+ through `resolve(uri)` (uri as written, still percent-encoded;
500
+ `gltfExternalUris(bytes)` lists them so an async caller can read them
501
+ first); .glb and data: uris need none. Missing normals produce FLAT
502
+ shading (the spec's rule): the primitive is un-indexed, one vertex per
503
+ corner. A mirroring node flips the winding so `cull: "back"` still
504
+ keeps the outside. Non-triangle primitives are skipped; a required
505
+ extension the parser does not implement throws naming it, and Draco or
506
+ meshopt compression throws "re-export without mesh compression" -
507
+ Blender exports Draco by DEFAULT, so that is the first error a real
508
+ file hits.
509
+ - `createModel(data, { material?, label? })` - uploads the images (repeat
510
+ wrap, mipmapped), makes one material per glTF material (default `lit({
511
+ color, map, transparent })`; pass `material(m, map)` for anything else,
512
+ it is called once per material and shared), one mesh per part, all
513
+ children of the returned `Model` (a Group): `add(scene.root, model)`,
514
+ place it with `setTransform`, find parts by name in `model.parts`
515
+ (`{ name, mesh }`), `model.bounds` for framing a camera. `dispose()`
516
+ detaches it and frees the geometry buffers and textures - the model owns
517
+ them, nothing else frees them.
518
+ - `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
519
+ and build. `loadModel` reads the baked `.srtm` written by `srt tool
520
+ 3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
521
+ under bun, stored in the GPU layout, so loading is views onto the file's
522
+ bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
523
+ on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
524
+ 40 ms for the whole baked load - the runtime parse is fine for small
525
+ models and a binary import (`import bytes from "./x.glb" with { type:
526
+ "binary" }` then `createModel(parseGltf(bytes))`, see
527
+ `examples/model.tsx`); bake anything big.
528
+
529
+ Not in the subset, reported or dropped: `doubleSided` is reported and NOT
530
+ applied (the standard materials cull back faces); vertex colors, tangents
531
+ and further UV sets are dropped; samplers are ignored (every texture
532
+ repeats); alphaMode MASK draws opaque; emissive/additive parts of a model
533
+ draw as their base color (a model's "glow" cards come out as dark wedges).
534
+ The follow-ups are filed in okf/backlog/3d-model-loader.md.
306
535
 
307
536
  ## Traps
308
537
 
538
+ - A model's vertices are in WORLD space at parse time (node transforms
539
+ baked), so `model.bounds` and each part's geometry already include the
540
+ file's placement; the Model group starts at identity and `setTransform`
541
+ on it moves the whole thing. Parts cannot be moved relative to their
542
+ glTF parent - that is the retained-hierarchy follow-up, not a bug.
309
543
  - The y-down clip flip is baked into `perspective()`; scene code and
310
544
  geometry are plain y-up right-handed, and CCW-outward winding culls
311
545
  correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
@@ -316,17 +550,18 @@ system.
316
550
  when it is instanced - never a bare 1 into an instanced entry. Hidden
317
551
  meshes skip uModel writes; the fresh matrix is
318
552
  written on unhide. A freshly attached entry starts off the same way and
319
- sync() turns it on when it writes uModel - never add one live: it has no
320
- world matrix yet, and drawn before the sync microtask it flashes at the
321
- world origin for a frame.
553
+ the core's flush turns it on when it writes uModel - never add one
554
+ live: it has no world matrix yet, and drawn before the sync microtask it
555
+ flashes at the world origin for a frame.
322
556
  - Instancing pairs strictly at add(), like layout: an instanced material
323
557
  needs a createInstancedMesh mesh (records included) and vice versa, and
324
558
  the record stride must match the material's attributes - each mismatch
325
559
  throws there. The instance buffer is MESH-owned (unlike shared geometry
326
560
  buffers): `disposeInstances` is its one free, and the mesh cannot be
327
- re-added afterwards. Capacity is fixed at creation - `setInstances` with
328
- more records than capacity throws rather than growing (growing is a new
329
- mesh; buffers do not resize).
561
+ re-added afterwards. Capacity grows by REPLACEMENT, never resize:
562
+ `setInstances` past capacity doubles (at least to the records written)
563
+ into a new buffer and swaps it in - amortized like a dynamic array, same
564
+ policy as @solidrt/2d; size the initial records to skip the copies.
330
565
  - An instanced mesh without explicit `bounds` has no BVH leaf: it never
331
566
  picks, pointer events never target it, and its transparent sort key
332
567
  falls back to the node's world position. That is deliberate - records
@@ -427,7 +662,34 @@ system.
427
662
  target state), independent of mesh count - never reintroduce per-mesh
428
663
  camera writes (uEye-style per-mesh params are exactly the O(scene) cost
429
664
  the shared channel removed). Scene scale honestly: hundreds to a
430
- few thousand objects, bounded by the interpreter, not the GPU.
665
+ few thousand objects, bounded by the interpreter, not the GPU. A view
666
+ is one more such write per camera change and one more entry per mesh
667
+ at attach; a view's per-frame cost is the core's (one params write per
668
+ sink per moved node), never JS.
669
+ - A CASTING light's position matters (nothing else about a directional
670
+ light's position does): the shadow camera is placed AT the light node's
671
+ world position, Three's rule, so a `castShadow` sun at the origin
672
+ pointing down shadows nothing above it - give it a `position` above the
673
+ scene and a frustum (`shadow.camera`) that covers the casters. Acne
674
+ knobs are Three's: `shadow.bias` (map depth units) and
675
+ `shadow.normalBias` (world units along the receiver normal, the one to
676
+ reach for first, ~0.02); the depth pass culls FRONT faces (Three's
677
+ shadowSide default), so closed casters need little bias but a
678
+ single-sided plane casts nothing. Opting out of receiving is on the
679
+ MATERIAL here (`receiveShadow: false`), not the object (Three's
680
+ `mesh.receiveShadow`) - Godot's split, and URP's - and instanced
681
+ meshes never cast (the depth override cannot know their records) - the
682
+ additive follow-up is a per-class `shadowVertex`. Every casting light
683
+ is a full extra pass over the casters plus a sampler unit on every
684
+ receiving program (MAX_LIGHTS of those are always bound, placeholders
685
+ included), so cast from the lights that matter, not all of them.
686
+ - A mesh's entries are mirrored into every view at attach and dropped at
687
+ detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
688
+ `overrideMaterial` is validated against every mesh's layout (at
689
+ createView for the meshes present, at add() for later ones) exactly like
690
+ a mesh's own material, so an override reading `aColor` throws for a
691
+ standard-layout mesh. Views are disposed by the scene; `view.dispose()`
692
+ only for dropping one early.
431
693
  - SCENE-WIDE uniforms go through that same shared channel via
432
694
  `scene.setParams({ uTime })`, and this is the single highest-leverage
433
695
  pattern in the library. It merges an app-owned name in beside
@@ -473,18 +735,19 @@ system.
473
735
  a cache. A class instance has no `dispose` of its own; disposing the
474
736
  class invalidates every instance.
475
737
  - A parameterised class whose variants (mapped/unmapped, ...) are SEPARATE
476
- classes must have every variant reference every shared uniform it is
477
- seeded with: a declared-but-unused per-entry name compiles out and
478
- throws at add(). Open item: `okf/backlog/gpu-inactive-uniform-two-tier.md`.
738
+ classes may seed every variant with one param/texture object: a uniform
739
+ a variant declares but does not use compiles out, and the engine then
740
+ accepts the write with a warning and skips it. A name no variant
741
+ DECLARES still throws at add().
479
742
  - The standard-set contract is checked TEXTUALLY at shaderMaterial()
480
743
  creation (uModel and uViewProj must appear in the vertex source) and
481
- strictly at add() for the per-entry names: a uModel or uNormal that is
482
- declared but never USED compiles out, and the scene's entry seed then
483
- throws at attach (the engine rejects unknown entry uniform names). The
484
- shared names have no such backstop - a declared-but-unused uViewProj or
485
- uCamPos is skipped silently (shared params tolerate zero coverage), so
486
- the symptom is an untransformed or unlit render, not an error. Use what
487
- you declare.
744
+ at add() for the per-entry names: a uModel or uNormal that is declared
745
+ but never USED compiles out, and the scene's entry seed is then skipped
746
+ with an engine warning (the engine rejects only names the program never
747
+ declared). The shared names have no such backstop - a declared-but-unused
748
+ uViewProj or uCamPos is skipped silently (shared params tolerate zero
749
+ coverage), so the symptom is an untransformed or unlit render, not an
750
+ error. Use what you declare.
488
751
  - The layout scan is textual the same way: any `aColor` token in the
489
752
  vertex source - a comment counts - selects the "colored" layout, and
490
753
  the material then rejects standard geometry at add(). Do not mention
@@ -496,11 +759,11 @@ system.
496
759
  interpreter-hostile; that tier is core work (BVH descent per the
497
760
  differentiators ladder).
498
761
  - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
499
- LAYOUT frame (every ancestor transform and viewBox fit is already
762
+ LAYOUT frame (every ancestor transform and design-size fit is already
500
763
  undone by the element hit test). `handlers` therefore assumes leaf
501
764
  layout == target pixels; scaling by `getBoundingBox` would be WRONG -
502
765
  the box composes transforms, and it would double-correct the built-in
503
- leaf under a viewBox. Only a leaf whose layout size deliberately
766
+ leaf under a design size. Only a leaf whose layout size deliberately
504
767
  differs from the target (supersampling) needs `handlersFor`, fed the
505
768
  layout size the app itself set.
506
769
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating