@solidrt/3d 0.0.71 → 0.0.73

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/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
@@ -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)