@opsydyn/effect-three-rapier 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/src/worker.ts ADDED
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Physics in a worker, behind the same engine contract (ADR-017). In the page,
3
+ * `remotePhysics(worker)` is a `PhysicsEngine` whose worlds send what the scene graph asks
4
+ * of them to the worker, a frame's worth in one message, and answer from what the worker
5
+ * last sent back. In the worker, `servePhysics(self, loadRapier())` does what each world
6
+ * is asked with a real engine and sends back the steps' collisions and forces and every
7
+ * body's pose. Nothing here imports Rapier, so the page does not load its WebAssembly.
8
+ *
9
+ * ```ts
10
+ * // physics.worker.ts
11
+ * import { loadRapier } from '@opsydyn/effect-three-rapier/deterministic';
12
+ * import { servePhysics } from '@opsydyn/effect-three-rapier/worker';
13
+ * servePhysics(self, loadRapier());
14
+ *
15
+ * // the page
16
+ * import { remotePhysics } from '@opsydyn/effect-three-rapier/worker';
17
+ * const worker = new Worker(new URL('./physics.worker.ts', import.meta.url), { type: 'module' });
18
+ * const environment = EffectThreeFoldkit.makeEnvironment(window, { physics: remotePhysics(worker) });
19
+ * ```
20
+ *
21
+ * The stepping is pipelined: what a world shows and reports lags what it was asked by
22
+ * the time the worker takes to answer, about a frame. A world on `physics({ until })` is
23
+ * reported as there only once all it was asked has come back, so its checksum is the one
24
+ * an engine in the page would give, a frame later.
25
+ *
26
+ * @module
27
+ */
28
+ import type {
29
+ Body,
30
+ BodyPose,
31
+ Collider,
32
+ Collision,
33
+ ContactForce,
34
+ DebugLines,
35
+ Physics,
36
+ PhysicsEngine,
37
+ PhysicsJoint,
38
+ PhysicsWorld,
39
+ Vec3,
40
+ } from '@opsydyn/effect-three';
41
+
42
+ import { readSnapshot } from './snapshot.ts';
43
+
44
+ /** One thing a world is asked to do, as `PhysicsWorld`'s method of that name. */
45
+ export type Call =
46
+ | { readonly _tag: 'Configure'; readonly physics: Physics }
47
+ | {
48
+ readonly _tag: 'Add';
49
+ readonly key: string;
50
+ readonly body: Body;
51
+ readonly colliders: ReadonlyArray<Collider>;
52
+ readonly pose: BodyPose;
53
+ }
54
+ | { readonly _tag: 'Update'; readonly key: string; readonly body: Body }
55
+ | { readonly _tag: 'Teleport'; readonly key: string; readonly pose: BodyPose }
56
+ | { readonly _tag: 'MoveKinematic'; readonly key: string; readonly pose: BodyPose }
57
+ | { readonly _tag: 'ApplyImpulse'; readonly key: string; readonly impulse: Vec3 }
58
+ | { readonly _tag: 'Remove'; readonly key: string }
59
+ | {
60
+ readonly _tag: 'AddJoint';
61
+ readonly id: string;
62
+ readonly joint: PhysicsJoint;
63
+ readonly a: string;
64
+ readonly b: string;
65
+ }
66
+ | { readonly _tag: 'RemoveJoint'; readonly id: string }
67
+ | { readonly _tag: 'Step'; readonly seconds: number }
68
+ | { readonly _tag: 'Snapshot' };
69
+
70
+ /**
71
+ * What the page sends the worker. A world's creation is answered as its batch 1; the
72
+ * batches after it are numbered on from 2.
73
+ */
74
+ export type ToWorker =
75
+ | {
76
+ readonly _tag: 'Create';
77
+ readonly world: number;
78
+ readonly physics: Physics;
79
+ /** A snapshot to make the world from (`PhysicsEngine.restoreWorld`). */
80
+ readonly snapshot?: Uint8Array;
81
+ }
82
+ | {
83
+ readonly _tag: 'Batch';
84
+ readonly world: number;
85
+ readonly batch: number;
86
+ readonly calls: ReadonlyArray<Call>;
87
+ }
88
+ | { readonly _tag: 'Dispose'; readonly world: number };
89
+
90
+ /** What the worker sends back after each batch, or why one could not be done. */
91
+ export type FromWorker =
92
+ | {
93
+ readonly _tag: 'Done';
94
+ readonly world: number;
95
+ readonly batch: number;
96
+ readonly collisions: ReadonlyArray<Collision>;
97
+ readonly contactForces: ReadonlyArray<ContactForce>;
98
+ readonly poses: ReadonlyArray<readonly [string, BodyPose]>;
99
+ readonly awake: boolean;
100
+ /** The world as lines, while it is configured to draw them. */
101
+ readonly debugLines: DebugLines | null;
102
+ /** The world's snapshot, when the batch asked for one. */
103
+ readonly snapshot?: Uint8Array;
104
+ }
105
+ | { readonly _tag: 'Failed'; readonly world: number; readonly reason: string };
106
+
107
+ /**
108
+ * Either end of the channel: a `Worker` in the page, the worker's own global scope (`self`)
109
+ * inside it, or a `MessagePort`.
110
+ */
111
+ export type PhysicsPort = {
112
+ readonly postMessage: (message: unknown) => void;
113
+ readonly addEventListener: (type: 'message', listener: (event: MessageEvent) => void) => void;
114
+ /** A `MessagePort`'s: it delivers nothing to its listeners until started. */
115
+ readonly start?: () => void;
116
+ };
117
+
118
+ const noLines: DebugLines = { vertices: new Float32Array(), colors: new Float32Array() };
119
+
120
+ /**
121
+ * A world in the worker, as the page sees it: `heard` takes what the worker sends back,
122
+ * and `forget` is called when it is disposed.
123
+ */
124
+ const remoteWorld = (
125
+ id: number,
126
+ port: PhysicsPort,
127
+ forget: () => void,
128
+ ): { readonly world: PhysicsWorld; readonly heard: (message: FromWorker) => void } => {
129
+ let calls: Array<Call> = [];
130
+ let flushing = false;
131
+ let disposed = false;
132
+ /** Batches sent (its creation the first), and the last one the worker has answered. */
133
+ let sent = 1;
134
+ let answered = 0;
135
+ let collisions: Array<Collision> = [];
136
+ let forces: Array<ContactForce> = [];
137
+ let awake = false;
138
+ let lines = noLines;
139
+ /** A snapshot come back and not yet taken, and whether one has been asked for. */
140
+ let snapshot: Uint8Array | undefined;
141
+ let asking = false;
142
+ const poses = new Map<string, BodyPose>();
143
+ /** Bodies placed here: an answer to a batch sent before then would put them back. */
144
+ const placedIn = new Map<string, number>();
145
+
146
+ const flush = (): void => {
147
+ flushing = false;
148
+ if (disposed || calls.length === 0) return;
149
+ sent += 1;
150
+ const message: ToWorker = { _tag: 'Batch', world: id, batch: sent, calls };
151
+ calls = [];
152
+ port.postMessage(message);
153
+ };
154
+ /** Queues a call; the frame's calls go together, once what asked for them is done. */
155
+ const call = (next: Call): void => {
156
+ calls.push(next);
157
+ if (flushing) return;
158
+ flushing = true;
159
+ queueMicrotask(flush);
160
+ };
161
+ const place = (key: string, pose: BodyPose | undefined): void => {
162
+ if (pose === undefined) poses.delete(key);
163
+ else poses.set(key, pose);
164
+ placedIn.set(key, sent + 1);
165
+ };
166
+
167
+ const heard = (message: FromWorker): void => {
168
+ if (disposed) return;
169
+ if (message._tag === 'Failed') {
170
+ // Nothing in the page can do it instead: say so where the page's errors go.
171
+ queueMicrotask(() => {
172
+ throw new Error(`Physics in the worker failed: ${message.reason}`);
173
+ });
174
+ return;
175
+ }
176
+ answered = message.batch;
177
+ collisions.push(...message.collisions);
178
+ forces.push(...message.contactForces);
179
+ awake = message.awake;
180
+ lines = message.debugLines ?? noLines;
181
+ if (message.snapshot !== undefined) {
182
+ snapshot = message.snapshot;
183
+ asking = false;
184
+ }
185
+ for (const [key, pose] of message.poses) {
186
+ if ((placedIn.get(key) ?? 0) <= message.batch) poses.set(key, pose);
187
+ }
188
+ };
189
+
190
+ const world: PhysicsWorld = {
191
+ configure: (physics) => call({ _tag: 'Configure', physics }),
192
+ add: (key, body, colliders, pose) => {
193
+ call({ _tag: 'Add', key, body, colliders, pose });
194
+ place(key, pose);
195
+ },
196
+ update: (key, body) => call({ _tag: 'Update', key, body }),
197
+ teleport: (key, pose) => {
198
+ call({ _tag: 'Teleport', key, pose });
199
+ place(key, pose);
200
+ },
201
+ moveKinematic: (key, pose) => call({ _tag: 'MoveKinematic', key, pose }),
202
+ applyImpulse: (key, impulse) => call({ _tag: 'ApplyImpulse', key, impulse }),
203
+ remove: (key) => {
204
+ call({ _tag: 'Remove', key });
205
+ place(key, undefined);
206
+ },
207
+ addJoint: (jointId, joint, a, b) => call({ _tag: 'AddJoint', id: jointId, joint, a, b }),
208
+ removeJoint: (jointId) => call({ _tag: 'RemoveJoint', id: jointId }),
209
+ // Its collisions come back later, through `received`.
210
+ step: (seconds) => {
211
+ call({ _tag: 'Step', seconds });
212
+ return [];
213
+ },
214
+ contactForces: () => {
215
+ const drained = forces;
216
+ forces = [];
217
+ return drained;
218
+ },
219
+ debugLines: () => lines,
220
+ pose: (key) => poses.get(key),
221
+ // Moving, as far as the page knows, until the worker has answered everything.
222
+ awake: () => awake || calls.length > 0 || answered < sent,
223
+ dispose: () => {
224
+ disposed = true;
225
+ calls = [];
226
+ forget();
227
+ const message: ToWorker = { _tag: 'Dispose', world: id };
228
+ port.postMessage(message);
229
+ },
230
+ received: () => {
231
+ const drained = collisions;
232
+ collisions = [];
233
+ return { collisions: drained, pending: calls.length > 0 || answered < sent };
234
+ },
235
+ // Asked for, it comes back with the batch that asked; until then there is none.
236
+ snapshot: () => {
237
+ const taken = snapshot;
238
+ snapshot = undefined;
239
+ if (taken === undefined && !asking) {
240
+ asking = true;
241
+ call({ _tag: 'Snapshot' });
242
+ }
243
+ return taken;
244
+ },
245
+ };
246
+ return { world, heard };
247
+ };
248
+
249
+ /**
250
+ * A physics engine in a worker, for the page: each world it makes is made in the worker
251
+ * that `servePhysics` runs in, on the far end of `port`.
252
+ */
253
+ export const remotePhysics = (port: PhysicsPort): PhysicsEngine => {
254
+ const worlds = new Map<number, (message: FromWorker) => void>();
255
+ let made = 0;
256
+ port.addEventListener('message', (event) => {
257
+ // SAFETY: only `servePhysics` posts on this channel, and only `FromWorker` messages.
258
+ const message = event.data as FromWorker;
259
+ worlds.get(message.world)?.(message);
260
+ });
261
+ port.start?.();
262
+ const make = (physics: Physics, snapshot?: Uint8Array): PhysicsWorld => {
263
+ made += 1;
264
+ const id = made;
265
+ const create: ToWorker =
266
+ snapshot === undefined
267
+ ? { _tag: 'Create', world: id, physics }
268
+ : { _tag: 'Create', world: id, physics, snapshot };
269
+ port.postMessage(create);
270
+ const { world, heard } = remoteWorld(id, port, () => worlds.delete(id));
271
+ worlds.set(id, heard);
272
+ return world;
273
+ };
274
+ return {
275
+ createWorld: (physics) => make(physics),
276
+ // What the world holds is in the snapshot's index, which the page can read itself.
277
+ restoreWorld: (physics, snapshot) => {
278
+ const { index } = readSnapshot(snapshot);
279
+ return {
280
+ world: make(physics, snapshot),
281
+ bodies: index.bodies.map((body) => body.key),
282
+ joints: index.joints.map((joint) => joint.id),
283
+ };
284
+ },
285
+ };
286
+ };
287
+
288
+ type Served = {
289
+ readonly world: PhysicsWorld;
290
+ readonly keys: Set<string>;
291
+ debug: boolean;
292
+ /** The snapshot the batch being done asked for. */
293
+ snapshot: Uint8Array | undefined;
294
+ };
295
+
296
+ /** Does one call to a world; a step's collisions are added to `collisions`. */
297
+ const perform = (served: Served, next: Call, collisions: Array<Collision>): void => {
298
+ const { world } = served;
299
+ switch (next._tag) {
300
+ case 'Configure':
301
+ world.configure(next.physics);
302
+ served.debug = next.physics.debug;
303
+ return;
304
+ case 'Add':
305
+ world.add(next.key, next.body, next.colliders, next.pose);
306
+ served.keys.add(next.key);
307
+ return;
308
+ case 'Update':
309
+ world.update(next.key, next.body);
310
+ return;
311
+ case 'Teleport':
312
+ world.teleport(next.key, next.pose);
313
+ return;
314
+ case 'MoveKinematic':
315
+ world.moveKinematic(next.key, next.pose);
316
+ return;
317
+ case 'ApplyImpulse':
318
+ world.applyImpulse(next.key, next.impulse);
319
+ return;
320
+ case 'Remove':
321
+ world.remove(next.key);
322
+ served.keys.delete(next.key);
323
+ return;
324
+ case 'AddJoint':
325
+ world.addJoint(next.id, next.joint, next.a, next.b);
326
+ return;
327
+ case 'RemoveJoint':
328
+ world.removeJoint(next.id);
329
+ return;
330
+ case 'Step':
331
+ collisions.push(...world.step(next.seconds));
332
+ return;
333
+ case 'Snapshot':
334
+ if (world.snapshot === undefined) throw new Error('This engine cannot take snapshots');
335
+ served.snapshot = world.snapshot();
336
+ }
337
+ };
338
+
339
+ /** Does a batch to a world and says what came of it. */
340
+ const answer = (id: number, batch: number, served: Served, calls: ReadonlyArray<Call>) => {
341
+ const collisions: Array<Collision> = [];
342
+ served.snapshot = undefined;
343
+ for (const next of calls) perform(served, next, collisions);
344
+ const poses: Array<readonly [string, BodyPose]> = [];
345
+ for (const key of served.keys) {
346
+ const pose = served.world.pose(key);
347
+ if (pose !== undefined) poses.push([key, pose]);
348
+ }
349
+ const done: FromWorker = {
350
+ _tag: 'Done',
351
+ world: id,
352
+ batch,
353
+ collisions,
354
+ contactForces: served.world.contactForces(),
355
+ poses,
356
+ awake: served.world.awake(),
357
+ debugLines: served.debug ? served.world.debugLines() : null,
358
+ ...(served.snapshot === undefined ? {} : { snapshot: served.snapshot }),
359
+ };
360
+ return done;
361
+ };
362
+
363
+ /** A world as the page asked for it made: new, or from a snapshot. */
364
+ const create = (
365
+ engine: PhysicsEngine,
366
+ message: Extract<ToWorker, { readonly _tag: 'Create' }>,
367
+ ): Served => {
368
+ const { physics, snapshot } = message;
369
+ if (snapshot === undefined) {
370
+ return {
371
+ world: engine.createWorld(physics),
372
+ keys: new Set(),
373
+ debug: physics.debug,
374
+ snapshot: undefined,
375
+ };
376
+ }
377
+ if (engine.restoreWorld === undefined) throw new Error('This engine cannot restore snapshots');
378
+ const restored = engine.restoreWorld(physics, snapshot);
379
+ return {
380
+ world: restored.world,
381
+ keys: new Set(restored.bodies),
382
+ debug: physics.debug,
383
+ snapshot: undefined,
384
+ };
385
+ };
386
+
387
+ /**
388
+ * Serves the worlds a page's `remotePhysics` makes, with `engine` (or the engine it is
389
+ * loading): in the worker, `servePhysics(self, loadRapier())`. Messages are handled in
390
+ * the order they came, once the engine has loaded.
391
+ */
392
+ export const servePhysics = (
393
+ port: PhysicsPort,
394
+ engine: PhysicsEngine | Promise<PhysicsEngine>,
395
+ ): void => {
396
+ const loaded = Promise.resolve(engine);
397
+ const worlds = new Map<number, Served>();
398
+ const handle = (ready: PhysicsEngine, message: ToWorker): void => {
399
+ if (message._tag === 'Create') {
400
+ const made = create(ready, message);
401
+ worlds.set(message.world, made);
402
+ // Answered as batch 1: the page then knows where a restored world's bodies are.
403
+ port.postMessage(answer(message.world, 1, made, []));
404
+ return;
405
+ }
406
+ const served = worlds.get(message.world);
407
+ if (served === undefined) return;
408
+ if (message._tag === 'Dispose') {
409
+ served.world.dispose();
410
+ worlds.delete(message.world);
411
+ return;
412
+ }
413
+ port.postMessage(answer(message.world, message.batch, served, message.calls));
414
+ };
415
+ let queue: Promise<void> = Promise.resolve();
416
+ port.addEventListener('message', (event) => {
417
+ // SAFETY: only `remotePhysics` posts on this channel, and only `ToWorker` messages.
418
+ const message = event.data as ToWorker;
419
+ queue = queue
420
+ .then(() => loaded)
421
+ .then((ready) => handle(ready, message))
422
+ .catch((cause: unknown) => {
423
+ const failed: FromWorker = {
424
+ _tag: 'Failed',
425
+ world: message.world,
426
+ reason: cause instanceof Error ? cause.message : String(cause),
427
+ };
428
+ port.postMessage(failed);
429
+ });
430
+ });
431
+ port.start?.();
432
+ };