@solidrt/3d 0.0.51 → 0.0.52

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,108 @@ 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 (each map is a pass, capped by
58
+ MAX_LIGHTS = MAX_SHADOWS): shadow slot i is directional light i's -
59
+ the map's depth id binds as the target-level `uShadowMap<i>` of the
60
+ scene and every non-shadow view (a white texel when light i does not
61
+ cast), `uShadowMatrix[i]` is its view's own view-projection (the whole
62
+ array is one write per shadow-camera move), `uShadowCast[i]` says
63
+ whether it casts, `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs;
64
+ `SHADOW_SLOTS` in glsl declares the set. Every `lit` material RECEIVES by default
65
+ (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
66
+ material out and drops the map from its program - a material option,
67
+ as with vertexColors/triplanar, because the material picks the program
68
+ (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s 3x3 PCF
69
+ on each casting light's own term. `examples/shadows.tsx` (three
70
+ casting lights) is the shape.
71
+ - RETARGETED motion is native: `setTransition(node, { position:
72
+ { duration: 400 }, ... })` makes setTransform writes TARGETS the core
73
+ animates toward every frame (position/scale per lane, rotation along
74
+ the quaternion geodesic - a spring keeps its velocity through
75
+ retargets), so a mesh gliding to a slot or a camera rig easing costs
76
+ one JS write per target change, zero per frame. The declaration lives
77
+ on the SceneNode and re-applies on every scene enter; the pose a node
78
+ enters with always snaps. Each natural settle calls the node's
79
+ `onTransitionEnd` (plain field like the pointer handlers) with
80
+ `{ component }`; the raw "spatialTransitionEnd" engine event
81
+ (srt:events, carrying the CORE node id `_node`) stays for flux:spatial
82
+ consumers.
83
+ - One interleaved vertex buffer per geometry, described by an open layout
84
+ (`Geometry.layout`, absent = "standard"): an ordered attribute list that
85
+ always starts with the standard prefix `aPos` vec3 + `aNormal` vec3 +
86
+ `aUV` vec2 (what every generator emits) and may carry any named channels
87
+ after it. `withAttribute(geometry, { name, format }, fill)` appends one
88
+ (Three's `setAttribute` for an interleave); "colored" names the common
89
+ case, the prefix plus `aColor` vec4 - the per-vertex data channel (a
90
+ tint, baked AO, any four scalars; standard name, your contents) - and
91
+ `withColors(geometry, fill)` is its spelling. Fill is a flat
92
+ size-per-vertex array or a per-vertex callback receiving `(index, pos,
93
+ normal, uv)`. Materials read attributes BY NAME: a material's vertex
94
+ stage may declare any subset of its geometry's channels, and a channel
95
+ the program reads that the geometry lacks (name + format) throws at
96
+ add(). What a program reads is the ENGINE's word (`material.attributes()`
97
+ = `programAttributes` reflection of the linked program, instance
98
+ attributes excluded), not a parse of the GLSL: an `in` the compiler
99
+ dropped does not count, and the engine also rejects a pipeline whose
100
+ attribute lists leave a read attribute uncovered. The material
101
+ keeps one program and builds one pipeline per layout its meshes bring,
102
+ so a geometry may carry more than a material reads. The whole layout
35
103
  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.
104
+ attributes only keep the stride), so extra channels cost their floats on
105
+ every draw of that geometry - keep data-light passes (a wireframe
106
+ reading only aPos) on standard geometry. `layoutStride`/`layoutSlot`/
107
+ `layoutKey`/`layoutAttributes` are the layout arithmetic; two layouts
108
+ with equal keys interleave identically (merge requires that).
39
109
  Indices are uint16 or uint32 - the `Geometry.indices` array type picks
40
110
  the draw's index format, so hand-built geometry past 64k vertices just
41
111
  uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
@@ -47,20 +117,26 @@ blendMode and pointer events like any element. Design rationale:
47
117
  (unlit color, unlit map, each opaque or transparent), `depth: true` +
48
118
  `cull: "back"`; an instance is
49
119
  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.
120
+ - The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
121
+ `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
122
+ GPU-free BY DESIGN so they can be checked headless (and, for the two
123
+ model modules, run under bun in `tools/model.ts`); keep them that way.
124
+ The rigs under `checks/`
125
+ (`geometry-check`, `sweep-check`, `pick-check`, `order-check`,
126
+ `gltf-check`) run on
127
+ flux from the repo root: `bunx srt bundle -f --stdout
128
+ packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
129
+ touching what you changed.
55
130
 
56
131
  ## Components
57
132
 
58
133
  | Component | Props |
59
134
  | --- | --- |
60
- | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
135
+ | `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
136
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
62
137
  | `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 |
138
+ | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
139
+ | `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
140
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
65
141
 
66
142
  Output composition: without `output`, `Scene` emits a minimal
@@ -79,8 +155,11 @@ Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
79
155
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
80
156
  zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
81
157
  - 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
158
+ auto-orbit. The first argument is anything with the scene's `setCamera`: a
159
+ Scene, or a View to drive one view's camera independently (one orbit per
160
+ view, each handed the handlers of its own viewport element). Input runs on
161
+ core's `createTransform` recognizer, so drag and pinch arbitrate in the
162
+ app-wide gesture arena (a viewport inside a scroller
84
163
  does not double-handle) and rotation starts after the recognizer's slop;
85
164
  `zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
86
165
  scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
@@ -115,13 +194,16 @@ Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
115
194
  through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
116
195
  world units, nearest first; every hit along the ray, not just the front
117
196
  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
197
+ under it.
198
+ The index and the narrowphase live in the spatial core: every attached
199
+ mesh's local box is a leaf in a dynamic AABB tree the flush refits from
200
+ the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
201
+ an ordinary mesh is then tested per triangle against its geometry's
202
+ shape (one CPU copy per distinct geometry, created with its GPU buffers),
203
+ so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
204
+ a ray through a knot's hole misses. An instanced mesh is box-only (its
205
+ explicit population bounds; records are opaque), so its hits have none of
206
+ the three. Both methods
125
207
  flush pending writes first (the lookAt/project immediacy contract), and
126
208
  both skip invisible meshes.
127
209
 
@@ -140,29 +222,41 @@ automatically (opt out: `events={false}`); an `output` leaf or
140
222
  imperative composition spreads `{...scene.handlers}` onto the element
141
223
  showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
142
224
  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
225
+ size, under any ancestor transforms or design-size fits (the hit test
144
226
  undoes them; localX/localY arrive in the leaf's layout frame). A leaf
145
227
  laid out at a different size (the supersampling pattern) uses
146
228
  `scene.handlersFor(() => ({ width, height }))` with its layout size.
147
229
 
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?)`
230
+ Geometry generators take ONE options object, every field optional with
231
+ a default, named as Three names them: `box({ width, height, depth })`
232
+ (1x1x1); `plane({ width, height })`, `circle({ radius, segments })` and
233
+ `ring({ innerRadius, outerRadius, segments })` (XY, facing +z - rotate
234
+ `[-Math.PI/2, 0, 0]` for a floor); `sphere({ radius, widthSegments,
235
+ heightSegments })`; `cylinder({ radiusTop, radiusBottom, height,
236
+ radialSegments })` (y axis, capped; unequal radii taper it) and
237
+ `cone({ radius, height, radialSegments })`; `torus({ radius, tube,
238
+ radialSegments, tubularSegments })` (lying flat, hole on the y axis) and
239
+ `torusKnot({ radius, tube, tubularSegments, radialSegments, p, q })`
155
240
  (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.
241
+ No positional form: `box()` is the default cube, `box({ label: "rock" })`
242
+ names it. Every options object (the profile kit's `extrude`/`lathe`/
243
+ `sweep`/`tube` too) also takes `label` and `layout` - `layout` makes the
244
+ generator emit that layout in one pass (standard channels written, the
245
+ extra slots zero), so `box({ layout: "colored" })` then
246
+ `fillColors(g, fill)` builds colored geometry without the
247
+ generate-then-repack copy; the result is byte-identical to
248
+ `withColors(box(), fill)`. `packGeometry(verts, indices, options?)` is
249
+ the tail every generator ends in, for your own generators.
250
+ `withAttribute(geometry, attr, fill, label?)` derives a copy of any
251
+ geometry (generator or hand-built) with one more channel after its
252
+ current layout; the source is untouched. `withColors(geometry, fill,
253
+ label?)` is the aColor vec4 case, keeping the "colored" preset name.
254
+ `fillAttribute(geometry, name, fill, first?, count?)` is the in-place
255
+ primitive under both: overwrites one channel the geometry's layout
256
+ already carries (withAttribute ADDS one), reading pos/normal/uv from the
257
+ buffer itself - so a builder that bakes transforms while writing hands
258
+ the baker world-space vertices. `fill` indexes relative to `first`.
259
+ `fillColors(geometry, fill, first?, count?)` is its aColor spelling.
166
260
 
167
261
  Geometry as data: `transformGeometry(geometry, { position?, rotation?,
168
262
  quaternion?, scale? }, label?)` bakes a placement into a copy (the
@@ -188,11 +282,11 @@ closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
188
282
  share an averaged normal - `fillet(points, radius, segs?)` and
189
283
  `roundRect(w?, h?, radius?, segs?)` emit those (arc corners smooth).
190
284
  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
285
+ `extrude(profile, { depth, bevel, bevelSegments })` sweeps along z,
286
+ centered, with a quarter-round bevel at both rims; `lathe(profile, {
287
+ segments, angle, start })` revolves a CLOSED (x = radius, y = height) profile about the y
194
288
  axis - watertight by construction, flat caps on partial sweeps;
195
- `sweep(profile, path)` runs the profile along an open 3D polyline with
289
+ `sweep(profile, path, options?)` runs the profile along an open 3D polyline with
196
290
  MITRED joints (each cross-section sits on its bend's bisector plane, so
197
291
  bends never gape or overlap) and flat caps at both ends. The path
198
292
  mirrors the profile convention: bare `[x, y, z]` points crease (a strap
@@ -200,10 +294,10 @@ folding over an edge), `{ p, smooth }` points shade continuous (tag a
200
294
  sampled curve's points); the profile's y starts as close to world up as
201
295
  the first segment allows, then parallel-transports without spinning.
202
296
  Closed loops are NOT supported yet - overlap the ends by a segment to
203
- fake one. `tube(path, radius?, radialSegs?)` is the round-profile
297
+ fake one. `tube(path, { radius, radialSegments })` is the round-profile
204
298
  shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
205
299
  per-segment frames (tangents, cross-section axes, arc lengths) for
206
- custom work along a path. `shape(profile)` fills one flat (facing +z,
300
+ custom work along a path. `shape(profile, options?)` fills one flat (facing +z,
207
301
  like circle); `triangulate(points)` is the ear-clipping core (fan
208
302
  fallback, never drops a cap), exported for custom flat work. These pick
209
303
  uint16/uint32 indices by vertex count automatically.
@@ -212,6 +306,17 @@ Materials:
212
306
 
213
307
  - `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
214
308
  internally.
309
+ - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
310
+ that turns to face the camera IN THE VERTEX STAGE (off the shared
311
+ uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
312
+ only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
313
+ tree/character sprite; the default `"full"` is Three's Sprite, flat to
314
+ the screen). No per-frame JS however many sprites. `transparent`
315
+ defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
316
+ off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
317
+ shared unit plane, no geometry argument, `scale` = world size, rotation
318
+ ignored. Picks by a unit box around its center (its reach at any
319
+ facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
215
320
  - `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
216
321
  depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
217
322
  custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
@@ -229,9 +334,11 @@ Materials:
229
334
  inverse-transpose, written beside uModel for this material's meshes;
230
335
  take `mat3(uNormal)` - correct under non-uniform scale, where
231
336
  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
337
+ geometry's layout by name; the ones the linked program actually reads
338
+ (engine reflection, instance attributes excluded) must all be in the
339
+ mesh's geometry layout or add() throws - so a used `in vec4 aColor`
340
+ needs `withColors()` geometry and a custom channel needs
341
+ `withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
235
342
  pipeline preamble. App-driven uniforms beyond the standard set: seed
236
343
  via `params`, then write per mesh with
237
344
  `setMeshParams(mesh, { name: value })` (validated names; values persist
@@ -258,14 +365,15 @@ Instancing - one draw entry covering a population:
258
365
  label? })` returns an ordinary Mesh whose entry draws the geometry once
259
366
  per record. `records` is the interleaved per-instance data (stride = the
260
367
  material's instanceAttributes summed, a mismatch throws), uploaded to a
261
- mesh-owned buffer whose CAPACITY is fixed at creation. `count` picks how
368
+ mesh-owned buffer whose capacity starts at the records given. `count` picks how
262
369
  many records draw (default all). Everything mesh works unchanged:
263
370
  setTransform moves the whole population through one uModel, setVisible
264
371
  zeroes the drawn count and restores the record count on unhide,
265
372
  renderOrder/params/geometry/material swaps apply. `setInstances(mesh,
266
373
  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;
374
+ records written; more than capacity GROWS: capacity doubles into a
375
+ replacement buffer, the entry is re-pointed via `setDrawBuffers`, the old
376
+ buffer is freed), `setInstanceCount(mesh, n)` is the population dial (clamped to capacity;
269
377
  frame-rate-safe), and `disposeInstances(mesh)` detaches and frees the
270
378
  record buffer - the one explicit free, geometry-buffer rule. Records are
271
379
  opaque data (position/yaw/tint/whatever your shader reads), NOT matrices:
@@ -295,17 +403,121 @@ Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
295
403
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
296
404
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
297
405
  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`
406
+ plus the colored layout's aColor forwarded raw as vColor - using it makes
407
+ the material need that channel) and the pure functions `HEMISPHERE`
300
408
  (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
301
409
  `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.
410
+ (`fresnel(n, v, power)`), and the shadow trio composed IN ORDER:
411
+ `SHADOW_SLOTS` (the scene's shadow set: `uShadowMap0..N-1`,
412
+ `uShadowMatrix[N]`, `uShadowCast[N]`, `uShadowBias[N]`,
413
+ `uShadowNormalBias[N]`, slot i = directional light i), `SHADOW`
414
+ (`shadow(map, coord, bias)` - one map's 3x3 PCF factor) and
415
+ `SHADOW_LOOKUP` (`lightShadow(i, worldPos, n)` - light i's factor, 1
416
+ when it does not cast; it hides the if-chain that picks the map, since
417
+ GLSL ES 3.00 forbids dynamic sampler indexing). A receiving fragment
418
+ multiplies light i's term by `lightShadow(i, ...)`, exactly what `lit`
419
+ composes; a non-receiving one composes none of the three and declares no
420
+ samplers. Lights, colors and exponents are arguments, so
421
+ nothing is pinned but the function names; `lit` is composed from these
422
+ same constants - customizing never means leaving the system.
423
+
424
+ Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
425
+ direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
426
+ travelling along `direction` in the node's LOCAL space (default `[0, -1,
427
+ 0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
428
+ and position/scale do not matter - deliberately a direction, not Three's
429
+ position-minus-target. `createHemisphereLight({ sky?, ground?, intensity?
430
+ })` / `<HemisphereLight>` is the ambient term, a gradient by the WORLD
431
+ normal's tilt (fixed to world up, the node's transform is ignored); one per
432
+ scene, the last attached wins. Placement goes through setTransform, the
433
+ light's own fields through `setLight(light, { ... })` (frame-rate-safe,
434
+ like setMeshParams). At most `MAX_LIGHTS` (4, exported from `/glsl`)
435
+ directional lights per scene - the fifth throws at add(); it is a
436
+ shader-source constant, fixed per app. `uLightDir` is core-driven: each
437
+ directional light's slot is a spatial-core shared-slot sink following
438
+ the node's world rotation, so a MOVING light costs no JS. The sync
439
+ rewrites the rest whenever a light attaches, detaches or changes a
440
+ field -
441
+ `uHemiSky`/`uHemiGround` (vec3, intensity folded in), `uLightCount` (int),
442
+ `uLightDir[MAX_LIGHTS]`/`uLightColor[MAX_LIGHTS]` (world-space vector
443
+ TOWARD the light, normalized; intensity folded into the color) - so a
444
+ custom fragment declaring those names reads the same list, and a light
445
+ change costs one write however many meshes. Everything starts black: a
446
+ lit scene with no light shows nothing, on purpose, like Three.
447
+
448
+ `lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
449
+ the directional list, Lambert diffuse, Blinn-Phong highlight when
450
+ `specular` (0..1 strength) is set with `shininess` (default 30), the
451
+ same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
452
+ multiply by the colored layout's aColor (so the geometry must carry it),
453
+ and `triplanar: n` to sample `map` by world position at `n` repeats per
454
+ world unit, blended across the three axis planes by the normal. Triplanar
455
+ is an OPTION, not the default: generators emit 0..1 UVs per face, so a
456
+ map on a plane is a decal (UV) while a map on generated scenery wants one
457
+ density across parts of any size (triplanar); the map must be created
458
+ with `wrap: "repeat"`. Internally one `shaderMaterialClass` per option
459
+ combination (map x vertexColors x triplanar x transparent), cached for
460
+ the app's lifetime, one pipeline per vertex layout - a thousand lit
461
+ meshes share one program. The view vector comes from the shared uCamPos;
462
+ `uTriplanar` is declared only by the triplanar classes so the other
463
+ classes do not warn about an inactive uniform.
464
+
465
+ ## Models
466
+
467
+ Authored models come in as glTF 2.0 (.gltf with its .bin and image files
468
+ next to it, or single-file .glb) and become a Group of meshes, Three's
469
+ `gltf.scene`. Three layers, use the lowest that fits:
470
+
471
+ - `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
472
+ bun and on flux): `ModelData` = `parts` (one per mesh node, its NAME
473
+ kept, vertices in the standard layout with the node's WORLD transform
474
+ baked in), `materials` (base color factor, `map` = index into `images`,
475
+ `doubleSided`, `transparent` = alphaMode BLEND), `images` (the encoded
476
+ PNG/JPEG bytes, undecoded) and `bounds`. A .gltf's external files come
477
+ through `resolve(uri)` (uri as written, still percent-encoded;
478
+ `gltfExternalUris(bytes)` lists them so an async caller can read them
479
+ first); .glb and data: uris need none. Missing normals produce FLAT
480
+ shading (the spec's rule): the primitive is un-indexed, one vertex per
481
+ corner. A mirroring node flips the winding so `cull: "back"` still
482
+ keeps the outside. Non-triangle primitives are skipped; a required
483
+ extension the parser does not implement throws naming it, and Draco or
484
+ meshopt compression throws "re-export without mesh compression" -
485
+ Blender exports Draco by DEFAULT, so that is the first error a real
486
+ file hits.
487
+ - `createModel(data, { material?, label? })` - uploads the images (repeat
488
+ wrap, mipmapped), makes one material per glTF material (default `lit({
489
+ color, map, transparent })`; pass `material(m, map)` for anything else,
490
+ it is called once per material and shared), one mesh per part, all
491
+ children of the returned `Model` (a Group): `add(scene.root, model)`,
492
+ place it with `setTransform`, find parts by name in `model.parts`
493
+ (`{ name, mesh }`), `model.bounds` for framing a camera. `dispose()`
494
+ detaches it and frees the geometry buffers and textures - the model owns
495
+ them, nothing else frees them.
496
+ - `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
497
+ and build. `loadModel` reads the baked `.srtm` written by `srt tool
498
+ 3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
499
+ under bun, stored in the GPU layout, so loading is views onto the file's
500
+ bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
501
+ on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
502
+ 40 ms for the whole baked load - the runtime parse is fine for small
503
+ models and a binary import (`import bytes from "./x.glb" with { type:
504
+ "binary" }` then `createModel(parseGltf(bytes))`, see
505
+ `examples/model.tsx`); bake anything big.
506
+
507
+ Not in the subset, reported or dropped: `doubleSided` is reported and NOT
508
+ applied (the standard materials cull back faces); vertex colors, tangents
509
+ and further UV sets are dropped; samplers are ignored (every texture
510
+ repeats); alphaMode MASK draws opaque; emissive/additive parts of a model
511
+ draw as their base color (a model's "glow" cards come out as dark wedges).
512
+ The follow-ups are filed in okf/backlog/3d-model-loader.md.
306
513
 
307
514
  ## Traps
308
515
 
516
+ - A model's vertices are in WORLD space at parse time (node transforms
517
+ baked), so `model.bounds` and each part's geometry already include the
518
+ file's placement; the Model group starts at identity and `setTransform`
519
+ on it moves the whole thing. Parts cannot be moved relative to their
520
+ glTF parent - that is the retained-hierarchy follow-up, not a bug.
309
521
  - The y-down clip flip is baked into `perspective()`; scene code and
310
522
  geometry are plain y-up right-handed, and CCW-outward winding culls
311
523
  correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
@@ -316,17 +528,18 @@ system.
316
528
  when it is instanced - never a bare 1 into an instanced entry. Hidden
317
529
  meshes skip uModel writes; the fresh matrix is
318
530
  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.
531
+ the core's flush turns it on when it writes uModel - never add one
532
+ live: it has no world matrix yet, and drawn before the sync microtask it
533
+ flashes at the world origin for a frame.
322
534
  - Instancing pairs strictly at add(), like layout: an instanced material
323
535
  needs a createInstancedMesh mesh (records included) and vice versa, and
324
536
  the record stride must match the material's attributes - each mismatch
325
537
  throws there. The instance buffer is MESH-owned (unlike shared geometry
326
538
  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).
539
+ re-added afterwards. Capacity grows by REPLACEMENT, never resize:
540
+ `setInstances` past capacity doubles (at least to the records written)
541
+ into a new buffer and swaps it in - amortized like a dynamic array, same
542
+ policy as @solidrt/2d; size the initial records to skip the copies.
330
543
  - An instanced mesh without explicit `bounds` has no BVH leaf: it never
331
544
  picks, pointer events never target it, and its transparent sort key
332
545
  falls back to the node's world position. That is deliberate - records
@@ -427,7 +640,34 @@ system.
427
640
  target state), independent of mesh count - never reintroduce per-mesh
428
641
  camera writes (uEye-style per-mesh params are exactly the O(scene) cost
429
642
  the shared channel removed). Scene scale honestly: hundreds to a
430
- few thousand objects, bounded by the interpreter, not the GPU.
643
+ few thousand objects, bounded by the interpreter, not the GPU. A view
644
+ is one more such write per camera change and one more entry per mesh
645
+ at attach; a view's per-frame cost is the core's (one params write per
646
+ sink per moved node), never JS.
647
+ - A CASTING light's position matters (nothing else about a directional
648
+ light's position does): the shadow camera is placed AT the light node's
649
+ world position, Three's rule, so a `castShadow` sun at the origin
650
+ pointing down shadows nothing above it - give it a `position` above the
651
+ scene and a frustum (`shadow.camera`) that covers the casters. Acne
652
+ knobs are Three's: `shadow.bias` (map depth units) and
653
+ `shadow.normalBias` (world units along the receiver normal, the one to
654
+ reach for first, ~0.02); the depth pass culls FRONT faces (Three's
655
+ shadowSide default), so closed casters need little bias but a
656
+ single-sided plane casts nothing. Opting out of receiving is on the
657
+ MATERIAL here (`receiveShadow: false`), not the object (Three's
658
+ `mesh.receiveShadow`) - Godot's split, and URP's - and instanced
659
+ meshes never cast (the depth override cannot know their records) - the
660
+ additive follow-up is a per-class `shadowVertex`. Every casting light
661
+ is a full extra pass over the casters plus a sampler unit on every
662
+ receiving program (MAX_LIGHTS of those are always bound, placeholders
663
+ included), so cast from the lights that matter, not all of them.
664
+ - A mesh's entries are mirrored into every view at attach and dropped at
665
+ detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
666
+ `overrideMaterial` is validated against every mesh's layout (at
667
+ createView for the meshes present, at add() for later ones) exactly like
668
+ a mesh's own material, so an override reading `aColor` throws for a
669
+ standard-layout mesh. Views are disposed by the scene; `view.dispose()`
670
+ only for dropping one early.
431
671
  - SCENE-WIDE uniforms go through that same shared channel via
432
672
  `scene.setParams({ uTime })`, and this is the single highest-leverage
433
673
  pattern in the library. It merges an app-owned name in beside
@@ -473,18 +713,19 @@ system.
473
713
  a cache. A class instance has no `dispose` of its own; disposing the
474
714
  class invalidates every instance.
475
715
  - 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`.
716
+ classes may seed every variant with one param/texture object: a uniform
717
+ a variant declares but does not use compiles out, and the engine then
718
+ accepts the write with a warning and skips it. A name no variant
719
+ DECLARES still throws at add().
479
720
  - The standard-set contract is checked TEXTUALLY at shaderMaterial()
480
721
  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.
722
+ at add() for the per-entry names: a uModel or uNormal that is declared
723
+ but never USED compiles out, and the scene's entry seed is then skipped
724
+ with an engine warning (the engine rejects only names the program never
725
+ declared). The shared names have no such backstop - a declared-but-unused
726
+ uViewProj or uCamPos is skipped silently (shared params tolerate zero
727
+ coverage), so the symptom is an untransformed or unlit render, not an
728
+ error. Use what you declare.
488
729
  - The layout scan is textual the same way: any `aColor` token in the
489
730
  vertex source - a comment counts - selects the "colored" layout, and
490
731
  the material then rejects standard geometry at add(). Do not mention
@@ -496,11 +737,11 @@ system.
496
737
  interpreter-hostile; that tier is core work (BVH descent per the
497
738
  differentiators ladder).
498
739
  - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
499
- LAYOUT frame (every ancestor transform and viewBox fit is already
740
+ LAYOUT frame (every ancestor transform and design-size fit is already
500
741
  undone by the element hit test). `handlers` therefore assumes leaf
501
742
  layout == target pixels; scaling by `getBoundingBox` would be WRONG -
502
743
  the box composes transforms, and it would double-correct the built-in
503
- leaf under a viewBox. Only a leaf whose layout size deliberately
744
+ leaf under a design size. Only a leaf whose layout size deliberately
504
745
  differs from the target (supersampling) needs `handlersFor`, fed the
505
746
  layout size the app itself set.
506
747
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
package/README.md CHANGED
@@ -77,18 +77,22 @@ blinn, fresnel, a standard vertex stage) to compose your own lit looks
77
77
  from plain template literals.
78
78
 
79
79
  v1 scope: unlit color/textured materials plus `shaderMaterial` (your own
80
- GLSL as a first-class material), geometry generators (box, plane, circle,
80
+ GLSL as a first-class material), sprites (`<Sprite>` with a `sprite()`
81
+ material: a quad facing the camera in the vertex stage, full or fixed-y
82
+ billboarding), geometry generators (box, plane, circle,
81
83
  ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
82
84
  solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
83
85
  mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
84
86
  helpers), geometry as data (`transformGeometry` bakes a placement into
85
87
  vertices and `mergeGeometries` concatenates parts, so a static scene is
86
- one mesh per material), a per-vertex data channel
87
- (`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
88
- to any geometry, for materials that read it), one perspective camera
88
+ one mesh per material), open vertex layouts (`withAttribute` appends any
89
+ named channel to a geometry's interleave, `withColors` is the `aColor`
90
+ vec4 case - tint, baked AO, any four scalars - and materials read channels
91
+ by name), one perspective camera
89
92
  with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit),
90
- mesh picking with pointer events, and scene backgrounds.
91
- Lights, transparency and model loading are
92
- staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
93
- usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
94
- [examples/](examples/).
93
+ mesh picking with pointer events, scene backgrounds, transparency, and
94
+ lights (`lit` material with hemisphere ambient, up to four directional
95
+ light nodes, Blinn-Phong highlight and triplanar mapping).
96
+ Model loading and shadows are staged next - see `okf/notes/3d-roadmap.md`
97
+ for the ranked list. Full usage notes and traps: [AGENTS.md](AGENTS.md);
98
+ runnable examples: [examples/](examples/).