@solidrt/3d 0.0.49 → 0.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scene.ts CHANGED
@@ -3,10 +3,12 @@
3
3
  // component boundary (components.tsx). A scene compiles to one draw
4
4
  // target: every mesh is one draw entry whose uModel (and, for materials
5
5
  // declaring it, uNormal) this module keeps in step with the tree, and the
6
- // camera is the target's SHARED uViewProj + uCamPos - one setTargetParams
7
- // per camera move, not one write per mesh. uCamPos rides unconditionally:
8
- // shared params tolerate zero coverage (stored and skipped until a
9
- // declaring material arrives), so no bookkeeping tracks who reads it.
6
+ // camera is the target's SHARED uViewProj + uCamPos + uCamRight/uCamUp -
7
+ // one setTargetParams per camera move, not one write per mesh. The
8
+ // non-matrix names ride unconditionally: shared params tolerate zero
9
+ // coverage (stored and skipped until a declaring material arrives), so no
10
+ // bookkeeping tracks who reads them. scene.setParams merges app-owned
11
+ // names into the same set.
10
12
  // Mutations batch to a microtask, so a burst of writes (a whole subtree
11
13
  // moved, many effects in one flush) syncs once.
12
14
  //
@@ -17,17 +19,20 @@
17
19
  // each write lands here, the microtask syncs the affected uModels, and the
18
20
  // flush renders once that frame.
19
21
 
20
- import { addDraw, createDrawTarget, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
21
- 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"
22
24
  import { getOwner, onCleanup } from "@solidrt/core"
23
25
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
24
26
  // The scene's lookAt() aims a node; math's builds a camera's view matrix -
25
27
  // the same pairing (and the same name) as Three's Object3D/Matrix4.
26
- import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
27
- import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
28
- 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"
29
33
  import type { Geometry } from "./geometry.ts"
30
34
  import { backgroundPipeline } from "./material.ts"
35
+ import { orderEntries } from "./order.ts"
31
36
  import type { Material } from "./material.ts"
32
37
  import { createBvh, rayBoxDistance } from "./bvh.ts"
33
38
 
@@ -51,6 +56,10 @@ let upScratch: Vec3 = [0, 0, 0]
51
56
  let pickInv = mat4()
52
57
  let pickOrigin: Vec4 = [0, 0, 0, 0]
53
58
  let pickDir: Vec3 = [0, 0, 0]
59
+ // setTransform's rotation compare happens AFTER conversion, so an euler and
60
+ // the quaternion it produces are the same write. Nothing outlives the call.
61
+ let rotScratch = quat()
62
+ let scaleScratch: Vec3 = [1, 1, 1]
54
63
 
55
64
  // The scene half a node needs to reach: attach/detach entries and schedule
56
65
  // a sync. Kept separate from the public Scene type so internals stay off
@@ -62,6 +71,8 @@ type SceneHooks = {
62
71
  _attach(mesh: Mesh): void
63
72
  _detach(mesh: Mesh): void
64
73
  _setParams(mesh: Mesh, params: ShaderParams): void
74
+ _setCount(mesh: Mesh): void
75
+ _reorder(): void
65
76
  }
66
77
 
67
78
  export type SceneNode = {
@@ -97,13 +108,55 @@ export type Mesh = SceneNode & {
97
108
  kind: "mesh"
98
109
  geometry: Geometry
99
110
  material: Material
111
+ /** Explicit draw-order key (default 0), Three's name: lower draws first.
112
+ * Sorts within the opaque group and within the transparent group; the
113
+ * transparent group always follows the opaque one. Set with setRenderOrder. */
114
+ renderOrder: number
100
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
120
+ /** material.transparent as of the last attach - the entry's actual
121
+ * pipeline state, and what _detach counts against (setMaterial swaps
122
+ * mesh.material before the rebuild). */
123
+ _transparent: boolean
124
+ /** World-space center of the geometry bounds, kept by the sync walk
125
+ * beside the picking leaf: the transparent sort key. */
126
+ _center: Vec3
101
127
  _hidden: boolean
102
128
  _fresh: boolean
103
129
  _params: ShaderParams | null
104
130
  _pickLeaf: number | null
131
+ /** Instance state when the mesh was made by createInstancedMesh; null on
132
+ * an ordinary mesh. */
133
+ _instances: MeshInstances | null
105
134
  }
106
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
+
107
160
  /** One picking intersection: the mesh, the camera-ray distance in world
108
161
  * units, and the world-space point - Three's intersect result minus the
109
162
  * triangle fields (`face`, `uv`), which cannot exist at the volume tier. */
@@ -184,6 +237,15 @@ export type Scene = {
184
237
  /** Partial camera update; absent keys keep their current value. */
185
238
  setCamera(update: CameraUpdate): void
186
239
  setSize(width: number, height: number): void
240
+ /**
241
+ * Scene-wide uniforms: merge app-owned names into the target's SHARED
242
+ * params, beside the standard uViewProj/uCamPos/uCamRight/uCamUp the
243
+ * camera writes. One write per frame however many meshes read the name
244
+ * (a clock, a sun direction, fog) - the per-mesh channel is
245
+ * setMeshParams. Merge semantics, no unset; a material that does not
246
+ * declare a name simply skips it. Frame-rate-safe like setTransform.
247
+ */
248
+ setParams(params: ShaderParams): void
187
249
  /**
188
250
  * Set, replace, or remove (null) the scene's background: fragment GLSL
189
251
  * drawn as the FIRST entry of the scene's own pass - one target, no
@@ -249,9 +311,10 @@ export type Scene = {
249
311
  * layout just works: `scene.handlersFor(() => ({ width: w(), height:
250
312
  * h() }))`. */
251
313
  handlersFor(layout: () => { width: number; height: number }): SceneHandlers
252
- /** Destroy the target (entries die with it). Idempotent. Geometry
253
- * buffers and material pipelines are shared and survive - they are
254
- * 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). */
255
318
  dispose(): void
256
319
  }
257
320
 
@@ -279,14 +342,149 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
279
342
  let mesh = makeNode("mesh") as Mesh
280
343
  mesh.geometry = geometry
281
344
  mesh.material = material
345
+ mesh.renderOrder = 0
282
346
  mesh._entry = null
347
+ mesh._buffers = null
348
+ mesh._transparent = false
349
+ mesh._center = [0, 0, 0]
283
350
  mesh._hidden = false
284
351
  mesh._fresh = false
285
352
  mesh._params = null
286
353
  mesh._pickLeaf = null
354
+ mesh._instances = null
287
355
  return mesh
288
356
  }
289
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
+
290
488
  /** Attach `child` under `parent` (re-parenting detaches it first). */
291
489
  export function add(parent: SceneNode, child: SceneNode): void {
292
490
  if (child.parent !== null) remove(child)
@@ -322,51 +520,52 @@ function leaveScene(node: SceneNode): void {
322
520
  for (let c of node.children) leaveScene(c)
323
521
  }
324
522
 
325
- export type TransformUpdate = {
326
- position?: Vec3
327
- /** Euler radians in XYZ order (x first), Three's `Euler` default -
328
- * converted to the node's quaternion on write. */
329
- rotation?: Vec3
330
- /** The rotation itself. Normalized on write, so a hand-built or
331
- * drifted quaternion cannot silently scale the geometry. Passing this
332
- * together with `rotation` is an error, not a precedence question. */
333
- quaternion?: Quat
334
- /** A number is uniform scale. */
335
- scale?: Vec3 | number
336
- }
523
+ export type { TransformUpdate } from "./math.ts"
337
524
 
338
525
  /**
339
526
  * The one write path for node transforms (so the scene knows to sync).
340
527
  * Values are copied in; absent keys keep their current value. This is also
341
528
  * the frame-rate escape hatch: call it from onFrame on a node grabbed via
342
529
  * `ref`, bypassing signals entirely.
530
+ *
531
+ * A write that changes nothing schedules nothing, so driving every node
532
+ * unconditionally from onFrame costs only the compare for the nodes that
533
+ * did not move. Rotation is compared after conversion, so passing an euler
534
+ * equal to the node's current quaternion is also a no-op.
343
535
  */
344
536
  export function setTransform(node: SceneNode, update: TransformUpdate): void {
537
+ // A no-op write costs nothing: driving every node from onFrame is the
538
+ // intended shape, and most nodes did not move. Exact compares, like
539
+ // setVisible - a value that survives a float round trip unchanged is the
540
+ // same value, and an epsilon would need a scale-dependent one anyway.
541
+ let changed = false
345
542
  let p = update.position
346
- if (p) {
543
+ if (p && (p[0] !== node.position[0] || p[1] !== node.position[1] || p[2] !== node.position[2])) {
347
544
  node.position[0] = p[0]
348
545
  node.position[1] = p[1]
349
546
  node.position[2] = p[2]
547
+ changed = true
350
548
  }
351
- let r = update.rotation
352
- let q = update.quaternion
353
- if (r !== undefined && q !== undefined) {
354
- throw new Error("Pass rotation or quaternion to setTransform, not both")
549
+ if (updateRotation(rotScratch, update, "setTransform")) {
550
+ let n = node.quaternion
551
+ if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
552
+ n[0] = rotScratch[0]
553
+ n[1] = rotScratch[1]
554
+ n[2] = rotScratch[2]
555
+ n[3] = rotScratch[3]
556
+ changed = true
557
+ }
355
558
  }
356
- if (r !== undefined) quatFromEuler(node.quaternion, r)
357
- else if (q !== undefined) quatNormalize(node.quaternion, q)
358
- let s = update.scale
359
- if (s !== undefined) {
360
- if (typeof s === "number") {
361
- node.scale[0] = s
362
- node.scale[1] = s
363
- node.scale[2] = s
364
- } else {
365
- node.scale[0] = s[0]
366
- node.scale[1] = s[1]
367
- node.scale[2] = s[2]
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]
565
+ changed = true
368
566
  }
369
567
  }
568
+ if (!changed) return
370
569
  node._localDirty = true
371
570
  node._scene?._schedule()
372
571
  }
@@ -482,8 +681,15 @@ export function setVisible(node: SceneNode, visible: boolean): void {
482
681
  node._scene?._schedule()
483
682
  }
484
683
 
485
- /** Swap a mesh's geometry: its draw entry is rebuilt (appended last -
486
- * order is irrelevant while every entry is opaque and depth-tested). */
684
+ /** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
685
+ export function setRenderOrder(mesh: Mesh, order: number): void {
686
+ if (mesh.renderOrder === order) return
687
+ mesh.renderOrder = order
688
+ mesh._scene?._reorder()
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). */
487
693
  export function setGeometry(mesh: Mesh, geometry: Geometry): void {
488
694
  if (mesh.geometry === geometry) return
489
695
  mesh.geometry = geometry
@@ -545,18 +751,41 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
545
751
  let capture = new Map<number, Mesh>()
546
752
  let hover = new Map<number, Mesh>()
547
753
 
548
- // Live mesh entries in list (draw) order, so a background set after
549
- // meshes exist can insert BEFORE the first one. Mesh entries append;
550
- // rebuilds re-append; the background entry never joins this list.
551
- let entryOrder: DrawId[] = []
754
+ // Live meshes (those holding a draw entry) in add order; the background
755
+ // entry never joins this list. Draw order is derived from it by
756
+ // orderEntries (order.ts) whenever orderDirty. Camera moves and
757
+ // transparent-mesh moves only dirty the order when two or more transparent
758
+ // meshes exist - fewer cannot change relative order.
759
+ let meshes: Mesh[] = []
760
+ let transparentCount = 0
761
+ let orderDirty = false
762
+ // The order last handed to the engine: a resort that lands on the same
763
+ // permutation (the common case under a moving camera) issues nothing.
764
+ let lastOrder: DrawId[] = []
552
765
  let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
766
+ let sortEntries = () => {
767
+ orderDirty = false
768
+ let order = orderEntries(meshes, view, background?.entry)
769
+ if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
770
+ lastOrder = order
771
+ setDrawOrder(texture, order)
772
+ }
553
773
 
554
774
  // Reinsert or refit a mesh's broadphase leaf from its fresh world matrix:
555
775
  // the local box's center/extents carried through the absolute matrix (the
556
776
  // standard tight-AABB-of-a-transformed-AABB construction).
557
777
  let updateLeaf = (mesh: Mesh): void => {
558
- let b = geometryBounds(mesh.geometry)
778
+ let b = localBounds(mesh)
559
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
+ }
560
789
  let cx = (b[0]! + b[3]!) / 2
561
790
  let cy = (b[1]! + b[4]!) / 2
562
791
  let cz = (b[2]! + b[5]!) / 2
@@ -566,6 +795,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
566
795
  let wx = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
567
796
  let wy = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
568
797
  let wz = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
798
+ mesh._center[0] = wx
799
+ mesh._center[1] = wy
800
+ mesh._center[2] = wz
569
801
  let rx = Math.abs(m[0]) * ex + Math.abs(m[4]) * ey + Math.abs(m[8]) * ez
570
802
  let ry = Math.abs(m[1]) * ex + Math.abs(m[5]) * ey + Math.abs(m[9]) * ez
571
803
  let rz = Math.abs(m[2]) * ex + Math.abs(m[6]) * ey + Math.abs(m[10]) * ez
@@ -610,7 +842,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
610
842
  // holds. Entries are untouched - uModel is camera-independent, and
611
843
  // uCamPos is stored even when no current material declares it.
612
844
  cameraPending = false
613
- setTargetParams(texture, { uViewProj: viewProj, uCamPos: eye })
845
+ // The camera basis rides along: the view matrix's first two rows are
846
+ // the camera's world-space right and up (no clip flip - that lives in
847
+ // the projection), so a billboard needs no reconstruction from uViewProj.
848
+ setTargetParams(texture, {
849
+ uViewProj: viewProj,
850
+ uCamPos: eye,
851
+ uCamRight: [view[0], view[4], view[8]],
852
+ uCamUp: [view[1], view[5], view[9]],
853
+ })
854
+ if (transparentCount > 1) orderDirty = true
614
855
  }
615
856
  let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
616
857
  let changed = parentChanged
@@ -627,11 +868,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
627
868
  let mesh = node as Mesh
628
869
  if (mesh._entry !== null) {
629
870
  if (mesh._hidden === shown) {
630
- // Mismatch: flip the entry's cheap off switch.
631
- 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 })
632
874
  mesh._hidden = !shown
633
875
  if (shown) mesh._fresh = true
634
876
  }
877
+ if (changed && mesh._transparent && transparentCount > 1) orderDirty = true
635
878
  if (!mesh._hidden && (changed || mesh._fresh)) {
636
879
  if (mesh.material.normalMatrix) {
637
880
  setDrawParams(texture, mesh._entry, {
@@ -655,6 +898,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
655
898
  for (let c of node.children) walk(c, changed, shown)
656
899
  }
657
900
  walk(root, false, true)
901
+ if (orderDirty) sortEntries()
658
902
  }
659
903
 
660
904
  let hooks: SceneHooks = {
@@ -676,7 +920,30 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
676
920
  "' - a material reading aColor needs withColors() geometry, and colored geometry needs such a material",
677
921
  )
678
922
  }
679
- 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
680
947
  // The uNormal seed keys off the material flag because entry params
681
948
  // validate strictly - and a material declaring uNormal without using
682
949
  // it therefore throws right here, at add().
@@ -693,9 +960,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
693
960
  indexBuffer: bufs.index,
694
961
  indexFormat: bufs.indexFormat,
695
962
  textures: mesh.material.textures,
963
+ instanceBuffer: inst !== null ? inst.buffer : undefined,
696
964
  instanceCount: 0,
697
965
  })
698
- entryOrder.push(mesh._entry)
966
+ meshes.push(mesh)
967
+ mesh._transparent = mesh.material.transparent === true
968
+ if (mesh._transparent) transparentCount++
969
+ orderDirty = true
699
970
  mesh._hidden = true
700
971
  mesh._fresh = true
701
972
  this._schedule()
@@ -703,8 +974,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
703
974
  _detach(mesh) {
704
975
  if (mesh._entry !== null) {
705
976
  if (!disposed) removeDraw(texture, mesh._entry)
706
- let i = entryOrder.indexOf(mesh._entry)
707
- if (i >= 0) entryOrder.splice(i, 1)
977
+ if (mesh._buffers !== null) releaseGeometryBuffers(mesh._buffers)
978
+ mesh._buffers = null
979
+ let i = meshes.indexOf(mesh)
980
+ if (i >= 0) meshes.splice(i, 1)
981
+ if (mesh._transparent) transparentCount--
982
+ orderDirty = true
708
983
  }
709
984
  mesh._entry = null
710
985
  // The leaf goes with the entry: a geometry swap rebuilds the entry,
@@ -717,6 +992,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
717
992
  _setParams(mesh, params) {
718
993
  if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
719
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
+ },
1001
+ _reorder() {
1002
+ orderDirty = true
1003
+ this._schedule()
1004
+ },
720
1005
  }
721
1006
 
722
1007
  let root = makeNode("group")
@@ -864,6 +1149,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
864
1149
  cameraDirty = true
865
1150
  hooks._schedule()
866
1151
  },
1152
+ setParams(params) {
1153
+ if (!disposed) setTargetParams(texture, params)
1154
+ },
867
1155
  setBackground(source) {
868
1156
  if (disposed) return
869
1157
  if (background !== null) {
@@ -874,9 +1162,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
874
1162
  }
875
1163
  if (source === null) return
876
1164
  let built = backgroundPipeline(source, (opts?.label ?? "scene") + "-background")
877
- // First in list order: meshes append behind it forever (rebuilds
878
- // re-append too), so pinning happens once, here.
879
- let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: entryOrder[0] })
1165
+ // First in list order: inserted before the first mesh entry, and every
1166
+ // later sort keeps it there.
1167
+ let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: meshes[0]?._entry ?? undefined })
880
1168
  background = { entry, pipeline: built.pipeline, program: built.program }
881
1169
  },
882
1170
  project(point) {
@@ -936,7 +1224,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
936
1224
  pickDir[1] = dy
937
1225
  pickDir[2] = dz
938
1226
  transformVector(pickDir, pickInv, pickDir)
939
- 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
940
1231
  let t = rayBoxDistance(
941
1232
  pickOrigin[0], pickOrigin[1], pickOrigin[2],
942
1233
  pickDir[0], pickDir[1], pickDir[2],
@@ -954,6 +1245,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
954
1245
  dispose() {
955
1246
  if (disposed) return
956
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)
957
1252
  destroyTexture(texture)
958
1253
  if (background !== null) {
959
1254
  // The entry died with the target; the pipeline and program are the