@solidrt/3d 0.0.50 → 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
@@ -39,17 +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
47
  (unlit color, unlit map, each opaque or transparent), `depth: true` +
46
48
  `cull: "back"`; an instance is
47
49
  just per-entry uniforms (`uColor`) and bindings (`uMap`).
48
- - The pure pieces (`math.ts`, `bvh.ts`, `order.ts`) have check rigs in
50
+ - The pure pieces (`math.ts`, `bvh.ts`, `order.ts`, `geometry.ts`) have check rigs in
49
51
  `checks/`, run headless on flux from the repo root:
50
52
  `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.
53
+ They print PASS or FAIL lines and throw on failure, which exits nonzero.
54
+ Extend the rig when you change the module.
53
55
 
54
56
  ## Components
55
57
 
@@ -58,6 +60,7 @@ blendMode and pointer events like any element. Design rationale:
58
60
  | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
59
61
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
60
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 |
61
64
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
62
65
 
63
66
  Output composition: without `output`, `Scene` emits a minimal
@@ -161,6 +164,25 @@ hands the baker world-space vertices. `fill` indexes relative to
161
164
  `first`. It trusts the buffer's layout (no tag to check); withColors is
162
165
  the checked path.
163
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
+
164
186
  Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
165
187
  closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
166
188
  share an averaged normal - `fillet(points, radius, segs?)` and
@@ -225,6 +247,34 @@ Materials:
225
247
  pipeline with its own values. `dispose()` lives on the class alone.
226
248
  `shaderMaterial(opts)` is exactly a class with one instance (its
227
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.
228
278
 
229
279
  Background: `scene.setBackground(source | null)`, the `background` option
230
280
  on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
@@ -262,11 +312,26 @@ system.
262
312
  "fix" the negated row of `perspective()` - both would mirror the winding
263
313
  and show mesh interiors.
264
314
  - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
265
- 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
266
318
  written on unhide. A freshly attached entry starts off the same way and
267
319
  sync() turns it on when it writes uModel - never add one live: it has no
268
320
  world matrix yet, and drawn before the sync microtask it flashes at the
269
321
  world origin for a frame.
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.
270
335
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
271
336
  [r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
272
337
  (or `shaderMaterial({ transparent: true })`) builds the pipeline with
package/README.md CHANGED
@@ -67,7 +67,11 @@ change - plus your own uniforms: scene-wide via `scene.setParams` (one write
67
67
  however many meshes read it), or per mesh, declaratively via the `params`
68
68
  prop on `<Mesh>` or imperatively via `setMeshParams`. `shaderMaterialClass`
69
69
  compiles one program and hands out `instance()` materials that differ only
70
- in params/textures. And
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
71
75
  `@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
72
76
  blinn, fresnel, a standard vertex stage) to compose your own lit looks
73
77
  from plain template literals.
@@ -77,7 +81,9 @@ GLSL as a first-class material), geometry generators (box, plane, circle,
77
81
  ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
78
82
  solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
79
83
  mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
80
- 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
81
87
  (`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
82
88
  to any geometry, for materials that read it), one perspective camera
83
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.50",
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.50"
21
+ "@solidrt/core": "0.0.51"
21
22
  }
22
23
  }
@@ -11,10 +11,14 @@ 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,
20
24
  setRenderOrder,
@@ -22,7 +26,7 @@ import {
22
26
  setVisible,
23
27
  } from "./scene.ts"
24
28
  import type { ShaderParams } from "@solidrt/core/gpu"
25
- 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"
26
30
  import type { Geometry } from "./geometry.ts"
27
31
  import type { Material } from "./material.ts"
28
32
  import type { Quat, Vec3 } from "./math.ts"
@@ -216,6 +220,74 @@ export let Mesh: VoidComponent<MeshProps> = props => {
216
220
  return null
217
221
  }
218
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
+
219
291
  export type PerspectiveCameraProps = {
220
292
  /** Vertical field of view in DEGREES (default 60). */
221
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
+ }
package/src/geometry.ts CHANGED
@@ -12,16 +12,13 @@
12
12
  // unused by the unlit materials so the layout is ready for lights without
13
13
  // a geometry change (inactive attributes are skipped but keep the stride).
14
14
  //
15
- // GPU buffers are created lazily on first use and shared by every mesh and
16
- // scene drawing the geometry. They are app-lifetime by design - one
17
- // geometry commonly outlives the component that first drew it, so
18
- // owner-scoped auto-free would free a buffer other scenes still draw from.
19
- // disposeGeometry frees them when an app is done with a geometry for good.
15
+ // Pure module by design - geometry is data, and every function here is
16
+ // array math (the check rig checks/geometry-check.ts runs it headless on
17
+ // flux). The GPU buffer step lives in geometry-gpu.ts.
20
18
 
21
- import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
22
- import type { BufferId, IndexFormat, VertexAttribute } from "@solidrt/core/gpu"
23
- import { add, cross, normalize, sub } from "./math.ts"
24
- import type { Vec2, Vec3, Vec4 } from "./math.ts"
19
+ import type { VertexAttribute } from "@solidrt/core/gpu"
20
+ import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
21
+ import type { Quat, TransformUpdate, Vec2, Vec3, Vec4 } from "./math.ts"
25
22
 
26
23
  export type VertexLayout = "standard" | "colored"
27
24
 
@@ -61,8 +58,6 @@ export type Geometry = {
61
58
  layout?: VertexLayout
62
59
  /** Debug name for the lazily-created GPU buffers. */
63
60
  label?: string
64
- _buffer?: BufferId
65
- _index?: BufferId
66
61
  _bounds?: Float32Array
67
62
  }
68
63
 
@@ -94,42 +89,6 @@ export function geometryBounds(geometry: Geometry): Float32Array {
94
89
  return bounds
95
90
  }
96
91
 
97
- /** The geometry's GPU buffers, created on first use and cached on it,
98
- * plus the index format the draw entry must bind them with. */
99
- export function geometryBuffers(geometry: Geometry): {
100
- buffer: BufferId
101
- index: BufferId
102
- indexFormat: IndexFormat
103
- } {
104
- let buffer = geometry._buffer
105
- let index = geometry._index
106
- if (buffer === undefined || index === undefined) {
107
- buffer = createBuffer(geometry.vertices, {
108
- autoFree: false,
109
- label: geometry.label ? geometry.label + "-verts" : undefined,
110
- })
111
- index = createBuffer(geometry.indices, {
112
- autoFree: false,
113
- label: geometry.label ? geometry.label + "-indices" : undefined,
114
- })
115
- geometry._buffer = buffer
116
- geometry._index = index
117
- }
118
- return { buffer, index, indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16" }
119
- }
120
-
121
- /**
122
- * Free the geometry's GPU buffers. Draw entries created from them hold
123
- * their own reference, so destruction order is safe; the geometry can be
124
- * used again afterwards (fresh buffers are created on next use).
125
- */
126
- export function disposeGeometry(geometry: Geometry): void {
127
- if (geometry._buffer !== undefined) destroyBuffer(geometry._buffer)
128
- if (geometry._index !== undefined) destroyBuffer(geometry._index)
129
- geometry._buffer = undefined
130
- geometry._index = undefined
131
- }
132
-
133
92
  /** Per-vertex aColor values for withColors/fillColors: a flat 4-per-vertex
134
93
  * array, or a callback deriving each vertex's vec4 from the vertex data. */
135
94
  export type ColorFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => Vec4)
@@ -210,6 +169,94 @@ export function fillColors(vertices: Float32Array, fill: ColorFill, first = 0, c
210
169
  return vertices
211
170
  }
212
171
 
172
+ /**
173
+ * Bake a placement (the setTransform shape: Euler XYZ radians or a
174
+ * quaternion, not both; number = uniform scale; absent = identity) into a
175
+ * geometry: a new geometry (the source is
176
+ * untouched, its GPU buffers stay independent) whose positions are moved
177
+ * by the transform and whose normals follow through the inverse-transpose,
178
+ * renormalized - correct under non-uniform scale. UVs, colors, indices and
179
+ * layout copy through. This is Three's `geometry.applyMatrix4`, the first
180
+ * half of authoring a static scene as data: transform each part into place,
181
+ * mergeGeometries the parts, draw one mesh.
182
+ */
183
+ export function transformGeometry(geometry: Geometry, transform: TransformUpdate, label?: string): Geometry {
184
+ let rot: Quat = [0, 0, 0, 1]
185
+ updateRotation(rot, transform, "transformGeometry")
186
+ let scl: Vec3 = [1, 1, 1]
187
+ if (transform.scale !== undefined) updateScale(scl, transform.scale)
188
+ let m = compose(mat4(), transform.position ?? [0, 0, 0], rot, scl)
189
+ let n = normalMatrix(mat4(), m)
190
+ let stride = geometry.layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
191
+ let src = geometry.vertices
192
+ if (src.length % stride !== 0) {
193
+ throw new Error("transformGeometry: vertex data is not a whole number of " + (geometry.layout ?? "standard") + "-layout vertices")
194
+ }
195
+ let out = new Float32Array(src)
196
+ for (let i = 0; i < out.length; i += stride) {
197
+ let x = src[i]!, y = src[i + 1]!, z = src[i + 2]!
198
+ out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
199
+ out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
200
+ out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
201
+ let nx = src[i + 3]!, ny = src[i + 4]!, nz = src[i + 5]!
202
+ let tx = n[0] * nx + n[4] * ny + n[8] * nz
203
+ let ty = n[1] * nx + n[5] * ny + n[9] * nz
204
+ let tz = n[2] * nx + n[6] * ny + n[10] * nz
205
+ let len = Math.hypot(tx, ty, tz) || 1
206
+ out[i + 3] = tx / len
207
+ out[i + 4] = ty / len
208
+ out[i + 5] = tz / len
209
+ }
210
+ return {
211
+ vertices: out,
212
+ indices: geometry.indices,
213
+ layout: geometry.layout,
214
+ label: label ?? (geometry.label ? geometry.label + "-transformed" : undefined),
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Concatenate geometries into one: vertices appended in order, indices
220
+ * offset to match, uint32 indices past 64k vertices. Every part must share
221
+ * one layout - a mixed list throws, because the strides differ and a merge
222
+ * that picked one would draw garbage, not a mesh missing a channel. The
223
+ * second half of authoring a static scene as data (Three's
224
+ * `BufferGeometryUtils.mergeGeometries`): the result is one draw entry and
225
+ * one uModel write however many parts went in, so only what actually moves
226
+ * keeps a node of its own.
227
+ */
228
+ export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
229
+ if (parts.length === 0) throw new Error("mergeGeometries: no parts")
230
+ let layout = parts[0]!.layout ?? "standard"
231
+ let stride = layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
232
+ let floats = 0
233
+ let indexCount = 0
234
+ for (let part of parts) {
235
+ if ((part.layout ?? "standard") !== layout) {
236
+ throw new Error("mergeGeometries: mixed layouts (" + layout + " and " + (part.layout ?? "standard") + ")")
237
+ }
238
+ if (part.vertices.length % stride !== 0) {
239
+ throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + layout + "-layout vertices")
240
+ }
241
+ floats += part.vertices.length
242
+ indexCount += part.indices.length
243
+ }
244
+ let vertexCount = floats / stride
245
+ let vertices = new Float32Array(floats)
246
+ let indices = vertexCount > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount)
247
+ let vOffset = 0
248
+ let iOffset = 0
249
+ for (let part of parts) {
250
+ vertices.set(part.vertices, vOffset)
251
+ let base = vOffset / stride
252
+ let src = part.indices
253
+ for (let i = 0; i < src.length; i++) indices[iOffset + i] = src[i]! + base
254
+ vOffset += part.vertices.length
255
+ iOffset += src.length
256
+ }
257
+ return { vertices, indices, layout: parts[0]!.layout, label }
258
+ }
259
+
213
260
  // Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
214
261
  // CCW triangles per cell, split across the row0col0-row1col1 diagonal -
215
262
  // the one quad pattern every grid generator here shares (rows run along
package/src/index.ts CHANGED
@@ -5,18 +5,20 @@
5
5
  // without Solid components) and the component face (Scene/Mesh/Group/
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
- export { add, createGroup, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, setRenderOrder, setTransform, setVisible, worldPosition } from "./scene.ts"
9
- export type { CameraUpdate, Hit, Mesh as MeshNode, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
10
- export { box, circle, cone, cylinder, disposeGeometry, fillColors, plane, ring, sphere, torus, torusKnot, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
8
+ export { add, createGroup, createInstancedMesh, createMesh, createScene, disposeInstances, getRotation, lookAt, remove, setGeometry, setInstanceCount, setInstances, setMaterial, setMeshParams, setRenderOrder, setTransform, setVisible, worldPosition } from "./scene.ts"
9
+ export type { CameraUpdate, Hit, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
10
+ export { disposeGeometry } from "./geometry-gpu.ts"
11
+ export { box, circle, cone, cylinder, fillColors, geometryBounds, mergeGeometries, plane, ring, sphere, torus, torusKnot, transformGeometry, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
11
12
  export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
13
+ export { rayBoxDistance } from "./bvh.ts"
12
14
  export { fillet, roundRect, shape, triangulate } from "./profile.ts"
13
15
  export type { Profile, ProfilePoint } from "./profile.ts"
14
16
  export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
15
17
  export type { PathFrames, PathPoint, SweepPath } from "./sweep.ts"
16
18
  export { shaderMaterial, shaderMaterialClass, unlit } from "./material.ts"
17
19
  export type { Material, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
18
- export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
19
- export type { MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
20
+ export { Group, InstancedMesh, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
21
+ export type { InstancedMeshProps, MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
20
22
  export { createOrbitCamera } from "./orbit.ts"
21
23
  export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
22
24
  // math's lookAt (the camera view matrix) stays on the /math subpath: the
package/src/material.ts CHANGED
@@ -35,6 +35,7 @@ import type {
35
35
  ShaderStageId,
36
36
  TextureId,
37
37
  Topology,
38
+ VertexAttribute,
38
39
  } from "@solidrt/core/gpu"
39
40
  import { VERTEX_LAYOUTS } from "./geometry.ts"
40
41
  import type { VertexLayout } from "./geometry.ts"
@@ -59,6 +60,11 @@ export type Material = {
59
60
  * the scene draws this material's meshes after every opaque one, sorted
60
61
  * back-to-front by mesh origin, and re-sorts them when the camera moves. */
61
62
  transparent?: boolean
63
+ /** Per-instance attributes, when the material's pipeline declares them
64
+ * (shaderMaterialClass's `instanceAttributes`). Such a material draws
65
+ * instanced meshes only - createInstancedMesh supplies the record buffer,
66
+ * and createMesh meshes are rejected at add(). */
67
+ instanceAttributes?: VertexAttribute[]
62
68
  /** Present on materials that own their pipeline (shaderMaterial). */
63
69
  dispose?(): void
64
70
  }
@@ -226,6 +232,17 @@ export type ShaderMaterialClassOptions = {
226
232
  */
227
233
  vertex: string
228
234
  fragment: string
235
+ /**
236
+ * Per-instance attributes: the vertex stage reads these as `in` variables
237
+ * beside the layout's own, and each drawn instance gets one record from
238
+ * the mesh's instance buffer (interleaved floats in this order). A class
239
+ * with instance attributes makes INSTANCED materials: attach their meshes
240
+ * with createInstancedMesh, which carries the records - a createMesh mesh
241
+ * is rejected at add(). A per-instance transform is data, not a matrix:
242
+ * a position/yaw/scale record beats four vec4 columns for most fleets,
243
+ * and the composed uModel still places the whole population.
244
+ */
245
+ instanceAttributes?: VertexAttribute[]
229
246
  /** Blend over what is behind, with the scene sorting this material's
230
247
  * meshes back-to-front after the opaque ones (see Material.transparent).
231
248
  * Sets the pipeline defaults blend "alpha" and depthWrite false; the
@@ -298,6 +315,9 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
298
315
  let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
299
316
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
300
317
  let depth = opts.depth ?? true
318
+ // An empty list declares nothing - same as absent (the engine requires an
319
+ // instance buffer exactly when attributes are declared).
320
+ let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
301
321
  let pipelineFor = (): RenderPipelineId => {
302
322
  if (pipeline === undefined) {
303
323
  let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
@@ -307,6 +327,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
307
327
  destroyShader(fs)
308
328
  pipeline = createRenderPipeline(program, {
309
329
  attributes: VERTEX_LAYOUTS[layout],
330
+ instanceAttributes,
310
331
  depth,
311
332
  // depthWrite needs a depth buffer, so the transparent default
312
333
  // only applies when there is one.
@@ -321,7 +342,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
321
342
  }
322
343
  return {
323
344
  instance(inst = {}) {
324
- return { normalMatrix, layout, transparent, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
345
+ return { normalMatrix, layout, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
325
346
  },
326
347
  dispose() {
327
348
  if (pipeline !== undefined) {
package/src/math.ts CHANGED
@@ -213,6 +213,51 @@ export function quatNormalize(out: Quat, q: Quat): Quat {
213
213
  return out
214
214
  }
215
215
 
216
+ /**
217
+ * A transform update - the shape setTransform writes and transformGeometry
218
+ * bakes. Absent keys mean "keep" (nodes) or identity (geometry).
219
+ */
220
+ export type TransformUpdate = {
221
+ position?: Vec3
222
+ /** Euler radians in XYZ order (x first), Three's `Euler` default -
223
+ * converted to a quaternion on use. */
224
+ rotation?: Vec3
225
+ /** The rotation itself. Normalized on use, so a hand-built or drifted
226
+ * quaternion cannot silently scale the geometry. Passing this together
227
+ * with `rotation` is an error, not a precedence question. */
228
+ quaternion?: Quat
229
+ /** A number is uniform scale. */
230
+ scale?: Vec3 | number
231
+ }
232
+
233
+ /**
234
+ * Resolve an update's rotation into `out`: euler converted, quaternion
235
+ * normalized. Returns false (out untouched) when the update carries
236
+ * neither; throws when it carries both. `caller` names the verb in the
237
+ * error.
238
+ */
239
+ export function updateRotation(out: Quat, update: TransformUpdate, caller: string): boolean {
240
+ let r = update.rotation
241
+ let q = update.quaternion
242
+ if (r !== undefined && q !== undefined) {
243
+ throw new Error("Pass rotation or quaternion to " + caller + ", not both")
244
+ }
245
+ if (r !== undefined) quatFromEuler(out, r)
246
+ else if (q !== undefined) quatNormalize(out, q)
247
+ else return false
248
+ return true
249
+ }
250
+
251
+ /** Expand an update's scale (number = uniform) into `out`. */
252
+ export function updateScale(out: Vec3, scale: Vec3 | number): Vec3 {
253
+ if (typeof scale === "number") {
254
+ out[0] = scale; out[1] = scale; out[2] = scale
255
+ } else {
256
+ out[0] = scale[0]; out[1] = scale[1]; out[2] = scale[2]
257
+ }
258
+ return out
259
+ }
260
+
216
261
  /**
217
262
  * Euler radians to a quaternion, in XYZ order: x applied first, then y,
218
263
  * then z (R = Rx * Ry * Rz on column vectors), Three's `Euler` default - a
package/src/scene.ts CHANGED
@@ -19,15 +19,17 @@
19
19
  // each write lands here, the microtask syncs the affected uModels, and the
20
20
  // flush renders once that frame.
21
21
 
22
- import { addDraw, createDrawTarget, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
23
- import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
22
+ import { addDraw, createBuffer, createDrawTarget, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize, writeBuffer } from "@solidrt/core/gpu"
23
+ import type { BufferId, DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, VertexAttribute, WrapMode } from "@solidrt/core/gpu"
24
24
  import { getOwner, onCleanup } from "@solidrt/core"
25
25
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
26
26
  // The scene's lookAt() aims a node; math's builds a camera's view matrix -
27
27
  // the same pairing (and the same name) as Three's Object3D/Matrix4.
28
- import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
29
- import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
30
- import { geometryBounds, geometryBuffers } from "./geometry.ts"
28
+ import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat, quatFromFrame, transformPoint, transformVector, updateRotation, updateScale } from "./math.ts"
29
+ import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
30
+ import { geometryBounds } from "./geometry.ts"
31
+ import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
32
+ import type { GeometryBuffers } from "./geometry-gpu.ts"
31
33
  import type { Geometry } from "./geometry.ts"
32
34
  import { backgroundPipeline } from "./material.ts"
33
35
  import { orderEntries } from "./order.ts"
@@ -57,6 +59,7 @@ let pickDir: Vec3 = [0, 0, 0]
57
59
  // setTransform's rotation compare happens AFTER conversion, so an euler and
58
60
  // the quaternion it produces are the same write. Nothing outlives the call.
59
61
  let rotScratch = quat()
62
+ let scaleScratch: Vec3 = [1, 1, 1]
60
63
 
61
64
  // The scene half a node needs to reach: attach/detach entries and schedule
62
65
  // a sync. Kept separate from the public Scene type so internals stay off
@@ -68,6 +71,7 @@ type SceneHooks = {
68
71
  _attach(mesh: Mesh): void
69
72
  _detach(mesh: Mesh): void
70
73
  _setParams(mesh: Mesh, params: ShaderParams): void
74
+ _setCount(mesh: Mesh): void
71
75
  _reorder(): void
72
76
  }
73
77
 
@@ -109,6 +113,10 @@ export type Mesh = SceneNode & {
109
113
  * transparent group always follows the opaque one. Set with setRenderOrder. */
110
114
  renderOrder: number
111
115
  _entry: DrawId | null
116
+ /** The geometry-buffer reference the entry was built from, acquired at
117
+ * attach and what _detach releases - like _transparent, a snapshot,
118
+ * because setGeometry swaps mesh.geometry before the rebuild. */
119
+ _buffers: GeometryBuffers | null
112
120
  /** material.transparent as of the last attach - the entry's actual
113
121
  * pipeline state, and what _detach counts against (setMaterial swaps
114
122
  * mesh.material before the rebuild). */
@@ -120,8 +128,35 @@ export type Mesh = SceneNode & {
120
128
  _fresh: boolean
121
129
  _params: ShaderParams | null
122
130
  _pickLeaf: number | null
131
+ /** Instance state when the mesh was made by createInstancedMesh; null on
132
+ * an ordinary mesh. */
133
+ _instances: MeshInstances | null
123
134
  }
124
135
 
136
+ /** The per-mesh half of instancing: the record buffer and its bookkeeping.
137
+ * Read the public fields freely; write through setInstances /
138
+ * setInstanceCount so the draw range follows. */
139
+ export type MeshInstances = {
140
+ /** The GPU record buffer, owned by the mesh (disposeInstances frees it). */
141
+ buffer: BufferId
142
+ /** Floats per record - the material's instanceAttributes summed. */
143
+ stride: number
144
+ /** Records the buffer has room for; fixed at creation, like every GPU
145
+ * buffer's byte size. */
146
+ capacity: number
147
+ /** Records currently drawn (the entry's instanceCount while visible). */
148
+ count: number
149
+ /** Explicit LOCAL bounds covering the whole population ([minX, minY,
150
+ * minZ, maxX, maxY, maxZ]), or null: the mesh then has no picking leaf -
151
+ * records are opaque data, so the library cannot derive where the
152
+ * instances are. */
153
+ bounds: Float32Array | null
154
+ }
155
+
156
+ /** A mesh from createInstancedMesh: an ordinary Mesh whose entry draws
157
+ * `instances.count` copies of the geometry, one record each. */
158
+ export type InstancedMesh = Mesh & { _instances: MeshInstances }
159
+
125
160
  /** One picking intersection: the mesh, the camera-ray distance in world
126
161
  * units, and the world-space point - Three's intersect result minus the
127
162
  * triangle fields (`face`, `uv`), which cannot exist at the volume tier. */
@@ -276,9 +311,10 @@ export type Scene = {
276
311
  * layout just works: `scene.handlersFor(() => ({ width: w(), height:
277
312
  * h() }))`. */
278
313
  handlersFor(layout: () => { width: number; height: number }): SceneHandlers
279
- /** Destroy the target (entries die with it). Idempotent. Geometry
280
- * buffers and material pipelines are shared and survive - they are
281
- * app-lifetime (see geometry.ts / material.ts). */
314
+ /** Destroy the target (entries die with it). Idempotent. Material
315
+ * pipelines are shared and survive (app-lifetime, see material.ts);
316
+ * geometry buffers are reference-counted and freed with their last
317
+ * entry (see geometry-gpu.ts). */
282
318
  dispose(): void
283
319
  }
284
320
 
@@ -308,15 +344,147 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
308
344
  mesh.material = material
309
345
  mesh.renderOrder = 0
310
346
  mesh._entry = null
347
+ mesh._buffers = null
311
348
  mesh._transparent = false
312
349
  mesh._center = [0, 0, 0]
313
350
  mesh._hidden = false
314
351
  mesh._fresh = false
315
352
  mesh._params = null
316
353
  mesh._pickLeaf = null
354
+ mesh._instances = null
317
355
  return mesh
318
356
  }
319
357
 
358
+ /** The local box picking and sorting work from: explicit instance bounds
359
+ * when the mesh is instanced (null without them - no leaf, no hits), the
360
+ * geometry's own bounds otherwise. */
361
+ function localBounds(mesh: Mesh): Float32Array | null {
362
+ return mesh._instances !== null ? mesh._instances.bounds : geometryBounds(mesh.geometry)
363
+ }
364
+
365
+ const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
366
+
367
+ function instanceStride(attributes: VertexAttribute[]): number {
368
+ let stride = 0
369
+ for (let a of attributes) stride += ATTRIBUTE_FLOATS[a.format]
370
+ return stride
371
+ }
372
+
373
+ export type InstancedMeshOptions = {
374
+ /** LOCAL bounds covering every instance the records place ([minX, minY,
375
+ * minZ, maxX, maxY, maxZ] - geometryBounds' shape), copied in. Records
376
+ * are opaque data, so only the app knows where its instances are: with
377
+ * bounds the mesh picks and transparent-sorts like any other
378
+ * (conservatively - one box around the whole population); without, it
379
+ * has no picking leaf and pointer events never target it. */
380
+ bounds?: ArrayLike<number>
381
+ /** Debug label for the record buffer. */
382
+ label?: string
383
+ }
384
+
385
+ /**
386
+ * A mesh drawing `geometry` once per record of `records`: one draw entry,
387
+ * one uModel write, N instances - the shape for forests, particles, and
388
+ * every fleet whose per-copy data is a few floats rather than a merged
389
+ * vertex buffer. The material must declare `instanceAttributes`
390
+ * (shaderMaterialClass); its vertex stage reads each record through those
391
+ * `in` variables. `records` is the interleaved attribute data (stride =
392
+ * the attributes' floats summed) and is uploaded here - the buffer's
393
+ * capacity is fixed at creation, like any GPU buffer. `count` limits how
394
+ * many records draw (default all); grow it later only up to capacity.
395
+ *
396
+ * The result is an ordinary Mesh: add/remove, setTransform (uModel places
397
+ * the whole population), setVisible (hiding zeroes the drawn count,
398
+ * unhiding restores it), setMeshParams and renderOrder all apply. Update
399
+ * records with setInstances, the drawn count with setInstanceCount, and
400
+ * free the record buffer with disposeInstances when done for good.
401
+ */
402
+ export function createInstancedMesh(
403
+ geometry: Geometry,
404
+ material: Material,
405
+ records: Float32Array,
406
+ count?: number,
407
+ opts?: InstancedMeshOptions,
408
+ ): InstancedMesh {
409
+ let attributes = material.instanceAttributes
410
+ if (attributes === undefined) {
411
+ throw new Error(
412
+ "createInstancedMesh: the material declares no instanceAttributes - build it with shaderMaterialClass({ instanceAttributes: [...] })",
413
+ )
414
+ }
415
+ let stride = instanceStride(attributes)
416
+ if (records.length % stride !== 0) {
417
+ throw new Error(
418
+ "createInstancedMesh: " + records.length + " floats is not a whole number of " + stride + "-float records",
419
+ )
420
+ }
421
+ let bounds: Float32Array | null = null
422
+ if (opts?.bounds !== undefined) {
423
+ if (opts.bounds.length !== 6) {
424
+ throw new Error("createInstancedMesh: bounds must be [minX, minY, minZ, maxX, maxY, maxZ]")
425
+ }
426
+ bounds = new Float32Array(6)
427
+ for (let i = 0; i < 6; i++) bounds[i] = opts.bounds[i]!
428
+ }
429
+ let capacity = records.length / stride
430
+ let mesh = createMesh(geometry, material) as InstancedMesh
431
+ mesh._instances = {
432
+ buffer: createBuffer(records, { autoFree: false, label: opts?.label }),
433
+ stride,
434
+ capacity,
435
+ count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
436
+ bounds,
437
+ }
438
+ return mesh
439
+ }
440
+
441
+ /**
442
+ * Overwrite an instanced mesh's records from the start of its buffer and
443
+ * (by default) draw exactly the records written - pass `count` to draw
444
+ * fewer, or to keep more previously written ones alive past a partial
445
+ * rewrite. The buffer's capacity is fixed at creation; more records than
446
+ * capacity throw (make a new mesh for a bigger population). Frame-rate-safe
447
+ * like setMeshParams.
448
+ */
449
+ export function setInstances(mesh: InstancedMesh, records: Float32Array, count?: number): void {
450
+ let inst = mesh._instances
451
+ if (records.length % inst.stride !== 0) {
452
+ throw new Error("setInstances: " + records.length + " floats is not a whole number of " + inst.stride + "-float records")
453
+ }
454
+ let written = records.length / inst.stride
455
+ if (written > inst.capacity) {
456
+ throw new Error(
457
+ "setInstances: " + written + " records exceed the buffer's capacity of " + inst.capacity + " (fixed at creation)",
458
+ )
459
+ }
460
+ writeBuffer(inst.buffer, records)
461
+ setInstanceCount(mesh, count ?? written)
462
+ }
463
+
464
+ /** Set how many records draw (clamped to [0, capacity]). The visibility
465
+ * switch composes: a hidden mesh stores the count and draws it on unhide. */
466
+ export function setInstanceCount(mesh: InstancedMesh, count: number): void {
467
+ let inst = mesh._instances
468
+ let n = Math.max(0, Math.min(Math.floor(count), inst.capacity))
469
+ if (n === inst.count) return
470
+ inst.count = n
471
+ mesh._scene?._setCount(mesh)
472
+ }
473
+
474
+ /**
475
+ * Detach the mesh (if attached) and free its record buffer. The buffer is
476
+ * mesh-owned with no reference count (unlike geometry buffers it is never
477
+ * shared), so this is the one explicit free; the mesh cannot be re-added
478
+ * afterwards.
479
+ */
480
+ export function disposeInstances(mesh: InstancedMesh): void {
481
+ let inst: MeshInstances | null = mesh._instances
482
+ if (inst === null) return
483
+ if (mesh._scene) remove(mesh)
484
+ destroyBuffer(inst.buffer)
485
+ ;(mesh as Mesh)._instances = null
486
+ }
487
+
320
488
  /** Attach `child` under `parent` (re-parenting detaches it first). */
321
489
  export function add(parent: SceneNode, child: SceneNode): void {
322
490
  if (child.parent !== null) remove(child)
@@ -352,18 +520,7 @@ function leaveScene(node: SceneNode): void {
352
520
  for (let c of node.children) leaveScene(c)
353
521
  }
354
522
 
355
- export type TransformUpdate = {
356
- position?: Vec3
357
- /** Euler radians in XYZ order (x first), Three's `Euler` default -
358
- * converted to the node's quaternion on write. */
359
- rotation?: Vec3
360
- /** The rotation itself. Normalized on write, so a hand-built or
361
- * drifted quaternion cannot silently scale the geometry. Passing this
362
- * together with `rotation` is an error, not a precedence question. */
363
- quaternion?: Quat
364
- /** A number is uniform scale. */
365
- scale?: Vec3 | number
366
- }
523
+ export type { TransformUpdate } from "./math.ts"
367
524
 
368
525
  /**
369
526
  * The one write path for node transforms (so the scene knows to sync).
@@ -377,11 +534,6 @@ export type TransformUpdate = {
377
534
  * equal to the node's current quaternion is also a no-op.
378
535
  */
379
536
  export function setTransform(node: SceneNode, update: TransformUpdate): void {
380
- let r = update.rotation
381
- let q = update.quaternion
382
- if (r !== undefined && q !== undefined) {
383
- throw new Error("Pass rotation or quaternion to setTransform, not both")
384
- }
385
537
  // A no-op write costs nothing: driving every node from onFrame is the
386
538
  // intended shape, and most nodes did not move. Exact compares, like
387
539
  // setVisible - a value that survives a float round trip unchanged is the
@@ -394,9 +546,7 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
394
546
  node.position[2] = p[2]
395
547
  changed = true
396
548
  }
397
- if (r !== undefined) quatFromEuler(rotScratch, r)
398
- else if (q !== undefined) quatNormalize(rotScratch, q)
399
- if (r !== undefined || q !== undefined) {
549
+ if (updateRotation(rotScratch, update, "setTransform")) {
400
550
  let n = node.quaternion
401
551
  if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
402
552
  n[0] = rotScratch[0]
@@ -406,15 +556,12 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
406
556
  changed = true
407
557
  }
408
558
  }
409
- let s = update.scale
410
- if (s !== undefined) {
411
- let sx = typeof s === "number" ? s : s[0]
412
- let sy = typeof s === "number" ? s : s[1]
413
- let sz = typeof s === "number" ? s : s[2]
414
- if (sx !== node.scale[0] || sy !== node.scale[1] || sz !== node.scale[2]) {
415
- node.scale[0] = sx
416
- node.scale[1] = sy
417
- node.scale[2] = sz
559
+ if (update.scale !== undefined) {
560
+ updateScale(scaleScratch, update.scale)
561
+ if (scaleScratch[0] !== node.scale[0] || scaleScratch[1] !== node.scale[1] || scaleScratch[2] !== node.scale[2]) {
562
+ node.scale[0] = scaleScratch[0]
563
+ node.scale[1] = scaleScratch[1]
564
+ node.scale[2] = scaleScratch[2]
418
565
  changed = true
419
566
  }
420
567
  }
@@ -628,8 +775,17 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
628
775
  // the local box's center/extents carried through the absolute matrix (the
629
776
  // standard tight-AABB-of-a-transformed-AABB construction).
630
777
  let updateLeaf = (mesh: Mesh): void => {
631
- let b = geometryBounds(mesh.geometry)
778
+ let b = localBounds(mesh)
632
779
  let m = mesh._world
780
+ if (b === null) {
781
+ // An instanced mesh without explicit bounds: records are opaque, so
782
+ // there is nothing to build a leaf from - the mesh never picks. Keep
783
+ // the transparent sort key at the node's own world position.
784
+ mesh._center[0] = m[12]
785
+ mesh._center[1] = m[13]
786
+ mesh._center[2] = m[14]
787
+ return
788
+ }
633
789
  let cx = (b[0]! + b[3]!) / 2
634
790
  let cy = (b[1]! + b[4]!) / 2
635
791
  let cz = (b[2]! + b[5]!) / 2
@@ -712,8 +868,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
712
868
  let mesh = node as Mesh
713
869
  if (mesh._entry !== null) {
714
870
  if (mesh._hidden === shown) {
715
- // Mismatch: flip the entry's cheap off switch.
716
- setDrawRange(texture, mesh._entry, { instanceCount: shown ? 1 : 0 })
871
+ // Mismatch: flip the entry's cheap off switch. An instanced
872
+ // mesh's "on" is its own record count, not 1.
873
+ setDrawRange(texture, mesh._entry, { instanceCount: shown ? (mesh._instances !== null ? mesh._instances.count : 1) : 0 })
717
874
  mesh._hidden = !shown
718
875
  if (shown) mesh._fresh = true
719
876
  }
@@ -763,7 +920,30 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
763
920
  "' - a material reading aColor needs withColors() geometry, and colored geometry needs such a material",
764
921
  )
765
922
  }
766
- let bufs = geometryBuffers(mesh.geometry)
923
+ // Instancing pairs the same way layout does: the pipeline's instance
924
+ // attributes describe the mesh's record buffer, so one without the
925
+ // other (or a record stride from a different attribute list) would
926
+ // bind garbage - errors here, at add().
927
+ let inst = mesh._instances
928
+ let instAttrs = mesh.material.instanceAttributes
929
+ if (instAttrs !== undefined && inst === null) {
930
+ throw new Error(
931
+ "Material declares instanceAttributes - create its meshes with createInstancedMesh (records included), not createMesh",
932
+ )
933
+ }
934
+ if (inst !== null) {
935
+ if (instAttrs === undefined) {
936
+ throw new Error("Instanced mesh with a non-instanced material - the material must declare instanceAttributes")
937
+ }
938
+ let stride = instanceStride(instAttrs)
939
+ if (stride !== inst.stride) {
940
+ throw new Error(
941
+ "Instanced mesh records are " + inst.stride + " floats but the material's instanceAttributes take " + stride,
942
+ )
943
+ }
944
+ }
945
+ let bufs = acquireGeometryBuffers(mesh.geometry)
946
+ mesh._buffers = bufs
767
947
  // The uNormal seed keys off the material flag because entry params
768
948
  // validate strictly - and a material declaring uNormal without using
769
949
  // it therefore throws right here, at add().
@@ -780,6 +960,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
780
960
  indexBuffer: bufs.index,
781
961
  indexFormat: bufs.indexFormat,
782
962
  textures: mesh.material.textures,
963
+ instanceBuffer: inst !== null ? inst.buffer : undefined,
783
964
  instanceCount: 0,
784
965
  })
785
966
  meshes.push(mesh)
@@ -793,6 +974,8 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
793
974
  _detach(mesh) {
794
975
  if (mesh._entry !== null) {
795
976
  if (!disposed) removeDraw(texture, mesh._entry)
977
+ if (mesh._buffers !== null) releaseGeometryBuffers(mesh._buffers)
978
+ mesh._buffers = null
796
979
  let i = meshes.indexOf(mesh)
797
980
  if (i >= 0) meshes.splice(i, 1)
798
981
  if (mesh._transparent) transparentCount--
@@ -809,6 +992,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
809
992
  _setParams(mesh, params) {
810
993
  if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
811
994
  },
995
+ _setCount(mesh) {
996
+ // A hidden entry stays at 0; the unhide write restores the count.
997
+ if (mesh._entry !== null && !mesh._hidden && !disposed && mesh._instances !== null) {
998
+ setDrawRange(texture, mesh._entry, { instanceCount: mesh._instances.count })
999
+ }
1000
+ },
812
1001
  _reorder() {
813
1002
  orderDirty = true
814
1003
  this._schedule()
@@ -1035,7 +1224,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1035
1224
  pickDir[1] = dy
1036
1225
  pickDir[2] = dz
1037
1226
  transformVector(pickDir, pickInv, pickDir)
1038
- let b = geometryBounds(mesh.geometry)
1227
+ // A boundless instanced mesh has no leaf, so the BVH never visits
1228
+ // it; this read is the bounds the leaf was built from.
1229
+ let b = localBounds(mesh)
1230
+ if (b === null) return
1039
1231
  let t = rayBoxDistance(
1040
1232
  pickOrigin[0], pickOrigin[1], pickOrigin[2],
1041
1233
  pickDir[0], pickDir[1], pickDir[2],
@@ -1053,6 +1245,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1053
1245
  dispose() {
1054
1246
  if (disposed) return
1055
1247
  disposed = true
1248
+ // Full mesh-side teardown, not just the target: _detach drops each
1249
+ // entry's geometry-buffer reference and pick leaf and clears _entry,
1250
+ // so a disposed scene leaves no mesh bookkeeping behind.
1251
+ for (let mesh of meshes.slice()) hooks._detach(mesh)
1056
1252
  destroyTexture(texture)
1057
1253
  if (background !== null) {
1058
1254
  // The entry died with the target; the pipeline and program are the