@opsydyn/effect-three 0.4.0 → 0.6.4

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 (53) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +4 -2
  3. package/dist/{index-h3vf50fn.js → index-re3rrfwd.js} +880 -79
  4. package/dist/index-re3rrfwd.js.map +56 -0
  5. package/dist/index.js +71 -2
  6. package/dist/index.js.map +3 -3
  7. package/dist/kernel/bodies.d.ts +20 -1
  8. package/dist/kernel/bodies.d.ts.map +1 -1
  9. package/dist/kernel/build.d.ts +24 -2
  10. package/dist/kernel/build.d.ts.map +1 -1
  11. package/dist/kernel/description.d.ts +68 -0
  12. package/dist/kernel/description.d.ts.map +1 -1
  13. package/dist/kernel/equality.d.ts +3 -1
  14. package/dist/kernel/equality.d.ts.map +1 -1
  15. package/dist/kernel/index.d.ts +7 -5
  16. package/dist/kernel/index.d.ts.map +1 -1
  17. package/dist/kernel/index.js +20 -2
  18. package/dist/kernel/index.js.map +1 -1
  19. package/dist/kernel/loop.d.ts.map +1 -1
  20. package/dist/kernel/paintOrder.d.ts +53 -0
  21. package/dist/kernel/paintOrder.d.ts.map +1 -0
  22. package/dist/kernel/physics.d.ts +39 -0
  23. package/dist/kernel/physics.d.ts.map +1 -1
  24. package/dist/kernel/pointer.d.ts +19 -1
  25. package/dist/kernel/pointer.d.ts.map +1 -1
  26. package/dist/kernel/preview.d.ts +32 -0
  27. package/dist/kernel/preview.d.ts.map +1 -0
  28. package/dist/kernel/sceneGraph.d.ts +3 -1
  29. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  30. package/dist/kernel/sceneIndex.d.ts +5 -0
  31. package/dist/kernel/sceneIndex.d.ts.map +1 -1
  32. package/dist/kernel/timeline.d.ts +60 -0
  33. package/dist/kernel/timeline.d.ts.map +1 -0
  34. package/dist/runtime/SceneFile.d.ts +14 -0
  35. package/dist/runtime/SceneFile.d.ts.map +1 -1
  36. package/dist/runtime/SceneSchema.d.ts +86 -0
  37. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  38. package/package.json +3 -2
  39. package/src/kernel/bodies.ts +299 -17
  40. package/src/kernel/build.ts +42 -1
  41. package/src/kernel/description.ts +68 -0
  42. package/src/kernel/equality.ts +22 -0
  43. package/src/kernel/index.ts +13 -1
  44. package/src/kernel/loop.ts +2 -0
  45. package/src/kernel/paintOrder.ts +274 -0
  46. package/src/kernel/physics.ts +86 -1
  47. package/src/kernel/pointer.ts +43 -5
  48. package/src/kernel/preview.ts +467 -0
  49. package/src/kernel/sceneGraph.ts +35 -3
  50. package/src/kernel/sceneIndex.ts +8 -0
  51. package/src/kernel/timeline.ts +93 -0
  52. package/src/runtime/SceneSchema.ts +72 -0
  53. package/dist/index-h3vf50fn.js.map +0 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsydyn/effect-three",
3
- "version": "0.4.0",
3
+ "version": "0.6.4",
4
4
  "description": "Effect-native declarative Three.js scenes: scoped resources, keyed reconciliation, testable frame loop.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,6 +55,7 @@
55
55
  "test": "bun test --pass-with-no-tests ./src ./test --coverage --coverage-reporter=lcov --coverage-dir=coverage && bun scripts/check-coverage.ts coverage/lcov.info",
56
56
  "build": "tsc -b && bun build ./src/index.ts ./src/kernel/index.ts ./src/webgl/index.ts ./src/webgpu/index.ts --root ./src --outdir ./dist --target browser --packages external --splitting --sourcemap=linked",
57
57
  "scene:diff": "bun scripts/scene-diff.ts",
58
+ "scene:preview": "bun scripts/scene-preview.ts",
58
59
  "bench": "bun bench/index.ts",
59
60
  "bench:check": "bun bench/index.ts --check",
60
61
  "api:report": "api-extractor run --local",
@@ -68,7 +69,7 @@
68
69
  "@types/bun": "1.4.3",
69
70
  "@types/pngjs": "6.0.5",
70
71
  "@types/three": "0.186.0",
71
- "effect": "4.0.0",
72
+ "effect": "4.1.0",
72
73
  "oxlint": "1.88.0",
73
74
  "oxlint-tsgolint": "7.0.2004",
74
75
  "pixelmatch": "8.0.0",
@@ -15,7 +15,16 @@ import { Euler, Quaternion, type Scene } from 'three';
15
15
  import { bodyId, type BodySource } from './bodySources.ts';
16
16
  import type { Body, Joint, Physics, Vec3 } from './description.ts';
17
17
  import { bodyEquals, collidersEqual, jointEquals, physicsEquals } from './equality.ts';
18
- import type { BodyPose, PhysicsEngine, PhysicsJoint, PhysicsWorld, Quat } from './physics.ts';
18
+ import {
19
+ type BodyPose,
20
+ type Collision,
21
+ type PhysicsEngine,
22
+ type PhysicsJoint,
23
+ type PhysicsWorld,
24
+ type Quat,
25
+ snapshotBytes,
26
+ snapshotText,
27
+ } from './physics.ts';
19
28
  import { createPhysicsDebug } from './physicsDebug.ts';
20
29
 
21
30
  /** Two bodies began (`started`) or stopped touching: their nodes' keys, and instances'. */
@@ -48,6 +57,19 @@ export type BodyContactForce = {
48
57
  readonly maxForceMagnitude: number;
49
58
  };
50
59
 
60
+ /**
61
+ * A world driven by `physics({ until })` has reached that step and waits: its run, what
62
+ * it has done (its step count since it was made), and a checksum of every body's pose
63
+ * there, so peers that should agree can check that they do.
64
+ */
65
+ export type PhysicsReached = {
66
+ readonly run: number;
67
+ readonly step: number;
68
+ readonly checksum: string;
69
+ /** At `physics({ snapshotAt })`: the world there, for another to start `from`. */
70
+ readonly snapshot?: string;
71
+ };
72
+
51
73
  type Hosted = {
52
74
  source: BodySource;
53
75
  /** What its colliders were made from; a change makes them again. */
@@ -69,6 +91,13 @@ export type BodyHost = {
69
91
  readonly drainCollisions: () => ReadonlyArray<BodyCollision>;
70
92
  /** Contact forces over their thresholds since the last call. */
71
93
  readonly drainContactForces: () => ReadonlyArray<BodyContactForce>;
94
+ /** Steps the Model has asked for and the world has reached since the last call. */
95
+ readonly drainReached: () => ReadonlyArray<PhysicsReached>;
96
+ /**
97
+ * Whether a world on `until` still owes steps, or a report: it needs frames even when
98
+ * nothing else moves, and even under reduced motion, as other peers wait on it.
99
+ */
100
+ readonly owesSteps: () => boolean;
72
101
  readonly dispose: () => void;
73
102
  };
74
103
 
@@ -108,6 +137,53 @@ const between = (a: BodyPose, b: BodyPose, alpha: number): BodyPose => {
108
137
  };
109
138
  };
110
139
 
140
+ /**
141
+ * How far behind `until` (in seconds of simulation) a world may run at scene time's pace,
142
+ * following an `until` that moves on as time passes; further back (a newcomer replaying,
143
+ * a page that was busy), it goes straight there.
144
+ */
145
+ const catchUpAfter = 0.25;
146
+
147
+ /** The most simulation a world on `until` runs in one frame, in seconds. */
148
+ const mostPerFrame = 1;
149
+
150
+ const checksumBytes = new DataView(new ArrayBuffer(8));
151
+
152
+ /** FNV-1a, a byte at a time. */
153
+ const mixByte = (hash: number, byte: number): number => Math.imul(hash ^ byte, 0x0100_0193) >>> 0;
154
+
155
+ const mixNumber = (hash: number, value: number): number => {
156
+ checksumBytes.setFloat64(0, value);
157
+ let mixed = hash;
158
+ for (let i = 0; i < 8; i++) mixed = mixByte(mixed, checksumBytes.getUint8(i));
159
+ return mixed;
160
+ };
161
+
162
+ const mixText = (hash: number, text: string): number => {
163
+ let mixed = hash;
164
+ for (let i = 0; i < text.length; i++) {
165
+ const code = text.charCodeAt(i);
166
+ mixed = mixByte(mixByte(mixed, code & 0xff), code >>> 8);
167
+ }
168
+ return mixed;
169
+ };
170
+
171
+ /**
172
+ * A checksum of where every body is, by id: the bits of each pose's numbers, so peers
173
+ * agree only if their worlds are the same to the last bit.
174
+ */
175
+ const checksumOf = (poses: ReadonlyArray<readonly [string, BodyPose]>): string => {
176
+ let hash = 0x811c_9dc5;
177
+ for (const [id, { position, quaternion }] of poses.toSorted(([a], [b]) =>
178
+ a < b ? -1 : a > b ? 1 : 0,
179
+ )) {
180
+ hash = mixText(hash, id);
181
+ for (const value of position) hash = mixNumber(hash, value);
182
+ for (const value of quaternion) hash = mixNumber(hash, value);
183
+ }
184
+ return hash.toString(16).padStart(8, '0');
185
+ };
186
+
111
187
  /** A change that needs a new body: another holder or kind, or colliders made otherwise. */
112
188
  const remade = (hosted: Hosted, source: BodySource, signature: string): boolean =>
113
189
  hosted.source.holder !== source.holder ||
@@ -156,6 +232,19 @@ export const createBodyHost = (engine: PhysicsEngine | undefined, scene: Scene):
156
232
  const debug = createPhysicsDebug(scene);
157
233
  let last: number | undefined;
158
234
  let accumulator = 0;
235
+ /** Steps taken since the world was made. */
236
+ let steps = 0;
237
+ /** The last `until` reported as reached. */
238
+ let reported: number | undefined;
239
+ /** The last `snapshotAt` reported, snapshot and all. */
240
+ let snapped: number | undefined;
241
+ let reached: Array<PhysicsReached> = [];
242
+ /** Snapshots of the latest stops, by step, oldest first, while `keep` asks for them. */
243
+ const kept = new Map<number, Uint8Array>();
244
+ /** A run just started from a snapshot: it goes straight to `until`, as one far behind. */
245
+ let rushing = false;
246
+ /** What a world made from a snapshot holds already, until this sync takes it on. */
247
+ let restored: { readonly bodies: Set<string>; readonly joints: Set<string> } | undefined;
159
248
 
160
249
  /** Draws the world's lines while the description asks for them. */
161
250
  const drawDebug = (): void => {
@@ -172,10 +261,51 @@ export const createBodyHost = (engine: PhysicsEngine | undefined, scene: Scene):
172
261
  joined.clear();
173
262
  accumulator = 0;
174
263
  last = undefined;
264
+ steps = 0;
265
+ reported = undefined;
266
+ snapped = undefined;
267
+ reached = [];
268
+ };
269
+
270
+ /**
271
+ * The world for a run: made from the snapshot it starts `from`, counting on from its
272
+ * step, when the engine can; otherwise from the description, at step 0.
273
+ */
274
+ const make = (active: PhysicsEngine, physics: Physics): PhysicsWorld => {
275
+ const { from } = physics;
276
+ const bytes =
277
+ from === null
278
+ ? undefined
279
+ : from.snapshot === null
280
+ ? kept.get(from.step)
281
+ : snapshotBytes(from.snapshot);
282
+ if (from === null || bytes === undefined || active.restoreWorld === undefined) {
283
+ kept.clear();
284
+ return active.createWorld(physics);
285
+ }
286
+ // What was kept after the step it goes back to was of a world that went another way.
287
+ for (const step of kept.keys())
288
+ if (step > from.step || from.snapshot !== null) kept.delete(step);
289
+ const made = active.restoreWorld(physics, bytes);
290
+ steps = from.step;
291
+ rushing = true;
292
+ restored = { bodies: new Set(made.bodies), joints: new Set(made.joints) };
293
+ return made.world;
175
294
  };
176
295
 
296
+ /** Takes on a body the restored world holds: shown where the world has it. */
297
+ const adopt = (active: PhysicsWorld, source: BodySource, signature: string): void => {
298
+ const pose = active.pose(source.id) ?? source.described();
299
+ source.place(pose);
300
+ hosted.set(source.id, { source, signature, previous: pose, current: pose, shown: pose });
301
+ };
302
+
303
+ /** Whether this sync begins a new run: bodies start where the description puts them. */
304
+ let restarting = false;
305
+
177
306
  const add = (active: PhysicsWorld, source: BodySource, signature: string): void => {
178
- const pose = source.pose();
307
+ const pose = restarting ? source.described() : source.pose();
308
+ if (restarting) source.place(pose);
179
309
  active.add(source.id, source.body, source.colliders(), pose);
180
310
  if (source.body.impulse !== null) active.applyImpulse(source.id, source.body.impulse.impulse);
181
311
  hosted.set(source.id, { source, signature, previous: pose, current: pose, shown: pose });
@@ -185,6 +315,10 @@ export const createBodyHost = (engine: PhysicsEngine | undefined, scene: Scene):
185
315
  const syncBody = (active: PhysicsWorld, source: BodySource): void => {
186
316
  const existing = hosted.get(source.id);
187
317
  const signature = source.signature();
318
+ if (existing === undefined && restored?.bodies.has(source.id) === true) {
319
+ adopt(active, source, signature);
320
+ return;
321
+ }
188
322
  if (existing === undefined || remade(existing, source, signature)) {
189
323
  if (existing !== undefined) active.remove(source.id);
190
324
  add(active, source, signature);
@@ -247,11 +381,27 @@ export const createBodyHost = (engine: PhysicsEngine | undefined, scene: Scene):
247
381
  const sync: BodyHost['sync'] = (physics, sources) => {
248
382
  if (physics === null || engine === undefined) {
249
383
  dispose();
384
+ kept.clear();
250
385
  return;
251
386
  }
252
- if (world === undefined) world = engine.createWorld(physics);
253
- // Joints are made below, not configured.
254
- else if (!physicsEquals(settings && { ...settings, joints: physics.joints }, physics)) {
387
+ // Another run: the world goes, and a new one counts from step 0.
388
+ restarting = settings !== null && settings.run !== physics.run;
389
+ if (restarting) dispose();
390
+ if (world === undefined) world = make(engine, physics);
391
+ // Joints are made below, and the clock and snapshots are the host's: none is configured.
392
+ else if (
393
+ !physicsEquals(
394
+ settings && {
395
+ ...settings,
396
+ joints: physics.joints,
397
+ until: physics.until,
398
+ snapshotAt: physics.snapshotAt,
399
+ from: physics.from,
400
+ keep: physics.keep,
401
+ },
402
+ physics,
403
+ )
404
+ ) {
255
405
  world.configure(physics);
256
406
  }
257
407
  settings = physics;
@@ -267,7 +417,20 @@ export const createBodyHost = (engine: PhysicsEngine | undefined, scene: Scene):
267
417
  world.remove(id);
268
418
  hosted.delete(id);
269
419
  }
420
+ if (restored !== undefined) {
421
+ // What the snapshot held and the description does not, goes; its joints that the
422
+ // description still has are kept as they were.
423
+ for (const id of restored.bodies) if (!seen.has(id)) world.remove(id);
424
+ for (const joint of physics.joints) {
425
+ if (restored.joints.has(joint.key)) joined.set(joint.key, joint);
426
+ }
427
+ for (const id of restored.joints) {
428
+ if (!joined.has(id)) world.removeJoint(id);
429
+ }
430
+ restored = undefined;
431
+ }
270
432
  syncJoints(world, physics.joints);
433
+ restarting = false;
271
434
  drawDebug();
272
435
  };
273
436
 
@@ -277,10 +440,8 @@ export const createBodyHost = (engine: PhysicsEngine | undefined, scene: Scene):
277
440
  }
278
441
  };
279
442
 
280
- const step = (active: PhysicsWorld, seconds: number): void => {
281
- for (const body of hosted.values()) body.previous = body.current;
282
- kinematicTargets(active);
283
- for (const collision of active.step(seconds)) {
443
+ const heard = (arrived: ReadonlyArray<Collision>, active: PhysicsWorld): void => {
444
+ for (const collision of arrived) {
284
445
  collisions.push({
285
446
  ...named(hosted.get(collision.a)?.source, hosted.get(collision.b)?.source, collision),
286
447
  started: collision.started,
@@ -293,21 +454,128 @@ export const createBodyHost = (engine: PhysicsEngine | undefined, scene: Scene):
293
454
  ...payload,
294
455
  });
295
456
  }
457
+ };
458
+
459
+ const step = (active: PhysicsWorld, seconds: number): void => {
460
+ for (const body of hosted.values()) body.previous = body.current;
461
+ kinematicTargets(active);
462
+ heard(active.step(seconds), active);
296
463
  for (const [id, body] of hosted) body.current = active.pose(id) ?? body.current;
464
+ steps += 1;
297
465
  };
298
466
 
299
- const tick: BodyHost['tick'] = (elapsed, animating) => {
300
- const delta = last === undefined ? 0 : Math.min(0.5, Math.max(0, elapsed - last));
301
- last = elapsed;
302
- if (world === undefined || settings === null || settings.paused || !animating) return;
303
- const timeStep = settings.timeStep;
467
+ /**
468
+ * What a world working elsewhere has sent back since the last frame: its collisions and
469
+ * forces, and where its bodies are now. Whether anything is still to come back; never,
470
+ * for a world that does what it is asked at once.
471
+ */
472
+ const collect = (active: PhysicsWorld): boolean => {
473
+ if (active.received === undefined) return false;
474
+ const { collisions: arrived, pending } = active.received();
475
+ heard(arrived, active);
476
+ for (const [id, body] of hosted) body.current = active.pose(id) ?? body.current;
477
+ return pending;
478
+ };
479
+
480
+ /** On scene time: every whole step the time since the last frame holds. */
481
+ const free = (active: PhysicsWorld, timeStep: number, delta: number): number => {
304
482
  accumulator += delta;
305
- scene.updateMatrixWorld();
306
483
  while (accumulator >= timeStep) {
307
- step(world, timeStep);
484
+ step(active, timeStep);
308
485
  accumulator -= timeStep;
309
486
  }
310
- const alpha = settings.interpolate ? accumulator / timeStep : 1;
487
+ collect(active);
488
+ return accumulator / timeStep;
489
+ };
490
+
491
+ /** Whether the world is to stop at `snapshotAt` on its way to `until` and report a snapshot there. */
492
+ const snapshotOwed = (physics: Physics, until: number): boolean =>
493
+ physics.snapshotAt !== null &&
494
+ snapped !== physics.snapshotAt &&
495
+ steps <= physics.snapshotAt &&
496
+ physics.snapshotAt <= until;
497
+
498
+ /**
499
+ * Reports where the world is: its checksum, and a snapshot of it when `snapshot` asks;
500
+ * one is kept too, while the description asks to `keep` some. False, reporting nothing,
501
+ * while a world working elsewhere has yet to send the snapshot (asking it for one).
502
+ */
503
+ const report = (active: PhysicsWorld, physics: Physics, snapshot: boolean): boolean => {
504
+ const wanted = snapshot || physics.keep > 0;
505
+ const bytes = wanted ? active.snapshot?.() : undefined;
506
+ if (wanted && active.snapshot !== undefined && bytes === undefined) return false;
507
+ if (physics.keep > 0 && bytes !== undefined) {
508
+ kept.delete(steps);
509
+ kept.set(steps, bytes);
510
+ for (const step of kept.keys()) if (kept.size > physics.keep) kept.delete(step);
511
+ }
512
+ const { run } = physics;
513
+ reached.push({
514
+ run,
515
+ step: steps,
516
+ checksum: checksumOf([...hosted.keys()].flatMap((id) => posed(active, id))),
517
+ ...(snapshot && bytes !== undefined ? { snapshot: snapshotText(bytes) } : {}),
518
+ });
519
+ return true;
520
+ };
521
+
522
+ /**
523
+ * Toward `until`: at scene time's pace while close behind it, straight there (a step
524
+ * budget a frame) when further back, never past it. A world that has reached it waits
525
+ * there and reports it once, with the bodies' checksum. A `snapshotAt` on the way is a
526
+ * stop too: the world reports there with a snapshot, and goes on.
527
+ */
528
+ const driven = (active: PhysicsWorld, physics: Physics, until: number, delta: number) => {
529
+ const { timeStep } = physics;
530
+ const snapping = snapshotOwed(physics, until);
531
+ const stop = snapping && physics.snapshotAt !== null ? physics.snapshotAt : until;
532
+ accumulator += delta;
533
+ const owed = Math.max(0, stop - steps);
534
+ const paced = Math.floor(accumulator / timeStep);
535
+ const behind = rushing || owed > Math.ceil(catchUpAfter / timeStep);
536
+ rushing = false;
537
+ const count = Math.min(owed, behind ? owed : paced, Math.ceil(mostPerFrame / timeStep));
538
+ for (let i = 0; i < count; i++) step(active, timeStep);
539
+ accumulator = Math.max(0, accumulator - count * timeStep);
540
+ const pending = collect(active);
541
+ if (steps < stop) return Math.min(1, accumulator / timeStep);
542
+ // A world working elsewhere is there once all it was asked has come back, poses and all.
543
+ if (stop < until) {
544
+ if (!pending && report(active, physics, true)) snapped = stop;
545
+ return Math.min(1, accumulator / timeStep);
546
+ }
547
+ // Waiting: time spent here is not owed once `until` moves on.
548
+ accumulator = 0;
549
+ // Reported there already, it reports again for a snapshot asked for afterwards.
550
+ if (
551
+ steps === until &&
552
+ (reported !== until || snapping) &&
553
+ !pending &&
554
+ report(active, physics, snapping)
555
+ ) {
556
+ reported = until;
557
+ if (snapping) snapped = until;
558
+ }
559
+ return 1;
560
+ };
561
+
562
+ const posed = (active: PhysicsWorld, id: string): ReadonlyArray<[string, BodyPose]> => {
563
+ const pose = active.pose(id);
564
+ return pose === undefined ? [] : [[id, pose]];
565
+ };
566
+
567
+ const tick: BodyHost['tick'] = (elapsed, animating) => {
568
+ const delta = last === undefined ? 0 : Math.min(0.5, Math.max(0, elapsed - last));
569
+ last = elapsed;
570
+ if (world === undefined || settings === null || settings.paused) return;
571
+ // A world on `until` steps under reduced motion too: other peers wait on it.
572
+ if (settings.until === null && !animating) return;
573
+ scene.updateMatrixWorld();
574
+ const progress =
575
+ settings.until === null
576
+ ? free(world, settings.timeStep, delta)
577
+ : driven(world, settings, settings.until, delta);
578
+ const alpha = settings.interpolate ? progress : 1;
311
579
  for (const body of hosted.values()) {
312
580
  if (body.source.body.type === 'dynamic') {
313
581
  body.shown = between(body.previous, body.current, alpha);
@@ -333,6 +601,20 @@ export const createBodyHost = (engine: PhysicsEngine | undefined, scene: Scene):
333
601
  contactForces = [];
334
602
  return drained;
335
603
  },
604
+ drainReached: () => {
605
+ if (reached.length === 0) return reached;
606
+ const drained = reached;
607
+ reached = [];
608
+ return drained;
609
+ },
610
+ owesSteps: () =>
611
+ world !== undefined &&
612
+ settings !== null &&
613
+ !settings.paused &&
614
+ settings.until !== null &&
615
+ (steps < settings.until ||
616
+ reported !== settings.until ||
617
+ snapshotOwed(settings, settings.until)),
336
618
  dispose,
337
619
  };
338
620
  };
@@ -6,6 +6,7 @@
6
6
  import { CatmullRomCurve3, Color, CubicBezierCurve3, QuadraticBezierCurve3, Vector3 } from 'three';
7
7
 
8
8
  import type {
9
+ Asset,
9
10
  AmbientLightNode,
10
11
  LineNode,
11
12
  PointLook,
@@ -1067,13 +1068,19 @@ const noJoints: ReadonlyArray<Joint> = [];
1067
1068
 
1068
1069
  /**
1069
1070
  * A physics world. Defaults as react-three-rapier's `Physics`: Earth gravity
1070
- * `[0, -9.81, 0]`, running, fixed steps of 1/60 s, interpolated; no joints.
1071
+ * `[0, -9.81, 0]`, running, fixed steps of 1/60 s, interpolated; no joints; on scene
1072
+ * time rather than `until` a step the Model sets.
1071
1073
  */
1072
1074
  export const physics = (options: Partial<Physics> = {}): Physics => ({
1073
1075
  gravity: options.gravity ?? [0, -9.81, 0],
1074
1076
  paused: options.paused ?? false,
1075
1077
  timeStep: options.timeStep ?? 1 / 60,
1076
1078
  interpolate: options.interpolate ?? true,
1079
+ until: options.until ?? null,
1080
+ run: options.run ?? 0,
1081
+ snapshotAt: options.snapshotAt ?? null,
1082
+ from: options.from ?? null,
1083
+ keep: options.keep ?? 0,
1077
1084
  debug: options.debug ?? false,
1078
1085
  joints: options.joints ?? noJoints,
1079
1086
  });
@@ -1674,6 +1681,38 @@ export const contactShadows = (
1674
1681
 
1675
1682
  const noOverlays: ReadonlyArray<Overlay> = [];
1676
1683
  const noMeasures: ReadonlyArray<string> = [];
1684
+ const noAssets: ReadonlyArray<Asset> = [];
1685
+
1686
+ /** What an asset builder takes: an id, the finest URL, and coarser ones to show first. */
1687
+ type AssetOptions = {
1688
+ readonly id: string;
1689
+ readonly url: string;
1690
+ readonly levels?: ReadonlyArray<string>;
1691
+ };
1692
+
1693
+ const noLevels: ReadonlyArray<string> = [];
1694
+
1695
+ const assetOf =
1696
+ (kind: Asset['kind']) =>
1697
+ ({ id, url, levels }: AssetOptions): Asset => ({ kind, id, url, levels: levels ?? noLevels });
1698
+
1699
+ /** A glTF or GLB model for `scene({ assets })`, lent under `id` for `borrowed({ id })`. */
1700
+ export const modelAsset = assetOf('model');
1701
+
1702
+ /** A colour texture (sRGB) for `scene({ assets })`, lent under `id` for a material's `map`. */
1703
+ export const textureAsset = assetOf('texture');
1704
+
1705
+ /**
1706
+ * An equirectangular HDR panorama (`.hdr`) for `scene({ assets })`, lent under `id` for
1707
+ * `environment({ map: id })`.
1708
+ */
1709
+ export const environmentAsset = assetOf('environment');
1710
+
1711
+ /**
1712
+ * A typeface JSON font (three.js's `FontLoader` format) for `scene({ assets })`, lent under
1713
+ * `id` for `text({ font: id })`.
1714
+ */
1715
+ export const fontAsset = assetOf('font');
1677
1716
 
1678
1717
  /** Drei's `zIndexRange` default: near points on top, far ones at 0. */
1679
1718
  const defaultZIndexRange: readonly [number, number] = [16777271, 0];
@@ -1730,6 +1769,7 @@ export const scene = (options: {
1730
1769
  readonly physics?: Physics | null;
1731
1770
  readonly xr?: Xr | null;
1732
1771
  readonly measure?: ReadonlyArray<string>;
1772
+ readonly assets?: ReadonlyArray<Asset>;
1733
1773
  readonly render?: RenderSettings;
1734
1774
  readonly resources?: Partial<ResourceDefinitions>;
1735
1775
  readonly children?: ReadonlyArray<SceneNode>;
@@ -1746,6 +1786,7 @@ export const scene = (options: {
1746
1786
  physics: options.physics ?? null,
1747
1787
  xr: options.xr ?? null,
1748
1788
  measure: options.measure ?? noMeasures,
1789
+ assets: options.assets ?? noAssets,
1749
1790
  render: options.render ?? defaultRenderSettings,
1750
1791
  resources: {
1751
1792
  geometries: options.resources?.geometries ?? noResources.geometries,
@@ -625,12 +625,56 @@ export type Physics = {
625
625
  readonly paused: boolean;
626
626
  readonly timeStep: number;
627
627
  readonly interpolate: boolean;
628
+ /**
629
+ * The step the Model lets the world reach, for a simulation that must run alike
630
+ * wherever it runs (lockstep, replays; ADR-014). The world advances toward it in fixed
631
+ * steps at scene time's pace (faster when far behind), stops there and reports
632
+ * `PhysicsReached`; whatever the description changes while it waits applies at that
633
+ * step, on every peer. Steps count from 0 when the world is made, and the world never
634
+ * goes back: a lower `until` holds it where it is. `null` (the default): the world runs
635
+ * on scene time alone, as react-three-rapier's does.
636
+ */
637
+ readonly until: number | null;
638
+ /**
639
+ * Which run of the simulation this is: a new number throws the world away and starts
640
+ * another at step 0, for a peer that takes a session's record and replays it, or a
641
+ * replay starting over. `0` by default.
642
+ */
643
+ readonly run: number;
644
+ /**
645
+ * A step the world also stops at on its way to `until`, to report there with a
646
+ * snapshot of itself (`PhysicsReached.snapshot`) for another world to start `from`. It
647
+ * does not wait there. `null` (the default): no snapshot. An engine that cannot take
648
+ * one reports there without.
649
+ */
650
+ readonly snapshotAt: number | null;
651
+ /**
652
+ * Where a run starts: a snapshot and the step it was taken at. The world is made from
653
+ * it, holding the bodies and joints it held, and counts on from that step, going
654
+ * straight to `until`; bodies the description has and it lacks are added where
655
+ * described. Read only when a run starts; `null` (the default): from the description,
656
+ * at step 0.
657
+ */
658
+ readonly from: PhysicsStart | null;
659
+ /**
660
+ * How many of its latest stops (each step it reported at) the world keeps a snapshot
661
+ * of, itself, for a run to start `from` without one in the description: to roll back
662
+ * a few steps and go again with an input that came late. `0` (the default): none.
663
+ */
664
+ readonly keep: number;
628
665
  /** Draw every collider and joint as lines, as react-three-rapier's `<Physics debug>`. */
629
666
  readonly debug: boolean;
630
667
  /** Joints between bodies, each active while both its bodies exist. */
631
668
  readonly joints: ReadonlyArray<Joint>;
632
669
  };
633
670
 
671
+ /**
672
+ * Where a run starts: the step, and a world's snapshot there (base64, as
673
+ * `PhysicsReached` carries it), or `null` for the one the world kept at that step itself
674
+ * (`keep`). A step it did not keep starts from the description, at step 0.
675
+ */
676
+ export type PhysicsStart = { readonly step: number; readonly snapshot: string | null };
677
+
634
678
  /** A body a joint holds: a node's, or one instance's of an instances node. */
635
679
  export type BodyRef = { readonly key: string; readonly instance: string | null };
636
680
 
@@ -1426,6 +1470,22 @@ export type XrGlide = {
1426
1470
  readonly snapDegrees: number;
1427
1471
  };
1428
1472
 
1473
+ /** What an asset is: a glTF or GLB model, a colour texture, an HDR panorama, a typeface font. */
1474
+ export type AssetKind = 'model' | 'texture' | 'environment' | 'font';
1475
+
1476
+ /**
1477
+ * An asset a scene asks for: its kind, the id it is lent under once loaded, and where it
1478
+ * comes from. `levels` are coarser versions, coarsest first, to show while `url` (the
1479
+ * finest) is on its way: each is lent as it arrives, if finer than what is shown, and
1480
+ * reported as a level of detail (ADR-015).
1481
+ */
1482
+ export type Asset = {
1483
+ readonly kind: AssetKind;
1484
+ readonly id: string;
1485
+ readonly url: string;
1486
+ readonly levels: ReadonlyArray<string>;
1487
+ };
1488
+
1429
1489
  export type SceneDescription = {
1430
1490
  readonly camera: Camera;
1431
1491
  /** Whether the scene claims the pointer's gestures from the page. */
@@ -1456,6 +1516,14 @@ export type SceneDescription = {
1456
1516
  * changes: what Drei's `Bounds` measures, for `fitCamera`. Loaded models included.
1457
1517
  */
1458
1518
  readonly measure: ReadonlyArray<string>;
1519
+ /**
1520
+ * Assets the scene asks for, each lent under its id once loaded: a `borrowed({ id })`
1521
+ * node shows a model, a material's `map: id` a texture, `environment({ map: id })` a
1522
+ * panorama and `text({ font: id })` a font. The host loads each once however many
1523
+ * scenes ask, cancels a load no scene asks for any more, and frees an asset when the
1524
+ * last scene stops asking (ADR-015); it reports each as loaded or failed.
1525
+ */
1526
+ readonly assets: ReadonlyArray<Asset>;
1459
1527
  readonly render: RenderSettings;
1460
1528
  readonly resources: ResourceDefinitions;
1461
1529
  readonly children: ReadonlyArray<SceneNode>;
@@ -5,6 +5,7 @@
5
5
  * @module
6
6
  */
7
7
  import type {
8
+ Asset,
8
9
  Animation,
9
10
  Behave,
10
11
  CameraShake,
@@ -690,6 +691,18 @@ export const shaderGridLookEquals = (a: ShaderGridNode, b: ShaderGridNode): bool
690
691
  Object.is(a.fadeFrom, b.fadeFrom) &&
691
692
  a.side === b.side;
692
693
 
694
+ const assetEquals = (a: Asset, b: Asset | undefined): boolean =>
695
+ b !== undefined &&
696
+ a.kind === b.kind &&
697
+ a.id === b.id &&
698
+ a.url === b.url &&
699
+ a.levels.length === b.levels.length &&
700
+ a.levels.every((level, i) => level === b.levels[i]);
701
+
702
+ /** Asset list equality: the same kinds, ids, urls and levels, in the same order. */
703
+ export const assetsEqual = (a: ReadonlyArray<Asset>, b: ReadonlyArray<Asset>): boolean =>
704
+ a === b || (a.length === b.length && a.every((asset, i) => assetEquals(asset, b[i])));
705
+
693
706
  /** Physics world equality. */
694
707
  export const physicsEquals = (a: Physics | null, b: Physics | null): boolean =>
695
708
  a === b ||
@@ -699,6 +712,15 @@ export const physicsEquals = (a: Physics | null, b: Physics | null): boolean =>
699
712
  a.paused === b.paused &&
700
713
  Object.is(a.timeStep, b.timeStep) &&
701
714
  a.interpolate === b.interpolate &&
715
+ a.until === b.until &&
716
+ a.run === b.run &&
717
+ a.snapshotAt === b.snapshotAt &&
718
+ a.keep === b.keep &&
719
+ (a.from === b.from ||
720
+ (a.from !== null &&
721
+ b.from !== null &&
722
+ a.from.step === b.from.step &&
723
+ a.from.snapshot === b.from.snapshot)) &&
702
724
  a.debug === b.debug &&
703
725
  jointsEqual(a.joints, b.joints));
704
726