@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.
- package/CHANGELOG.md +41 -0
- package/README.md +4 -2
- package/dist/{index-h3vf50fn.js → index-re3rrfwd.js} +880 -79
- package/dist/index-re3rrfwd.js.map +56 -0
- package/dist/index.js +71 -2
- package/dist/index.js.map +3 -3
- package/dist/kernel/bodies.d.ts +20 -1
- package/dist/kernel/bodies.d.ts.map +1 -1
- package/dist/kernel/build.d.ts +24 -2
- package/dist/kernel/build.d.ts.map +1 -1
- package/dist/kernel/description.d.ts +68 -0
- package/dist/kernel/description.d.ts.map +1 -1
- package/dist/kernel/equality.d.ts +3 -1
- package/dist/kernel/equality.d.ts.map +1 -1
- package/dist/kernel/index.d.ts +7 -5
- package/dist/kernel/index.d.ts.map +1 -1
- package/dist/kernel/index.js +20 -2
- package/dist/kernel/index.js.map +1 -1
- package/dist/kernel/loop.d.ts.map +1 -1
- package/dist/kernel/paintOrder.d.ts +53 -0
- package/dist/kernel/paintOrder.d.ts.map +1 -0
- package/dist/kernel/physics.d.ts +39 -0
- package/dist/kernel/physics.d.ts.map +1 -1
- package/dist/kernel/pointer.d.ts +19 -1
- package/dist/kernel/pointer.d.ts.map +1 -1
- package/dist/kernel/preview.d.ts +32 -0
- package/dist/kernel/preview.d.ts.map +1 -0
- package/dist/kernel/sceneGraph.d.ts +3 -1
- package/dist/kernel/sceneGraph.d.ts.map +1 -1
- package/dist/kernel/sceneIndex.d.ts +5 -0
- package/dist/kernel/sceneIndex.d.ts.map +1 -1
- package/dist/kernel/timeline.d.ts +60 -0
- package/dist/kernel/timeline.d.ts.map +1 -0
- package/dist/runtime/SceneFile.d.ts +14 -0
- package/dist/runtime/SceneFile.d.ts.map +1 -1
- package/dist/runtime/SceneSchema.d.ts +86 -0
- package/dist/runtime/SceneSchema.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/kernel/bodies.ts +299 -17
- package/src/kernel/build.ts +42 -1
- package/src/kernel/description.ts +68 -0
- package/src/kernel/equality.ts +22 -0
- package/src/kernel/index.ts +13 -1
- package/src/kernel/loop.ts +2 -0
- package/src/kernel/paintOrder.ts +274 -0
- package/src/kernel/physics.ts +86 -1
- package/src/kernel/pointer.ts +43 -5
- package/src/kernel/preview.ts +467 -0
- package/src/kernel/sceneGraph.ts +35 -3
- package/src/kernel/sceneIndex.ts +8 -0
- package/src/kernel/timeline.ts +93 -0
- package/src/runtime/SceneSchema.ts +72 -0
- 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
|
|
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.
|
|
72
|
+
"effect": "4.1.0",
|
|
72
73
|
"oxlint": "1.88.0",
|
|
73
74
|
"oxlint-tsgolint": "7.0.2004",
|
|
74
75
|
"pixelmatch": "8.0.0",
|
package/src/kernel/bodies.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
281
|
-
for (const
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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(
|
|
484
|
+
step(active, timeStep);
|
|
308
485
|
accumulator -= timeStep;
|
|
309
486
|
}
|
|
310
|
-
|
|
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
|
};
|
package/src/kernel/build.ts
CHANGED
|
@@ -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>;
|
package/src/kernel/equality.ts
CHANGED
|
@@ -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
|
|