@solidrt/3d 0.0.57 → 0.0.59

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 (72) hide show
  1. package/AGENTS.md +1479 -678
  2. package/README.md +4 -2
  3. package/examples/README.md +56 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +129 -0
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +187 -22
  51. package/src/geometry.ts +1718 -203
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +457 -154
  54. package/src/index.ts +32 -21
  55. package/src/input.ts +11 -2
  56. package/src/light.ts +7 -8
  57. package/src/lod.ts +148 -0
  58. package/src/material.ts +322 -190
  59. package/src/mesh.ts +492 -259
  60. package/src/mixer.ts +27 -14
  61. package/src/model-file.ts +54 -15
  62. package/src/model.ts +151 -54
  63. package/src/motion.ts +17 -0
  64. package/src/node.ts +423 -23
  65. package/src/orbit.ts +378 -116
  66. package/src/profile.ts +4 -25
  67. package/src/resolve.ts +181 -0
  68. package/src/scene-shadows.ts +94 -93
  69. package/src/scene.ts +608 -185
  70. package/src/skeleton.ts +211 -0
  71. package/src/sweep.ts +27 -8
  72. package/tools/model.ts +3 -1
package/src/node.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  // types only).
8
8
 
9
9
  import * as spatial from "flux:spatial"
10
- import type { NodeId, NodeTransition } from "flux:spatial"
10
+ import type { NodeId, NodeMotionSpec, NodeTransition } from "flux:spatial"
11
11
  import { on } from "srt:events"
12
+ import { createMutableTexture, destroyTexture } from "@solidrt/core/gpu"
12
13
  import type { ShaderParams, TextureId } from "@solidrt/core/gpu"
13
14
  import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
14
15
  // The scene's lookAt() aims a node; math's builds a camera's view matrix -
@@ -46,6 +47,69 @@ let scaleScratch: Vec3 = [1, 1, 1]
46
47
  let declared = new Map<NodeId, SceneNode>()
47
48
  let subscribed = false
48
49
 
50
+ // Free routing: destroy() leaves a node whose declaration carries an
51
+ // `exit` in the core as a LEAVING one (drawn, picked by nothing) until its
52
+ // exits settle, and the core's "spatialNodeFreed" event says when it is
53
+ // gone - the cue to detach its entries and recycle what was kept for it
54
+ // (an instance's record slot), then to run what was deferred to that
55
+ // moment (`then`: a disposer that reached the node mid-exit). Keyed by
56
+ // core id, with the scene the node was in (the handle's own `_scene` is
57
+ // already null: a destroyed handle routes no writes). One lazy
58
+ // subscription.
59
+ type Leaving = { node: SceneNode; scene: SceneHooks; then: (() => void)[] }
60
+ let leaving = new Map<NodeId, Leaving>()
61
+ let freeingSubscribed = false
62
+
63
+ function awaitFree(id: NodeId, node: SceneNode, scene: SceneHooks): void {
64
+ leaving.set(id, { node, scene, then: [] })
65
+ if (freeingSubscribed) return
66
+ freeingSubscribed = true
67
+ on("spatialNodeFreed", (event: { node: NodeId }) => {
68
+ let entry = leaving.get(event.node)
69
+ if (!entry) return
70
+ leaving.delete(event.node)
71
+ finishLeaving(entry)
72
+ })
73
+ }
74
+
75
+ function finishLeaving(entry: Leaving): void {
76
+ finishLeave(entry.node, entry.scene)
77
+ for (let fn of entry.then) fn()
78
+ }
79
+
80
+ /**
81
+ * Run `fn` once `node` has finished leaving, if it is on its way out
82
+ * (destroyed, its exit still playing): returns true and holds `fn` for
83
+ * the free; false when the node is not leaving, and the caller does its
84
+ * work now. What a disposer uses when destroy may just have let the node
85
+ * go: `disposeInstances` and `model.dispose` free buffers and textures
86
+ * the corpse still draws with, so on a leaving node they wait for it
87
+ * instead of cutting the exit short. A corpse freed early (`freeLeaving`,
88
+ * a parent's `destroy`) runs the held work at that point.
89
+ */
90
+ export function afterFree(node: SceneNode, fn: () => void): boolean {
91
+ if (node._node === null) return false
92
+ let entry = leaving.get(node._node)
93
+ if (!entry) return false
94
+ entry.then.push(fn)
95
+ return true
96
+ }
97
+
98
+ /**
99
+ * Free every leaving node `where` admits NOW (its exit cut short where it
100
+ * stands) and finish its leave, held work included: the teardown paths
101
+ * call this before they free what a corpse still draws with - a scene's
102
+ * targets, a layer's buffers.
103
+ */
104
+ export function freeLeaving(where: (node: SceneNode, scene: SceneHooks) => boolean): void {
105
+ for (let [id, entry] of [...leaving]) {
106
+ if (!where(entry.node, entry.scene)) continue
107
+ leaving.delete(id)
108
+ spatial.destroyNode(id)
109
+ finishLeaving(entry)
110
+ }
111
+ }
112
+
49
113
  function declareTransition(id: NodeId, node: SceneNode): void {
50
114
  declared.set(id, node)
51
115
  if (subscribed) return
@@ -74,6 +138,10 @@ export type SceneHooks = {
74
138
  * and record binding. */
75
139
  _attachInstance(instance: InstanceNode): void
76
140
  _detachInstance(instance: InstanceNode): void
141
+ /** A mesh is on its way out (destroy with an `exit`): drop it from pick
142
+ * routing now - the core skips a leaving node anyway - while its entries
143
+ * keep drawing until `_detach` at the free. */
144
+ _exiting(mesh: Mesh): void
77
145
  _attachLight(light: Light): void
78
146
  _detachLight(light: Light): void
79
147
  _lightChanged(): void
@@ -81,9 +149,14 @@ export type SceneHooks = {
81
149
  _setCount(mesh: Mesh): void
82
150
  /** Re-point the mesh's entries at its (replaced) instance buffers. */
83
151
  _setBuffer(mesh: Mesh): void
84
- /** The instanced mesh's style mirror has a dirty range: publish it at
152
+ /** Re-point the mesh's morphing entries at its population's (replaced)
153
+ * weights texture. */
154
+ _setMorphTexture(mesh: Mesh): void
155
+ /** The mesh's draw range changed (setDrawRange): apply it to its entries. */
156
+ _setRange(mesh: Mesh): void
157
+ /** One of the mesh's record streams has a dirty range: publish it at
85
158
  * the next sync. */
86
- _setStyle(mesh: InstancedMesh): void
159
+ _setRecords(mesh: Mesh): void
87
160
  /** The mesh's castShadow flag changed: re-evaluate the filtered views. */
88
161
  _setCast(mesh: Mesh): void
89
162
  /** The mesh's layers bitmask changed: re-evaluate every target. */
@@ -93,8 +166,17 @@ export type SceneHooks = {
93
166
  _reorder(): void
94
167
  /** The node's transform changed (for the sort and light bookkeeping). */
95
168
  _moved(node: SceneNode): void
169
+ /** Push the node's LOD declaration (`_lod`) to the core: at scene enter
170
+ * once its level children are in, and on every setLod. */
171
+ _bindLod(node: SceneNode): void
96
172
  }
97
173
 
174
+ /** A node's LOD declaration (createLod/setLod, createInstancedLod): the
175
+ * levels nearest first - each a child node, or none for a population's
176
+ * levels - with the projected size below which each hands over, and the
177
+ * cross-fade band. Re-applied whenever the node enters a scene. */
178
+ export type LodConfig = { levels: { node: SceneNode | null; size: number }[]; fade: number }
179
+
98
180
  export type SceneNode = {
99
181
  kind: "group" | "mesh" | "light" | "instance"
100
182
  parent: SceneNode | null
@@ -127,10 +209,16 @@ export type SceneNode = {
127
209
  * press (NodeTapEvent: `tapCount`); bubbles like down/move/up. */
128
210
  onTap?: (event: NodeTapEvent) => void
129
211
  /** A declared transition (setTransition) settled naturally on one
130
- * component; a cancel, snap or scene leave never fires. */
212
+ * component; a cancel, snap, scene leave or exit never fires. */
131
213
  onTransitionEnd?: (event: TransitionEndEvent) => void
132
- /** The core node while in a scene (created at add, freed at remove). */
214
+ /** The core node while in a scene (created at add, freed at remove or
215
+ * at destroy's free - a destroyed node with an `exit` keeps it while
216
+ * it animates out). */
133
217
  _node: NodeId | null
218
+ /** Gone for good (destroy): every write is a no-op, add/remove throw or
219
+ * skip, and the core node - if one is still animating out - is nobody's
220
+ * to write. */
221
+ _destroyed: boolean
134
222
  _moved: boolean
135
223
  /** The core writes this node's TRS itself (a clip player's target, a
136
224
  * root-motion anchor), so the JS mirror above is stale: setTransform
@@ -143,17 +231,28 @@ export type SceneNode = {
143
231
  * skin): bound to the core at every scene enter, so the flush writes
144
232
  * `inverse(anchorWorld) * world * post` - the model-local bone matrix -
145
233
  * to the palette texture's row whenever the node moves. `anchor` is the
146
- * model root; null for non-joints (the common case pays one null check). */
234
+ * model root - the body's, for a piece bound onto its skeleton - and
235
+ * null for non-joints (the common case pays one null check). */
147
236
  _palettes: { texture: TextureId; row: number; post: Float32Array; anchor: SceneNode }[] | null
237
+ /** The morph weights this node owns (see Mesh._morphOwner): the target
238
+ * names, the weights texture its core register is published to, and
239
+ * the JS-side weights a scene enter restores. null for the common case;
240
+ * created on demand for a mesh whose geometry carries targets, by
241
+ * createModel for a glTF node with them. */
242
+ _morph: MorphState | null
148
243
  /** A culling-only local box (a model joint's influence region, in joint
149
244
  * space): bound at every scene enter so the joint's world box follows
150
245
  * the pose without joining the picking index. null for the common case. */
151
246
  _cullBounds: Float32Array | null
247
+ /** The LOD group this node heads (see LodConfig); null for the common
248
+ * case. */
249
+ _lod: LodConfig | null
152
250
  }
153
251
 
154
- /** The settled component of a node transition. */
252
+ /** The settled component of a node transition: a transform component,
253
+ * or "weights" for the morph target weights lane. */
155
254
  export type TransitionEndEvent = {
156
- component: "position" | "rotation" | "scale"
255
+ component: "position" | "rotation" | "scale" | "weights"
157
256
  }
158
257
 
159
258
  /** What every scene pointer event carries, whichever handler sees it:
@@ -249,6 +348,31 @@ export type ScenePointerListener = {
249
348
  onTap?: (event: SceneTapEvent) => void
250
349
  }
251
350
 
351
+ /** A node's morph weights (internal; see SceneNode._morph). */
352
+ export type MorphState = {
353
+ /** One name per target, in the geometry's order. */
354
+ names: string[]
355
+ /** The weights texture: rgba32f, ceil(n / 4) texels wide - the node's
356
+ * core register published into `row` of it (bindWeightsSlot), what the
357
+ * entries bind as uMorphWeights. A plain owner's own one-row texture;
358
+ * an instance's is its population's (one row per record slot, the
359
+ * shader reads row gl_InstanceID), shared and owned by the mesh. */
360
+ texture: TextureId
361
+ row: number
362
+ /** The texture is the population's, not this node's to free. */
363
+ shared: boolean
364
+ /** The last JS write: what a scene enter pushes to the core. In a
365
+ * scene the core register is authoritative (getMorphWeights reads it). */
366
+ weights: Float32Array
367
+ /** The core node the texture's row is bound to (activateMorph), null
368
+ * when none is - the guard against rebinding at every entry build. */
369
+ bound: NodeId | null
370
+ }
371
+
372
+ /** The per-target weights of a setMorphWeights write: by name (a partial
373
+ * write, the other targets untouched) or every weight in target order. */
374
+ export type MorphWeights = Record<string, number> | ArrayLike<number>
375
+
252
376
  /** A bare node record (internal: the mesh and light constructors build on it). */
253
377
  export function makeNode(kind: SceneNode["kind"]): SceneNode {
254
378
  return {
@@ -260,12 +384,15 @@ export function makeNode(kind: SceneNode["kind"]): SceneNode {
260
384
  scale: [1, 1, 1],
261
385
  visible: true,
262
386
  _node: null,
387
+ _destroyed: false,
263
388
  _native: false,
264
389
  _moved: false,
265
390
  _scene: null,
266
391
  _transition: null,
267
392
  _palettes: null,
393
+ _morph: null,
268
394
  _cullBounds: null,
395
+ _lod: null,
269
396
  }
270
397
  }
271
398
 
@@ -274,25 +401,125 @@ export function createGroup(): SceneNode {
274
401
  }
275
402
 
276
403
  /** Attach `child` under `parent` (re-parenting detaches it first). An
277
- * instance is slot-bound to its mesh: addInstance places it. */
404
+ * instance is slot-bound to its mesh: addInstance places it. A destroyed
405
+ * node, on either side, is gone for good and throws. */
278
406
  export function add(parent: SceneNode, child: SceneNode): void {
279
- if (child.kind === "instance") throw new Error("add: an instance is slot-bound to its mesh - addInstance places it, removeInstance destroys it")
407
+ if (child.kind === "instance") throw new Error("add: an instance is slot-bound to its mesh - addInstance places it, destroy destroys it")
408
+ if (child._destroyed) throw new Error("add: the child was destroyed")
409
+ if (parent._destroyed) throw new Error("add: the parent was destroyed")
280
410
  if (child.parent !== null) remove(child)
281
411
  child.parent = parent
282
412
  parent.children.push(child)
283
413
  if (parent._scene) enterScene(child, parent._scene)
284
414
  }
285
415
 
286
- /** Detach `child` from its parent (and its meshes from the scene). An
287
- * instance is destroyed instead, by removeInstance. */
416
+ /**
417
+ * Detach `child` from its parent (and its meshes from the scene) - Three's
418
+ * `parent.remove(child)`, Godot's remove_child: the subtree stays intact
419
+ * and re-adds cleanly, so this is the verb for a node that is coming back
420
+ * (or about to be disposed). It SNAPS: a detached node plays no exit,
421
+ * that is `destroy`'s. An instance is slot-bound to its mesh and cannot
422
+ * be detached (destroy it); a destroyed node is already gone (no-op).
423
+ */
288
424
  export function remove(child: SceneNode): void {
289
- if (child.kind === "instance") throw new Error("remove: an instance is slot-bound to its mesh - removeInstance destroys it")
425
+ if (child.kind === "instance") throw new Error("remove: an instance is slot-bound to its mesh - destroy destroys it")
426
+ if (child._destroyed) return
290
427
  if (child._scene) leaveScene(child)
291
- let parent = child.parent
428
+ unlink(child)
429
+ }
430
+
431
+ /**
432
+ * Destroy a node and everything under it - Unity's Destroy, Godot's
433
+ * queue_free, @solidrt/2d's destroySprite/destroyGroup one dimension up:
434
+ * the subtree is gone for good and every handle in it goes inert (writes
435
+ * are no-ops, add throws, remove skips). This is the removal an `exit`
436
+ * rides on: a node whose transition declares one animates each such
437
+ * component to its exit value first and stays drawn meanwhile, a ghost
438
+ * that no pick, raycast, overlap or sweep sees, then frees when the last
439
+ * settles (no onTransitionEnd fires for an exit: the app already let go).
440
+ * Children go first, each animating its own exit, and their parent - an
441
+ * exit of its own or not - stays in the core as their frame until the
442
+ * last of them has settled, so a dying character's parts leave in its
443
+ * frame to the end; a mixer targeting a joint of the subtree keeps
444
+ * driving it while it goes. An instance is destroyed like any node here
445
+ * (its record slot recycles at the free). GPU resources stay on their own
446
+ * disposers (disposeGeometry, disposeInstances, model.dispose), as a
447
+ * sprite's atlas does; a disposer reaching a node still animating out
448
+ * frees it on the spot. Outside a scene there is nothing to animate: the
449
+ * subtree just goes inert.
450
+ */
451
+ export function destroy(node: SceneNode): void {
452
+ if (node._destroyed) return
453
+ for (let c of node.children.slice()) destroy(c)
454
+ letGo(node)
455
+ unlink(node)
456
+ }
457
+
458
+ function unlink(node: SceneNode): void {
459
+ let parent = node.parent
292
460
  if (parent !== null) {
293
- let i = parent.children.indexOf(child)
461
+ let i = parent.children.indexOf(node)
294
462
  if (i >= 0) parent.children.splice(i, 1)
295
- child.parent = null
463
+ node.parent = null
464
+ }
465
+ }
466
+
467
+ /**
468
+ * The per-node half of destroy: mark the handle inert and let the core
469
+ * node go through its declaration's exits (flux:spatial exitNode). A node
470
+ * kept leaving finishes at the free (awaitFree); one freed on the spot,
471
+ * or outside a scene, finishes now. The handle's scene reference goes
472
+ * here so every scene-routed setter is a no-op from this point; the entry
473
+ * on the leaving map carries the scene to the finish.
474
+ */
475
+ function letGo(node: SceneNode): void {
476
+ node._destroyed = true
477
+ let scene = node._scene
478
+ let id = node._node
479
+ node._scene = null
480
+ if (scene === null || id === null) {
481
+ finishLeave(node, null)
482
+ return
483
+ }
484
+ declared.delete(id)
485
+ if (node.kind === "mesh") scene._exiting(node as Mesh)
486
+ else if (node.kind === "instance") scene._detachInstance(node as InstanceNode)
487
+ if (spatial.exitNode(id)) awaitFree(id, node, scene)
488
+ else finishLeave(node, scene)
489
+ }
490
+
491
+ /**
492
+ * The end of a destroyed node's leave, once its core node is gone (or
493
+ * never was): its entries come off the scene, an instance's slot recycles
494
+ * and the handle drops its core id. `scene` is null for a node that was
495
+ * not in one.
496
+ */
497
+ function finishLeave(node: SceneNode, scene: SceneHooks | null): void {
498
+ node._node = null
499
+ if (node.kind === "instance") {
500
+ let instance = node as InstanceNode
501
+ let mesh = instance.mesh
502
+ if (mesh !== null) {
503
+ let nodes = mesh._instances?.nodes
504
+ if (nodes) {
505
+ nodes.slots[instance._slot] = null
506
+ nodes.free.push(instance._slot)
507
+ }
508
+ instance.mesh = null
509
+ }
510
+ return
511
+ }
512
+ if (scene !== null) {
513
+ if (node.kind === "mesh") scene._detach(node as Mesh)
514
+ else if (node.kind === "light") scene._detachLight(node as Light)
515
+ }
516
+ // A destroyed owner's weights texture goes with it: its entries (and
517
+ // those of the parts under a group owner, destroyed before it) are
518
+ // off the scene by now. An instance's row lives in its population's
519
+ // texture, which stays.
520
+ if (node._morph !== null) {
521
+ if (!node._morph.shared) destroyTexture(node._morph.texture)
522
+ node._morph = null
296
523
  }
297
524
  }
298
525
 
@@ -314,6 +541,10 @@ export function enterScene(node: SceneNode, scene: SceneHooks): void {
314
541
  if (p.anchor._node !== null) spatial.bindTextureSlot(node._node, p.texture, p.row, p.post, p.anchor._node)
315
542
  }
316
543
  }
544
+ // A morph owner with weights already (a model's node, a mesh written
545
+ // before its first enter) binds them now; a mesh owner nothing wrote
546
+ // gets its state when a morphing entry first needs it (activateMorph).
547
+ if (node._morph !== null) activateMorph(node)
317
548
  if (node._cullBounds !== null) spatial.setCullBounds(node._node, node._cullBounds)
318
549
  if (node.kind === "mesh") {
319
550
  spatial.setLayers(node._node, (node as Mesh).layers)
@@ -322,6 +553,8 @@ export function enterScene(node: SceneNode, scene: SceneHooks): void {
322
553
  else if (node.kind === "light") scene._attachLight(node as Light)
323
554
  else if (node.kind === "instance") scene._attachInstance(node as InstanceNode)
324
555
  for (let c of node.children) enterScene(c, scene)
556
+ // After the children: node levels must be live to be named.
557
+ if (node._lod !== null) scene._bindLod(node)
325
558
  scene._schedule()
326
559
  }
327
560
 
@@ -339,6 +572,160 @@ export function leaveScene(node: SceneNode): void {
339
572
  }
340
573
  }
341
574
 
575
+ /** A weights texture over `targets` targets and `rows` owners: rgba32f,
576
+ * four weights per texel, zeroed (the core writes the rows). */
577
+ export function createWeightsTexture(targets: number, rows: number, label?: string): TextureId {
578
+ let width = Math.max(1, Math.ceil(targets / 4))
579
+ return createMutableTexture(new Float32Array(width * 4 * rows), width, rows, { format: "rgba32f", filter: "nearest", autoFree: false, label: label ?? "morph-weights" })
580
+ }
581
+
582
+ /** Build a morph state for `names` seeded with `initial` (zeros without):
583
+ * the one-row weights texture is created here, freed when the owner is
584
+ * destroyed (finishLeave) or its geometry changes (resetMorph). */
585
+ export function createMorphState(names: string[], initial: ArrayLike<number> | null, label?: string): MorphState {
586
+ let weights = new Float32Array(names.length)
587
+ if (initial !== null) {
588
+ if (initial.length !== names.length) throw new Error("createMorphState: " + initial.length + " weights for " + names.length + " targets")
589
+ weights.set(initial)
590
+ }
591
+ return { names, texture: createWeightsTexture(names.length, 1, label), row: 0, shared: false, weights, bound: null }
592
+ }
593
+
594
+ /** The owner's morph state with its register bound to the weights
595
+ * texture and seeded, for a node in a scene (a scene enter, a morphing
596
+ * entry's build, an instance's attach): idempotent per core node. An
597
+ * instance binds its slot's row on every level's population texture
598
+ * (an instanced LOD's levels share the register). Outside a scene it
599
+ * only makes the state. */
600
+ export function activateMorph(node: SceneNode): MorphState {
601
+ let m = ensureMorph(node)
602
+ if (node._node !== null && m.bound !== node._node) {
603
+ for (let texture of morphTextures(node, m)) spatial.bindWeightsSlot(node._node, texture, m.row)
604
+ spatial.setWeights(node._node, m.weights)
605
+ m.bound = node._node
606
+ }
607
+ return m
608
+ }
609
+
610
+ /** Every weights texture the node's register publishes into: its own,
611
+ * plus for an instance the other levels' population textures. */
612
+ function morphTextures(node: SceneNode, m: MorphState): TextureId[] {
613
+ let textures = [m.texture]
614
+ let mesh = node.kind === "instance" ? (node as InstanceNode).mesh : null
615
+ for (let level of mesh?._instances.levels ?? []) {
616
+ let texture = level._instances?.morph?.texture
617
+ if (texture !== undefined && !textures.includes(texture)) textures.push(texture)
618
+ }
619
+ return textures
620
+ }
621
+
622
+ /** The node's morph state, made on first use for a mesh that owns its
623
+ * own targets or an instance of a population with targets; throws for a
624
+ * node without targets. */
625
+ export function ensureMorph(node: SceneNode): MorphState {
626
+ if (node._morph !== null) return node._morph
627
+ if (node.kind === "instance") {
628
+ let instance = node as InstanceNode
629
+ let population = instance.mesh?._instances.morph ?? null
630
+ if (instance.mesh === null || population === null) throw new Error("The instance's mesh has no morph targets (geometry with `morphs`)")
631
+ let names = instance.mesh.geometry.morphs!.names
632
+ node._morph = { names, texture: population.texture, row: instance._slot, shared: true, weights: new Float32Array(names.length), bound: null }
633
+ return node._morph
634
+ }
635
+ let mesh = node.kind === "mesh" ? (node as Mesh) : null
636
+ if (mesh === null || mesh._morphOwner !== mesh || mesh.geometry.morphs === undefined) {
637
+ throw new Error("The node has no morph targets (a mesh over geometry with `morphs`, or a model node with targets)")
638
+ }
639
+ node._morph = createMorphState(mesh.geometry.morphs.names, null, mesh.geometry.label ? mesh.geometry.label + "-weights" : undefined)
640
+ return node._morph
641
+ }
642
+
643
+ /** Move an instance's morph state to its population's replacement
644
+ * texture (growth: same targets, same row): the core register keeps its
645
+ * weights and any running track, only the slot moves - unbound from the
646
+ * old texture, bound to the new, staged at the next flush. */
647
+ export function rebindMorph(node: SceneNode, texture: TextureId): void {
648
+ let m = node._morph
649
+ if (m === null) return
650
+ let previous = m.texture
651
+ m.texture = texture
652
+ if (node._node !== null && m.bound === node._node) {
653
+ spatial.unbindWeightsSlot(node._node, previous)
654
+ spatial.bindWeightsSlot(node._node, texture, m.row)
655
+ }
656
+ }
657
+
658
+ /** Drop a node's morph state (setGeometry, a population's texture
659
+ * replaced): the next enter, or the next setMorphWeights, makes a fresh
660
+ * one. In a scene the core slots are unbound first; the entries rebuild
661
+ * after. An owner's own texture is freed, a shared one is the mesh's. */
662
+ export function resetMorph(node: SceneNode): void {
663
+ if (node._morph === null) return
664
+ if (node._node !== null) spatial.unbindWeightsSlot(node._node)
665
+ if (!node._morph.shared) destroyTexture(node._morph.texture)
666
+ node._morph = null
667
+ }
668
+
669
+ /**
670
+ * Write morph target weights on a node that owns them - a mesh whose
671
+ * geometry carries `morphs`, an instance of an instanced mesh over such
672
+ * geometry (each copy morphs by its own weights), or a model's node for
673
+ * a glTF mesh with targets (`model.nodes`; every part of that mesh
674
+ * follows it). By name,
675
+ * `{ smile: 0.7 }` leaves the other targets at their last written
676
+ * value; an array writes them all in target order. Weights are
677
+ * unbounded (glTF's model: 0..1 is the authored range, past it
678
+ * extrapolates). The write lands in the spatial core's register and
679
+ * reaches the vertex stage at the next flush. It is a TARGET like a
680
+ * setTransform write: a `weights` entry in the node's transition
681
+ * declaration (setTransition, `{ weights: { duration: 300 } }`) makes
682
+ * every write animate, and `transition` here animates this one write
683
+ * on a motion of its own (`{ duration, bounce? | curve, delay? }` or a
684
+ * shorthand string), declaration or not - a smile springs in; a settle
685
+ * calls `onTransitionEnd` with component "weights". A clip's weights
686
+ * track writes the same register, last write wins each frame (the
687
+ * players advance first, so a write from onFrame wins). Three's
688
+ * `morphTargetInfluences`, Godot's `set_blend_shape_value`, Unity's
689
+ * `SetBlendShapeWeight` (Unity's scale is 0..100).
690
+ */
691
+ export function setMorphWeights(node: SceneNode, weights: MorphWeights, transition?: NodeMotionSpec): void {
692
+ let m = ensureMorph(node)
693
+ if (typeof (weights as ArrayLike<number>).length === "number") {
694
+ let all = weights as ArrayLike<number>
695
+ if (all.length !== m.names.length) {
696
+ throw new Error("setMorphWeights: " + all.length + " weights for " + m.names.length + " targets (" + m.names.join(", ") + ")")
697
+ }
698
+ m.weights.set(all)
699
+ } else {
700
+ for (let [name, value] of Object.entries(weights as Record<string, number>)) {
701
+ let i = m.names.indexOf(name)
702
+ if (i < 0) throw new Error("setMorphWeights: no morph target '" + name + "' (targets: " + m.names.join(", ") + ")")
703
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("setMorphWeights: weight '" + name + "' must be a finite number, got " + value)
704
+ m.weights[i] = value
705
+ }
706
+ }
707
+ if (node._node !== null) spatial.writeWeights(node._node, m.weights, transition)
708
+ }
709
+
710
+ /** The node's morph weights, one per target in target order - a copy,
711
+ * read from the spatial core while the node is in a scene (a playing
712
+ * weights track shows here), from the last JS write otherwise. */
713
+ export function getMorphWeights(node: SceneNode): Float32Array {
714
+ let m = ensureMorph(node)
715
+ let out = new Float32Array(m.names.length)
716
+ if (node._node !== null) spatial.readWeights(node._node, out)
717
+ else out.set(m.weights)
718
+ return out
719
+ }
720
+
721
+ /** The node's morph target names, in target order (the index of each
722
+ * weight); empty for a node without targets. */
723
+ export function getMorphNames(node: SceneNode): string[] {
724
+ if (node._morph !== null) return node._morph.names.slice()
725
+ let mesh = node.kind === "mesh" ? (node as Mesh) : node.kind === "instance" ? (node as InstanceNode).mesh : null
726
+ return mesh !== null && (mesh._morphOwner === mesh || node.kind === "instance") && mesh.geometry.morphs !== undefined ? mesh.geometry.morphs.names.slice() : []
727
+ }
728
+
342
729
  /** The node's local transform in the FFI carrier. */
343
730
  export function fillTransform(node: SceneNode): Float32Array {
344
731
  let t = transformScratch
@@ -364,17 +751,28 @@ export type { TransformUpdate } from "./math.ts"
364
751
  * (position/scale per lane, rotation along the quaternion geodesic - a
365
752
  * spring keeps its velocity through retargets, the pursuit-safe shape),
366
753
  * so JS writes once per target change instead of once per frame. A spec
367
- * per component (position, rotation, scale) plus `all`; each
754
+ * per component (position, rotation, scale, and `weights` for a morph
755
+ * owner's setMorphWeights writes, one lane per target) plus `all`; each
368
756
  * `{ duration, bounce? }` (a spring, the default) / `{ duration, curve }`
369
757
  * (a tween) / a shorthand string like "300ms ease-out". The declaration
370
758
  * lives on the node and re-applies whenever it enters a scene; the pose
371
- * it enters with always snaps. Clearing cancels running tracks in place
372
- * (the node keeps its mid-flight transform) and later writes snap. Each
373
- * natural settle calls the node's `onTransitionEnd` with the component
374
- * (the raw "spatialTransitionEnd" engine event on srt:events stays for
375
- * flux:spatial consumers; it carries the core id, `_node`).
759
+ * it enters with snaps, unless a component's `from` (its lanes: `[x, y,
760
+ * z]`, a quaternion for rotation) animates it in from there at every
761
+ * scene enter (a weights `from` is one number per target); a
762
+ * component's `exit` is where it animates to when `destroy` lets go of
763
+ * the node (see there). Either endpoint takes the
764
+ * object form `{ value, duration?, curve?, bounce?, delay? }` to own its
765
+ * direction's motion, and `delay` on an entry holds its writes; `stagger`
766
+ * (ms) on a node spaces the enters and exits of its descendants that
767
+ * begin in one frame (a Group's declaration, the element rule: the
768
+ * orchestrator is always an ancestor). Clearing cancels running tracks in
769
+ * place (the node keeps its mid-flight transform) and later writes snap. Each natural settle calls the node's
770
+ * `onTransitionEnd` with the component (the raw "spatialTransitionEnd"
771
+ * engine event on srt:events stays for flux:spatial consumers; it carries
772
+ * the core id, `_node`).
376
773
  */
377
774
  export function setTransition(node: SceneNode, transition: NodeTransition | string | null): void {
775
+ if (node._destroyed) return
378
776
  node._transition = transition
379
777
  if (node._node !== null) {
380
778
  spatial.setTransition(node._node, transition)
@@ -395,6 +793,7 @@ export function setTransition(node: SceneNode, transition: NodeTransition | stri
395
793
  * equal to the node's current quaternion is also a no-op.
396
794
  */
397
795
  export function setTransform(node: SceneNode, update: TransformUpdate): void {
796
+ if (node._destroyed) return
398
797
  // A no-op write costs nothing: driving every node from onFrame is the
399
798
  // intended shape, and most nodes did not move. Exact compares, like
400
799
  // setVisible - a value that survives a float round trip unchanged is the
@@ -456,6 +855,7 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
456
855
  * if it were a rotation).
457
856
  */
458
857
  export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void {
858
+ if (node._destroyed) return
459
859
  let parent = node.parent
460
860
  if (parent === null) {
461
861
  // No ancestors: parent space IS world space, aim straight from the
@@ -570,7 +970,7 @@ function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
570
970
  /** Show or hide a node and its whole subtree (a hidden mesh costs one
571
971
  * `instanceCount: 0` draw range - the entry stays, drawing nothing). */
572
972
  export function setVisible(node: SceneNode, visible: boolean): void {
573
- if (node.visible === visible) return
973
+ if (node._destroyed || node.visible === visible) return
574
974
  node.visible = visible
575
975
  if (node._node !== null) {
576
976
  spatial.setVisible(node._node, visible)