@driftengine/physics 3.61.0
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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +503 -0
- package/dist/bodies.d.ts +121 -0
- package/dist/bodies.js +298 -0
- package/dist/buoyancy.d.ts +46 -0
- package/dist/buoyancy.js +45 -0
- package/dist/cloth.d.ts +226 -0
- package/dist/cloth.js +587 -0
- package/dist/collide/aabb.d.ts +10 -0
- package/dist/collide/aabb.js +11 -0
- package/dist/collide/body.d.ts +202 -0
- package/dist/collide/body.js +142 -0
- package/dist/collide/eject.d.ts +39 -0
- package/dist/collide/eject.js +142 -0
- package/dist/collide/index.d.ts +18 -0
- package/dist/collide/index.js +5 -0
- package/dist/collide/scratch.d.ts +8 -0
- package/dist/collide/scratch.js +9 -0
- package/dist/collide/segment.d.ts +12 -0
- package/dist/collide/segment.js +109 -0
- package/dist/collide/sweep.d.ts +3 -0
- package/dist/collide/sweep.js +1036 -0
- package/dist/colliderSet.d.ts +239 -0
- package/dist/colliderSet.js +546 -0
- package/dist/compoundContact.d.ts +70 -0
- package/dist/compoundContact.js +221 -0
- package/dist/controller.d.ts +351 -0
- package/dist/controller.js +641 -0
- package/dist/cylinder.d.ts +78 -0
- package/dist/cylinder.js +294 -0
- package/dist/cylinderContact.d.ts +7 -0
- package/dist/cylinderContact.js +704 -0
- package/dist/decompose.d.ts +105 -0
- package/dist/decompose.js +979 -0
- package/dist/events.d.ts +43 -0
- package/dist/events.js +95 -0
- package/dist/executor.d.ts +147 -0
- package/dist/executor.js +173 -0
- package/dist/faces.d.ts +26 -0
- package/dist/faces.js +34 -0
- package/dist/fingerprint.d.ts +61 -0
- package/dist/fingerprint.js +139 -0
- package/dist/groundMotor.d.ts +34 -0
- package/dist/groundMotor.js +41 -0
- package/dist/heightfieldShape.d.ts +128 -0
- package/dist/heightfieldShape.js +328 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +47 -0
- package/dist/island.d.ts +55 -0
- package/dist/island.js +227 -0
- package/dist/islandSolve.d.ts +70 -0
- package/dist/islandSolve.js +171 -0
- package/dist/islandWorker.d.ts +18 -0
- package/dist/islandWorker.js +125 -0
- package/dist/joints.d.ts +180 -0
- package/dist/joints.js +1094 -0
- package/dist/manifold.d.ts +89 -0
- package/dist/manifold.js +608 -0
- package/dist/mass.d.ts +53 -0
- package/dist/mass.js +353 -0
- package/dist/meshContact.d.ts +57 -0
- package/dist/meshContact.js +393 -0
- package/dist/meshShape.d.ts +76 -0
- package/dist/meshShape.js +175 -0
- package/dist/pairs.d.ts +62 -0
- package/dist/pairs.js +154 -0
- package/dist/query.d.ts +71 -0
- package/dist/query.js +683 -0
- package/dist/ragdoll.d.ts +135 -0
- package/dist/ragdoll.js +489 -0
- package/dist/shape.d.ts +177 -0
- package/dist/shape.js +533 -0
- package/dist/solver.d.ts +136 -0
- package/dist/solver.js +601 -0
- package/dist/stage.d.ts +167 -0
- package/dist/stage.js +350 -0
- package/dist/stallEscape.d.ts +141 -0
- package/dist/stallEscape.js +254 -0
- package/dist/sweptStep.d.ts +173 -0
- package/dist/sweptStep.js +472 -0
- package/dist/tree.d.ts +61 -0
- package/dist/tree.js +348 -0
- package/dist/vehicle.d.ts +154 -0
- package/dist/vehicle.js +343 -0
- package/dist/workerPool.d.ts +107 -0
- package/dist/workerPool.js +239 -0
- package/dist/workers.d.ts +37 -0
- package/dist/workers.js +35 -0
- package/dist/world.d.ts +298 -0
- package/dist/world.js +712 -0
- package/package.json +55 -0
- package/src/bodies.ts +369 -0
- package/src/buoyancy.ts +78 -0
- package/src/cloth.ts +734 -0
- package/src/collide/aabb.ts +28 -0
- package/src/collide/body.ts +336 -0
- package/src/collide/eject.ts +156 -0
- package/src/collide/index.ts +26 -0
- package/src/collide/scratch.ts +10 -0
- package/src/collide/segment.ts +127 -0
- package/src/collide/sweep.ts +1132 -0
- package/src/colliderSet.ts +637 -0
- package/src/compoundContact.ts +284 -0
- package/src/controller.ts +817 -0
- package/src/cylinder.ts +340 -0
- package/src/cylinderContact.ts +825 -0
- package/src/decompose.ts +1198 -0
- package/src/events.ts +108 -0
- package/src/executor.ts +224 -0
- package/src/faces.ts +37 -0
- package/src/fingerprint.ts +146 -0
- package/src/groundMotor.ts +53 -0
- package/src/heightfieldShape.ts +395 -0
- package/src/index.ts +164 -0
- package/src/island.ts +219 -0
- package/src/islandSolve.ts +218 -0
- package/src/islandWorker.ts +141 -0
- package/src/joints.ts +1367 -0
- package/src/manifold.ts +798 -0
- package/src/mass.ts +424 -0
- package/src/meshContact.ts +443 -0
- package/src/meshShape.ts +261 -0
- package/src/pairs.ts +169 -0
- package/src/query.ts +844 -0
- package/src/ragdoll.ts +662 -0
- package/src/shape.ts +656 -0
- package/src/solver.ts +873 -0
- package/src/stage.ts +406 -0
- package/src/stallEscape.ts +362 -0
- package/src/sweptStep.ts +609 -0
- package/src/tree.ts +366 -0
- package/src/vehicle.ts +489 -0
- package/src/workerPool.ts +283 -0
- package/src/workers.ts +38 -0
- package/src/world.ts +961 -0
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What touched what, drained after the tick rather than raised during it.
|
|
3
|
+
*
|
|
4
|
+
* **Not callbacks during the solve.** A callback that mutates the world mid-tick is the classic
|
|
5
|
+
* footgun — a body removed inside one invalidates every index the solver is holding — and it is
|
|
6
|
+
* also what would break the executor, since a worker solving one island cannot safely call into a
|
|
7
|
+
* consumer's code that touches another. Events accumulate into a caller-owned buffer and the caller
|
|
8
|
+
* drains them when the tick is over and the world is coherent again.
|
|
9
|
+
*
|
|
10
|
+
* **Ordered by pair key**, so a replay sees them in the same order it recorded them.
|
|
11
|
+
*
|
|
12
|
+
* Physics may not use core's `MessageQueue`: §2 of the design forbids importing an engine package,
|
|
13
|
+
* and that constraint gives the right answer here anyway. A queue with backpressure is the wrong
|
|
14
|
+
* shape for per-tick events a caller drains completely, and completely is the only way to drain
|
|
15
|
+
* these — a dropped `exit` leaves a consumer believing something is still inside a trigger.
|
|
16
|
+
*/
|
|
17
|
+
export declare const EVENT_ENTER = 0;
|
|
18
|
+
export declare const EVENT_STAY = 1;
|
|
19
|
+
export declare const EVENT_EXIT = 2;
|
|
20
|
+
export type EventKind = 0 | 1 | 2;
|
|
21
|
+
export declare class ContactEvents {
|
|
22
|
+
/** `kind`, `bodyA`, `bodyB` per event, in that order. Read `count` triples. */
|
|
23
|
+
data: Int32Array;
|
|
24
|
+
count: number;
|
|
25
|
+
/** Pair keys touching at the end of the last tick, sorted. */
|
|
26
|
+
private current;
|
|
27
|
+
private currentCount;
|
|
28
|
+
private previous;
|
|
29
|
+
private previousCount;
|
|
30
|
+
constructor(capacity?: number);
|
|
31
|
+
/** Start a tick's collection. The previous tick's set becomes what this one is compared against. */
|
|
32
|
+
begin(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Record a touching pair. **Keys must arrive ascending**, which the sorted pair list guarantees,
|
|
35
|
+
* because the enter and exit diff below is a merge of two sorted runs.
|
|
36
|
+
*/
|
|
37
|
+
touching(key: number): void;
|
|
38
|
+
/** Diff this tick's set against the last and write enter, stay and exit. */
|
|
39
|
+
end(pairA: (key: number) => number, pairB: (key: number) => number): void;
|
|
40
|
+
/** Forget everything, for a world whose indices have moved under it. */
|
|
41
|
+
clear(): void;
|
|
42
|
+
private push;
|
|
43
|
+
}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What touched what, drained after the tick rather than raised during it.
|
|
3
|
+
*
|
|
4
|
+
* **Not callbacks during the solve.** A callback that mutates the world mid-tick is the classic
|
|
5
|
+
* footgun — a body removed inside one invalidates every index the solver is holding — and it is
|
|
6
|
+
* also what would break the executor, since a worker solving one island cannot safely call into a
|
|
7
|
+
* consumer's code that touches another. Events accumulate into a caller-owned buffer and the caller
|
|
8
|
+
* drains them when the tick is over and the world is coherent again.
|
|
9
|
+
*
|
|
10
|
+
* **Ordered by pair key**, so a replay sees them in the same order it recorded them.
|
|
11
|
+
*
|
|
12
|
+
* Physics may not use core's `MessageQueue`: §2 of the design forbids importing an engine package,
|
|
13
|
+
* and that constraint gives the right answer here anyway. A queue with backpressure is the wrong
|
|
14
|
+
* shape for per-tick events a caller drains completely, and completely is the only way to drain
|
|
15
|
+
* these — a dropped `exit` leaves a consumer believing something is still inside a trigger.
|
|
16
|
+
*/
|
|
17
|
+
export const EVENT_ENTER = 0;
|
|
18
|
+
export const EVENT_STAY = 1;
|
|
19
|
+
export const EVENT_EXIT = 2;
|
|
20
|
+
export class ContactEvents {
|
|
21
|
+
/** `kind`, `bodyA`, `bodyB` per event, in that order. Read `count` triples. */
|
|
22
|
+
data;
|
|
23
|
+
count = 0;
|
|
24
|
+
/** Pair keys touching at the end of the last tick, sorted. */
|
|
25
|
+
current;
|
|
26
|
+
currentCount = 0;
|
|
27
|
+
previous;
|
|
28
|
+
previousCount = 0;
|
|
29
|
+
constructor(capacity = 64) {
|
|
30
|
+
this.data = new Int32Array(capacity * 3);
|
|
31
|
+
this.current = new Uint32Array(capacity);
|
|
32
|
+
this.previous = new Uint32Array(capacity);
|
|
33
|
+
}
|
|
34
|
+
/** Start a tick's collection. The previous tick's set becomes what this one is compared against. */
|
|
35
|
+
begin() {
|
|
36
|
+
const swap = this.previous;
|
|
37
|
+
this.previous = this.current;
|
|
38
|
+
this.previousCount = this.currentCount;
|
|
39
|
+
this.current = swap;
|
|
40
|
+
this.currentCount = 0;
|
|
41
|
+
this.count = 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Record a touching pair. **Keys must arrive ascending**, which the sorted pair list guarantees,
|
|
45
|
+
* because the enter and exit diff below is a merge of two sorted runs.
|
|
46
|
+
*/
|
|
47
|
+
touching(key) {
|
|
48
|
+
if (this.currentCount === this.current.length) {
|
|
49
|
+
const wider = new Uint32Array(this.current.length * 2);
|
|
50
|
+
wider.set(this.current);
|
|
51
|
+
this.current = wider;
|
|
52
|
+
}
|
|
53
|
+
this.current[this.currentCount++] = key;
|
|
54
|
+
}
|
|
55
|
+
/** Diff this tick's set against the last and write enter, stay and exit. */
|
|
56
|
+
end(pairA, pairB) {
|
|
57
|
+
let i = 0;
|
|
58
|
+
let j = 0;
|
|
59
|
+
while (i < this.currentCount || j < this.previousCount) {
|
|
60
|
+
const now = i < this.currentCount ? (this.current[i] ?? 0) : Infinity;
|
|
61
|
+
const was = j < this.previousCount ? (this.previous[j] ?? 0) : Infinity;
|
|
62
|
+
if (now === was) {
|
|
63
|
+
this.push(EVENT_STAY, now, pairA, pairB);
|
|
64
|
+
i++;
|
|
65
|
+
j++;
|
|
66
|
+
}
|
|
67
|
+
else if (now < was) {
|
|
68
|
+
this.push(EVENT_ENTER, now, pairA, pairB);
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
this.push(EVENT_EXIT, was, pairA, pairB);
|
|
73
|
+
j++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Forget everything, for a world whose indices have moved under it. */
|
|
78
|
+
clear() {
|
|
79
|
+
this.count = 0;
|
|
80
|
+
this.currentCount = 0;
|
|
81
|
+
this.previousCount = 0;
|
|
82
|
+
}
|
|
83
|
+
push(kind, key, pairA, pairB) {
|
|
84
|
+
if ((this.count + 1) * 3 > this.data.length) {
|
|
85
|
+
const wider = new Int32Array(this.data.length * 2);
|
|
86
|
+
wider.set(this.data);
|
|
87
|
+
this.data = wider;
|
|
88
|
+
}
|
|
89
|
+
const at = this.count * 3;
|
|
90
|
+
this.data[at] = kind;
|
|
91
|
+
this.data[at + 1] = pairA(key);
|
|
92
|
+
this.data[at + 2] = pairB(key);
|
|
93
|
+
this.count++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who runs the islands, and the gate that proves it does not matter.
|
|
3
|
+
*
|
|
4
|
+
* **No two islands share a body.** That is the definition, so solving them in any order, or
|
|
5
|
+
* simultaneously, produces bit-identical state provided each island's internal order is fixed —
|
|
6
|
+
* which it is, by body index. Parallelism across islands therefore carries no determinism risk at
|
|
7
|
+
* all, and the deployment constraint that gates a worker pool decides only *which character runs*.
|
|
8
|
+
*
|
|
9
|
+
* `ShuffledExecutor` exists to assert exactly that. It solves in reverse order, and a gate runs one
|
|
10
|
+
* scene through both characters and compares the state. One `solveIsland`, two characters, and a test
|
|
11
|
+
* proving they agree — which is what the rule about two implementations drifting actually asks for,
|
|
12
|
+
* since it forbids two implementations of one decision rather than two characters of one.
|
|
13
|
+
*
|
|
14
|
+
* **A worker pool over `SharedArrayBuffer` shipped 2026-09-05**, and what it measured is not what
|
|
15
|
+
* the plan for it predicted. `workerPool.ts` is the pool, `stage.ts` is the mirror it works in, and
|
|
16
|
+
* `islandWorker.ts` is the far end. A consumer opts in with `new PhysicsWorld({ workers: 4, pool:
|
|
17
|
+
* createIslandPool })` and gets serial physics with a sentence in `parallelism.reason` wherever that
|
|
18
|
+
* cannot be honoured.
|
|
19
|
+
*
|
|
20
|
+
* **The deployment constraint is real and unchanged.** On a page that is not cross-origin isolated
|
|
21
|
+
* `SharedArrayBuffer` is not defined at all; with `Cross-Origin-Opener-Policy: same-origin` and
|
|
22
|
+
* `Cross-Origin-Embedder-Policy: require-corp` it exists, and `Atomics.wait` still answers
|
|
23
|
+
* `TypeError: Atomics.wait cannot be called in this context` on the main thread. So the caller
|
|
24
|
+
* blocks where it may and spins where it may not, deciding by trying it rather than by sniffing for
|
|
25
|
+
* `window`, and it claims its own share first so the spin covers the imbalance and not the solve.
|
|
26
|
+
* `require-corp` still lands on the consumer's whole site, which is why this is a switch they throw.
|
|
27
|
+
*
|
|
28
|
+
* **What the pool is worth, measured on a 24-core desktop, 256 islands of ten boxes:**
|
|
29
|
+
*
|
|
30
|
+
* | workers | 1 | 2 | 4 | 8 |
|
|
31
|
+
* |---|---|---|---|---|
|
|
32
|
+
* | speedup | 1.05x | 1.22x | 1.26x | 1.39x |
|
|
33
|
+
*
|
|
34
|
+
* The old note here predicted 2x to 4x from Amdahl and the solve share. **It is 1.4x at eight
|
|
35
|
+
* workers**, and the staging copy accounts for 1.7% of that gap and no more. Nothing has measured
|
|
36
|
+
* what accounts for the rest, so nothing here names a cause. Two bounds do hold and both are
|
|
37
|
+
* scene-dependent: Amdahl's, from the solve share `island-bench.ts` prints, and `total island cost /
|
|
38
|
+
* largest island cost`, which is why a hundred jointed boxes gain nothing at any worker count.
|
|
39
|
+
*
|
|
40
|
+
* **`DisjointExecutor` below is the reason any of this is safe**, and it is worth more than the pool:
|
|
41
|
+
* it fails when two islands write one body, which is the assumption a pool cannot survive losing and
|
|
42
|
+
* which no other test in this package would notice.
|
|
43
|
+
*/
|
|
44
|
+
import type { BodySet } from './bodies.ts';
|
|
45
|
+
import type { IslandSolveState } from './islandSolve.ts';
|
|
46
|
+
/** Something that can advance one island through a whole tick. */
|
|
47
|
+
export interface IslandSolver {
|
|
48
|
+
solveIsland(island: number): void;
|
|
49
|
+
/**
|
|
50
|
+
* The same solve as flat state, for a character that works somewhere else.
|
|
51
|
+
*
|
|
52
|
+
* `SerialExecutor` and `ShuffledExecutor` call `solveIsland` and never read this. A staged or
|
|
53
|
+
* pooled character cannot: it solves a mirror of this state in shared memory, so it needs the state
|
|
54
|
+
* and not the method that closes over the live one. Optional because `Executor` is a seam a
|
|
55
|
+
* consumer may implement, and a character of their own owes nothing here.
|
|
56
|
+
*/
|
|
57
|
+
readonly solveState?: IslandSolveState;
|
|
58
|
+
}
|
|
59
|
+
export interface Executor {
|
|
60
|
+
/** A name, so a figure taken from a benchmark says which character produced it. */
|
|
61
|
+
readonly name: string;
|
|
62
|
+
run(count: number, solver: IslandSolver): void;
|
|
63
|
+
/**
|
|
64
|
+
* Release whatever the character holds. A pool terminates its workers.
|
|
65
|
+
*
|
|
66
|
+
* Optional, and declared here rather than tested for with `instanceof`, because reaching the pool
|
|
67
|
+
* class from `world.ts` would put the whole pool in every consumer's bundle — including the ones
|
|
68
|
+
* that never ask for a worker. Measured at 2,629 bytes gzipped, which is the difference between
|
|
69
|
+
* this seam being a seam and being a static dependency.
|
|
70
|
+
*/
|
|
71
|
+
dispose?(): void;
|
|
72
|
+
/**
|
|
73
|
+
* Set once a character has given up and gone serial, with a sentence saying why. Null while healthy.
|
|
74
|
+
*/
|
|
75
|
+
readonly failure?: string | null;
|
|
76
|
+
}
|
|
77
|
+
export declare class SerialExecutor implements Executor {
|
|
78
|
+
readonly name = "serial";
|
|
79
|
+
run(count: number, solver: IslandSolver): void;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Reverse order, and it is a gate rather than a character anybody should choose.
|
|
83
|
+
*
|
|
84
|
+
* Reversal rather than a random permutation because `Math.random` is banned on this path and a
|
|
85
|
+
* seeded generator would be one more thing whose sequence has to be frozen. Reversal is the
|
|
86
|
+
* strongest single permutation available for free: it moves every element.
|
|
87
|
+
*/
|
|
88
|
+
export declare class ShuffledExecutor implements Executor {
|
|
89
|
+
readonly name = "shuffled";
|
|
90
|
+
run(count: number, solver: IslandSolver): void;
|
|
91
|
+
}
|
|
92
|
+
/** Which body two islands both wrote, and which two they were. */
|
|
93
|
+
export interface IslandOverlap {
|
|
94
|
+
body: number;
|
|
95
|
+
first: number;
|
|
96
|
+
second: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The premise, turned into something that fails.
|
|
100
|
+
*
|
|
101
|
+
* **A worker pool is safe here because two islands never write the same body**, and that is an
|
|
102
|
+
* argument about four guards rather than a property anything checks. `applyImpulse` and
|
|
103
|
+
* `applyLinear` write only where `invMass > 0`; `applyAngular` writes only where the body is
|
|
104
|
+
* dynamic; `integrateVelocities` and `integratePositions` walk `bodyOrder`, which `island.ts` fills
|
|
105
|
+
* with dynamic indices alone. A static floor under two towers is read by both and written by
|
|
106
|
+
* neither.
|
|
107
|
+
*
|
|
108
|
+
* **One unguarded write to a static body would break the pool and nothing else.** Serial physics
|
|
109
|
+
* would stay correct, every existing test would stay green, and the pool would produce a state that
|
|
110
|
+
* depends on which worker got there first — on some ticks, on some machines. That is a defect that
|
|
111
|
+
* ships green, so this character exists to make it a red test instead of a bug report.
|
|
112
|
+
*
|
|
113
|
+
* It compares **bits** and not values, because the question is whether a write happened at all and
|
|
114
|
+
* `-0` over `0` is a write. It needs no threads and no timing, so it cannot be flaky in either
|
|
115
|
+
* direction: the overlap it reports is a real one and its silence is a real absence.
|
|
116
|
+
*
|
|
117
|
+
* **A gate and not a character.** Cost is one pass over every body per island, so a scene of a thousand
|
|
118
|
+
* singletons costs a million lane compares a tick. `SerialExecutor` remains the default.
|
|
119
|
+
*/
|
|
120
|
+
export declare class DisjointExecutor implements Executor {
|
|
121
|
+
readonly name = "disjoint";
|
|
122
|
+
/** Set by `run` when two islands wrote one body. Null after a clean tick. */
|
|
123
|
+
overlap: IslandOverlap | null;
|
|
124
|
+
private lanes;
|
|
125
|
+
private snapshot;
|
|
126
|
+
private owner;
|
|
127
|
+
private readonly bodies;
|
|
128
|
+
constructor(bodies: BodySet);
|
|
129
|
+
run(count: number, solver: IslandSolver): void;
|
|
130
|
+
/**
|
|
131
|
+
* Rebuild the bit views, every run.
|
|
132
|
+
*
|
|
133
|
+
* `BodySet.grow` replaces the arrays outright, so a view held from a previous tick can be over a
|
|
134
|
+
* buffer nothing writes any more — which would read as a tick where no island wrote anything, the
|
|
135
|
+
* one failure this class must not have. Thirteen typed-array constructions a tick is nothing a
|
|
136
|
+
* gate needs to avoid.
|
|
137
|
+
*/
|
|
138
|
+
private bind;
|
|
139
|
+
private capture;
|
|
140
|
+
/**
|
|
141
|
+
* Whoever changed a body's bits owns it, and a second owner is the finding.
|
|
142
|
+
*
|
|
143
|
+
* The first overlap is kept rather than the last, so a run reports where the invariant broke
|
|
144
|
+
* first instead of wherever the scan happened to end.
|
|
145
|
+
*/
|
|
146
|
+
private claim;
|
|
147
|
+
}
|
package/dist/executor.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who runs the islands, and the gate that proves it does not matter.
|
|
3
|
+
*
|
|
4
|
+
* **No two islands share a body.** That is the definition, so solving them in any order, or
|
|
5
|
+
* simultaneously, produces bit-identical state provided each island's internal order is fixed —
|
|
6
|
+
* which it is, by body index. Parallelism across islands therefore carries no determinism risk at
|
|
7
|
+
* all, and the deployment constraint that gates a worker pool decides only *which character runs*.
|
|
8
|
+
*
|
|
9
|
+
* `ShuffledExecutor` exists to assert exactly that. It solves in reverse order, and a gate runs one
|
|
10
|
+
* scene through both characters and compares the state. One `solveIsland`, two characters, and a test
|
|
11
|
+
* proving they agree — which is what the rule about two implementations drifting actually asks for,
|
|
12
|
+
* since it forbids two implementations of one decision rather than two characters of one.
|
|
13
|
+
*
|
|
14
|
+
* **A worker pool over `SharedArrayBuffer` shipped 2026-09-05**, and what it measured is not what
|
|
15
|
+
* the plan for it predicted. `workerPool.ts` is the pool, `stage.ts` is the mirror it works in, and
|
|
16
|
+
* `islandWorker.ts` is the far end. A consumer opts in with `new PhysicsWorld({ workers: 4, pool:
|
|
17
|
+
* createIslandPool })` and gets serial physics with a sentence in `parallelism.reason` wherever that
|
|
18
|
+
* cannot be honoured.
|
|
19
|
+
*
|
|
20
|
+
* **The deployment constraint is real and unchanged.** On a page that is not cross-origin isolated
|
|
21
|
+
* `SharedArrayBuffer` is not defined at all; with `Cross-Origin-Opener-Policy: same-origin` and
|
|
22
|
+
* `Cross-Origin-Embedder-Policy: require-corp` it exists, and `Atomics.wait` still answers
|
|
23
|
+
* `TypeError: Atomics.wait cannot be called in this context` on the main thread. So the caller
|
|
24
|
+
* blocks where it may and spins where it may not, deciding by trying it rather than by sniffing for
|
|
25
|
+
* `window`, and it claims its own share first so the spin covers the imbalance and not the solve.
|
|
26
|
+
* `require-corp` still lands on the consumer's whole site, which is why this is a switch they throw.
|
|
27
|
+
*
|
|
28
|
+
* **What the pool is worth, measured on a 24-core desktop, 256 islands of ten boxes:**
|
|
29
|
+
*
|
|
30
|
+
* | workers | 1 | 2 | 4 | 8 |
|
|
31
|
+
* |---|---|---|---|---|
|
|
32
|
+
* | speedup | 1.05x | 1.22x | 1.26x | 1.39x |
|
|
33
|
+
*
|
|
34
|
+
* The old note here predicted 2x to 4x from Amdahl and the solve share. **It is 1.4x at eight
|
|
35
|
+
* workers**, and the staging copy accounts for 1.7% of that gap and no more. Nothing has measured
|
|
36
|
+
* what accounts for the rest, so nothing here names a cause. Two bounds do hold and both are
|
|
37
|
+
* scene-dependent: Amdahl's, from the solve share `island-bench.ts` prints, and `total island cost /
|
|
38
|
+
* largest island cost`, which is why a hundred jointed boxes gain nothing at any worker count.
|
|
39
|
+
*
|
|
40
|
+
* **`DisjointExecutor` below is the reason any of this is safe**, and it is worth more than the pool:
|
|
41
|
+
* it fails when two islands write one body, which is the assumption a pool cannot survive losing and
|
|
42
|
+
* which no other test in this package would notice.
|
|
43
|
+
*/
|
|
44
|
+
export class SerialExecutor {
|
|
45
|
+
name = 'serial';
|
|
46
|
+
run(count, solver) {
|
|
47
|
+
for (let i = 0; i < count; i++)
|
|
48
|
+
solver.solveIsland(i);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Reverse order, and it is a gate rather than a character anybody should choose.
|
|
53
|
+
*
|
|
54
|
+
* Reversal rather than a random permutation because `Math.random` is banned on this path and a
|
|
55
|
+
* seeded generator would be one more thing whose sequence has to be frozen. Reversal is the
|
|
56
|
+
* strongest single permutation available for free: it moves every element.
|
|
57
|
+
*/
|
|
58
|
+
export class ShuffledExecutor {
|
|
59
|
+
name = 'shuffled';
|
|
60
|
+
run(count, solver) {
|
|
61
|
+
for (let i = count - 1; i >= 0; i--)
|
|
62
|
+
solver.solveIsland(i);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The premise, turned into something that fails.
|
|
67
|
+
*
|
|
68
|
+
* **A worker pool is safe here because two islands never write the same body**, and that is an
|
|
69
|
+
* argument about four guards rather than a property anything checks. `applyImpulse` and
|
|
70
|
+
* `applyLinear` write only where `invMass > 0`; `applyAngular` writes only where the body is
|
|
71
|
+
* dynamic; `integrateVelocities` and `integratePositions` walk `bodyOrder`, which `island.ts` fills
|
|
72
|
+
* with dynamic indices alone. A static floor under two towers is read by both and written by
|
|
73
|
+
* neither.
|
|
74
|
+
*
|
|
75
|
+
* **One unguarded write to a static body would break the pool and nothing else.** Serial physics
|
|
76
|
+
* would stay correct, every existing test would stay green, and the pool would produce a state that
|
|
77
|
+
* depends on which worker got there first — on some ticks, on some machines. That is a defect that
|
|
78
|
+
* ships green, so this character exists to make it a red test instead of a bug report.
|
|
79
|
+
*
|
|
80
|
+
* It compares **bits** and not values, because the question is whether a write happened at all and
|
|
81
|
+
* `-0` over `0` is a write. It needs no threads and no timing, so it cannot be flaky in either
|
|
82
|
+
* direction: the overlap it reports is a real one and its silence is a real absence.
|
|
83
|
+
*
|
|
84
|
+
* **A gate and not a character.** Cost is one pass over every body per island, so a scene of a thousand
|
|
85
|
+
* singletons costs a million lane compares a tick. `SerialExecutor` remains the default.
|
|
86
|
+
*/
|
|
87
|
+
export class DisjointExecutor {
|
|
88
|
+
name = 'disjoint';
|
|
89
|
+
/** Set by `run` when two islands wrote one body. Null after a clean tick. */
|
|
90
|
+
overlap = null;
|
|
91
|
+
lanes = [];
|
|
92
|
+
snapshot = new Uint32Array(0);
|
|
93
|
+
owner = new Int32Array(0);
|
|
94
|
+
bodies;
|
|
95
|
+
constructor(bodies) {
|
|
96
|
+
this.bodies = bodies;
|
|
97
|
+
}
|
|
98
|
+
run(count, solver) {
|
|
99
|
+
const n = this.bodies.count;
|
|
100
|
+
this.bind(n);
|
|
101
|
+
this.owner.fill(-1, 0, n);
|
|
102
|
+
this.overlap = null;
|
|
103
|
+
for (let island = 0; island < count; island++) {
|
|
104
|
+
this.capture(n);
|
|
105
|
+
solver.solveIsland(island);
|
|
106
|
+
this.claim(island, n);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Rebuild the bit views, every run.
|
|
111
|
+
*
|
|
112
|
+
* `BodySet.grow` replaces the arrays outright, so a view held from a previous tick can be over a
|
|
113
|
+
* buffer nothing writes any more — which would read as a tick where no island wrote anything, the
|
|
114
|
+
* one failure this class must not have. Thirteen typed-array constructions a tick is nothing a
|
|
115
|
+
* gate needs to avoid.
|
|
116
|
+
*/
|
|
117
|
+
bind(n) {
|
|
118
|
+
const b = this.bodies;
|
|
119
|
+
const bits = (a) => new Uint32Array(a.buffer, a.byteOffset, a.length);
|
|
120
|
+
this.lanes = [
|
|
121
|
+
bits(b.posX),
|
|
122
|
+
bits(b.posY),
|
|
123
|
+
bits(b.posZ),
|
|
124
|
+
bits(b.rotX),
|
|
125
|
+
bits(b.rotY),
|
|
126
|
+
bits(b.rotZ),
|
|
127
|
+
bits(b.rotW),
|
|
128
|
+
bits(b.velX),
|
|
129
|
+
bits(b.velY),
|
|
130
|
+
bits(b.velZ),
|
|
131
|
+
bits(b.angX),
|
|
132
|
+
bits(b.angY),
|
|
133
|
+
bits(b.angZ),
|
|
134
|
+
];
|
|
135
|
+
const wanted = this.lanes.length * n;
|
|
136
|
+
if (this.snapshot.length < wanted)
|
|
137
|
+
this.snapshot = new Uint32Array(wanted);
|
|
138
|
+
if (this.owner.length < n)
|
|
139
|
+
this.owner = new Int32Array(n);
|
|
140
|
+
}
|
|
141
|
+
capture(n) {
|
|
142
|
+
for (let l = 0; l < this.lanes.length; l++) {
|
|
143
|
+
const lane = this.lanes[l];
|
|
144
|
+
const at = l * n;
|
|
145
|
+
for (let i = 0; i < n; i++)
|
|
146
|
+
this.snapshot[at + i] = lane[i];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Whoever changed a body's bits owns it, and a second owner is the finding.
|
|
151
|
+
*
|
|
152
|
+
* The first overlap is kept rather than the last, so a run reports where the invariant broke
|
|
153
|
+
* first instead of wherever the scan happened to end.
|
|
154
|
+
*/
|
|
155
|
+
claim(island, n) {
|
|
156
|
+
for (let i = 0; i < n; i++) {
|
|
157
|
+
let wrote = false;
|
|
158
|
+
for (let l = 0; l < this.lanes.length && !wrote; l++) {
|
|
159
|
+
const lane = this.lanes[l];
|
|
160
|
+
if (lane[i] !== this.snapshot[l * n + i])
|
|
161
|
+
wrote = true;
|
|
162
|
+
}
|
|
163
|
+
if (!wrote)
|
|
164
|
+
continue;
|
|
165
|
+
const held = this.owner[i] ?? -1;
|
|
166
|
+
if (held >= 0 && held !== island) {
|
|
167
|
+
this.overlap ??= { body: i, first: held, second: island };
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
this.owner[i] = island;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
package/dist/faces.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ConvexShape } from './shape.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A shape's faces, as planes and as ordered vertex loops.
|
|
4
|
+
*
|
|
5
|
+
* **`faceNormals` is not this, and the two are deliberately separate.** That array is deduped
|
|
6
|
+
* *directions* — a box has three, because a plane and its opposite are one separating axis — which
|
|
7
|
+
* is exactly what SAT wants and exactly what clipping cannot use. A contact manifold needs the
|
|
8
|
+
* reference face's plane and the incident face's ordered loop, so a box needs six of each.
|
|
9
|
+
*
|
|
10
|
+
* CSR rather than an array of arrays: traversal allocates nothing, and a shape built of typed
|
|
11
|
+
* arrays alone is structured-cloneable, so it survives a `postMessage` into a worker.
|
|
12
|
+
*
|
|
13
|
+
* **What this gives up** is memory on every shape, whether or not anything clips it: four floats
|
|
14
|
+
* per face plus one index per face vertex. **What would make it wrong** is a consumer building tens
|
|
15
|
+
* of thousands of shapes for broad-phase-only use, at which point the answer is a builder flag
|
|
16
|
+
* rather than a second shape type.
|
|
17
|
+
*/
|
|
18
|
+
/** How many faces the shape has. Zero for a sphere or a capsule, which have none. */
|
|
19
|
+
export declare function faceCount(shape: ConvexShape): number;
|
|
20
|
+
/**
|
|
21
|
+
* Fill `out` with the vertex indices of one face, wound counter-clockwise seen from outside.
|
|
22
|
+
*
|
|
23
|
+
* Returns how many were written. `out` must hold at least that many; a face cannot have more
|
|
24
|
+
* vertices than the shape has, so `new Uint16Array(shape.vertices.length / 3)` always suffices.
|
|
25
|
+
*/
|
|
26
|
+
export declare function faceVertices(shape: ConvexShape, face: number, out: Uint16Array): number;
|
package/dist/faces.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shape's faces, as planes and as ordered vertex loops.
|
|
3
|
+
*
|
|
4
|
+
* **`faceNormals` is not this, and the two are deliberately separate.** That array is deduped
|
|
5
|
+
* *directions* — a box has three, because a plane and its opposite are one separating axis — which
|
|
6
|
+
* is exactly what SAT wants and exactly what clipping cannot use. A contact manifold needs the
|
|
7
|
+
* reference face's plane and the incident face's ordered loop, so a box needs six of each.
|
|
8
|
+
*
|
|
9
|
+
* CSR rather than an array of arrays: traversal allocates nothing, and a shape built of typed
|
|
10
|
+
* arrays alone is structured-cloneable, so it survives a `postMessage` into a worker.
|
|
11
|
+
*
|
|
12
|
+
* **What this gives up** is memory on every shape, whether or not anything clips it: four floats
|
|
13
|
+
* per face plus one index per face vertex. **What would make it wrong** is a consumer building tens
|
|
14
|
+
* of thousands of shapes for broad-phase-only use, at which point the answer is a builder flag
|
|
15
|
+
* rather than a second shape type.
|
|
16
|
+
*/
|
|
17
|
+
/** How many faces the shape has. Zero for a sphere or a capsule, which have none. */
|
|
18
|
+
export function faceCount(shape) {
|
|
19
|
+
return shape.faceVertexStart.length === 0 ? 0 : shape.faceVertexStart.length - 1;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fill `out` with the vertex indices of one face, wound counter-clockwise seen from outside.
|
|
23
|
+
*
|
|
24
|
+
* Returns how many were written. `out` must hold at least that many; a face cannot have more
|
|
25
|
+
* vertices than the shape has, so `new Uint16Array(shape.vertices.length / 3)` always suffices.
|
|
26
|
+
*/
|
|
27
|
+
export function faceVertices(shape, face, out) {
|
|
28
|
+
const start = shape.faceVertexStart[face] ?? 0;
|
|
29
|
+
const end = shape.faceVertexStart[face + 1] ?? start;
|
|
30
|
+
const n = end - start;
|
|
31
|
+
for (let i = 0; i < n; i++)
|
|
32
|
+
out[i] = shape.faceVertexIndices[start + i] ?? 0;
|
|
33
|
+
return n;
|
|
34
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { BodySet } from './bodies.ts';
|
|
2
|
+
import type { ColliderSet } from './colliderSet.ts';
|
|
3
|
+
/**
|
|
4
|
+
* A stable hash of the static geometry a simulation ran against.
|
|
5
|
+
*
|
|
6
|
+
* A recording made of a deterministic simulation is only replayable against the
|
|
7
|
+
* world it was recorded in. When world generation changes — a fixed bug, a
|
|
8
|
+
* retuned curve, a new build — recordings from before it desync, and they do it
|
|
9
|
+
* *silently*: the run is reproduced faithfully against geometry that is no longer
|
|
10
|
+
* there, so what appears on screen is a body moving through empty space with
|
|
11
|
+
* nothing to explain it.
|
|
12
|
+
*
|
|
13
|
+
* Storing this alongside a recording lets a caller compare the world it has
|
|
14
|
+
* against the world the recording was made in, and refuse rather than guess.
|
|
15
|
+
*
|
|
16
|
+
* **Determinism is the whole contract.** The same colliders must give the same
|
|
17
|
+
* string on every machine and in every build, so this reads the raw bytes of the
|
|
18
|
+
* bounds array: a float's decimal rendering is a platform question and its bit
|
|
19
|
+
* pattern is not. Nothing here iterates a `Map` or a `Set`, both of which would
|
|
20
|
+
* make the answer depend on insertion order.
|
|
21
|
+
*
|
|
22
|
+
* **No tolerance, deliberately.** A sub-millimetre difference changes the answer.
|
|
23
|
+
* Whether a given difference *matters* is the caller's judgement, and a hash that
|
|
24
|
+
* quietly rounded would take that judgement away from every caller at once.
|
|
25
|
+
*
|
|
26
|
+
* FNV-1a over 64 bits, carried as two 32-bit halves because JavaScript's bitwise
|
|
27
|
+
* operators are 32-bit and a single accumulator would silently lose the top half.
|
|
28
|
+
* Not a cryptographic hash: this detects change, it does not resist forgery, and
|
|
29
|
+
* a caller that needs the second property needs `crypto.subtle` and an await.
|
|
30
|
+
*
|
|
31
|
+
* **On a set that has been mutated, this answers "the same set with the same history" and not
|
|
32
|
+
* "the same world".** A set can take and drop groups now, and a slot freed by one region is
|
|
33
|
+
* refilled by another, so the same geometry sits at different indices depending on the path
|
|
34
|
+
* driven to reach it.
|
|
35
|
+
*
|
|
36
|
+
* *Replay is not what that costs.* The same inputs drive the same path, which produces the same
|
|
37
|
+
* load order and so the same slots, and a recording replays against the set it was recorded
|
|
38
|
+
* against exactly as it always did. What it costs is comparing two *sessions* that arrived at the
|
|
39
|
+
* same world by different routes.
|
|
40
|
+
*
|
|
41
|
+
* **`fingerprintBodies`'s escape hatch does not transfer**, which is worth saying because the two
|
|
42
|
+
* functions are otherwise the same construction. There, a caller wanting the multiset sorts what
|
|
43
|
+
* it feeds in, because it hands over an array. Here a caller hands over an object whose slot order
|
|
44
|
+
* is a load history they neither chose nor can observe, so there is nothing for them to sort. A
|
|
45
|
+
* canonical variant hashing live slots sorted by their packed bytes would answer the cross-session
|
|
46
|
+
* question and is about fifteen lines; it is not here because nothing is asking it yet.
|
|
47
|
+
*/
|
|
48
|
+
export declare function fingerprintColliders(set: ColliderSet): string;
|
|
49
|
+
/**
|
|
50
|
+
* A stable hash of every body's state, for comparing one run against another.
|
|
51
|
+
*
|
|
52
|
+
* The same FNV-1a construction and the same reasoning as `fingerprintColliders`: the raw bytes of a
|
|
53
|
+
* float, because its decimal rendering is a platform question and its bit pattern is not. No
|
|
54
|
+
* tolerance, deliberately — whether a difference *matters* is the caller's judgement, and a hash
|
|
55
|
+
* that quietly rounded would take that judgement away from every caller at once.
|
|
56
|
+
*
|
|
57
|
+
* **Order matters here and that is the point.** Two runs of the same scene must agree body for
|
|
58
|
+
* body. A caller comparing scenes *built* in different orders wants the multiset instead, and has
|
|
59
|
+
* to sort what it feeds in — see the permutation gate beside the solver baseline, which does.
|
|
60
|
+
*/
|
|
61
|
+
export declare function fingerprintBodies(bodies: BodySet): string;
|