@solidrt/3d 0.0.70 → 0.0.72

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/mesh.ts CHANGED
@@ -3,12 +3,12 @@
3
3
  // setter write paths that keep a live scene's draw entries in step. The
4
4
  // scene side is reached through the node's SceneHooks (node.ts).
5
5
 
6
- import { beginBufferWrite, createBuffer, destroyBuffer, destroyTexture, endBufferWrite, transferRecords, writeBuffer } from "@solidrt/core/gpu"
7
- import type { BufferId, DrawId, ShaderParams, TextureBindings, TextureId, VertexAttribute, VertexBufferLayout } from "@solidrt/core/gpu"
6
+ import { createBuffer, createRecordStream, destroyBuffer, destroyTexture, encodeRecord, layoutComponents, layoutFields, transferRecords, writeRecord as writeLayoutRecord, VERTEX_FORMATS } from "@solidrt/core/gpu"
7
+ import type { AttributeAccess, BufferId, DrawId, RecordStream, ShaderParams, TextureBindings, TextureId, VertexAttribute, VertexBufferLayout } from "@solidrt/core/gpu"
8
8
  import { checkCubeKnobs } from "./environment.ts"
9
9
  import type { EnvironmentOptions } from "./environment.ts"
10
- import { geometryBounds, isFloatLayout, layoutKey, layoutStride, plane, vertexBytes, vertexView, VERTEX_FORMATS } from "./geometry.ts"
11
- import type { AttributeAccess, Geometry } from "./geometry.ts"
10
+ import { geometryBounds, isFloatLayout, layoutKey, layoutStride, plane, vertexBytes, vertexView } from "./geometry.ts"
11
+ import type { Geometry } from "./geometry.ts"
12
12
  import { INSTANCE_MATRIX_ATTRIBUTES } from "./glsl.ts"
13
13
  import type { GeometryBuffers } from "./geometry-gpu.ts"
14
14
  import type { Material } from "./material.ts"
@@ -189,15 +189,22 @@ export type InstanceStream = {
189
189
  layout: VertexAttribute[]
190
190
  /** Bytes per record: the layout's stride. */
191
191
  stride: number
192
+ /** The GPU buffer the entry binds; `stream.buffer` where a stream
193
+ * exists (growth re-points both), the engine-held buffer otherwise. */
192
194
  buffer: BufferId
195
+ /** The core record stream behind the mirror (createRecordStream: the
196
+ * buffer, the byte mirror, the dirty range and the publish), or null
197
+ * where no JS mirror exists: the transfer form hands the records to
198
+ * the engine, the indexed order's id stream is the engine's to fill. */
199
+ stream: RecordStream | null
200
+ /** The mirror as the layout's view (a Float32Array over an all-float
201
+ * layout, bytes otherwise): `stream.bytes` viewed, re-derived on growth;
202
+ * zero-length without a stream. */
193
203
  data: ArrayBufferView
194
204
  /** The record a fresh (or recycled) instance slot starts with: the
195
205
  * material's instanceStyle encoded, on the first stream of an
196
206
  * instanced mesh; zeros otherwise. */
197
207
  blank: Uint8Array
198
- /** The byte range [lo, hi) of `data` written since the last publish,
199
- * or null. */
200
- dirty: [number, number] | null
201
208
  /** The DataView the accessors read through; follows `data`. */
202
209
  _view: DataView
203
210
  /** All-float32 layout: `data` is a Float32Array and a record write is
@@ -337,13 +344,6 @@ const MATRIX_KEY = layoutKey(INSTANCE_MATRIX_ATTRIBUTES)
337
344
  // doubles past it.
338
345
  const DEFAULT_CAPACITY = 64
339
346
 
340
- // Values per record of a layout: its attributes' components summed - what
341
- // setInstanceStyle and a material's instanceStyle count in.
342
- function layoutComponents(layout: VertexAttribute[]): number {
343
- let n = 0
344
- for (let a of layout) n += VERTEX_FORMATS[a.format].components
345
- return n
346
- }
347
347
 
348
348
  // Records in a byte view of `layout`: 4-aligned and a whole number of
349
349
  // strides, or a throw naming `site`.
@@ -356,22 +356,6 @@ function recordCount(records: ArrayBufferView, layout: VertexAttribute[], site:
356
356
  return records.byteLength / stride
357
357
  }
358
358
 
359
- // The accessors of a stream's attributes, reading through the stream's
360
- // current view so they survive growth.
361
- function streamFields(stream: InstanceStream): AttributeAccess[] {
362
- let offset = 0
363
- return stream.layout.map(attr => {
364
- let codec = VERTEX_FORMATS[attr.format]
365
- let at = offset
366
- offset += codec.bytes
367
- return {
368
- format: attr.format,
369
- components: codec.components,
370
- get: (i, k) => codec.get(stream._view, i * stream.stride + at, k),
371
- set: (i, k, v) => codec.set(stream._view, i * stream.stride + at, k, v),
372
- }
373
- })
374
- }
375
359
 
376
360
  // Point a stream's mirror at `bytes` (a fresh ArrayBuffer of whole
377
361
  // records): the handed-out view and the accessors' view.
@@ -384,24 +368,26 @@ function streamLabel(label: string | undefined, index: number): string | undefin
384
368
  return label === undefined ? undefined : label + "-" + index
385
369
  }
386
370
 
387
- // A stream of `capacity` zeroed records over `layout`; its GPU buffer is
388
- // sized and unwritten (the mirror is the truth, publishes follow dirty).
371
+ // A stream of `capacity` zeroed records over `layout` on a core record
372
+ // stream; its GPU buffer is sized and unwritten (the mirror is the truth,
373
+ // publishes follow the dirty range).
389
374
  function makeStream(layout: VertexAttribute[], capacity: number, blank: Uint8Array | null, label: string | undefined): InstanceStream {
390
375
  let stride = layoutStride(layout)
376
+ let records = createRecordStream(stride, capacity, { autoFree: false, label })
391
377
  let stream: InstanceStream = {
392
378
  layout,
393
379
  stride,
394
- buffer: createBuffer(capacity * stride, { autoFree: false, label }),
380
+ buffer: records.buffer,
381
+ stream: records,
395
382
  data: new Uint8Array(0),
396
383
  blank: blank ?? new Uint8Array(stride),
397
- dirty: null,
398
384
  _view: new DataView(new ArrayBuffer(0)),
399
385
  _floats: isFloatLayout(layout),
400
386
  _components: layoutComponents(layout),
401
387
  _fields: [],
402
388
  }
403
- viewStream(stream, new ArrayBuffer(capacity * stride))
404
- stream._fields = streamFields(stream)
389
+ viewStream(stream, records.bytes.buffer)
390
+ stream._fields = layoutFields(stream.layout, () => stream._view)
405
391
  return stream
406
392
  }
407
393
 
@@ -416,9 +402,9 @@ function makeIndexStream(layout: VertexAttribute[], capacity: number, group: num
416
402
  layout,
417
403
  stride,
418
404
  buffer: createBuffer(Math.ceil(capacity / group) * stride, { autoFree: false, label }),
405
+ stream: null,
419
406
  data: new Uint8Array(0),
420
407
  blank: new Uint8Array(stride),
421
- dirty: null,
422
408
  _view: new DataView(new ArrayBuffer(0)),
423
409
  _floats: false,
424
410
  _components: layoutComponents(layout),
@@ -442,9 +428,9 @@ function makeTransferredStream(layout: VertexAttribute[], records: Uint8Array, o
442
428
  buffer: ordered
443
429
  ? createBuffer(records.byteLength, { autoFree: false, label })
444
430
  : createBuffer(records, { autoFree: false, label }),
431
+ stream: null,
445
432
  data: new Uint8Array(0),
446
433
  blank: new Uint8Array(stride),
447
- dirty: null,
448
434
  _view: new DataView(new ArrayBuffer(0)),
449
435
  _floats: isFloatLayout(layout),
450
436
  _components: layoutComponents(layout),
@@ -455,41 +441,18 @@ function makeTransferredStream(layout: VertexAttribute[], records: Uint8Array, o
455
441
  }
456
442
 
457
443
  // Encode `values` (one per component, in layout order, as the shader
458
- // sees them) into record `i` of a stream: one typed-array set over an
459
- // all-float layout (the values ARE the bytes), the codecs otherwise.
444
+ // sees them) into record `i` of a stream: core's writeRecord over the
445
+ // stream's layout, accessors and float view.
460
446
  function writeRecord(stream: InstanceStream, i: number, values: ArrayLike<number>, site: string): void {
461
- let n = stream._components
462
- if (values.length !== n) throw new Error(site + ": a " + layoutKey(stream.layout) + " record is " + n + " values, got " + values.length)
463
- if (stream._floats) {
464
- // Indexed stores beat TypedArray.set over a plain array under QuickJS,
465
- // which walks the array-like generically.
466
- let data = stream.data as Float32Array
467
- let at = i * n
468
- for (let k = 0; k < n; k++) data[at + k] = values[k]!
469
- return
470
- }
471
- let j = 0
472
- for (let f of stream._fields) for (let k = 0; k < f.components; k++) f.set(i, k, values[j++]!)
447
+ writeLayoutRecord(stream.layout, stream._fields, stream._floats ? (stream.data as Float32Array) : null, i, values, site)
473
448
  }
474
449
 
475
- // One record of `layout` encoded from `values`, the bytes a blank slot copies.
476
- function encodeRecord(layout: VertexAttribute[], values: ArrayLike<number>, site: string): Uint8Array {
477
- let stride = layoutStride(layout)
478
- let one: InstanceStream = { layout, stride, buffer: 0 as BufferId, data: new Uint8Array(0), blank: new Uint8Array(0), dirty: null, _view: new DataView(new ArrayBuffer(0)), _floats: isFloatLayout(layout), _components: layoutComponents(layout), _fields: [] }
479
- viewStream(one, new ArrayBuffer(stride))
480
- one._fields = streamFields(one)
481
- writeRecord(one, 0, values, site)
482
- return vertexBytes(one.data)
483
- }
484
450
 
485
- // Extend a stream's dirty range over [lo, hi) bytes of the mirror and ask
486
- // the scene to publish it at the next sync.
451
+ // Mark records [lo, hi) of a stream's mirror and ask the scene to publish
452
+ // them at the next sync.
487
453
  function markRecords(mesh: Mesh, stream: InstanceStream, lo: number, hi: number): void {
488
- if (stream.dirty === null) stream.dirty = [lo, hi]
489
- else {
490
- if (lo < stream.dirty[0]) stream.dirty[0] = lo
491
- if (hi > stream.dirty[1]) stream.dirty[1] = hi
492
- }
454
+ if (stream.stream === null) throw new Error("markRecords: the stream has no mirror (its records were transferred to the engine)")
455
+ stream.stream.mark(lo, hi)
493
456
  mesh._scene?._setRecords(mesh)
494
457
  }
495
458
 
@@ -869,7 +832,7 @@ export function setInstanceStyle(instance: InstanceNode, values: ArrayLike<numbe
869
832
  let stream = l._instances.streams[0]
870
833
  if (stream === undefined) continue
871
834
  writeRecord(stream, instance._slot, values, "setInstanceStyle")
872
- markRecords(l, stream, instance._slot * stream.stride, (instance._slot + 1) * stream.stride)
835
+ markRecords(l, stream, instance._slot, instance._slot + 1)
873
836
  }
874
837
  }
875
838
 
@@ -944,34 +907,23 @@ export function updateRecords(mesh: InstancedMesh | RecordMesh, options: UpdateR
944
907
  if (!Number.isInteger(first) || !Number.isInteger(count) || first < 0 || count < 0 || first + count > inst.capacity) {
945
908
  throw new Error("updateRecords: range [" + first + ", " + (first + count) + ") is outside the mesh's " + inst.capacity + " records")
946
909
  }
947
- if (count > 0) markRecords(mesh, stream, first * stream.stride, (first + count) * stream.stride)
910
+ if (count > 0) markRecords(mesh, stream, first, first + count)
948
911
  }
949
912
 
950
913
  /** Publish a mesh's pending record writes, one buffer write per dirty
951
- * stream (the scene calls it from its sync). An ordered mesh publishes
952
- * the LIVE record set whole through the write lease - the engine gathers
953
- * it into key order during the copy, and a byte range has no stable
954
- * position under a permutation (writeBuffer throws on an ordered buffer). */
914
+ * stream (the scene calls it from its sync): the dirty record range, up
915
+ * to the capacity (a record mesh writes ahead of its count). An ordered
916
+ * mesh publishes the LIVE record set whole through the write lease - the
917
+ * engine gathers it into key order during the copy, and a byte range has
918
+ * no stable position under a permutation (writeBuffer throws on an
919
+ * ordered buffer). */
955
920
  export function publishRecords(mesh: Mesh): void {
956
921
  let inst = mesh._instances
957
922
  if (inst === null) return
958
- if (inst.order !== null) {
959
- for (let s of inst.streams) {
960
- if (s.dirty === null) continue
961
- s.dirty = null
962
- let bytes = inst.count * s.stride
963
- if (bytes === 0) continue
964
- let block = beginBufferWrite(s.buffer)
965
- new Uint8Array(block.buffer, 0, bytes).set(vertexBytes(s.data).subarray(0, bytes))
966
- endBufferWrite(s.buffer, bytes)
967
- }
968
- return
969
- }
923
+ let ordered = inst.order !== null
970
924
  for (let s of inst.streams) {
971
- if (s.dirty === null) continue
972
- let [lo, hi] = s.dirty
973
- s.dirty = null
974
- writeBuffer(s.buffer, vertexBytes(s.data).subarray(lo, hi), lo)
925
+ if (s.stream === null || !s.stream.dirty) continue
926
+ s.stream.publish(ordered, ordered ? inst.count : inst.capacity)
975
927
  }
976
928
  }
977
929
 
@@ -984,7 +936,7 @@ export function markLiveRecords(mesh: Mesh): void {
984
936
  // A transferred mesh has no mirror; the engine's own (buffer-lifetime)
985
937
  // mirror seeds a re-attached entry instead.
986
938
  if (inst === null || inst.count === 0 || inst.transfer) return
987
- for (let s of inst.streams) markRecords(mesh, s, 0, inst.count * s.stride)
939
+ for (let s of inst.streams) markRecords(mesh, s, 0, inst.count)
988
940
  }
989
941
 
990
942
  /**
@@ -1033,7 +985,7 @@ export function addInstance(mesh: InstancedMesh, update?: TransformUpdate, paren
1033
985
  // A recycled slot must not wear its last occupant's records.
1034
986
  for (let s of li.streams) {
1035
987
  vertexBytes(s.data).set(s.blank, slot * s.stride)
1036
- markRecords(l, s, slot * s.stride, (slot + 1) * s.stride)
988
+ markRecords(l, s, slot, slot + 1)
1037
989
  }
1038
990
  }
1039
991
  if (parent._scene) enterScene(instance, parent._scene)
@@ -1069,14 +1021,13 @@ export function reparentInstance(instance: InstanceNode, parent: SceneNode): voi
1069
1021
 
1070
1022
  // Grow the population's buffers to `next` records: replacements (never a
1071
1023
  // resize). An instanced mesh's live matrix records move to the new buffer
1072
- // in one core call and republish at the next flush; every stream's mirror
1073
- // is copied over and marked whole, so the scene republishes it; the entry
1074
- // re-points, then the old buffers, which the entry held alive until now,
1075
- // are freed.
1024
+ // in one core call and republish at the next flush; every stream grows
1025
+ // into a replacement with its mirror copied over and marked whole, so the
1026
+ // scene republishes it; the entry re-points, then the old buffers, which
1027
+ // the entry held alive until now, are freed.
1076
1028
  function growInstances(mesh: InstancedMesh | RecordMesh, next: number): void {
1077
1029
  let inst: MeshInstances = mesh._instances
1078
1030
  if (inst.transfer) throw new Error("growInstances: a transferred population is fixed at its creation records")
1079
- let previous = inst.capacity
1080
1031
  inst.capacity = next
1081
1032
  let freed: BufferId[] = []
1082
1033
  if (inst.matrix !== null) {
@@ -1085,15 +1036,13 @@ function growInstances(mesh: InstancedMesh | RecordMesh, next: number): void {
1085
1036
  // Out of a scene nothing is bound, and retargeting an empty source throws.
1086
1037
  if (inst.nodes !== null && inst.nodes.slots.some(n => n !== null && n._node !== null)) spatial.retargetRecords(freed[0]!, inst.matrix)
1087
1038
  }
1088
- inst.streams.forEach((s, i) => {
1089
- freed.push(s.buffer)
1090
- s.buffer = createBuffer(next * s.stride, { autoFree: false, label: streamLabel(inst.label, i) })
1091
- let held = vertexBytes(s.data)
1092
- viewStream(s, new ArrayBuffer(next * s.stride))
1093
- vertexBytes(s.data).set(held)
1094
- s.dirty = null
1095
- markRecords(mesh, s, 0, previous * s.stride)
1096
- })
1039
+ for (let s of inst.streams) {
1040
+ if (s.stream === null) throw new Error("growInstances: a stream without a mirror cannot grow")
1041
+ freed.push(s.stream.grow(next))
1042
+ s.buffer = s.stream.buffer
1043
+ viewStream(s, s.stream.bytes.buffer)
1044
+ mesh._scene?._setRecords(mesh)
1045
+ }
1097
1046
  mesh._scene?._setBuffer(mesh)
1098
1047
  for (let b of freed) destroyBuffer(b)
1099
1048
  if (inst.morph !== null) replacePopulationMorph(mesh, populationMorph(mesh.geometry, next, inst.label), true)
@@ -1180,7 +1129,7 @@ export function createRecordMesh(
1180
1129
  function copyRecords(mesh: RecordMesh, records: ArrayBufferView, written: number): void {
1181
1130
  let stream = mesh._instances.streams[0]!
1182
1131
  vertexBytes(stream.data).set(vertexBytes(records))
1183
- if (written > 0) markRecords(mesh, stream, 0, written * stream.stride)
1132
+ if (written > 0) markRecords(mesh, stream, 0, written)
1184
1133
  }
1185
1134
 
1186
1135
  /**
@@ -1220,7 +1169,7 @@ export function setRecordCount(mesh: RecordMesh, count: number): void {
1220
1169
  // old population's nearest records, a growth its stale tail. A
1221
1170
  // transferred mesh has no mirror to publish from; the engine follows
1222
1171
  // the entry's instance count into its own mirror's prefix instead.
1223
- if (inst.order !== null && !inst.transfer && n > 0) for (let s of inst.streams) markRecords(mesh, s, 0, n * s.stride)
1172
+ if (inst.order !== null && !inst.transfer && n > 0) for (let s of inst.streams) markRecords(mesh, s, 0, n)
1224
1173
  mesh._scene?._setCount(mesh)
1225
1174
  }
1226
1175
 
@@ -1259,7 +1208,10 @@ export function disposeInstances(mesh: InstancedMesh | RecordMesh): void {
1259
1208
  spatial.flush()
1260
1209
  }
1261
1210
  if (inst.matrix !== null) destroyBuffer(inst.matrix)
1262
- for (let s of inst.streams) destroyBuffer(s.buffer)
1211
+ for (let s of inst.streams) {
1212
+ if (s.stream !== null) s.stream.destroy()
1213
+ else destroyBuffer(s.buffer)
1214
+ }
1263
1215
  if (inst.morph !== null) destroyTexture(inst.morph.texture)
1264
1216
  for (let t of inst.textures) destroyTexture(t)
1265
1217
  ;(mesh as Mesh)._instances = null
package/src/mixer.ts CHANGED
@@ -401,7 +401,12 @@ export function createMixer(model: Model, mixerOpts: MixerOptions = {}): Mixer {
401
401
  if (i >= 0) actions.splice(i, 1)
402
402
  }
403
403
  let start = (clip: ModelClip, opts: MixerPlayOptions, weight: number, fade: number): void => {
404
- let player = spatial.createPlayer(clipFor(clip, opts), targetsFor(clip), opts.speed ?? 1, opts.loop ?? true, weight, fade)
404
+ let targets = targetsFor(clip)
405
+ // The player's time runs at the model node's rate (setTimeScale on
406
+ // it or an ancestor): targetsFor threw if the model is not in a scene.
407
+ let clock = model._node
408
+ if (clock === null) throw new Error("play: the model must be in a scene (add() it first) before clips can play")
409
+ let player = spatial.createPlayer(clipFor(clip, opts), targets, clock, { speed: opts.speed, loop: opts.loop, weight, fade })
405
410
  let action: Action = { name: clip.name, player, fadingOut: false, finished: false }
406
411
  PLAYER_ENDS.set(player, (reason) => {
407
412
  if (reason === "finished") {
package/src/node.ts CHANGED
@@ -217,8 +217,9 @@ export type SceneNode = {
217
217
  /** The wheel over the node (NodeWheelEvent: `deltaX`/`deltaY`), bubbling
218
218
  * like down/move/up. */
219
219
  onWheel?: (event: NodeWheelEvent) => void
220
- /** A press released on this node within the slop, alone for its whole
221
- * press (NodeTapEvent: `tapCount`); bubbles like down/move/up. */
220
+ /** A primary-button press (a right click never taps) released on this
221
+ * node within the slop, alone for its whole press (NodeTapEvent:
222
+ * `tapCount`); bubbles like down/move/up. */
222
223
  onTap?: (event: NodeTapEvent) => void
223
224
  /** A declared transition (setTransition) settled naturally on one
224
225
  * component; a cancel, snap, scene leave or exit never fires. */
@@ -239,6 +240,9 @@ export type SceneNode = {
239
240
  _scene: SceneHooks | null
240
241
  /** The declared transition, re-applied on every scene enter. */
241
242
  _transition: NodeTransition | string | null
243
+ /** The declared time scale (setTimeScale), re-applied on every scene
244
+ * enter; null inherits. */
245
+ _timeScale: number | null
242
246
  /** Skin palette rows this node feeds (a model joint carries one per
243
247
  * skin): bound to the core at every scene enter, so the flush writes
244
248
  * `inverse(anchorWorld) * world * post` - the model-local bone matrix -
@@ -289,6 +293,9 @@ export type SceneEventBase = {
289
293
  predicted: boolean
290
294
  pointerId: number
291
295
  pointerType: string
296
+ /** The pressed button on a down, up or tap (0 = primary, 2 = secondary;
297
+ * touch is always 0), as the element event carries it; absent on a
298
+ * move, cancel or wheel. */
292
299
  button?: number
293
300
  shiftKey: boolean
294
301
  ctrlKey: boolean
@@ -407,6 +414,7 @@ export function makeNode(kind: SceneNode["kind"]): SceneNode {
407
414
  _moved: false,
408
415
  _scene: null,
409
416
  _transition: null,
417
+ _timeScale: null,
410
418
  _palettes: null,
411
419
  _morph: null,
412
420
  _cullBounds: null,
@@ -548,6 +556,7 @@ export function enterScene(node: SceneNode, scene: SceneHooks): void {
548
556
  spatial.setTransition(node._node, node._transition)
549
557
  declareTransition(node._node, node)
550
558
  }
559
+ if (node._timeScale !== null) spatial.setTimeScale(node._node, node._timeScale)
551
560
  // The parent is in the scene already (add() enters the child only then),
552
561
  // and the scene root is the one node without a parent.
553
562
  if (node.parent !== null && node.parent._node !== null) spatial.setParent(node._node, node.parent._node)
@@ -799,6 +808,37 @@ export function setTransition(node: SceneNode, transition: NodeTransition | stri
799
808
  }
800
809
  }
801
810
 
811
+ /**
812
+ * Declare (null clears) the rate the native motion in the node's subtree
813
+ * runs at: transitions with their delays, enters, exits and stagger,
814
+ * morph weight tracks, and the clip players of the models under it
815
+ * (a mixer's players are clocked on their model's node, so this is also
816
+ * Three's `mixer.timeScale`). 0 freezes, 1 is app time, other values
817
+ * are slow or fast motion. The nearest declaring ancestor wins, never a
818
+ * product: a world at 0 with a group inside it declaring 1 keeps that
819
+ * group running (a preview on a pause screen). A frozen subtree asks for
820
+ * no frames; this write brings the one that resumes it. The declaration
821
+ * lives on the SceneNode and re-applies on every scene enter. What it
822
+ * does not cover: `onFrame` logic, timers, video and audio stay on app
823
+ * time - multiply your own step by `timeRate(node)`. For the whole scene,
824
+ * `scene.setTimeScale` / `<Scene timeScale>` declare it on `scene.root`.
825
+ */
826
+ export function setTimeScale(node: SceneNode, scale: number | null): void {
827
+ if (node._destroyed) return
828
+ node._timeScale = scale
829
+ if (node._node !== null) spatial.setTimeScale(node._node, scale)
830
+ }
831
+
832
+ /**
833
+ * The rate the node's motion runs at (setTimeScale): its own scale, else
834
+ * the nearest declaring ancestor's, else 1. Null outside a scene, where
835
+ * nothing runs and no ancestry resolves.
836
+ */
837
+ export function timeRate(node: SceneNode): number | null {
838
+ if (node._node === null) return null
839
+ return spatial.timeRate(node._node)
840
+ }
841
+
802
842
  /**
803
843
  * The one write path for node transforms (so the scene knows to sync).
804
844
  * Values are copied in; absent keys keep their current value. This is also
@@ -941,7 +981,11 @@ export function getTransform(node: SceneNode): { position: Vec3; quaternion: Qua
941
981
  /**
942
982
  * A node's position in world space, copied into `out` (or a fresh Vec3) -
943
983
  * Three's `getWorldPosition`. Brings the ancestor chain up to date first,
944
- * so it is exact before the pending sync has run.
984
+ * so it is exact before the pending sync has run, and exact against
985
+ * native motion: a transition or a clip player has stepped to this
986
+ * frame's pose before any of the frame's JS runs, so a read in onFrame
987
+ * or the late pass of onBeforeRender is where the node is drawn this
988
+ * frame.
945
989
  */
946
990
  export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
947
991
  let world = worldInto(worldScratch, node)
@@ -12,11 +12,12 @@
12
12
  // turns. Capture is per pointer to the press target, the root included: a
13
13
  // drag from empty space keeps delivering to the root as it crosses meshes,
14
14
  // exactly as a drag from a mesh keeps naming that mesh. Enter/leave pair
15
- // on the struck node alone. Taps are synthesized here (DOM's click,
16
- // Unity's IPointerClickHandler): a press that releases on the same target
17
- // within the slop, the only pointer down for its whole press, counted up
18
- // for repeats (DOM's detail, Unity's clickCount). @solidrt/2d's
19
- // dispatch.ts is the same model one dimension down.
15
+ // on the struck node alone. Taps are synthesized here (DOM's click, which
16
+ // is primary-button only; Unity's IPointerClickHandler fires for every
17
+ // button and reports it, we do not): a primary-button press that releases
18
+ // on the same target within the slop, the only pointer down for its whole
19
+ // press, counted up for repeats (DOM's detail, Unity's clickCount).
20
+ // @solidrt/2d's dispatch.ts is the same model one dimension down.
20
21
  //
21
22
  // Pure BY DESIGN (types only, nothing with GPU imports) so
22
23
  // tests/dispatch.test.ts drives it headless with a fake pick.
@@ -240,9 +241,11 @@ export function makePointerInput(deps: PointerInputDeps): PointerInput {
240
241
  presses.delete(e.pointerId)
241
242
  let under = hitOn(press.target, x, y)
242
243
  walk("onPointerUp", makeEvent(e, press.target, x, y, under), !press.claimed)
243
- // The tap rule: no travel past the slop, alone for the whole
244
- // press, released over the target it pressed (empty space for the
245
- // root itself).
244
+ // The tap rule: the primary button (DOM click; core's press and
245
+ // long-press gate the same way), no travel past the slop, alone
246
+ // for the whole press, released over the target it pressed (empty
247
+ // space for the root itself).
248
+ if (e.button != null && e.button !== 0) return
246
249
  if (press.moved || !press.alone) return
247
250
  let release = nearest(x, y)
248
251
  if (press.target === null ? release !== null : release === null || !sameHit(release, press.target)) return
package/src/scene.ts CHANGED
@@ -63,7 +63,7 @@ import type { EnvironmentOptions, Prefilter } from "./environment.ts"
63
63
 
64
64
  export type { EnvironmentOptions } from "./environment.ts"
65
65
  import type { Material } from "./material.ts"
66
- import { activateMorph, fillTransform, freeLeaving, leaveScene, makeNode, setTransition, worldInto } from "./node.ts"
66
+ import { activateMorph, fillTransform, freeLeaving, leaveScene, makeNode, setTimeScale, setTransition, timeRate, worldInto } from "./node.ts"
67
67
  import type { SceneHooks, SceneNode, ScenePointerListener } from "./node.ts"
68
68
  import { checkInstancePairing, checkMask, checkOrderPairing, drawCount, instanceBinding, localBounds, markLiveRecords, publishRecords, spriteScreenPx } from "./mesh.ts"
69
69
  import type { InstancedMesh, InstanceNode, Mesh, MeshInstances, ResolvedInstanceOrder } from "./mesh.ts"
@@ -474,6 +474,10 @@ export type SceneOptions = {
474
474
  * the whole-scene form of a Group's stagger (nested groups declaring
475
475
  * their own win for what is under them). See setStagger. */
476
476
  stagger?: number
477
+ /** The rate the scene's native motion runs at - `setTimeScale(scene.root,
478
+ * scale)` at creation: 0 freezes the world, 1 is app time (nodes
479
+ * declaring their own win for what is under them). See setTimeScale. */
480
+ timeScale?: number
477
481
  /** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
478
482
  autoFree?: boolean
479
483
  filter?: FilterMode
@@ -833,6 +837,16 @@ export type Scene = {
833
837
  /** Stagger (ms) on the scene's root node, live (SceneOptions.stagger
834
838
  * is its creation form): null removes it. The 2d layer's setStagger. */
835
839
  setStagger(ms: number | null): void
840
+ /** The time scale on the scene's root node, live (SceneOptions.timeScale
841
+ * is its creation form): the rate every transition, enter, exit and
842
+ * clip player in the scene runs at, 0 a pause that leaves the app's own
843
+ * UI moving; null removes it (app time). A node declaring its own keeps
844
+ * its subtree at that rate (see node setTimeScale). The 2d layer's
845
+ * setTimeScale. */
846
+ setTimeScale(scale: number | null): void
847
+ /** The rate the scene's root runs at (setTimeScale): what the app's own
848
+ * per-frame logic in this scene multiplies its step by. */
849
+ timeRate(): number
836
850
  /** The level a LOD group draws in the scene's own render (the thing to
837
851
  * read while tuning thresholds): the index into its levels, nearest
838
852
  * first, `levels.length` when culled past the last one, null before
@@ -2071,7 +2085,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2071
2085
  for (let v of views) attachView(v, mesh)
2072
2086
  // Record writes made while the mesh was out of a scene kept their
2073
2087
  // dirty ranges: publish them now.
2074
- if (inst !== null && inst.streams.some(s => s.dirty !== null)) recordsDirty.add(mesh)
2088
+ if (inst !== null && inst.streams.some(s => s.stream !== null && s.stream.dirty)) recordsDirty.add(mesh)
2075
2089
  // Picking: an ordinary mesh's node carries its geometry's shape,
2076
2090
  // which is its box in the core index (following every
2077
2091
  // updateVertices) and its triangle narrowphase. An instanced mesh's
@@ -2272,22 +2286,45 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2272
2286
  root._scene = hooks
2273
2287
  root._node = spatial.createNode(fillTransform(root), true)
2274
2288
  if (opts?.stagger !== undefined) setTransition(root, { stagger: opts.stagger })
2289
+ if (opts?.timeScale !== undefined) setTimeScale(root, opts.timeScale)
2275
2290
  // The first light rewrite seeds the (empty) light set and the shadow
2276
2291
  // slots - placeholders, no casts - so receivers draw plain from the
2277
2292
  // first frame.
2278
2293
  lightsDirty = true
2279
2294
  hooks._schedule()
2295
+ // What the sync derives from poses the core moves on its own, placed
2296
+ // every frame: a casting light's shadow cameras follow a light under a
2297
+ // transition or a clip player in the frame the lighting moves, which
2298
+ // the core writes itself. The compare in placeShadowCamera makes this
2299
+ // one world read per caster and no write when nothing moved; the
2300
+ // records, the lights and the budgets wait for a write to schedule the
2301
+ // sync, which places the cameras itself.
2302
+ let follow = () => {
2303
+ if (disposed) return
2304
+ shadowSys.placeCameras(false)
2305
+ for (let v of views) {
2306
+ ensureCamera(v.camera, v.width, v.height)
2307
+ if (!v.camera.pending) continue
2308
+ v.camera.pending = false
2309
+ setTargetParams(v.texture, cameraParams(v.camera, v.width, v.height))
2310
+ setView(v.texture, v.camera)
2311
+ if (v.shadowFilter !== null) shadowSys.markMatricesDirty()
2312
+ }
2313
+ shadowSys.flushMatrices(params => receivingTargets(t => setTargetParams(t, params)))
2314
+ }
2280
2315
  // The publish pass of every frame runs the pending sync ahead of the
2281
2316
  // paint, so a write made anywhere in the frame's JS is in that frame's
2282
2317
  // picture: the count, the buffers and the bytes of a record write land
2283
- // together, and nothing shows a frame late. The microtask stays for
2284
- // writes made outside a frame. Registered outside any owner: the scene's
2285
- // own dispose unhooks it (an autoFree: false scene outlives the owner it
2318
+ // together, and nothing shows a frame late. With nothing scheduled it
2319
+ // follows native motion instead. The microtask stays for writes made
2320
+ // outside a frame. Registered outside any owner: the scene's own
2321
+ // dispose unhooks it (an autoFree: false scene outlives the owner it
2286
2322
  // was created in).
2287
2323
  let unhook = runWithOwner(null, () =>
2288
2324
  onBeforeRender(
2289
2325
  () => {
2290
2326
  if (scheduled) sync()
2327
+ else follow()
2291
2328
  },
2292
2329
  { publish: true },
2293
2330
  ),
@@ -2444,6 +2481,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2444
2481
  if (disposed) return
2445
2482
  setTransition(root, ms === null ? null : { stagger: ms })
2446
2483
  },
2484
+ setTimeScale(scale) {
2485
+ if (disposed) return
2486
+ setTimeScale(root, scale)
2487
+ },
2488
+ timeRate() {
2489
+ return timeRate(root) ?? 1
2490
+ },
2447
2491
  setParams(params) {
2448
2492
  if (disposed) return
2449
2493
  Object.assign(sceneParams, params)