@solidrt/3d 0.0.49 → 0.0.51

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
@@ -11,7 +11,7 @@ blendMode and pointer events like any element. Design rationale:
11
11
 
12
12
  - Two layers. The imperative core is Solid-free: `createScene`,
13
13
  `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
14
- `lookAt`, `getRotation`, `setVisible` - plain objects with dirty flags, batched to a
14
+ `lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects with dirty flags, batched to a
15
15
  microtask,
16
16
  one `setDrawParams` (uModel, plus uNormal for materials declaring it)
17
17
  per changed mesh and ONE `setTargetParams` (the shared uViewProj +
@@ -39,11 +39,19 @@ blendMode and pointer events like any element. Design rationale:
39
39
  Indices are uint16 or uint32 - the `Geometry.indices` array type picks
40
40
  the draw's index format, so hand-built geometry past 64k vertices just
41
41
  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.
42
+ lazy, shared, and reference-counted by draw entries: removing the last
43
+ entry frees them at the end of the microtask (a same-tick rebuild keeps
44
+ the upload), so swapping `<Mesh geometry>` reactively never accumulates
45
+ old generations. `disposeGeometry` is the immediate explicit free.
44
46
  - Materials dedupe hard: one program + one pipeline per material CLASS
45
- (unlit color, unlit map), `depth: true` + `cull: "back"`; an instance is
47
+ (unlit color, unlit map, each opaque or transparent), `depth: true` +
48
+ `cull: "back"`; an instance is
46
49
  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.
47
55
 
48
56
  ## Components
49
57
 
@@ -52,6 +60,7 @@ blendMode and pointer events like any element. Design rationale:
52
60
  | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
53
61
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
54
62
  | `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 |
55
64
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
56
65
 
57
66
  Output composition: without `output`, `Scene` emits a minimal
@@ -155,6 +164,25 @@ hands the baker world-space vertices. `fill` indexes relative to
155
164
  `first`. It trusts the buffer's layout (no tag to check); withColors is
156
165
  the checked path.
157
166
 
167
+ Geometry as data: `transformGeometry(geometry, { position?, rotation?,
168
+ quaternion?, scale? }, label?)` bakes a placement into a copy (the
169
+ setTransform shape: Euler XYZ radians or a quaternion, number = uniform
170
+ scale), positions through the matrix and normals through its
171
+ inverse-transpose, renormalized - correct under non-uniform scale; uvs,
172
+ colors, indices and layout copy through. `mergeGeometries(parts, label?)`
173
+ concatenates parts into one geometry with offset indices (uint32 past 64k
174
+ vertices); parts must share one layout, a mixed list throws. Together
175
+ they collapse a static scene to one mesh per material - transform each
176
+ part into place, merge, draw once - so only what actually moves keeps a
177
+ node, a draw entry and a per-frame `uModel` write of its own. Both are
178
+ pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
179
+ call, and the source geometries are untouched. `geometryBounds(geometry)`
180
+ returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
181
+ `rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
182
+ slab test (entry t >= 0 in units of the direction's length, 0 from
183
+ inside, -1 for a miss) - for ray-testing boxes you keep yourself
184
+ (triggers, collision volumes) without meshes you do not want to draw.
185
+
158
186
  Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
159
187
  closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
160
188
  share an averaged normal - `fillet(points, radius, segs?)` and
@@ -194,7 +222,10 @@ Materials:
194
222
  at shaderMaterial() creation. The rest is opt-in by declare-and-use:
195
223
  `uniform vec3 uCamPos` (the camera's world position, shared and written
196
224
  with uViewProj - the specular/fresnel view vector is
197
- `normalize(uCamPos - worldPos)`) and `uniform mat4 uNormal` (the world
225
+ `normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
226
+ (the camera's world-space view axes, shared likewise - a billboard is
227
+ `center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
228
+ uViewProj rows, that carries the clip flip) and `uniform mat4 uNormal` (the world
198
229
  inverse-transpose, written beside uModel for this material's meshes;
199
230
  take `mat3(uNormal)` - correct under non-uniform scale, where
200
231
  mat3(uModel) bends normals off the surface). Attributes come from the
@@ -208,6 +239,42 @@ Materials:
208
239
  with the `Mesh` `params` prop (same merge semantics - a key that
209
240
  disappears from the object keeps its old value; for per-frame values
210
241
  prefer `ref` + setMeshParams from onFrame, the setTransform split).
242
+ Scene-wide values (a clock, a sun direction, fog) go through
243
+ `scene.setParams({ uTime })` instead - one write for every mesh.
244
+ - `shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
245
+ class/instance split for your own GLSL: compiles once, and
246
+ `cls.instance({ params?, textures? })` returns a Material sharing that
247
+ pipeline with its own values. `dispose()` lives on the class alone.
248
+ `shaderMaterial(opts)` is exactly a class with one instance (its
249
+ `dispose` forwards to the class).
250
+ - `instanceAttributes: [{ name, format }]` on either shader-material form
251
+ makes an INSTANCED material: the vertex stage reads them as `in`
252
+ variables beside the layout's own, and each drawn instance gets one
253
+ record of the mesh's instance buffer. Its meshes come from
254
+ `createInstancedMesh` (below); a `createMesh` mesh is rejected at add().
255
+
256
+ Instancing - one draw entry covering a population:
257
+ `createInstancedMesh(geometry, material, records, count?, { bounds?,
258
+ label? })` returns an ordinary Mesh whose entry draws the geometry once
259
+ per record. `records` is the interleaved per-instance data (stride = the
260
+ material's instanceAttributes summed, a mismatch throws), uploaded to a
261
+ mesh-owned buffer whose CAPACITY is fixed at creation. `count` picks how
262
+ many records draw (default all). Everything mesh works unchanged:
263
+ setTransform moves the whole population through one uModel, setVisible
264
+ zeroes the drawn count and restores the record count on unhide,
265
+ renderOrder/params/geometry/material swaps apply. `setInstances(mesh,
266
+ 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;
269
+ frame-rate-safe), and `disposeInstances(mesh)` detaches and frees the
270
+ record buffer - the one explicit free, geometry-buffer rule. Records are
271
+ opaque data (position/yaw/tint/whatever your shader reads), NOT matrices:
272
+ a per-instance mat4 would be four vec4 columns reassembled in the shader,
273
+ but most fleets want a few floats. Picking: the library cannot know where
274
+ records place instances, so an instanced mesh has NO picking leaf unless
275
+ you pass `bounds` (local, covering the population) - then it picks and
276
+ transparent-sorts conservatively as one box. `examples/instanced.tsx` is
277
+ the live proof.
211
278
 
212
279
  Background: `scene.setBackground(source | null)`, the `background` option
213
280
  on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
@@ -245,14 +312,46 @@ system.
245
312
  "fix" the negated row of `perspective()` - both would mirror the winding
246
313
  and show mesh interiors.
247
314
  - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
248
- cheap off switch). Hidden meshes skip uModel writes; the fresh matrix is
315
+ cheap off switch); unhiding writes 1, or the mesh's own record count
316
+ when it is instanced - never a bare 1 into an instanced entry. Hidden
317
+ meshes skip uModel writes; the fresh matrix is
249
318
  written on unhide. A freshly attached entry starts off the same way and
250
319
  sync() turns it on when it writes uModel - never add one live: it has no
251
320
  world matrix yet, and drawn before the sync microtask it flashes at the
252
321
  world origin for a frame.
253
- - Alpha does not blend in v1: pipelines are opaque (`blend: "none"`), a
254
- translucent color overwrites. Transparency waits on blend factors +
255
- sorting (research note, staging step 4).
322
+ - Instancing pairs strictly at add(), like layout: an instanced material
323
+ needs a createInstancedMesh mesh (records included) and vice versa, and
324
+ the record stride must match the material's attributes - each mismatch
325
+ throws there. The instance buffer is MESH-owned (unlike shared geometry
326
+ 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).
330
+ - An instanced mesh without explicit `bounds` has no BVH leaf: it never
331
+ picks, pointer events never target it, and its transparent sort key
332
+ falls back to the node's world position. That is deliberate - records
333
+ are opaque to the library, so any inferred box would be a guess. Supply
334
+ `bounds` for anything pickable or transparent.
335
+ - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
336
+ [r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
337
+ (or `shaderMaterial({ transparent: true })`) builds the pipeline with
338
+ `blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
339
+ behind opaques without occluding other translucents). The one inference:
340
+ a `shaderMaterial` with any `blend` but "none" is transparent unless told
341
+ `transparent: false` - every blended draw belongs after the opaques, and
342
+ back-to-front is harmless for add/multiply. The scene owns the
343
+ order: background, opaque meshes by `renderOrder` then add order,
344
+ transparent meshes by `renderOrder` then back-to-front by the CENTER of
345
+ the mesh's world bounds in view space (not the origin: off-origin geometry
346
+ sorts by where it is; not the nearest bounds point: a big translucent
347
+ ground plane would cover the small translucents on it) - one `setDrawOrder` from sync() whenever the list changed, a
348
+ renderOrder changed, or (with two or more transparent meshes) the camera
349
+ or a transparent mesh moved, and skipped when the resort lands on the
350
+ permutation already issued. Per-mesh sort only: one non-convex translucent
351
+ mesh still overlaps itself in vertex order, and two large interpenetrating
352
+ translucents can sort wrong (center distance, not per-pixel) - that is the
353
+ engine contract, no OIT. A `shaderMaterial({ transparent: true })`
354
+ fragment must write PREMULTIPLIED output (`vec4(rgb * a, a)`).
256
355
  - Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
257
356
  always unit). There is exactly one rotation field: no `node.rotation`
258
357
  shadowing it, because a second field is a second thing to go stale (an
@@ -329,9 +428,36 @@ system.
329
428
  camera writes (uEye-style per-mesh params are exactly the O(scene) cost
330
429
  the shared channel removed). Scene scale honestly: hundreds to a
331
430
  few thousand objects, bounded by the interpreter, not the GPU.
431
+ - SCENE-WIDE uniforms go through that same shared channel via
432
+ `scene.setParams({ uTime })`, and this is the single highest-leverage
433
+ pattern in the library. It merges an app-owned name in beside
434
+ uViewProj/uCamPos/uCamRight/uCamUp - names merge, a target tolerates
435
+ zero coverage, neither side clobbers the other. One write per frame
436
+ however many meshes read it, with the motion itself in vertex shaders
437
+ off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
438
+ is O(meshes) per frame; reach for it only when the value genuinely
439
+ differs per mesh. (`scene.texture` IS the draw target id, so
440
+ `setTargetParams(scene.texture, ...)` is the same write - setParams is
441
+ the sanctioned spelling.)
442
+ - Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
443
+ `setCamera`, params), so ONE scratch array reused every frame is safe -
444
+ allocating three arrays per node per frame is pure waste. The node's own
445
+ `position`/`quaternion`/`scale` are the live arrays: read them, do not
446
+ hand them out and do not mutate them (that write does not sync).
447
+ - `setTransform` early-outs on an unchanged value (rotation compared AFTER
448
+ euler conversion), so driving every node unconditionally from `onFrame`
449
+ costs only the compare for nodes that did not move. Compares are exact,
450
+ like `setVisible`.
451
+ - Per-generator conventions - orientation, UV mapping, which axis a solid
452
+ stands on, what a cap looks like - live on each generator's doc comment,
453
+ not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
454
+ lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
455
+ map inscribed in the unit square) but the doc comment is the source.
332
456
  - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
333
- list END. Irrelevant while everything is opaque + depth-tested; revisit
334
- when transparency lands.
457
+ list END and dirty the order, so the next sync() re-sorts and the mesh
458
+ keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
459
+ (setMaterial swaps `mesh.material` before the rebuild, so _detach must
460
+ not read the new material's flag).
335
461
  - `lathe` takes a CLOSED profile (a cross-section with thickness, or run
336
462
  to the axis at x = 0) - it is a solid of revolution, NOT Three's open
337
463
  polyline shell. An "open" outline must be closed by the author;
@@ -342,7 +468,14 @@ system.
342
468
  compile twice - no dedupe by source value (deliberate; hidden
343
469
  content-keyed caches are the anti-pattern the GPU layer avoids). Create
344
470
  one per look at app scope, share across meshes, `dispose()` when done
345
- for good.
471
+ for good. Looks that differ only in params/textures are ONE
472
+ `shaderMaterialClass` and many `instance()`s - the app-owned split, not
473
+ a cache. A class instance has no `dispose` of its own; disposing the
474
+ class invalidates every instance.
475
+ - 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`.
346
479
  - The standard-set contract is checked TEXTUALLY at shaderMaterial()
347
480
  creation (uModel and uViewProj must appear in the vertex source) and
348
481
  strictly at add() for the per-entry names: a uModel or uNormal that is
@@ -380,9 +513,8 @@ system.
380
513
  Geometry instead.
381
514
  - The background covers the whole target with depth off, drawn first: it
382
515
  REPLACES the clearColor visually (the clear still runs; you just never
383
- see it), and a translucent mesh does not blend over it in-pass (blend
384
- is none|add today - the fade-over-backdrop look still needs the
385
- two-layer composition until blend factors land).
516
+ see it), and a `transparent: true` mesh blends over it in-pass since the
517
+ background is always entry zero.
386
518
  - The background pipeline/program are SCENE-OWNED (unlike shared
387
519
  material pipelines): setBackground(null), replacement, and dispose()
388
520
  destroy them. Do not hand the background's pipeline to anything else.
package/README.md CHANGED
@@ -62,9 +62,16 @@ bounding-box accurate in v1. A scene also takes a `background` - fragment
62
62
  GLSL drawn inside its own pass behind the meshes, replacing the stacked
63
63
  backdrop-texture pattern.
64
64
  Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
65
- shared `uViewProj`/`uCamPos`, each written once per change - plus your own
66
- uniforms per mesh, declaratively via the `params` prop on `<Mesh>` or
67
- imperatively via `setMeshParams` - and
65
+ shared `uViewProj`/`uCamPos`/`uCamRight`/`uCamUp`, each written once per
66
+ change - plus your own uniforms: scene-wide via `scene.setParams` (one write
67
+ however many meshes read it), or per mesh, declaratively via the `params`
68
+ prop on `<Mesh>` or imperatively via `setMeshParams`. `shaderMaterialClass`
69
+ compiles one program and hands out `instance()` materials that differ only
70
+ in params/textures - and with `instanceAttributes` it makes an instanced
71
+ material: `<InstancedMesh records>` (or `createInstancedMesh`) then draws
72
+ the geometry once per interleaved record as ONE draw entry, the shape for
73
+ forests, particles, and every fleet whose per-copy data is a few floats
74
+ (`examples/instanced.tsx`). And
68
75
  `@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
69
76
  blinn, fresnel, a standard vertex stage) to compose your own lit looks
70
77
  from plain template literals.
@@ -74,7 +81,9 @@ GLSL as a first-class material), geometry generators (box, plane, circle,
74
81
  ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
75
82
  solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
76
83
  mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
77
- helpers), a per-vertex data channel
84
+ helpers), geometry as data (`transformGeometry` bakes a placement into
85
+ vertices and `mergeGeometries` concatenates parts, so a static scene is
86
+ one mesh per material), a per-vertex data channel
78
87
  (`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
79
88
  to any geometry, for materials that read it), one perspective camera
80
89
  with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit),
@@ -23,3 +23,8 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
23
23
  scene's own pass (`<Scene background>`): one target, no stacked
24
24
  backdrop texture, no resize plumbing; the source is shader-target
25
25
  compatible verbatim.
26
+ - `instanced.tsx` - instanced meshes: one material class declaring
27
+ `instanceAttributes`, two `<InstancedMesh>` fleets (400 scattered
28
+ rocks, a ring of pines) each ONE draw entry and ONE uModel, a spinning
29
+ group moving both with two matrix writes, and `setInstanceCount` from
30
+ onFrame breathing the pine population.
@@ -0,0 +1,158 @@
1
+ // Instanced meshes: one draw entry covering a whole population. A material
2
+ // class declares `instanceAttributes`, createInstancedMesh (here via the
3
+ // <InstancedMesh> component) supplies one interleaved record per instance,
4
+ // and the vertex stage reads each record through the matching `in`
5
+ // variables. Two meshes share the one class: 400 scattered "rocks" and a
6
+ // breathing ring of "pines" - each is ONE entry and ONE uModel however
7
+ // many instances it draws, so the spinning group below moves both fleets
8
+ // with two matrix writes per frame.
9
+ //
10
+ // setInstanceCount is the population dial (the pines breathe); records are
11
+ // data, not matrices - position/scale/tint here, whatever your shader
12
+ // wants in general. The explicit `bounds` cover the scatter so picking
13
+ // still works (one conservative box around the population; omit bounds and
14
+ // the mesh simply never picks).
15
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
16
+ import { glsl } from "@solidrt/core/gpu"
17
+ import {
18
+ box,
19
+ cone,
20
+ Group,
21
+ InstancedMesh,
22
+ Mesh,
23
+ PerspectiveCamera,
24
+ plane,
25
+ Scene,
26
+ setInstanceCount,
27
+ shaderMaterialClass,
28
+ unlit,
29
+ } from "@solidrt/3d"
30
+ import type { InstancedMeshNode } from "@solidrt/3d"
31
+ import { HEMISPHERE } from "@solidrt/3d/glsl"
32
+
33
+ const SIZE = 720
34
+
35
+ const INSTANCE_VERTEX = glsl`
36
+ in vec3 aPos;
37
+ in vec3 aNormal;
38
+ in vec3 iPos;
39
+ in float iScale;
40
+ in vec3 iTint;
41
+ out vec3 vNormal;
42
+ out vec3 vTint;
43
+ uniform mat4 uModel;
44
+ uniform mat4 uViewProj;
45
+
46
+ void main() {
47
+ vec3 p = aPos * iScale + iPos;
48
+ gl_Position = uViewProj * uModel * vec4(p, 1.0);
49
+ vNormal = mat3(uModel) * aNormal;
50
+ vTint = iTint;
51
+ }
52
+ `
53
+
54
+ const INSTANCE_FRAGMENT = glsl`
55
+ in vec3 vNormal;
56
+ in vec3 vTint;
57
+ ${HEMISPHERE}
58
+
59
+ void main() {
60
+ vec3 c = vTint * hemisphere(normalize(vNormal), vec3(1.05, 1.0, 0.95), vec3(0.35, 0.32, 0.3));
61
+ fragColor = vec4(c, 1.0);
62
+ }
63
+ `
64
+
65
+ // One record per instance, interleaved in attribute order: 7 floats.
66
+ const STRIDE = 7
67
+
68
+ // A deterministic scatter (no per-run surprises when eyeballing).
69
+ function rocks(count: number): Float32Array {
70
+ let records = new Float32Array(count * STRIDE)
71
+ let a = 0
72
+ for (let i = 0; i < count; i++) {
73
+ a += 2.399963 // golden angle: an even spiral scatter
74
+ let r = 0.35 + 3.4 * Math.sqrt((i + 0.5) / count)
75
+ let s = 0.05 + 0.11 * ((i * 7) % 10) / 10
76
+ let o = i * STRIDE
77
+ records[o] = Math.cos(a) * r
78
+ records[o + 1] = s / 2
79
+ records[o + 2] = Math.sin(a) * r
80
+ records[o + 3] = s
81
+ records[o + 4] = 0.55 + 0.3 * ((i * 3) % 5) / 5
82
+ records[o + 5] = 0.5 + 0.2 * ((i * 11) % 7) / 7
83
+ records[o + 6] = 0.45
84
+ }
85
+ return records
86
+ }
87
+
88
+ function pines(count: number): Float32Array {
89
+ let records = new Float32Array(count * STRIDE)
90
+ for (let i = 0; i < count; i++) {
91
+ let a = (i / count) * Math.PI * 2
92
+ let s = 0.5 + 0.25 * ((i * 5) % 8) / 8
93
+ let o = i * STRIDE
94
+ records[o] = Math.cos(a) * 2.4
95
+ records[o + 1] = s / 2
96
+ records[o + 2] = Math.sin(a) * 2.4
97
+ records[o + 3] = s
98
+ records[o + 4] = 0.15
99
+ records[o + 5] = 0.4 + 0.25 * ((i * 3) % 6) / 6
100
+ records[o + 6] = 0.2
101
+ }
102
+ return records
103
+ }
104
+
105
+ const PINE_COUNT = 48
106
+
107
+ function App() {
108
+ let [spin, setSpin] = createSignal(0)
109
+ let pinesMesh!: InstancedMeshNode
110
+ onFrame(tick => {
111
+ setSpin(tick / 6000)
112
+ // The population dial: draw the first N records. The buffer holds the
113
+ // full ring; only the draw range moves, one setDrawRange per change.
114
+ let n = Math.round(PINE_COUNT * (0.5 + 0.5 * Math.sin(tick / 900)))
115
+ if (pinesMesh) setInstanceCount(pinesMesh, n)
116
+ })
117
+
118
+ return (
119
+ <window>
120
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
121
+ <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.08, 0.1, 1]} label="instanced">
122
+ <PerspectiveCamera fov={55} position={[0, 3.2, 5.4]} lookAt={[0, 0.2, 0]} />
123
+ <Mesh geometry={plane(9, 9, "meadow")} material={unlit({ color: [0.16, 0.18, 0.16] })} rotation={[-Math.PI / 2, 0, 0]} />
124
+ <Group rotation={[0, spin(), 0]}>
125
+ <InstancedMesh
126
+ geometry={box(1, 1, 1, "rock")}
127
+ material={instancedLook.instance()}
128
+ records={rocks(400)}
129
+ bounds={[-3.9, 0, -3.9, 3.9, 0.2, 3.9]}
130
+ />
131
+ <InstancedMesh
132
+ geometry={cone(0.3, 1, 10, "pine")}
133
+ material={instancedLook.instance()}
134
+ records={pines(PINE_COUNT)}
135
+ bounds={[-2.8, 0, -2.8, 2.8, 0.8, 2.8]}
136
+ ref={m => (pinesMesh = m)}
137
+ />
138
+ </Group>
139
+ </Scene>
140
+ </view>
141
+ </window>
142
+ )
143
+ }
144
+
145
+ // One class, one compiled pipeline; each mesh gets its own instance() so
146
+ // per-mesh uniforms stay independent (none are used here).
147
+ let instancedLook = shaderMaterialClass({
148
+ vertex: INSTANCE_VERTEX,
149
+ fragment: INSTANCE_FRAGMENT,
150
+ instanceAttributes: [
151
+ { name: "iPos", format: "vec3" },
152
+ { name: "iScale", format: "f32" },
153
+ { name: "iTint", format: "vec3" },
154
+ ],
155
+ label: "instanced-look",
156
+ })
157
+
158
+ render(() => <App />)
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "license": "MIT",
5
+ "funding": "https://github.com/sponsors/wellawaretech",
5
6
  "author": "Antoine van Wel",
6
7
  "type": "module",
7
8
  "main": "src/index.ts",
@@ -17,6 +18,6 @@
17
18
  ],
18
19
  "peerDependencies": {
19
20
  "@solidjs/signals": "2.0.0-rc.0",
20
- "@solidrt/core": "0.0.49"
21
+ "@solidrt/core": "0.0.51"
21
22
  }
22
23
  }
package/src/bvh.ts CHANGED
@@ -12,8 +12,8 @@
12
12
  // pays nothing. Storage is flat parallel arrays indexed by node id (no
13
13
  // per-node objects, no allocation at steady state past tree growth).
14
14
  //
15
- // Pure module by design: no engine imports, so the differential check rig
16
- // (checks/pick-check.ts) runs it under plain bun against a linear oracle.
15
+ // Pure module by design: no GUI imports, so the differential check rig
16
+ // (checks/pick-check.ts) runs it headless on flux against a linear oracle.
17
17
 
18
18
  /** Fat-margin fraction of a leaf's largest extent. Bigger = fewer
19
19
  * re-inserts while moving, worse query pruning; 5% is the usual trade. */
@@ -11,17 +11,22 @@ import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidr
11
11
  import {
12
12
  add,
13
13
  createGroup,
14
+ createInstancedMesh,
14
15
  createMesh,
15
16
  createScene,
17
+ disposeInstances,
16
18
  remove,
17
19
  setGeometry,
20
+ setInstanceCount,
21
+ setInstances,
18
22
  setMaterial,
19
23
  setMeshParams,
24
+ setRenderOrder,
20
25
  setTransform,
21
26
  setVisible,
22
27
  } from "./scene.ts"
23
28
  import type { ShaderParams } from "@solidrt/core/gpu"
24
- import type { Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent } from "./scene.ts"
29
+ import type { InstancedMesh as InstancedMeshNode, Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent } from "./scene.ts"
25
30
  import type { Geometry } from "./geometry.ts"
26
31
  import type { Material } from "./material.ts"
27
32
  import type { Quat, Vec3 } from "./math.ts"
@@ -179,6 +184,8 @@ export type MeshProps = TransformProps & PointerEventProps & {
179
184
  * changing every frame prefer `ref` + setMeshParams from onFrame, the
180
185
  * same split as setTransform. */
181
186
  params?: ShaderParams
187
+ /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
188
+ renderOrder?: number
182
189
  ref?: (mesh: MeshNode) => void
183
190
  }
184
191
 
@@ -203,12 +210,84 @@ export let Mesh: VoidComponent<MeshProps> = props => {
203
210
  if (p !== undefined) setMeshParams(mesh, p)
204
211
  },
205
212
  )
213
+ createEffect(
214
+ () => props.renderOrder,
215
+ o => setRenderOrder(mesh, o ?? 0),
216
+ )
206
217
  syncNode(mesh, props)
207
218
  untrack(() => props.ref)?.(mesh)
208
219
  onCleanup(() => remove(mesh))
209
220
  return null
210
221
  }
211
222
 
223
+ export type InstancedMeshProps = TransformProps & PointerEventProps & {
224
+ geometry: Geometry
225
+ /** Must declare instanceAttributes (shaderMaterialClass). */
226
+ material: Material
227
+ /** Interleaved per-instance records (stride = the material's instance
228
+ * attributes summed). Reactive, but the buffer's CAPACITY is fixed by the
229
+ * first value - a later array may hold at most that many records. */
230
+ records: Float32Array
231
+ /** How many records draw; default all of the latest `records`. */
232
+ count?: number
233
+ /** LOCAL bounds covering every instance ([minX..maxZ]), fixed at
234
+ * creation. Without them the mesh has no picking leaf, so pointer events
235
+ * never target it. */
236
+ bounds?: ArrayLike<number>
237
+ /** Per-mesh uniforms, merge semantics - as on Mesh. */
238
+ params?: ShaderParams
239
+ /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
240
+ renderOrder?: number
241
+ ref?: (mesh: InstancedMeshNode) => void
242
+ }
243
+
244
+ /** One draw entry covering N instances: geometry repeated per record of
245
+ * `records` (createInstancedMesh as a component). The record buffer is
246
+ * component-owned and freed on unmount. */
247
+ export let InstancedMesh: VoidComponent<InstancedMeshProps> = props => {
248
+ let ctx = useContext(SceneContext)
249
+ let mesh = untrack(() =>
250
+ createInstancedMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
251
+ )
252
+ add(ctx.parent, mesh)
253
+ createEffect(
254
+ () => props.records,
255
+ r => setInstances(mesh, r, untrack(() => props.count)),
256
+ { defer: true },
257
+ )
258
+ createEffect(
259
+ () => props.count,
260
+ c => {
261
+ if (c !== undefined) setInstanceCount(mesh, c)
262
+ },
263
+ { defer: true },
264
+ )
265
+ createEffect(
266
+ () => props.geometry,
267
+ g => setGeometry(mesh, g),
268
+ { defer: true },
269
+ )
270
+ createEffect(
271
+ () => props.material,
272
+ m => setMaterial(mesh, m),
273
+ { defer: true },
274
+ )
275
+ createEffect(
276
+ () => props.params,
277
+ p => {
278
+ if (p !== undefined) setMeshParams(mesh, p)
279
+ },
280
+ )
281
+ createEffect(
282
+ () => props.renderOrder,
283
+ o => setRenderOrder(mesh, o ?? 0),
284
+ )
285
+ syncNode(mesh, props)
286
+ untrack(() => props.ref)?.(mesh)
287
+ onCleanup(() => disposeInstances(mesh))
288
+ return null
289
+ }
290
+
212
291
  export type PerspectiveCameraProps = {
213
292
  /** Vertical field of view in DEGREES (default 60). */
214
293
  fov?: number
@@ -0,0 +1,86 @@
1
+ // Geometry on the GPU: the lazy buffer step for geometry.ts's data. Buffers
2
+ // are created on first acquire and shared by every mesh and scene drawing
3
+ // the geometry; each draw entry holds one reference, and the buffers are
4
+ // freed when the last reference is released - deferred to a microtask, so
5
+ // a same-tick entry rebuild (a material swap, a geometry that comes right
6
+ // back) keeps its upload. The handles and the reference count live in a
7
+ // map private to this module, keeping Geometry itself plain data.
8
+ // disposeGeometry frees immediately, the explicit override; either way the
9
+ // geometry stays usable - fresh buffers are created on next acquire.
10
+
11
+ import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
12
+ import type { BufferId, IndexFormat } from "@solidrt/core/gpu"
13
+ import type { Geometry } from "./geometry.ts"
14
+
15
+ /** An acquired reference to a geometry's GPU buffers: what a draw entry
16
+ * binds, and the token releaseGeometryBuffers takes - releasing the exact
17
+ * acquisition keeps the pairing correct however the caller's geometry
18
+ * fields have moved since. */
19
+ export type GeometryBuffers = {
20
+ buffer: BufferId
21
+ index: BufferId
22
+ indexFormat: IndexFormat
23
+ }
24
+
25
+ type GpuEntry = GeometryBuffers & { geometry: Geometry; refs: number }
26
+
27
+ let entries = new WeakMap<Geometry, GpuEntry>()
28
+
29
+ /** The geometry's GPU buffers, created on first use, plus the index format
30
+ * the draw entry must bind them with. Takes a reference - pair every
31
+ * acquire with a releaseGeometryBuffers of the returned token when the
32
+ * entry built from it goes. */
33
+ export function acquireGeometryBuffers(geometry: Geometry): GeometryBuffers {
34
+ let entry = entries.get(geometry)
35
+ if (entry === undefined) {
36
+ entry = {
37
+ geometry,
38
+ buffer: createBuffer(geometry.vertices, {
39
+ autoFree: false,
40
+ label: geometry.label ? geometry.label + "-verts" : undefined,
41
+ }),
42
+ index: createBuffer(geometry.indices, {
43
+ autoFree: false,
44
+ label: geometry.label ? geometry.label + "-indices" : undefined,
45
+ }),
46
+ indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16",
47
+ refs: 0,
48
+ }
49
+ entries.set(geometry, entry)
50
+ }
51
+ entry.refs++
52
+ return entry
53
+ }
54
+
55
+ /** Release one acquire. At zero references the buffers are freed at the
56
+ * end of the microtask; an acquire before then keeps them, so a detach and
57
+ * re-attach in one tick never re-uploads. A token orphaned by an explicit
58
+ * disposeGeometry releases against the orphan, never against a successor's
59
+ * fresh buffers. */
60
+ export function releaseGeometryBuffers(acquired: GeometryBuffers): void {
61
+ let entry = acquired as GpuEntry
62
+ if (entry.refs === 0) return
63
+ entry.refs--
64
+ if (entry.refs > 0) return
65
+ queueMicrotask(() => {
66
+ if (entries.get(entry.geometry) !== entry || entry.refs > 0) return
67
+ entries.delete(entry.geometry)
68
+ destroyBuffer(entry.buffer)
69
+ destroyBuffer(entry.index)
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Free the geometry's GPU buffers now, held references or not - the
75
+ * explicit override for geometry an app is done with for good. Draw
76
+ * entries created from them hold their own reference, so destruction order
77
+ * is safe; the geometry can be used again afterwards (fresh buffers are
78
+ * created on next use).
79
+ */
80
+ export function disposeGeometry(geometry: Geometry): void {
81
+ let entry = entries.get(geometry)
82
+ if (entry === undefined) return
83
+ entries.delete(geometry)
84
+ destroyBuffer(entry.buffer)
85
+ destroyBuffer(entry.index)
86
+ }