@solidrt/3d 0.0.54 → 0.0.56

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.
Files changed (54) hide show
  1. package/AGENTS.md +1121 -177
  2. package/README.md +15 -7
  3. package/examples/README.md +86 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +20 -5
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +173 -0
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +773 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1390 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +682 -216
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +211 -34
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1304 -1532
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
package/src/mesh.ts ADDED
@@ -0,0 +1,728 @@
1
+ // Meshes: the drawable nodes - plain, sprite and instanced - with their
2
+ // create-time state (geometry, material, per-mesh bindings) and the
3
+ // setter write paths that keep a live scene's draw entries in step. The
4
+ // scene side is reached through the node's SceneHooks (node.ts).
5
+
6
+ import { createBuffer, destroyBuffer, writeBuffer } from "@solidrt/core/gpu"
7
+ import type { BufferId, DrawId, InstanceAttribute, ShaderParams, TextureBindings, VertexAttribute } from "@solidrt/core/gpu"
8
+ import { geometryBounds, plane } from "./geometry.ts"
9
+ import type { Geometry } from "./geometry.ts"
10
+ import type { GeometryBuffers } from "./geometry-gpu.ts"
11
+ import type { Material } from "./material.ts"
12
+ import type { TransformUpdate, Vec3 } from "./math.ts"
13
+ import * as spatial from "flux:spatial"
14
+ import { enterScene, leaveScene, makeNode, remove, setTransform } from "./node.ts"
15
+ import type { SceneNode } from "./node.ts"
16
+
17
+ export type Mesh = SceneNode & {
18
+ kind: "mesh"
19
+ geometry: Geometry
20
+ material: Material
21
+ /** Explicit draw-order key (default 0), Three's name: lower draws first.
22
+ * Sorts within the opaque group and within the transparent group; the
23
+ * transparent group always follows the opaque one. Set with setRenderOrder. */
24
+ renderOrder: number
25
+ /** Draw into the scene's shadow map (default false, Three's default).
26
+ * Set with setCastShadow. A casting instanced mesh is skipped (the
27
+ * depth pass cannot know its record layout). */
28
+ castShadow: boolean
29
+ /** Layer membership bitmask (default 1, Three's `object.layers`): a
30
+ * target draws the mesh when its mask intersects this. Not inherited
31
+ * from ancestor Groups (Three's and Godot's rule). Set with setLayers. */
32
+ layers: number
33
+ /** Whether every target's frustum gates the mesh (default true, Three's
34
+ * `frustumCulled`): outside it the entry draws nothing. Off for geometry
35
+ * a vertex stage moves beyond its box (a fullscreen quad, a custom
36
+ * displacement). Set with setCulling. */
37
+ frustumCulled: boolean
38
+ /** World units the frustum test grows the box by on every side (default
39
+ * 0, Godot's `extra_cull_margin`): the pad for wind, wobble and any other
40
+ * vertex-stage displacement that stays bounded. Set with setCulling. */
41
+ cullMargin: number
42
+ /** Joint nodes whose world boxes, united, stand in for this mesh's own
43
+ * in the frustum test - a skinned part's cull box follows its pose.
44
+ * null for an unskinned mesh. */
45
+ _cullJoints: SceneNode[] | null
46
+ _entry: DrawId | null
47
+ /** The geometry-buffer reference the entry was built from, acquired at
48
+ * attach and what _detach releases - like _transparent, a snapshot,
49
+ * because setGeometry swaps mesh.geometry before the rebuild. */
50
+ _buffers: GeometryBuffers | null
51
+ /** material.transparent as of the last attach - the entry's actual
52
+ * pipeline state, and what _detach counts against (setMaterial swaps
53
+ * mesh.material before the rebuild). */
54
+ _transparent: boolean
55
+ /** World-space center of the local bounds, refreshed at sort time: the
56
+ * transparent sort key. */
57
+ _center: Vec3
58
+ _params: ShaderParams | null
59
+ /** Per-mesh sampler bindings merged over the material's at attach (a
60
+ * skin's uBones palette texture): create-time state, set before the
61
+ * mesh joins a scene, applied to entries drawn with the mesh's OWN
62
+ * material or a skinned stand-in (Material.skinned - a skinned shadow
63
+ * variant declares uBones) - any other override validates its bindings
64
+ * against a program that may not declare these names. */
65
+ _textures: TextureBindings | null
66
+ /** Instance state when the mesh was made by createInstancedMesh; null on
67
+ * an ordinary mesh. */
68
+ _instances: MeshInstances | null
69
+ /** True for a createSprite mesh: its quad faces the camera in the
70
+ * vertex stage, so it picks by a unit box instead of its flat triangles. */
71
+ _sprite: boolean
72
+ }
73
+
74
+ /** The per-mesh half of instancing: the record buffer and its bookkeeping.
75
+ * Read the public fields freely; write through the population's own
76
+ * functions (addInstance/removeInstance, setRecords/setRecordCount) so
77
+ * the draw range follows. */
78
+ export type MeshInstances = {
79
+ /** The GPU record buffer (instance slot 0), owned by the mesh
80
+ * (disposeInstances frees it): the core-written matrices on an
81
+ * instanced mesh, the app's records on a record mesh. */
82
+ buffer: BufferId
83
+ /** Floats per record: INSTANCE_FLOATS on an instanced mesh, the
84
+ * material's slot-0 instanceAttributes summed on a record mesh. */
85
+ stride: number
86
+ /** Records the buffer has room for; doubles on growth (a replacement
87
+ * buffer, never a resize). */
88
+ capacity: number
89
+ /** The buffer label, carried to replacement buffers on growth. */
90
+ label: string | undefined
91
+ /** Records currently drawn (the entry's instanceCount while visible):
92
+ * the slot high-water mark on an instanced mesh, the records written
93
+ * on a record mesh. */
94
+ count: number
95
+ /** Explicit LOCAL bounds covering the whole population ([minX, minY,
96
+ * minZ, maxX, maxY, maxZ]) or null: the mesh node's own box, what the
97
+ * frustum test and the transparent sort use, and on a record mesh the
98
+ * only picking leaf (records are opaque data). An instanced mesh picks
99
+ * per instance regardless and, without them, culls by its instances'
100
+ * union (the scene's cull group over the live instance nodes). */
101
+ bounds: Float32Array | null
102
+ /** The node-backed population (createInstancedMesh): handles by slot
103
+ * (null = free) and the free list; null on a record mesh. */
104
+ nodes: InstanceSlots | null
105
+ /** The app-written style records (instance slot 1) of an instanced mesh
106
+ * whose material declares slot-1 attributes; null otherwise (and on a
107
+ * record mesh, whose one buffer is the app's already). */
108
+ style: InstanceStyle | null
109
+ }
110
+
111
+ export type InstanceSlots = { slots: (InstanceNode | null)[]; free: number[] }
112
+
113
+ /**
114
+ * The style half of an instanced mesh: one record per instance slot of
115
+ * the material's slot-1 instanceAttributes (a tint, a frame index, any
116
+ * per-copy floats the vertex stage reads), owned by the app and written
117
+ * per instance with setInstanceStyle - the JS twin of the core's matrix
118
+ * records in slot 0, @solidrt/2d's pose/style split one dimension up.
119
+ * Writes land in `data` and publish as ONE coalesced buffer write per
120
+ * mesh at the scene's next sync, whatever the number of instances
121
+ * styled; `buffer` grows with the matrix buffer.
122
+ */
123
+ export type InstanceStyle = {
124
+ /** The GPU style buffer (instance slot 1), mesh-owned like `buffer`. */
125
+ buffer: BufferId
126
+ /** Floats per style record: the material's slot-1 attributes summed. */
127
+ stride: number
128
+ /** The JS mirror, capacity * stride floats; the buffer is published
129
+ * from it. Read freely, write through setInstanceStyle. */
130
+ data: Float32Array
131
+ /** The record a fresh (or recycled) slot starts with: the material's
132
+ * instanceStyle, zeros without one. */
133
+ blank: Float32Array
134
+ /** The float range [lo, hi) of `data` written since the last publish,
135
+ * or null. */
136
+ dirty: [number, number] | null
137
+ }
138
+
139
+ /** A mesh from createInstancedMesh: one draw entry drawing the geometry
140
+ * once per instance node (addInstance). */
141
+ export type InstancedMesh = Mesh & { _instances: MeshInstances & { nodes: InstanceSlots } }
142
+
143
+ /** A mesh from createRecordMesh: one draw entry drawing the geometry once
144
+ * per record of a JS-written buffer (setRecords). */
145
+ export type RecordMesh = Mesh & { _instances: MeshInstances & { nodes: null } }
146
+
147
+ /**
148
+ * One instance of an InstancedMesh: a scene node like any other
149
+ * (setTransform, setTransition, setVisible, lookAt, worldPosition,
150
+ * pointer handlers, children of its own) whose placement inside the mesh
151
+ * the core writes into the mesh's record buffer at `_slot` - one
152
+ * coalesced buffer write per flush however many instances moved. A
153
+ * hidden instance draws nothing (its record collapses to zero scale).
154
+ * Slot-bound to its mesh: created by addInstance, destroyed by
155
+ * removeInstance; the generic add/remove reject it.
156
+ */
157
+ export type InstanceNode = SceneNode & {
158
+ kind: "instance"
159
+ /** The owning mesh; null once removed (the handle is inert). */
160
+ mesh: InstancedMesh | null
161
+ /** The record slot, fixed for the instance's life. */
162
+ readonly _slot: number
163
+ }
164
+
165
+ // Layer masks are 32-bit sets, Three's Object3D.layers width.
166
+ export function checkMask(mask: number, site: string): number {
167
+ if (!Number.isInteger(mask) || mask < 0 || mask > 0xffffffff) {
168
+ throw new Error(site + ": layers must be an integer bitmask in 0..0xffffffff, got " + mask)
169
+ }
170
+ return mask
171
+ }
172
+
173
+ export function createMesh(geometry: Geometry, material: Material): Mesh {
174
+ let mesh = makeNode("mesh") as Mesh
175
+ mesh.geometry = geometry
176
+ mesh.material = material
177
+ mesh.renderOrder = 0
178
+ mesh.castShadow = false
179
+ mesh.layers = 1
180
+ mesh.frustumCulled = true
181
+ mesh.cullMargin = 0
182
+ mesh._cullJoints = null
183
+ mesh._entry = null
184
+ mesh._buffers = null
185
+ mesh._transparent = false
186
+ mesh._center = [0, 0, 0]
187
+ mesh._params = null
188
+ mesh._textures = null
189
+ mesh._instances = null
190
+ mesh._sprite = false
191
+ return mesh
192
+ }
193
+
194
+ // Every sprite draws the same unit quad, built once: geometry is data
195
+ // and its GPU buffers are acquired per mesh, so one shared value is the
196
+ // normal sharing story. The box is the quad's reach at any facing: the
197
+ // unit quad's corners lie on a sphere of radius sqrt(0.5), so the box of
198
+ // that sphere holds it however the camera turns it - what the frustum
199
+ // test needs, and close enough for a pick.
200
+ let spriteQuad: Geometry | undefined
201
+ const SPRITE_REACH = Math.SQRT1_2
202
+ const SPRITE_BOUNDS = new Float32Array([-SPRITE_REACH, -SPRITE_REACH, -SPRITE_REACH, SPRITE_REACH, SPRITE_REACH, SPRITE_REACH])
203
+
204
+ /**
205
+ * A camera-facing quad, Three's `Sprite`: a unit plane drawn with a
206
+ * `sprite()` material (any material works, but only a sprite material
207
+ * turns the quad; there is no `geometry` argument). Size it with `scale` -
208
+ * a scale of [2, 1, 1] is a 2 x 1 world-unit quad - and place it like any
209
+ * mesh; its rotation is ignored, the camera decides the facing. Picking
210
+ * is by a unit box around the center (the quad's reach at any facing, an
211
+ * approximation), so hits carry no normal/face/uv.
212
+ */
213
+ export function createSprite(material: Material): Mesh {
214
+ if (spriteQuad === undefined) spriteQuad = plane({ label: "sprite" })
215
+ let mesh = createMesh(spriteQuad, material)
216
+ mesh._sprite = true
217
+ return mesh
218
+ }
219
+
220
+ /** The local box picking and sorting work from: explicit instance bounds
221
+ * when the mesh is instanced (null without them - no leaf, no hits), the
222
+ * unit box for a sprite, the geometry's own bounds otherwise. */
223
+ export function localBounds(mesh: Mesh): Float32Array | null {
224
+ if (mesh._instances !== null) return mesh._instances.bounds
225
+ return mesh._sprite ? SPRITE_BOUNDS : geometryBounds(mesh.geometry)
226
+ }
227
+
228
+ const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
229
+
230
+ /** Floats per record of one instance slot of an attribute list (slot 0
231
+ * by default; the list's `slot` keys pick the others). */
232
+ export function instanceStride(attributes: InstanceAttribute[], slot = 0): number {
233
+ let stride = 0
234
+ for (let a of attributes) if ((a.slot ?? 0) === slot) stride += ATTRIBUTE_FLOATS[a.format]
235
+ return stride
236
+ }
237
+
238
+ // The highest instance slot an attribute list names.
239
+ function instanceSlots(attributes: InstanceAttribute[]): number {
240
+ let top = 0
241
+ for (let a of attributes) top = Math.max(top, a.slot ?? 0)
242
+ return top + 1
243
+ }
244
+
245
+ /** Floats per instance record on an instanced mesh: one column-major
246
+ * mat4, the instance's placement inside the mesh (the material reads it
247
+ * through INSTANCE_MATRIX_ATTRIBUTES / INSTANCE_MATRIX in ./glsl). */
248
+ export const INSTANCE_FLOATS = 16
249
+ // Instance slots an instanced mesh reserves without a capacity; growth
250
+ // doubles past it.
251
+ const DEFAULT_CAPACITY = 64
252
+ // The instance buffers a mesh binds: the matrix slot and the style slot.
253
+ const MESH_INSTANCE_SLOTS = 2
254
+
255
+ /** What both population kinds take. */
256
+ type PopulationOptions = {
257
+ /** LOCAL bounds covering the population ([minX, minY, minZ, maxX, maxY,
258
+ * maxZ] - geometryBounds' shape), copied in: the mesh node's own box,
259
+ * what the frustum test and the transparent sort use. Absent, an
260
+ * instanced mesh culls by the union of its live instances' boxes (the
261
+ * core follows them) and sorts by its node position; a record mesh is
262
+ * never culled. */
263
+ bounds?: ArrayLike<number>
264
+ /** Debug label for the record buffer. */
265
+ label?: string
266
+ }
267
+
268
+ export type InstancedMeshOptions = PopulationOptions & {
269
+ /** Instance slots reserved up front (default 64). addInstance past it
270
+ * doubles the buffer: a replacement whose live records the core moves
271
+ * in one call and republishes at the next flush - amortized like a
272
+ * dynamic array; reserve realistically to skip the copies. */
273
+ capacity?: number
274
+ }
275
+
276
+ /** A record mesh's bounds are also its ONLY picking leaf: records are
277
+ * opaque data, so without them the mesh never picks and pointer events
278
+ * never target it. */
279
+ export type RecordMeshOptions = PopulationOptions
280
+
281
+ function copyBounds(bounds: ArrayLike<number> | undefined, site: string): Float32Array | null {
282
+ if (bounds === undefined) return null
283
+ if (bounds.length !== 6) throw new Error(site + ": bounds must be [minX, minY, minZ, maxX, maxY, maxZ]")
284
+ let out = new Float32Array(6)
285
+ for (let i = 0; i < 6; i++) out[i] = bounds[i]!
286
+ return out
287
+ }
288
+
289
+ function instancedAttributes(material: Material, site: string): InstanceAttribute[] {
290
+ let attributes = material.instanceAttributes
291
+ if (attributes === undefined) {
292
+ throw new Error(site + ": the material declares no instanceAttributes - build it with shaderMaterialClass({ instanceAttributes: [...] }) or a stock material's `instanced`")
293
+ }
294
+ return attributes
295
+ }
296
+
297
+ /**
298
+ * Whether a material's instance attributes fit a mesh's population: the
299
+ * slot-0 stride must be the mesh's record stride, a slot-1 layout must
300
+ * match the mesh's style stride (a record mesh has no slot 1), and no
301
+ * slot beyond. Throws otherwise - at creation for the mesh's own
302
+ * material, at add() for a swapped one, at a shadow view's attach for a
303
+ * shadow variant.
304
+ */
305
+ export function checkInstancePairing(material: Material, inst: MeshInstances, site: string): void {
306
+ let attrs = instancedAttributes(material, site)
307
+ let slots = instanceSlots(attrs)
308
+ if (slots > MESH_INSTANCE_SLOTS) {
309
+ throw new Error(site + ": instance attributes name slot " + (slots - 1) + "; a mesh binds slots 0 (records) and 1 (style) only")
310
+ }
311
+ let stride = instanceStride(attrs, 0)
312
+ if (stride !== inst.stride) {
313
+ throw new Error(site + ": the material's slot-0 instance attributes take " + stride + " floats but the mesh's records are " + inst.stride)
314
+ }
315
+ let styleStride = instanceStride(attrs, 1)
316
+ if (styleStride > 0) {
317
+ if (inst.style === null) {
318
+ throw new Error(site + ": the material declares slot-1 (style) instance attributes but the mesh has no style records" + (inst.nodes === null ? " (a record mesh is slot 0 only)" : ""))
319
+ }
320
+ if (styleStride !== inst.style.stride) {
321
+ throw new Error(site + ": the material's slot-1 (style) attributes take " + styleStride + " floats but the mesh's style records are " + inst.style.stride)
322
+ }
323
+ }
324
+ }
325
+
326
+ /** The instance-buffer binding of a draw entry for a material over a
327
+ * population: the plain slot-0 key, or both slots when the material's
328
+ * pipeline reads the style records too (the two spellings are exclusive
329
+ * and must match the pipeline's slot count exactly). */
330
+ export function instanceBinding(material: Material, inst: MeshInstances): { instanceBuffer?: BufferId; instanceBuffers?: BufferId[] } {
331
+ let attrs = material.instanceAttributes
332
+ if (attrs !== undefined && inst.style !== null && instanceSlots(attrs) > 1) return { instanceBuffers: [inst.buffer, inst.style.buffer] }
333
+ return { instanceBuffer: inst.buffer }
334
+ }
335
+
336
+ /**
337
+ * A mesh drawing `geometry` once per instance NODE: one draw entry, one
338
+ * uModel placing the whole population, and N instances (addInstance)
339
+ * that are scene nodes of their own, whose placement inside the mesh the
340
+ * core writes into the record buffer - so native transitions, clip
341
+ * players and picking reach every instance, and JS writes nothing per
342
+ * frame. Three's InstancedMesh count constructor over Unity's
343
+ * one-transform-per-instance model; for records only JS can compute at
344
+ * scale, createRecordMesh is the raw form. The material must declare the
345
+ * instance-matrix attributes in slot 0 (INSTANCE_MATRIX_ATTRIBUTES, read
346
+ * through INSTANCE_MATRIX in ./glsl as `uModel * instanceMatrix()` - a
347
+ * stock material's `instanced` does both); anything else throws here.
348
+ * Slot-1 attributes, when the material declares any (a stock material's
349
+ * `instanceColors`, a custom class's own layout), give every instance a
350
+ * STYLE record beside its matrix: app-owned floats written with
351
+ * setInstanceStyle, starting from the material's instanceStyle.
352
+ *
353
+ * The result is an ordinary Mesh: add/remove, setTransform (the whole
354
+ * population), setVisible (hiding zeroes the drawn count, unhiding
355
+ * restores it), setMeshParams and renderOrder all apply; disposeInstances
356
+ * frees the record buffers when done for good.
357
+ */
358
+ export function createInstancedMesh(geometry: Geometry, material: Material, opts?: InstancedMeshOptions): InstancedMesh {
359
+ let attrs = instancedAttributes(material, "createInstancedMesh")
360
+ let stride = instanceStride(attrs, 0)
361
+ if (stride !== INSTANCE_FLOATS) {
362
+ throw new Error(
363
+ "createInstancedMesh: the material's slot-0 instanceAttributes take " + stride + " floats per record; an instance record is the " + INSTANCE_FLOATS + "-float matrix of INSTANCE_MATRIX_ATTRIBUTES",
364
+ )
365
+ }
366
+ let capacity = opts?.capacity ?? DEFAULT_CAPACITY
367
+ if (!(Number.isInteger(capacity) && capacity > 0)) {
368
+ throw new Error("createInstancedMesh: capacity must be a positive integer, got " + capacity)
369
+ }
370
+ let styleStride = instanceStride(attrs, 1)
371
+ let style: InstanceStyle | null = null
372
+ if (styleStride > 0) {
373
+ let blank = new Float32Array(styleStride)
374
+ if (material.instanceStyle !== undefined) {
375
+ if (material.instanceStyle.length !== styleStride) {
376
+ throw new Error("createInstancedMesh: the material's instanceStyle has " + material.instanceStyle.length + " floats but its slot-1 attributes take " + styleStride)
377
+ }
378
+ blank.set(material.instanceStyle)
379
+ }
380
+ style = {
381
+ buffer: createBuffer(capacity * styleStride * 4, { autoFree: false, label: opts?.label === undefined ? undefined : opts.label + "-style" }),
382
+ stride: styleStride,
383
+ data: new Float32Array(capacity * styleStride),
384
+ blank,
385
+ dirty: null,
386
+ }
387
+ }
388
+ let mesh = createMesh(geometry, material) as InstancedMesh
389
+ mesh._instances = {
390
+ buffer: createBuffer(capacity * INSTANCE_FLOATS * 4, { autoFree: false, label: opts?.label }),
391
+ stride: INSTANCE_FLOATS,
392
+ capacity,
393
+ label: opts?.label,
394
+ count: 0,
395
+ bounds: copyBounds(opts?.bounds, "createInstancedMesh"),
396
+ nodes: { slots: [], free: [] },
397
+ style,
398
+ }
399
+ checkInstancePairing(material, mesh._instances, "createInstancedMesh")
400
+ return mesh
401
+ }
402
+
403
+ // Extend a style's dirty range over [lo, hi) floats of the mirror and
404
+ // ask the scene to publish it at the next sync.
405
+ function markStyle(mesh: InstancedMesh, style: InstanceStyle, lo: number, hi: number): void {
406
+ if (style.dirty === null) style.dirty = [lo, hi]
407
+ else {
408
+ if (lo < style.dirty[0]) style.dirty[0] = lo
409
+ if (hi > style.dirty[1]) style.dirty[1] = hi
410
+ }
411
+ mesh._scene?._setStyle(mesh)
412
+ }
413
+
414
+ /**
415
+ * Write an instance's style record - the floats of the material's
416
+ * slot-1 instance attributes, in order (`[r, g, b, a]` for a stock
417
+ * material's instanceColors): the app's per-copy data beside the matrix
418
+ * the core writes. Any number of instances styled between two frames
419
+ * cost one coalesced buffer write at the scene's sync, so this is a
420
+ * frame-rate path like setTransform. Throws on a material without
421
+ * slot-1 attributes or a record of the wrong length; a no-op on a
422
+ * removed instance.
423
+ */
424
+ export function setInstanceStyle(instance: InstanceNode, values: ArrayLike<number>): void {
425
+ let mesh = instance.mesh
426
+ if (mesh === null) return
427
+ let style = mesh._instances.style
428
+ if (style === null) throw new Error("setInstanceStyle: the mesh's material declares no slot-1 (style) instance attributes")
429
+ if (values.length !== style.stride) {
430
+ throw new Error("setInstanceStyle: a style record is " + style.stride + " floats, got " + values.length)
431
+ }
432
+ let at = instance._slot * style.stride
433
+ style.data.set(values, at)
434
+ markStyle(mesh, style, at, at + style.stride)
435
+ }
436
+
437
+ /** Publish an instanced mesh's pending style writes as one buffer write
438
+ * (the scene calls it from its sync; nothing to do without a dirty
439
+ * range). */
440
+ export function publishInstanceStyle(mesh: InstancedMesh): void {
441
+ let style = mesh._instances.style
442
+ if (style === null || style.dirty === null) return
443
+ let [lo, hi] = style.dirty
444
+ style.dirty = null
445
+ writeBuffer(style.buffer, style.data.subarray(lo, hi), lo * 4)
446
+ }
447
+
448
+ /**
449
+ * Add an instance to an instanced mesh: a scene node placed by `update`
450
+ * (position/rotation/quaternion/scale; absent keys at their identity)
451
+ * under `parent` - the mesh itself by default, or a node inside the
452
+ * mesh's subtree (a squad group within a fleet: the record stays
453
+ * mesh-relative through it). Its record slot is fixed for its life (a
454
+ * removed instance's slot recycles to the next add); past the
455
+ * reservation the buffer doubles. From here on it is a node like any
456
+ * other: setTransform/setTransition/setVisible, lookAt, worldPosition,
457
+ * pointer handlers, children of its own (a headlight mesh under a car
458
+ * instance) - and removeInstance, never remove.
459
+ */
460
+ export function addInstance(mesh: InstancedMesh, update?: TransformUpdate, parent: SceneNode = mesh): InstanceNode {
461
+ let inst: (MeshInstances & { nodes: InstanceSlots }) | null = mesh._instances
462
+ if (inst === null) throw new Error("addInstance: the mesh's instances are disposed")
463
+ for (let p: SceneNode | null = parent; p !== mesh; p = p.parent) {
464
+ if (p === null) throw new Error("addInstance: parent must be the mesh or a node inside its subtree")
465
+ }
466
+ let slot = inst.nodes.free.pop() ?? inst.nodes.slots.length
467
+ if (slot >= inst.capacity) growInstances(mesh, inst.capacity * 2)
468
+ let instance = makeNode("instance") as InstanceNode
469
+ instance.mesh = mesh
470
+ ;(instance as { _slot: number })._slot = slot
471
+ if (update !== undefined) setTransform(instance, update)
472
+ inst.nodes.slots[slot] = instance
473
+ instance.parent = parent
474
+ parent.children.push(instance)
475
+ if (slot >= inst.count) {
476
+ inst.count = slot + 1
477
+ mesh._scene?._setCount(mesh)
478
+ }
479
+ // A recycled slot must not wear its last occupant's style.
480
+ let style = inst.style
481
+ if (style !== null) {
482
+ let at = slot * style.stride
483
+ style.data.set(style.blank, at)
484
+ markStyle(mesh, style, at, at + style.stride)
485
+ }
486
+ if (parent._scene) enterScene(instance, parent._scene)
487
+ return instance
488
+ }
489
+
490
+ // Double the record buffers: replacements (never a resize) the live
491
+ // instances' record sinks move to in one core call, republished whole at
492
+ // the next flush, and the style mirror re-published whole from JS; the
493
+ // entry re-points, then the old buffers, which the entry held alive until
494
+ // now, are freed.
495
+ function growInstances(mesh: InstancedMesh, next: number): void {
496
+ let inst = mesh._instances
497
+ let previous = inst.buffer
498
+ inst.buffer = createBuffer(next * INSTANCE_FLOATS * 4, { autoFree: false, label: inst.label })
499
+ inst.capacity = next
500
+ // Out of a scene nothing is bound, and retargeting an empty source throws.
501
+ if (inst.nodes.slots.some(n => n !== null && n._node !== null)) spatial.retargetRecords(previous, inst.buffer)
502
+ let style = inst.style
503
+ let previousStyle: BufferId | null = null
504
+ if (style !== null) {
505
+ previousStyle = style.buffer
506
+ style.buffer = createBuffer(next * style.stride * 4, { autoFree: false, label: inst.label === undefined ? undefined : inst.label + "-style" })
507
+ let grown = new Float32Array(next * style.stride)
508
+ grown.set(style.data)
509
+ style.data = grown
510
+ style.dirty = null
511
+ markStyle(mesh, style, 0, inst.count * style.stride)
512
+ }
513
+ mesh._scene?._setBuffer(mesh)
514
+ destroyBuffer(previous)
515
+ if (previousStyle !== null) destroyBuffer(previousStyle)
516
+ }
517
+
518
+ /**
519
+ * Destroy an instance: its slot hides at the next flush and recycles to
520
+ * the next addInstance, and the handle goes inert (`mesh` null; later
521
+ * writes are no-ops). Its children leave the scene with it and stay
522
+ * parented to the dead node - remove() one to re-add it elsewhere. Remove
523
+ * means destroy here, as for a 2d sprite: an instance cannot exist
524
+ * outside its mesh.
525
+ */
526
+ export function removeInstance(instance: InstanceNode): void {
527
+ let mesh = instance.mesh
528
+ if (mesh === null) return
529
+ if (instance._scene) leaveScene(instance)
530
+ let parent = instance.parent
531
+ if (parent !== null) {
532
+ let i = parent.children.indexOf(instance)
533
+ if (i >= 0) parent.children.splice(i, 1)
534
+ instance.parent = null
535
+ }
536
+ let nodes = mesh._instances.nodes
537
+ nodes.slots[instance._slot] = null
538
+ nodes.free.push(instance._slot)
539
+ instance.mesh = null
540
+ }
541
+
542
+ /**
543
+ * A mesh drawing `geometry` once per record of `records`: one draw entry,
544
+ * one uModel write, N instances whose per-copy data is a few JS-written
545
+ * floats - the raw escape hatch for motion only JS can compute at scale
546
+ * (a particle sim, a crowd stepped in a worker), Three's InstancedMesh
547
+ * and Godot's MultiMesh as plain data. The material must declare
548
+ * `instanceAttributes` (shaderMaterialClass); its vertex stage reads each
549
+ * record through those `in` variables. `records` is the interleaved
550
+ * attribute data (stride = the attributes' floats summed) and is uploaded
551
+ * here; its length is the buffer's initial capacity, which setRecords
552
+ * grows past on demand. `count` limits how many records draw (default
553
+ * all), up to capacity. Everything mesh applies as on an instanced mesh;
554
+ * the population is written with setRecords, dialed with setRecordCount,
555
+ * and its buffer freed with disposeInstances.
556
+ */
557
+ export function createRecordMesh(
558
+ geometry: Geometry,
559
+ material: Material,
560
+ records: Float32Array,
561
+ count?: number,
562
+ opts?: RecordMeshOptions,
563
+ ): RecordMesh {
564
+ let attrs = instancedAttributes(material, "createRecordMesh")
565
+ let stride = instanceStride(attrs, 0)
566
+ if (records.length % stride !== 0) {
567
+ throw new Error("createRecordMesh: " + records.length + " floats is not a whole number of " + stride + "-float records")
568
+ }
569
+ let capacity = records.length / stride
570
+ let mesh = createMesh(geometry, material) as RecordMesh
571
+ mesh._instances = {
572
+ buffer: createBuffer(records, { autoFree: false, label: opts?.label }),
573
+ stride,
574
+ capacity,
575
+ label: opts?.label,
576
+ count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
577
+ bounds: copyBounds(opts?.bounds, "createRecordMesh"),
578
+ nodes: null,
579
+ style: null,
580
+ }
581
+ checkInstancePairing(material, mesh._instances, "createRecordMesh")
582
+ return mesh
583
+ }
584
+
585
+ /**
586
+ * Overwrite a record mesh's records from the start of its buffer and (by
587
+ * default) draw exactly the records written - pass `count` to draw fewer,
588
+ * or to keep more previously written ones alive past a partial rewrite.
589
+ * More records than the buffer holds grow it: capacity doubles (or jumps
590
+ * to the records written when that is more), a new buffer is created and
591
+ * written, the mesh's entry is re-pointed at it and the old buffer freed
592
+ * - so a population grows without a new mesh, with the copies amortized
593
+ * like any dynamic array (size the initial records to skip them).
594
+ * Frame-rate-safe like setMeshParams when no growth happens.
595
+ */
596
+ export function setRecords(mesh: RecordMesh, records: Float32Array, count?: number): void {
597
+ let inst = mesh._instances
598
+ if (records.length % inst.stride !== 0) {
599
+ throw new Error("setRecords: " + records.length + " floats is not a whole number of " + inst.stride + "-float records")
600
+ }
601
+ let written = records.length / inst.stride
602
+ if (written > inst.capacity) {
603
+ let previous = inst.buffer
604
+ inst.capacity = Math.max(written, inst.capacity * 2)
605
+ inst.buffer = createBuffer(inst.capacity * inst.stride * 4, { autoFree: false, label: inst.label })
606
+ mesh._scene?._setBuffer(mesh)
607
+ destroyBuffer(previous)
608
+ }
609
+ writeBuffer(inst.buffer, records)
610
+ setRecordCount(mesh, count ?? written)
611
+ }
612
+
613
+ /** Set how many records draw (clamped to [0, capacity]). The visibility
614
+ * switch composes: a hidden mesh stores the count and draws it on unhide. */
615
+ export function setRecordCount(mesh: RecordMesh, count: number): void {
616
+ let inst = mesh._instances
617
+ let n = Math.max(0, Math.min(Math.floor(count), inst.capacity))
618
+ if (n === inst.count) return
619
+ inst.count = n
620
+ mesh._scene?._setCount(mesh)
621
+ }
622
+
623
+ /**
624
+ * Detach the mesh (if attached) and free its record buffers (the style
625
+ * buffer with the matrix one). The buffers are mesh-owned with no
626
+ * reference count (unlike geometry buffers they are never shared), so
627
+ * this is the one explicit free; the mesh cannot be re-added afterwards.
628
+ * An instanced mesh's instances go inert with it.
629
+ */
630
+ export function disposeInstances(mesh: InstancedMesh | RecordMesh): void {
631
+ let inst: MeshInstances | null = mesh._instances
632
+ if (inst === null) return
633
+ if (mesh._scene) remove(mesh)
634
+ if (inst.nodes !== null) {
635
+ for (let n of inst.nodes.slots) if (n !== null) n.mesh = null
636
+ // The destroyed instance nodes' hiding writes land now, while the
637
+ // buffer still exists (a write into a freed buffer warns).
638
+ spatial.flush()
639
+ }
640
+ destroyBuffer(inst.buffer)
641
+ if (inst.style !== null) destroyBuffer(inst.style.buffer)
642
+ ;(mesh as Mesh)._instances = null
643
+ }
644
+
645
+ /** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
646
+ export function setRenderOrder(mesh: Mesh, order: number): void {
647
+ if (mesh.renderOrder === order) return
648
+ mesh.renderOrder = order
649
+ mesh._scene?._reorder()
650
+ }
651
+
652
+ /**
653
+ * Set a mesh's layer membership (bitmask, default 1). A target draws the
654
+ * mesh when its mask intersects this: the scene's own mask (`layers` on
655
+ * createScene, `scene.setLayers`), each view's (`layers` on createView,
656
+ * `view.setLayers`); shadow views follow the scene's. A mesh masked out of
657
+ * the scene is also skipped by pick()/raycast(), like an invisible one -
658
+ * unless a raycast passes its own mask (QueryOptions.layers), which is
659
+ * how an undrawn collision-only mesh stays queryable. `layers: 0` draws
660
+ * nowhere. Not inherited from ancestor Groups.
661
+ */
662
+ export function setLayers(mesh: Mesh, layers: number): void {
663
+ checkMask(layers, "setLayers")
664
+ if (mesh.layers === layers) return
665
+ mesh.layers = layers
666
+ mesh._scene?._setLayers(mesh)
667
+ }
668
+
669
+ /** Draw the mesh into the scene's shadow map, or stop (see Mesh.castShadow). */
670
+ export function setCastShadow(mesh: Mesh, cast: boolean): void {
671
+ if (mesh.castShadow === cast) return
672
+ mesh.castShadow = cast
673
+ mesh._scene?._setCast(mesh)
674
+ }
675
+
676
+ /** Frustum culling per mesh: `frustumCulled` (default true) switches the
677
+ * gate, `cullMargin` (world units, default 0) grows the tested box. */
678
+ export function setCulling(mesh: Mesh, options: { frustumCulled?: boolean; cullMargin?: number }): void {
679
+ let culled = options.frustumCulled ?? mesh.frustumCulled
680
+ let margin = options.cullMargin ?? mesh.cullMargin
681
+ if (!(margin >= 0)) throw new Error("setCulling: cullMargin must be >= 0")
682
+ if (culled === mesh.frustumCulled && margin === mesh.cullMargin) return
683
+ mesh.frustumCulled = culled
684
+ mesh.cullMargin = margin
685
+ if (mesh._node !== null) {
686
+ spatial.setCull(mesh._node, culled, margin)
687
+ mesh._scene?._schedule()
688
+ }
689
+ }
690
+
691
+ /** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
692
+ * the list, so the mesh keeps its place). */
693
+ export function setGeometry(mesh: Mesh, geometry: Geometry): void {
694
+ if (mesh._sprite) throw new Error("setGeometry: a sprite draws the shared unit quad and takes no geometry")
695
+ if (mesh.geometry === geometry) return
696
+ mesh.geometry = geometry
697
+ rebuildEntry(mesh)
698
+ }
699
+
700
+ /** Swap a mesh's material: its draw entry is rebuilt. */
701
+ export function setMaterial(mesh: Mesh, material: Material): void {
702
+ if (mesh.material === material) return
703
+ mesh.material = material
704
+ rebuildEntry(mesh)
705
+ }
706
+
707
+ function rebuildEntry(mesh: Mesh): void {
708
+ let scene = mesh._scene
709
+ if (scene) {
710
+ scene._detach(mesh)
711
+ scene._attach(mesh)
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Write per-mesh uniforms - the channel for a custom material's app-driven
717
+ * values (a time, a per-object tint). Names must be
718
+ * declared and used by the mesh's material shaders (unknown names throw at
719
+ * the call site, the engine's validation contract). Values persist on the
720
+ * mesh: they survive geometry/material entry rebuilds and re-apply then.
721
+ * Also the frame-rate path - like setTransform, call it from onFrame
722
+ * freely.
723
+ */
724
+ export function setMeshParams(mesh: Mesh, params: ShaderParams): void {
725
+ if (mesh._params === null) mesh._params = {}
726
+ Object.assign(mesh._params, params)
727
+ mesh._scene?._setParams(mesh, params)
728
+ }