@solidrt/3d 0.0.50 → 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,60 +4,139 @@ 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
42
- lazy, shared, and app-lifetime (owner-scoped free would break sharing);
43
- `disposeGeometry` frees them.
112
+ lazy, shared, and reference-counted by draw entries: removing the last
113
+ entry frees them at the end of the microtask (a same-tick rebuild keeps
114
+ the upload), so swapping `<Mesh geometry>` reactively never accumulates
115
+ old generations. `disposeGeometry` is the immediate explicit free.
44
116
  - Materials dedupe hard: one program + one pipeline per material CLASS
45
117
  (unlit color, unlit map, each opaque or transparent), `depth: true` +
46
118
  `cull: "back"`; an instance is
47
119
  just per-entry uniforms (`uColor`) and bindings (`uMap`).
48
- - The pure pieces (`math.ts`, `bvh.ts`, `order.ts`) have check rigs in
49
- `checks/`, run headless on flux from the repo root:
50
- `bunx srt bundle -f --stdout packages/3d/checks/<name>-check.ts | target/release/flux - [seed]`.
51
- They print PASS or FAIL lines and throw on failure (the flux binary exits
52
- 0 either way - read the output). 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.
53
130
 
54
131
  ## Components
55
132
 
56
133
  | Component | Props |
57
134
  | --- | --- |
58
- | `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) |
59
136
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
60
137
  | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
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 |
61
140
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
62
141
 
63
142
  Output composition: without `output`, `Scene` emits a minimal
@@ -76,8 +155,11 @@ Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
76
155
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
77
156
  zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
78
157
  - drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
79
- auto-orbit. Input runs on core's `createTransform` recognizer, so drag and
80
- 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
81
163
  does not double-handle) and rotation starts after the recognizer's slop;
82
164
  `zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
83
165
  scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
@@ -112,13 +194,16 @@ Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
112
194
  through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
113
195
  world units, nearest first; every hit along the ray, not just the front
114
196
  one). `scene.raycast(origin, direction)` is the world-space primitive
115
- under it. The volume tier: hits test each mesh's local bounding box,
116
- transformed exactly under any node transform (non-uniform scale
117
- included), so results are conservative - a ray through a knot's hole
118
- still hits (no `face`/`uv` fields until a triangle tier exists).
119
- Broadphase is a dynamic AABB tree (BVH) the sync walk keeps current from
120
- its own dirty set - maintenance is O(changed), a query O(log meshes) -
121
- 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
122
207
  flush pending writes first (the lookAt/project immediacy contract), and
123
208
  both skip invisible meshes.
124
209
 
@@ -137,40 +222,71 @@ automatically (opt out: `events={false}`); an `output` leaf or
137
222
  imperative composition spreads `{...scene.handlers}` onto the element
138
223
  showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
139
224
  the target size - true for the built-in leaf and a d-texture at natural
140
- size, under any ancestor transforms or viewBox fits (the hit test
225
+ size, under any ancestor transforms or design-size fits (the hit test
141
226
  undoes them; localX/localY arrive in the leaf's layout frame). A leaf
142
227
  laid out at a different size (the supersampling pattern) uses
143
228
  `scene.handlersFor(() => ({ width, height }))` with its layout size.
144
229
 
145
- Geometry: `box(w?, h?, d?)`; `plane(w?, h?)`, `circle(radius?, seg?)` and
146
- `ring(inner?, outer?, seg?)` (XY, facing +z - rotate `[-Math.PI/2, 0, 0]`
147
- for a floor); `sphere(radius?, wSeg?, hSeg?)`;
148
- `cylinder(rTop?, rBottom?, height?, radialSeg?)` (y axis, capped; unequal
149
- radii taper it) and `cone(radius?, height?, radialSeg?)`;
150
- `torus(radius?, tube?, radialSeg?, tubularSeg?)` (lying flat, hole on the
151
- 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 })`
152
240
  (standing y-up) - both oriented for the y-up world, unlike Three's z-up.
153
- `withColors(geometry, fill, label?)` derives a "colored"-layout copy of
154
- any standard-layout geometry (generator or hand-built), adding the
155
- `aColor` vec4 channel; the source is untouched.
156
- `fillColors(vertices, fill, first?, count?)` is the in-place primitive
157
- under it: writes the aColor slots of a colored-layout interleave you
158
- already own (a merging builder's packed buffer), reading pos/normal/uv
159
- from the buffer itself - so a packer that bakes transforms while writing
160
- hands the baker world-space vertices. `fill` indexes relative to
161
- `first`. It trusts the buffer's layout (no tag to check); withColors is
162
- 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.
260
+
261
+ Geometry as data: `transformGeometry(geometry, { position?, rotation?,
262
+ quaternion?, scale? }, label?)` bakes a placement into a copy (the
263
+ setTransform shape: Euler XYZ radians or a quaternion, number = uniform
264
+ scale), positions through the matrix and normals through its
265
+ inverse-transpose, renormalized - correct under non-uniform scale; uvs,
266
+ colors, indices and layout copy through. `mergeGeometries(parts, label?)`
267
+ concatenates parts into one geometry with offset indices (uint32 past 64k
268
+ vertices); parts must share one layout, a mixed list throws. Together
269
+ they collapse a static scene to one mesh per material - transform each
270
+ part into place, merge, draw once - so only what actually moves keeps a
271
+ node, a draw entry and a per-frame `uModel` write of its own. Both are
272
+ pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
273
+ call, and the source geometries are untouched. `geometryBounds(geometry)`
274
+ returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
275
+ `rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
276
+ slab test (entry t >= 0 in units of the direction's length, 0 from
277
+ inside, -1 for a miss) - for ray-testing boxes you keep yourself
278
+ (triggers, collision volumes) without meshes you do not want to draw.
163
279
 
164
280
  Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
165
281
  closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
166
282
  share an averaged normal - `fillet(points, radius, segs?)` and
167
283
  `roundRect(w?, h?, radius?, segs?)` emit those (arc corners smooth).
168
284
  Winding is normalized, so either authoring direction works.
169
- `extrude(profile, depth?, bevel?, bevelSegs?)` sweeps along z, centered,
170
- with a quarter-round bevel at both rims; `lathe(profile, segs?, angle?,
171
- 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
172
288
  axis - watertight by construction, flat caps on partial sweeps;
173
- `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
174
290
  MITRED joints (each cross-section sits on its bend's bisector plane, so
175
291
  bends never gape or overlap) and flat caps at both ends. The path
176
292
  mirrors the profile convention: bare `[x, y, z]` points crease (a strap
@@ -178,10 +294,10 @@ folding over an edge), `{ p, smooth }` points shade continuous (tag a
178
294
  sampled curve's points); the profile's y starts as close to world up as
179
295
  the first segment allows, then parallel-transports without spinning.
180
296
  Closed loops are NOT supported yet - overlap the ends by a segment to
181
- fake one. `tube(path, radius?, radialSegs?)` is the round-profile
297
+ fake one. `tube(path, { radius, radialSegments })` is the round-profile
182
298
  shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
183
299
  per-segment frames (tangents, cross-section axes, arc lengths) for
184
- 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,
185
301
  like circle); `triangulate(points)` is the ear-clipping core (fan
186
302
  fallback, never drops a cap), exported for custom flat work. These pick
187
303
  uint16/uint32 indices by vertex count automatically.
@@ -190,6 +306,17 @@ Materials:
190
306
 
191
307
  - `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
192
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`.
193
320
  - `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
194
321
  depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
195
322
  custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
@@ -207,9 +334,11 @@ Materials:
207
334
  inverse-transpose, written beside uModel for this material's meshes;
208
335
  take `mat3(uNormal)` - correct under non-uniform scale, where
209
336
  mat3(uModel) bends normals off the surface). Attributes come from the
210
- geometry's layout by name; a vertex stage reading `in vec4 aColor` opts
211
- the material into the "colored" layout, and its meshes then need
212
- `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
213
342
  pipeline preamble. App-driven uniforms beyond the standard set: seed
214
343
  via `params`, then write per mesh with
215
344
  `setMeshParams(mesh, { name: value })` (validated names; values persist
@@ -225,6 +354,35 @@ Materials:
225
354
  pipeline with its own values. `dispose()` lives on the class alone.
226
355
  `shaderMaterial(opts)` is exactly a class with one instance (its
227
356
  `dispose` forwards to the class).
357
+ - `instanceAttributes: [{ name, format }]` on either shader-material form
358
+ makes an INSTANCED material: the vertex stage reads them as `in`
359
+ variables beside the layout's own, and each drawn instance gets one
360
+ record of the mesh's instance buffer. Its meshes come from
361
+ `createInstancedMesh` (below); a `createMesh` mesh is rejected at add().
362
+
363
+ Instancing - one draw entry covering a population:
364
+ `createInstancedMesh(geometry, material, records, count?, { bounds?,
365
+ label? })` returns an ordinary Mesh whose entry draws the geometry once
366
+ per record. `records` is the interleaved per-instance data (stride = the
367
+ material's instanceAttributes summed, a mismatch throws), uploaded to a
368
+ mesh-owned buffer whose capacity starts at the records given. `count` picks how
369
+ many records draw (default all). Everything mesh works unchanged:
370
+ setTransform moves the whole population through one uModel, setVisible
371
+ zeroes the drawn count and restores the record count on unhide,
372
+ renderOrder/params/geometry/material swaps apply. `setInstances(mesh,
373
+ records, count?)` rewrites records from the start (count defaults to the
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;
377
+ frame-rate-safe), and `disposeInstances(mesh)` detaches and frees the
378
+ record buffer - the one explicit free, geometry-buffer rule. Records are
379
+ opaque data (position/yaw/tint/whatever your shader reads), NOT matrices:
380
+ a per-instance mat4 would be four vec4 columns reassembled in the shader,
381
+ but most fleets want a few floats. Picking: the library cannot know where
382
+ records place instances, so an instanced mesh has NO picking leaf unless
383
+ you pass `bounds` (local, covering the population) - then it picks and
384
+ transparent-sorts conservatively as one box. `examples/instanced.tsx` is
385
+ the live proof.
228
386
 
229
387
  Background: `scene.setBackground(source | null)`, the `background` option
230
388
  on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
@@ -245,28 +403,148 @@ Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
245
403
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
246
404
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
247
405
  varyings, normals via mat3(uNormal)), `LIT_VERTEX_COLORED` (the same
248
- plus the colored layout's aColor forwarded raw as vColor - using it opts
249
- 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`
250
408
  (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
251
409
  `BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
252
- (`fresnel(n, v, power)`). Lights, colors and exponents are arguments, so
253
- nothing is pinned but the function names; future lit material classes
254
- compose from these same constants - customizing never means leaving the
255
- 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.
256
513
 
257
514
  ## Traps
258
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.
259
521
  - The y-down clip flip is baked into `perspective()`; scene code and
260
522
  geometry are plain y-up right-handed, and CCW-outward winding culls
261
523
  correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
262
524
  "fix" the negated row of `perspective()` - both would mirror the winding
263
525
  and show mesh interiors.
264
526
  - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
265
- cheap off switch). Hidden meshes skip uModel writes; the fresh matrix is
527
+ cheap off switch); unhiding writes 1, or the mesh's own record count
528
+ when it is instanced - never a bare 1 into an instanced entry. Hidden
529
+ meshes skip uModel writes; the fresh matrix is
266
530
  written on unhide. A freshly attached entry starts off the same way and
267
- sync() turns it on when it writes uModel - never add one live: it has no
268
- world matrix yet, and drawn before the sync microtask it flashes at the
269
- 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.
534
+ - Instancing pairs strictly at add(), like layout: an instanced material
535
+ needs a createInstancedMesh mesh (records included) and vice versa, and
536
+ the record stride must match the material's attributes - each mismatch
537
+ throws there. The instance buffer is MESH-owned (unlike shared geometry
538
+ buffers): `disposeInstances` is its one free, and the mesh cannot be
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.
543
+ - An instanced mesh without explicit `bounds` has no BVH leaf: it never
544
+ picks, pointer events never target it, and its transparent sort key
545
+ falls back to the node's world position. That is deliberate - records
546
+ are opaque to the library, so any inferred box would be a guess. Supply
547
+ `bounds` for anything pickable or transparent.
270
548
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
271
549
  [r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
272
550
  (or `shaderMaterial({ transparent: true })`) builds the pipeline with
@@ -362,7 +640,34 @@ system.
362
640
  target state), independent of mesh count - never reintroduce per-mesh
363
641
  camera writes (uEye-style per-mesh params are exactly the O(scene) cost
364
642
  the shared channel removed). Scene scale honestly: hundreds to a
365
- 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.
366
671
  - SCENE-WIDE uniforms go through that same shared channel via
367
672
  `scene.setParams({ uTime })`, and this is the single highest-leverage
368
673
  pattern in the library. It merges an app-owned name in beside
@@ -408,18 +713,19 @@ system.
408
713
  a cache. A class instance has no `dispose` of its own; disposing the
409
714
  class invalidates every instance.
410
715
  - A parameterised class whose variants (mapped/unmapped, ...) are SEPARATE
411
- classes must have every variant reference every shared uniform it is
412
- seeded with: a declared-but-unused per-entry name compiles out and
413
- 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().
414
720
  - The standard-set contract is checked TEXTUALLY at shaderMaterial()
415
721
  creation (uModel and uViewProj must appear in the vertex source) and
416
- strictly at add() for the per-entry names: a uModel or uNormal that is
417
- declared but never USED compiles out, and the scene's entry seed then
418
- throws at attach (the engine rejects unknown entry uniform names). The
419
- shared names have no such backstop - a declared-but-unused uViewProj or
420
- uCamPos is skipped silently (shared params tolerate zero coverage), so
421
- the symptom is an untransformed or unlit render, not an error. Use what
422
- 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.
423
729
  - The layout scan is textual the same way: any `aColor` token in the
424
730
  vertex source - a comment counts - selects the "colored" layout, and
425
731
  the material then rejects standard geometry at add(). Do not mention
@@ -431,11 +737,11 @@ system.
431
737
  interpreter-hostile; that tier is core work (BVH descent per the
432
738
  differentiators ladder).
433
739
  - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
434
- LAYOUT frame (every ancestor transform and viewBox fit is already
740
+ LAYOUT frame (every ancestor transform and design-size fit is already
435
741
  undone by the element hit test). `handlers` therefore assumes leaf
436
742
  layout == target pixels; scaling by `getBoundingBox` would be WRONG -
437
743
  the box composes transforms, and it would double-correct the built-in
438
- leaf under a viewBox. Only a leaf whose layout size deliberately
744
+ leaf under a design size. Only a leaf whose layout size deliberately
439
745
  differs from the target (supersampling) needs `handlersFor`, fed the
440
746
  layout size the app itself set.
441
747
  - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating