@driftengine/physics 3.61.1 → 3.61.2
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/package.json +2 -1
- package/scripts/decompose-check.ts +551 -0
- package/scripts/island-bench.ts +235 -0
- package/scripts/streaming-check.ts +358 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@driftengine/physics",
|
|
3
|
-
"version": "3.61.
|
|
3
|
+
"version": "3.61.2",
|
|
4
4
|
"description": "Collision shapes, spatial queries and swept kinematic collision",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
22
|
"!src/**/*.test.mjs",
|
|
23
23
|
"!src/**/__snapshots__",
|
|
24
|
+
"scripts",
|
|
24
25
|
"README.md",
|
|
25
26
|
"LICENSE",
|
|
26
27
|
"NOTICE"
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* That convex decomposition produces hulls worth having, on meshes generated here.
|
|
3
|
+
*
|
|
4
|
+
* **No asset, no GPU, no network** — every mesh is built by this file, so the check runs anywhere
|
|
5
|
+
* and is the same run twice. The meshes are not a sample of what people import; each one is chosen
|
|
6
|
+
* because it breaks a different assumption a decomposition can be built on.
|
|
7
|
+
*
|
|
8
|
+
* | Mesh | What it is for |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | Prism | Convex already. The answer is one part, and anything else means the merge cannot recognise a solid it need not divide |
|
|
11
|
+
* | L-beam | The simplest concave solid. Two parts is the right answer and its bloat should be nothing at all |
|
|
12
|
+
* | Cup | A cavity, open at the top. The decomposition that fills it passes every count-based check and is useless |
|
|
13
|
+
* | Torus | A hole straight through, which is what defeats a fill that assumes a watertight surface |
|
|
14
|
+
* | Staircase | Ten steps against a budget of eight, so the merge has to choose which to join |
|
|
15
|
+
*
|
|
16
|
+
* **Two numbers carry the result and they fail in opposite directions.** `bloat` is the empty space
|
|
17
|
+
* the hulls added: a single hull of a cup has an enormous one. `coverage` is the fraction of the
|
|
18
|
+
* solid that ended up inside its own part: support sampling can only lose material, never add it,
|
|
19
|
+
* so this is the other side of the same ledger. A decomposition is good when both are small
|
|
20
|
+
* departures from their ideals, and a check that watched only one of them would pass a hull that
|
|
21
|
+
* swallowed the cavity or a hull that had shrunk away from the thing it stands for.
|
|
22
|
+
*
|
|
23
|
+
* **The resolution sweep is the assertion, not a detail.** Every one of these bounds held at a
|
|
24
|
+
* single resolution while the fill was silently leaking, because the leak inflated the source
|
|
25
|
+
* volume as much as it inflated the hulls. Running eight resolutions is what made the defect
|
|
26
|
+
* visible: the answers were correct at four of them and wrong at the other four, with nothing in
|
|
27
|
+
* the shapes to explain which.
|
|
28
|
+
*
|
|
29
|
+
* npx tsx packages/physics/scripts/decompose-check.ts
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { BODY_DYNAMIC, BODY_STATIC } from '../src/bodies.ts';
|
|
33
|
+
import { PhysicsWorld } from '../src/world.ts';
|
|
34
|
+
import { boxShape, sphereShape } from '../src/shape.ts';
|
|
35
|
+
import { meshShape } from '../src/meshShape.ts';
|
|
36
|
+
import { decomposeConvex } from '../src/decompose.ts';
|
|
37
|
+
import type { ConvexPart, Decomposition } from '../src/decompose.ts';
|
|
38
|
+
import { hullShape } from '../src/shape.ts';
|
|
39
|
+
|
|
40
|
+
interface Mesh {
|
|
41
|
+
readonly positions: number[];
|
|
42
|
+
readonly indices: number[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emptyMesh(): Mesh {
|
|
46
|
+
return { positions: [], indices: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A box, as twelve triangles, appended to a mesh already under construction. */
|
|
50
|
+
function addBox(
|
|
51
|
+
mesh: Mesh,
|
|
52
|
+
cx: number,
|
|
53
|
+
cy: number,
|
|
54
|
+
cz: number,
|
|
55
|
+
hx: number,
|
|
56
|
+
hy: number,
|
|
57
|
+
hz: number,
|
|
58
|
+
): void {
|
|
59
|
+
const base = mesh.positions.length / 3;
|
|
60
|
+
for (let i = 0; i < 8; i++) {
|
|
61
|
+
mesh.positions.push(cx + (i & 1 ? hx : -hx), cy + (i & 2 ? hy : -hy), cz + (i & 4 ? hz : -hz));
|
|
62
|
+
}
|
|
63
|
+
const faces = [
|
|
64
|
+
[0, 2, 3, 1],
|
|
65
|
+
[4, 5, 7, 6],
|
|
66
|
+
[0, 1, 5, 4],
|
|
67
|
+
[2, 6, 7, 3],
|
|
68
|
+
[0, 4, 6, 2],
|
|
69
|
+
[1, 3, 7, 5],
|
|
70
|
+
];
|
|
71
|
+
for (const face of faces) {
|
|
72
|
+
const [a, b, c, d] = face as [number, number, number, number];
|
|
73
|
+
mesh.indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A ring of `sides` points at a radius and a height, returning its first index. */
|
|
78
|
+
function addRing(mesh: Mesh, sides: number, radius: number, y: number): number {
|
|
79
|
+
const base = mesh.positions.length / 3;
|
|
80
|
+
for (let i = 0; i < sides; i++) {
|
|
81
|
+
const angle = (i / sides) * Math.PI * 2;
|
|
82
|
+
mesh.positions.push(Math.cos(angle) * radius, y, Math.sin(angle) * radius);
|
|
83
|
+
}
|
|
84
|
+
return base;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function addQuad(mesh: Mesh, a: number, b: number, c: number, d: number): void {
|
|
88
|
+
mesh.indices.push(a, b, c, a, c, d);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A wall between two rings wound the same way, seen from outside the lower one. */
|
|
92
|
+
function addTube(mesh: Mesh, sides: number, lo: number, hi: number): void {
|
|
93
|
+
for (let i = 0; i < sides; i++) {
|
|
94
|
+
const j = (i + 1) % sides;
|
|
95
|
+
addQuad(mesh, lo + i, lo + j, hi + j, hi + i);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A closed n-gon prism: the side wall and a fan closing each end. */
|
|
100
|
+
function prismMesh(sides: number, radius: number, height: number): Mesh {
|
|
101
|
+
const mesh = emptyMesh();
|
|
102
|
+
const lo = addRing(mesh, sides, radius, 0);
|
|
103
|
+
const hi = addRing(mesh, sides, radius, height);
|
|
104
|
+
addTube(mesh, sides, lo, hi);
|
|
105
|
+
const centreLo = mesh.positions.length / 3;
|
|
106
|
+
mesh.positions.push(0, 0, 0);
|
|
107
|
+
const centreHi = mesh.positions.length / 3;
|
|
108
|
+
mesh.positions.push(0, height, 0);
|
|
109
|
+
for (let i = 0; i < sides; i++) {
|
|
110
|
+
const j = (i + 1) % sides;
|
|
111
|
+
mesh.indices.push(centreLo, lo + j, lo + i);
|
|
112
|
+
mesh.indices.push(centreHi, hi + i, hi + j);
|
|
113
|
+
}
|
|
114
|
+
return mesh;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Two boxes meeting at a corner. The one shape whose right answer is not in doubt. */
|
|
118
|
+
function lBeamMesh(): Mesh {
|
|
119
|
+
const mesh = emptyMesh();
|
|
120
|
+
addBox(mesh, 0, 0, 0, 1, 0.25, 0.25);
|
|
121
|
+
addBox(mesh, -0.75, 0.75, 0, 0.25, 0.5, 0.25);
|
|
122
|
+
return mesh;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A cup: an outer wall, a cavity open at the top, and a floor between them. */
|
|
126
|
+
function cupMesh(sides: number, outer: number, inner: number, height: number, floor: number): Mesh {
|
|
127
|
+
const mesh = emptyMesh();
|
|
128
|
+
const outerLo = addRing(mesh, sides, outer, 0);
|
|
129
|
+
const outerHi = addRing(mesh, sides, outer, height);
|
|
130
|
+
const innerLo = addRing(mesh, sides, inner, floor);
|
|
131
|
+
const innerHi = addRing(mesh, sides, inner, height);
|
|
132
|
+
addTube(mesh, sides, outerLo, outerHi);
|
|
133
|
+
addTube(mesh, sides, innerHi, innerLo);
|
|
134
|
+
const centreLo = mesh.positions.length / 3;
|
|
135
|
+
mesh.positions.push(0, 0, 0);
|
|
136
|
+
const centreFloor = mesh.positions.length / 3;
|
|
137
|
+
mesh.positions.push(0, floor, 0);
|
|
138
|
+
for (let i = 0; i < sides; i++) {
|
|
139
|
+
const j = (i + 1) % sides;
|
|
140
|
+
mesh.indices.push(centreLo, outerLo + j, outerLo + i);
|
|
141
|
+
mesh.indices.push(centreFloor, innerLo + i, innerLo + j);
|
|
142
|
+
addQuad(mesh, outerHi + i, outerHi + j, innerHi + j, innerHi + i);
|
|
143
|
+
}
|
|
144
|
+
return mesh;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A torus, as a grid of quads. A hole through the middle and no end caps to close. */
|
|
148
|
+
function torusMesh(major: number, minor: number, ringCount: number, sides: number): Mesh {
|
|
149
|
+
const mesh = emptyMesh();
|
|
150
|
+
for (let r = 0; r < ringCount; r++) {
|
|
151
|
+
const a = (r / ringCount) * Math.PI * 2;
|
|
152
|
+
const cx = Math.cos(a) * major;
|
|
153
|
+
const cz = Math.sin(a) * major;
|
|
154
|
+
for (let s = 0; s < sides; s++) {
|
|
155
|
+
const b = (s / sides) * Math.PI * 2;
|
|
156
|
+
const radial = major + Math.cos(b) * minor;
|
|
157
|
+
mesh.positions.push(Math.cos(a) * radial, Math.sin(b) * minor, Math.sin(a) * radial);
|
|
158
|
+
}
|
|
159
|
+
void cx;
|
|
160
|
+
void cz;
|
|
161
|
+
}
|
|
162
|
+
for (let r = 0; r < ringCount; r++) {
|
|
163
|
+
const rn = (r + 1) % ringCount;
|
|
164
|
+
for (let s = 0; s < sides; s++) {
|
|
165
|
+
const sn = (s + 1) % sides;
|
|
166
|
+
addQuad(mesh, r * sides + s, rn * sides + s, rn * sides + sn, r * sides + sn);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return mesh;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Ten steps rising along x, each a box. More parts than the budget, on purpose. */
|
|
173
|
+
function stairsMesh(steps: number): Mesh {
|
|
174
|
+
const mesh = emptyMesh();
|
|
175
|
+
for (let i = 0; i < steps; i++) {
|
|
176
|
+
const height = (i + 1) * 0.1;
|
|
177
|
+
addBox(mesh, i * 0.2 - 1, height * 0.5, 0, 0.1, height * 0.5, 0.5);
|
|
178
|
+
}
|
|
179
|
+
return mesh;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface Bound {
|
|
183
|
+
readonly maxParts: number;
|
|
184
|
+
/**
|
|
185
|
+
* The most the parts' volumes may add up to beyond the source, as a fraction of it.
|
|
186
|
+
*
|
|
187
|
+
* **This one double-counts wherever two hulls overlap, and that is deliberate**: it is the number
|
|
188
|
+
* a body's mass is computed from, because `combineMassProperties` sums the parts. `maxBulge` below
|
|
189
|
+
* is the same measurement without the double count, and the two differ by a lot on exactly the
|
|
190
|
+
* shapes where the parts pile up on each other.
|
|
191
|
+
*/
|
|
192
|
+
readonly maxBloat: number;
|
|
193
|
+
/** The most space the parts occupy **together**, counting shared space once. */
|
|
194
|
+
readonly maxBulge: number;
|
|
195
|
+
/** The least of the solid that must end up inside its own part. */
|
|
196
|
+
readonly minCoverage: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The volume the parts occupy together, by sampling, counting shared space once.
|
|
201
|
+
*
|
|
202
|
+
* **Measured at one resolution per mesh rather than at all eight**, because it costs a grid of
|
|
203
|
+
* samples against every part and the summed figure is what moves with resolution anyway. What it is
|
|
204
|
+
* here to catch is the case the sum cannot distinguish: parts that pile up on each other read as
|
|
205
|
+
* enormous bloat while the collider they form is tight, and a decomposition should not be failed
|
|
206
|
+
* for that.
|
|
207
|
+
*/
|
|
208
|
+
function bulgeOf(parts: readonly ConvexPart[], samples: number): number {
|
|
209
|
+
const shapes = parts.map((part) => hullShape(part.points));
|
|
210
|
+
let minX = Infinity,
|
|
211
|
+
minY = Infinity,
|
|
212
|
+
minZ = Infinity;
|
|
213
|
+
let maxX = -Infinity,
|
|
214
|
+
maxY = -Infinity,
|
|
215
|
+
maxZ = -Infinity;
|
|
216
|
+
for (const shape of shapes) {
|
|
217
|
+
for (let i = 0; i < shape.vertices.length; i += 3) {
|
|
218
|
+
minX = Math.min(minX, shape.vertices[i] as number);
|
|
219
|
+
maxX = Math.max(maxX, shape.vertices[i] as number);
|
|
220
|
+
minY = Math.min(minY, shape.vertices[i + 1] as number);
|
|
221
|
+
maxY = Math.max(maxY, shape.vertices[i + 1] as number);
|
|
222
|
+
minZ = Math.min(minZ, shape.vertices[i + 2] as number);
|
|
223
|
+
maxZ = Math.max(maxZ, shape.vertices[i + 2] as number);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const inside = (shape: (typeof shapes)[number], x: number, y: number, z: number): boolean => {
|
|
227
|
+
const planes = shape.facePlanes;
|
|
228
|
+
for (let i = 0; i + 3 < planes.length; i += 4) {
|
|
229
|
+
const d =
|
|
230
|
+
(planes[i] as number) * x +
|
|
231
|
+
(planes[i + 1] as number) * y +
|
|
232
|
+
(planes[i + 2] as number) * z -
|
|
233
|
+
(planes[i + 3] as number);
|
|
234
|
+
if (d > 1e-9) return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
};
|
|
238
|
+
let hits = 0;
|
|
239
|
+
for (let ix = 0; ix < samples; ix++) {
|
|
240
|
+
const x = minX + ((ix + 0.5) / samples) * (maxX - minX);
|
|
241
|
+
for (let iy = 0; iy < samples; iy++) {
|
|
242
|
+
const y = minY + ((iy + 0.5) / samples) * (maxY - minY);
|
|
243
|
+
for (let iz = 0; iz < samples; iz++) {
|
|
244
|
+
const z = minZ + ((iz + 0.5) / samples) * (maxZ - minZ);
|
|
245
|
+
for (const shape of shapes) {
|
|
246
|
+
if (inside(shape, x, y, z)) {
|
|
247
|
+
hits++;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return (hits / (samples * samples * samples)) * (maxX - minX) * (maxY - minY) * (maxZ - minZ);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const RESOLUTIONS = [32, 40, 48, 56, 64, 72, 80, 96];
|
|
258
|
+
|
|
259
|
+
const CASES: readonly {
|
|
260
|
+
name: string;
|
|
261
|
+
mesh: Mesh;
|
|
262
|
+
maxHulls: number;
|
|
263
|
+
bound: Bound;
|
|
264
|
+
}[] = [
|
|
265
|
+
{
|
|
266
|
+
name: 'prism (convex)',
|
|
267
|
+
mesh: prismMesh(24, 1, 1),
|
|
268
|
+
maxHulls: 8,
|
|
269
|
+
/*
|
|
270
|
+
* **One part, and this is the control for everything below.** A convex solid needs no
|
|
271
|
+
* decomposition, so a run that returns two has a merge which cannot see that it is finished —
|
|
272
|
+
* and every bloat figure on a concave mesh would then be measuring the wrong thing.
|
|
273
|
+
*/
|
|
274
|
+
bound: { maxParts: 1, maxBloat: 0.2, maxBulge: 0.2, minCoverage: 0.93 },
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: 'L-beam',
|
|
278
|
+
mesh: lBeamMesh(),
|
|
279
|
+
maxHulls: 8,
|
|
280
|
+
bound: { maxParts: 2, maxBloat: 0.02, maxBulge: 0.02, minCoverage: 0.99 },
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: 'cup (cavity)',
|
|
284
|
+
mesh: cupMesh(24, 1, 0.75, 1, 0.25),
|
|
285
|
+
maxHulls: 16,
|
|
286
|
+
/*
|
|
287
|
+
* **The bound that decides whether this row shipped anything.** Filling the cavity roughly
|
|
288
|
+
* doubles the volume the hulls hold, so anything near 1.0 here is a decomposition that has
|
|
289
|
+
* produced a solid cylinder and called it a cup. Ten per cent leaves room for the staircase the
|
|
290
|
+
* cells make of a curved wall and no room at all for the failure.
|
|
291
|
+
*/
|
|
292
|
+
bound: { maxParts: 16, maxBloat: 0.1, maxBulge: 0.06, minCoverage: 0.9 },
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: 'torus (hole)',
|
|
296
|
+
mesh: torusMesh(1, 0.35, 32, 16),
|
|
297
|
+
maxHulls: 16,
|
|
298
|
+
/*
|
|
299
|
+
* **The loosest bounds here, and they are the measurement rather than an aspiration.** A fat
|
|
300
|
+
* curved tube is the worst case for a decomposition seeded from axis-aligned boxes: every hull
|
|
301
|
+
* chords the ring it follows, and the budget is not what limits it — the same torus measures
|
|
302
|
+
* 26.7% at eight parts and 20.6% at thirty-two, so the parts are not the constraint and neither
|
|
303
|
+
* is the merge, which reaches the same figure with its tail switched off entirely.
|
|
304
|
+
*
|
|
305
|
+
* The two numbers are far apart here for the reason `maxBloat` explains: **27.0% summed against
|
|
306
|
+
* 16.2% occupied**, so eleven points of it is the same space counted twice where neighbouring
|
|
307
|
+
* hulls overlap. The collider bulges by a sixth; the mass computed from it is out by a quarter.
|
|
308
|
+
*
|
|
309
|
+
* *What would improve it* is seeds that follow curvature instead of the axes, or splitting by
|
|
310
|
+
* fitted planes instead of merging boxes. `docs/IMPROVEMENTS.md` carries that.
|
|
311
|
+
*/
|
|
312
|
+
bound: { maxParts: 16, maxBloat: 0.32, maxBulge: 0.2, minCoverage: 0.88 },
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: 'staircase',
|
|
316
|
+
mesh: stairsMesh(10),
|
|
317
|
+
maxHulls: 8,
|
|
318
|
+
bound: { maxParts: 8, maxBloat: 0.2, maxBulge: 0.15, minCoverage: 0.95 },
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
function report(d: Decomposition): string {
|
|
323
|
+
const points = d.parts.map((p) => p.points.length / 3);
|
|
324
|
+
return (
|
|
325
|
+
`${String(d.parts.length).padStart(2)} parts ` +
|
|
326
|
+
`bloat ${(d.bloat * 100).toFixed(1).padStart(6)}% ` +
|
|
327
|
+
`coverage ${(d.coverage * 100).toFixed(1).padStart(5)}% ` +
|
|
328
|
+
`points ${String(Math.max(...points)).padStart(2)}`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let failures = 0;
|
|
333
|
+
const fail = (message: string): void => {
|
|
334
|
+
failures++;
|
|
335
|
+
console.log(` FAIL ${message}`);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
console.log('Convex decomposition, on meshes this file generates.\n');
|
|
339
|
+
|
|
340
|
+
for (const testCase of CASES) {
|
|
341
|
+
console.log(`${testCase.name} (budget ${testCase.maxHulls})`);
|
|
342
|
+
let worstBloat = -Infinity;
|
|
343
|
+
let worstCoverage = Infinity;
|
|
344
|
+
let mostParts = 0;
|
|
345
|
+
let mostPoints = 0;
|
|
346
|
+
|
|
347
|
+
for (const resolution of RESOLUTIONS) {
|
|
348
|
+
const d = decomposeConvex(testCase.mesh.positions, testCase.mesh.indices, {
|
|
349
|
+
resolution,
|
|
350
|
+
maxHulls: testCase.maxHulls,
|
|
351
|
+
});
|
|
352
|
+
console.log(` res ${String(resolution).padStart(2)} ${report(d)}`);
|
|
353
|
+
worstBloat = Math.max(worstBloat, d.bloat);
|
|
354
|
+
worstCoverage = Math.min(worstCoverage, d.coverage);
|
|
355
|
+
mostParts = Math.max(mostParts, d.parts.length);
|
|
356
|
+
for (const part of d.parts) mostPoints = Math.max(mostPoints, part.points.length / 3);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* The occupied volume, once, at the middle of the sweep. */
|
|
360
|
+
const middle = decomposeConvex(testCase.mesh.positions, testCase.mesh.indices, {
|
|
361
|
+
resolution: 64,
|
|
362
|
+
maxHulls: testCase.maxHulls,
|
|
363
|
+
});
|
|
364
|
+
const bulge = (bulgeOf(middle.parts, 110) - middle.sourceVolume) / middle.sourceVolume;
|
|
365
|
+
console.log(
|
|
366
|
+
` res 64 occupied together: ${(bulge * 100).toFixed(1)}% over the source, ` +
|
|
367
|
+
`against ${(middle.bloat * 100).toFixed(1)}% summed`,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const { bound } = testCase;
|
|
371
|
+
if (bulge > bound.maxBulge) {
|
|
372
|
+
fail(
|
|
373
|
+
`${testCase.name}: the parts occupy ${(bulge * 100).toFixed(1)}% over the source, against a ` +
|
|
374
|
+
`bound of ${(bound.maxBulge * 100).toFixed(0)}%`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (mostParts > bound.maxParts) {
|
|
378
|
+
fail(`${testCase.name}: ${mostParts} parts against a bound of ${bound.maxParts}`);
|
|
379
|
+
}
|
|
380
|
+
if (worstBloat > bound.maxBloat) {
|
|
381
|
+
fail(
|
|
382
|
+
`${testCase.name}: bloat ${(worstBloat * 100).toFixed(1)}% against a bound of ` +
|
|
383
|
+
`${(bound.maxBloat * 100).toFixed(0)}%`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
if (worstCoverage < bound.minCoverage) {
|
|
387
|
+
fail(
|
|
388
|
+
`${testCase.name}: coverage ${(worstCoverage * 100).toFixed(1)}% against a bound of ` +
|
|
389
|
+
`${(bound.minCoverage * 100).toFixed(0)}%`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
/*
|
|
393
|
+
* **`hullShape` refuses more than 64 points**, so a part that reached it would not be a poor hull
|
|
394
|
+
* — it would be an exception at the moment somebody tried to use the decomposition. The direction
|
|
395
|
+
* set caps this by construction and the bound is here to say so if that ever stops being true.
|
|
396
|
+
*/
|
|
397
|
+
if (mostPoints > 64)
|
|
398
|
+
fail(`${testCase.name}: a part carries ${mostPoints} points, over hullShape's 64`);
|
|
399
|
+
console.log('');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/*
|
|
403
|
+
* **Determinism, run twice in one process and compared byte for byte.** The merge is a heap over
|
|
404
|
+
* candidate pairs and a heap without a tie-break orders equal costs by insertion, so two runs that
|
|
405
|
+
* inserted in a different order would decompose the same mesh differently. Nothing else in this
|
|
406
|
+
* check would notice: every bound above is a summary statistic and would hold on either answer.
|
|
407
|
+
*/
|
|
408
|
+
{
|
|
409
|
+
const mesh = cupMesh(24, 1, 0.75, 1, 0.25);
|
|
410
|
+
const a = decomposeConvex(mesh.positions, mesh.indices, { resolution: 48 });
|
|
411
|
+
const b = decomposeConvex(mesh.positions, mesh.indices, { resolution: 48 });
|
|
412
|
+
let identical = a.parts.length === b.parts.length;
|
|
413
|
+
for (let i = 0; identical && i < a.parts.length; i++) {
|
|
414
|
+
const pa = a.parts[i] as { points: Float32Array };
|
|
415
|
+
const pb = b.parts[i] as { points: Float32Array };
|
|
416
|
+
identical = pa.points.length === pb.points.length;
|
|
417
|
+
for (let k = 0; identical && k < pa.points.length; k++) {
|
|
418
|
+
identical = pa.points[k] === pb.points[k];
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
console.log(`determinism: two runs of the cup agree ${identical ? 'exactly' : 'NOT AT ALL'}`);
|
|
422
|
+
if (!identical) fail('two runs of the same mesh produced different hulls');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/*
|
|
426
|
+
* **The fill, measured against a volume that is known rather than reported.** A closed prism's
|
|
427
|
+
* volume has a closed form, and the check that the voxelised source approaches it from above is the
|
|
428
|
+
* one that would have caught the tangency defect on the day it was written: a leaking fill answers
|
|
429
|
+
* with the shell, which is a small fraction of the solid and shrinks as the cells do.
|
|
430
|
+
*/
|
|
431
|
+
{
|
|
432
|
+
const sides = 24;
|
|
433
|
+
const mesh = prismMesh(sides, 1, 1);
|
|
434
|
+
const exact = 0.5 * sides * Math.sin((Math.PI * 2) / sides);
|
|
435
|
+
console.log(`\nfill against the closed form (${exact.toFixed(4)}):`);
|
|
436
|
+
let worst = 0;
|
|
437
|
+
for (const resolution of RESOLUTIONS) {
|
|
438
|
+
const d = decomposeConvex(mesh.positions, mesh.indices, { resolution, maxHulls: 8 });
|
|
439
|
+
const ratio = d.sourceVolume / exact;
|
|
440
|
+
console.log(
|
|
441
|
+
` res ${String(resolution).padStart(2)} ${d.sourceVolume.toFixed(4)} ${ratio.toFixed(3)}x`,
|
|
442
|
+
);
|
|
443
|
+
if (ratio < 1)
|
|
444
|
+
fail(
|
|
445
|
+
`res ${resolution}: the fill answered ${ratio.toFixed(3)}x, below the solid it rasterised`,
|
|
446
|
+
);
|
|
447
|
+
worst = Math.max(worst, ratio);
|
|
448
|
+
}
|
|
449
|
+
if (worst > 1.25)
|
|
450
|
+
fail(`the fill over-marks by ${worst.toFixed(3)}x, more than a cell of surface explains`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/*
|
|
454
|
+
* **What a volume figure cannot say.** Everything above measures the hulls against the mesh; nothing
|
|
455
|
+
* above measures whether a body made of them behaves like the thing it stands for. A decomposition
|
|
456
|
+
* that quietly filled a cavity would pass a bloat bound set a little too loose and fail here by a
|
|
457
|
+
* whole ball radius.
|
|
458
|
+
*/
|
|
459
|
+
{
|
|
460
|
+
console.log('\nA ball dropped into the cup, against the same cup as one hull:');
|
|
461
|
+
const mesh = cupMesh(24, 1, 0.75, 1, 0.25);
|
|
462
|
+
const rest = (maxHulls: number): number => {
|
|
463
|
+
const d = decomposeConvex(mesh.positions, mesh.indices, { resolution: 64, maxHulls });
|
|
464
|
+
const shapes = d.parts.map((part) => hullShape(part.points));
|
|
465
|
+
const world = new PhysicsWorld();
|
|
466
|
+
if (shapes.length === 1)
|
|
467
|
+
world.addBody({ type: BODY_STATIC, shape: shapes[0] as ReturnType<typeof hullShape> });
|
|
468
|
+
else world.addBody({ type: BODY_STATIC, shapes });
|
|
469
|
+
const ball = world.addBody({ type: BODY_DYNAMIC, shape: sphereShape(0.4), y: 2.2 });
|
|
470
|
+
for (let i = 0; i < 400; i++) world.step(1 / 120);
|
|
471
|
+
return world.bodies.posY[ball] ?? 0;
|
|
472
|
+
};
|
|
473
|
+
const decomposed = rest(16);
|
|
474
|
+
const single = rest(1);
|
|
475
|
+
console.log(` decomposed: rests at y = ${decomposed.toFixed(3)}`);
|
|
476
|
+
console.log(` one hull: rests at y = ${single.toFixed(3)}`);
|
|
477
|
+
/*
|
|
478
|
+
* **The claim is where the ball comes to rest, not merely that it is low**, and the difference is
|
|
479
|
+
* a mutation that passed. "Below the rim" was the first wording, and a compound whose narrow phase
|
|
480
|
+
* only ever looked at part 0 satisfied it by dropping the ball through the world to **y = -52.3**:
|
|
481
|
+
* below the rim, comfortably separated from the control, and the feature entirely absent.
|
|
482
|
+
*
|
|
483
|
+
* The cavity floor is at 0.25 and the ball's radius is 0.4, so a ball the cup actually holds rests
|
|
484
|
+
* at 0.65. The rim is at 1.0, so one held out rests at 1.4.
|
|
485
|
+
*/
|
|
486
|
+
if (Math.abs(decomposed - 0.65) > 0.15) {
|
|
487
|
+
fail(`the ball rests at ${decomposed.toFixed(3)}, and the cup's floor would hold it at 0.65`);
|
|
488
|
+
}
|
|
489
|
+
if (Math.abs(single - 1.4) > 0.15) {
|
|
490
|
+
fail(`the control ball rests at ${single.toFixed(3)}, and the rim would hold it at 1.4`);
|
|
491
|
+
}
|
|
492
|
+
if (!(single - decomposed > 0.4)) {
|
|
493
|
+
fail(
|
|
494
|
+
`the two rest heights differ by ${(single - decomposed).toFixed(3)}, which decides nothing`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/*
|
|
500
|
+
* **And the manifold budget, which nothing else here would notice.** A compound against a mesh
|
|
501
|
+
* shares one set of contact planes between its parts. Giving the whole budget to whichever part is
|
|
502
|
+
* tried first leaves the far parts with no plane at all, and the body tips — on a *box* floor every
|
|
503
|
+
* part gets a plane by construction, so the two floors are the same scene reached twice.
|
|
504
|
+
*/
|
|
505
|
+
{
|
|
506
|
+
console.log('\nA decomposed staircase settling on a mesh floor, against a box floor:');
|
|
507
|
+
const stairs = stairsMesh(10);
|
|
508
|
+
const d = decomposeConvex(stairs.positions, stairs.indices, { resolution: 48, maxHulls: 8 });
|
|
509
|
+
const shapes = d.parts.map((part) => hullShape(part.points));
|
|
510
|
+
const settle = (onMesh: boolean): { y: number; tilt: number } => {
|
|
511
|
+
const world = new PhysicsWorld();
|
|
512
|
+
if (onMesh) {
|
|
513
|
+
const floor: number[] = [];
|
|
514
|
+
const index: number[] = [];
|
|
515
|
+
for (let i = 0; i < 4; i++) {
|
|
516
|
+
floor.push(i & 1 ? 6 : -6, 0, i & 2 ? 6 : -6);
|
|
517
|
+
}
|
|
518
|
+
index.push(0, 2, 1, 1, 2, 3);
|
|
519
|
+
world.addBody({
|
|
520
|
+
type: BODY_STATIC,
|
|
521
|
+
shape: meshShape(new Float32Array(floor), new Uint32Array(index)),
|
|
522
|
+
});
|
|
523
|
+
} else {
|
|
524
|
+
world.addBody({ type: BODY_STATIC, shape: boxShape(6, 0.5, 6), y: -0.5 });
|
|
525
|
+
}
|
|
526
|
+
const body = world.addBody({ type: BODY_DYNAMIC, shapes, y: 1.5 });
|
|
527
|
+
for (let i = 0; i < 600; i++) world.step(1 / 120);
|
|
528
|
+
const qx = world.bodies.rotX[body] ?? 0;
|
|
529
|
+
const qz = world.bodies.rotZ[body] ?? 0;
|
|
530
|
+
return { y: world.bodies.posY[body] ?? 0, tilt: Math.sqrt(qx * qx + qz * qz) };
|
|
531
|
+
};
|
|
532
|
+
const onMesh = settle(true);
|
|
533
|
+
const onBox = settle(false);
|
|
534
|
+
console.log(` mesh floor: y = ${onMesh.y.toFixed(4)} tilt = ${onMesh.tilt.toFixed(4)}`);
|
|
535
|
+
console.log(` box floor: y = ${onBox.y.toFixed(4)} tilt = ${onBox.tilt.toFixed(4)}`);
|
|
536
|
+
/* The solver's own slop is 5 mm, so that is what "the same" means here. */
|
|
537
|
+
if (Math.abs(onMesh.y - onBox.y) > 0.005) {
|
|
538
|
+
fail(
|
|
539
|
+
`the staircase settles ${Math.abs(onMesh.y - onBox.y).toFixed(4)} apart on the two floors, ` +
|
|
540
|
+
'so parts are being crowded out of the manifold budget',
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (Math.abs(onMesh.tilt - onBox.tilt) > 0.02) {
|
|
544
|
+
fail(
|
|
545
|
+
`the staircase tilts differently on the two floors: ${onMesh.tilt.toFixed(4)} against ${onBox.tilt.toFixed(4)}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
console.log(failures === 0 ? '\nAll bounds held.' : `\n${failures} bound(s) failed.`);
|
|
551
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the island solve costs, and what a worker pool behind the executor seam could take off it.
|
|
3
|
+
*
|
|
4
|
+
* **`executor.ts` says the pool's threshold is a measurement, and this is that measurement.** It is
|
|
5
|
+
* a benchmark rather than a test: nothing here asserts, because a wall-clock number on one machine
|
|
6
|
+
* is evidence rather than a contract. `npm run bench:islands`.
|
|
7
|
+
*
|
|
8
|
+
* It prints three things a decision needs:
|
|
9
|
+
*
|
|
10
|
+
* 1. **How much of a tick is the island solve.** That is the fraction a pool can touch at all, and
|
|
11
|
+
* Amdahl's law caps every speedup at it.
|
|
12
|
+
* 2. **How many islands a world actually has.** The parallelism is `min(workers, islands)` — never
|
|
13
|
+
* the body count, which is what `executor.ts`'s own comment named and which is wrong: a
|
|
14
|
+
* thousand-box tower is one island and a pool can do nothing with it.
|
|
15
|
+
* 3. **What one island costs**, against the handshake a fork-join pays.
|
|
16
|
+
*/
|
|
17
|
+
import { BODY_DYNAMIC, BODY_STATIC } from '../src/bodies';
|
|
18
|
+
import type { Executor, IslandSolver } from '../src/executor';
|
|
19
|
+
import { JOINT_DISTANCE } from '../src/joints';
|
|
20
|
+
import { boxShape } from '../src/shape';
|
|
21
|
+
import { PhysicsWorld } from '../src/world';
|
|
22
|
+
import { createIslandPool } from '../src/workers';
|
|
23
|
+
import { Worker } from 'node:worker_threads';
|
|
24
|
+
|
|
25
|
+
/** The real entry point, spawned the way Node spawns one. A browser uses the package default. */
|
|
26
|
+
const spawn = () =>
|
|
27
|
+
new Worker(new URL('../src/islandWorker.ts', import.meta.url)) as unknown as {
|
|
28
|
+
postMessage(value: unknown): void;
|
|
29
|
+
terminate(): void | Promise<number>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const DT = 1 / 60;
|
|
33
|
+
|
|
34
|
+
/** A serial runner that also records how long the islands took and how many there were. */
|
|
35
|
+
class TimingExecutor implements Executor {
|
|
36
|
+
readonly name = 'timing';
|
|
37
|
+
totalUs = 0;
|
|
38
|
+
ticks = 0;
|
|
39
|
+
islands = 0;
|
|
40
|
+
|
|
41
|
+
run(count: number, solver: IslandSolver): void {
|
|
42
|
+
const started = performance.now();
|
|
43
|
+
for (let i = 0; i < count; i++) solver.solveIsland(i);
|
|
44
|
+
this.totalUs += (performance.now() - started) * 1000;
|
|
45
|
+
this.ticks += 1;
|
|
46
|
+
this.islands = count;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
reset(): void {
|
|
50
|
+
this.totalUs = 0;
|
|
51
|
+
this.ticks = 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `stacks` towers of `high` boxes, spaced far enough apart that each tower is its own island. */
|
|
56
|
+
function build(stacks: number, high: number): { world: PhysicsWorld; timer: TimingExecutor } {
|
|
57
|
+
const world = new PhysicsWorld({ allowSleep: false });
|
|
58
|
+
const timer = new TimingExecutor();
|
|
59
|
+
world.executor = timer;
|
|
60
|
+
world.addBody({ type: BODY_STATIC, shape: boxShape(2000, 1, 2000), y: -1, friction: 0.8 });
|
|
61
|
+
for (let s = 0; s < stacks; s++) {
|
|
62
|
+
for (let i = 0; i < high; i++) {
|
|
63
|
+
world.addBody({
|
|
64
|
+
type: BODY_DYNAMIC,
|
|
65
|
+
shape: boxShape(0.5, 0.5, 0.5),
|
|
66
|
+
x: s * 6,
|
|
67
|
+
y: 0.5 + i * 1.02,
|
|
68
|
+
density: 500,
|
|
69
|
+
friction: 0.8,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { world, timer };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A hundred boxes strung together by joints, which is **one island** by definition.
|
|
78
|
+
*
|
|
79
|
+
* A tall tower would have been the obvious shape and is the wrong one: it topples inside a second
|
|
80
|
+
* and becomes seventy islands, which measures the opposite of what it was built to show. Joints put
|
|
81
|
+
* bodies in one island whatever they then do.
|
|
82
|
+
*/
|
|
83
|
+
function chain(links: number): { world: PhysicsWorld; timer: TimingExecutor } {
|
|
84
|
+
const world = new PhysicsWorld({ allowSleep: false });
|
|
85
|
+
const timer = new TimingExecutor();
|
|
86
|
+
world.executor = timer;
|
|
87
|
+
world.addBody({ type: BODY_STATIC, shape: boxShape(2000, 1, 2000), y: -1, friction: 0.8 });
|
|
88
|
+
let previous = -1;
|
|
89
|
+
for (let i = 0; i < links; i++) {
|
|
90
|
+
const body = world.addBody({
|
|
91
|
+
type: BODY_DYNAMIC,
|
|
92
|
+
shape: boxShape(0.3, 0.3, 0.3),
|
|
93
|
+
x: i * 0.8,
|
|
94
|
+
y: 4,
|
|
95
|
+
density: 500,
|
|
96
|
+
friction: 0.8,
|
|
97
|
+
});
|
|
98
|
+
if (previous >= 0) {
|
|
99
|
+
world.addJoint({ type: JOINT_DISTANCE, bodyA: previous, bodyB: body, length: 0.8 });
|
|
100
|
+
}
|
|
101
|
+
previous = body;
|
|
102
|
+
}
|
|
103
|
+
return { world, timer };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function report(world: PhysicsWorld, timer: TimingExecutor, bodies: number): void {
|
|
107
|
+
for (let i = 0; i < 60; i++) world.step(DT);
|
|
108
|
+
timer.reset();
|
|
109
|
+
const runs = 180;
|
|
110
|
+
const started = performance.now();
|
|
111
|
+
for (let i = 0; i < runs; i++) world.step(DT);
|
|
112
|
+
const tick = ((performance.now() - started) / runs) * 1000;
|
|
113
|
+
const solve = timer.totalUs / timer.ticks;
|
|
114
|
+
const perIsland = solve / Math.max(timer.islands, 1);
|
|
115
|
+
console.log(
|
|
116
|
+
[
|
|
117
|
+
String(bodies).padStart(7),
|
|
118
|
+
String(timer.islands).padStart(8),
|
|
119
|
+
tick.toFixed(0).padStart(8),
|
|
120
|
+
solve.toFixed(0).padStart(9),
|
|
121
|
+
`${((solve / tick) * 100).toFixed(0)}%`.padStart(8),
|
|
122
|
+
perIsland.toFixed(1).padStart(11),
|
|
123
|
+
].join(' | '),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function row(stacks: number, high: number): void {
|
|
128
|
+
const { world, timer } = build(stacks, high);
|
|
129
|
+
report(world, timer, stacks * high);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(' bodies | islands | tick us | solve us | solve% | us/island');
|
|
133
|
+
for (const [stacks, high] of [
|
|
134
|
+
[1, 10],
|
|
135
|
+
[4, 10],
|
|
136
|
+
[16, 10],
|
|
137
|
+
[64, 10],
|
|
138
|
+
[256, 10],
|
|
139
|
+
] as const) {
|
|
140
|
+
row(stacks, high);
|
|
141
|
+
}
|
|
142
|
+
const linked = chain(100);
|
|
143
|
+
report(linked.world, linked.timer, 100);
|
|
144
|
+
console.log(
|
|
145
|
+
'\nThe last row is a hundred jointed boxes: one island, however many bodies, so a pool of any\n' +
|
|
146
|
+
'size can do nothing with it. The parallelism is bounded by the island count and never by the\n' +
|
|
147
|
+
'body count, which is what `executor.ts` said until this was measured.',
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* And what the pool actually takes off a tick, against both bounds it is held between.
|
|
152
|
+
*
|
|
153
|
+
* **Two ceilings, and the scene picks which one binds.** Amdahl's is `1 / (1 - solve share)`, from
|
|
154
|
+
* the column above. The other is `total island cost / largest island cost`, because the tick cannot
|
|
155
|
+
* end before its slowest island does — which is why the jointed chain gains nothing at any worker
|
|
156
|
+
* count and why a printed speedup means little without the bound beside it.
|
|
157
|
+
*
|
|
158
|
+
* Nothing here asserts. A wall-clock number on one machine is evidence, and the gate that has to
|
|
159
|
+
* hold is `workerPool.test.mjs`, which compares bits.
|
|
160
|
+
*/
|
|
161
|
+
function pooled(stacks: number, high: number, workers: number): void {
|
|
162
|
+
const world = new PhysicsWorld({ allowSleep: false, workers, spawn, pool: createIslandPool });
|
|
163
|
+
if (world.parallelism.running !== workers) {
|
|
164
|
+
console.log(` ${workers} workers: not running — ${world.parallelism.reason}`);
|
|
165
|
+
world.dispose();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const serial = new PhysicsWorld({ allowSleep: false });
|
|
169
|
+
for (const target of [world, serial]) {
|
|
170
|
+
target.addBody({ type: BODY_STATIC, shape: boxShape(2000, 1, 2000), y: -1, friction: 0.8 });
|
|
171
|
+
for (let s = 0; s < stacks; s++) {
|
|
172
|
+
for (let i = 0; i < high; i++) {
|
|
173
|
+
target.addBody({
|
|
174
|
+
type: BODY_DYNAMIC,
|
|
175
|
+
shape: boxShape(0.5, 0.5, 0.5),
|
|
176
|
+
x: s * 6,
|
|
177
|
+
y: 0.5 + i * 1.02,
|
|
178
|
+
density: 500,
|
|
179
|
+
friction: 0.8,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const time = (target: PhysicsWorld): number => {
|
|
185
|
+
for (let i = 0; i < 30; i++) target.step(DT);
|
|
186
|
+
const started = performance.now();
|
|
187
|
+
for (let i = 0; i < 120; i++) target.step(DT);
|
|
188
|
+
return ((performance.now() - started) / 120) * 1000;
|
|
189
|
+
};
|
|
190
|
+
const one = time(serial);
|
|
191
|
+
const many = time(world);
|
|
192
|
+
const solved = (world.executor as { solvedPerWorker: number[] }).solvedPerWorker;
|
|
193
|
+
console.log(
|
|
194
|
+
` ${String(workers).padStart(2)} workers: ${one.toFixed(0).padStart(6)} us serial | ` +
|
|
195
|
+
`${many.toFixed(0).padStart(6)} us pooled | ${(one / many).toFixed(2)}x | ` +
|
|
196
|
+
`islands taken off this thread: ${solved.reduce((a, b) => a + b, 0)}`,
|
|
197
|
+
);
|
|
198
|
+
world.dispose();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log('\n256 stacks of 10, so there are 256 islands to hand out:');
|
|
202
|
+
for (const workers of [1, 2, 4, 8]) pooled(256, 10, workers);
|
|
203
|
+
console.log('\nAnd the jointed chain, which is one island and therefore has nothing to split:');
|
|
204
|
+
const oneIsland = new PhysicsWorld({
|
|
205
|
+
allowSleep: false,
|
|
206
|
+
workers: 4,
|
|
207
|
+
spawn,
|
|
208
|
+
pool: createIslandPool,
|
|
209
|
+
});
|
|
210
|
+
if (oneIsland.parallelism.running === 4) {
|
|
211
|
+
oneIsland.addBody({ type: BODY_STATIC, shape: boxShape(2000, 1, 2000), y: -1, friction: 0.8 });
|
|
212
|
+
let last = -1;
|
|
213
|
+
for (let i = 0; i < 100; i++) {
|
|
214
|
+
const body = oneIsland.addBody({
|
|
215
|
+
type: BODY_DYNAMIC,
|
|
216
|
+
shape: boxShape(0.3, 0.3, 0.3),
|
|
217
|
+
x: i * 0.8,
|
|
218
|
+
y: 4,
|
|
219
|
+
density: 500,
|
|
220
|
+
friction: 0.8,
|
|
221
|
+
});
|
|
222
|
+
if (last >= 0)
|
|
223
|
+
oneIsland.addJoint({ type: JOINT_DISTANCE, bodyA: last, bodyB: body, length: 0.8 });
|
|
224
|
+
last = body;
|
|
225
|
+
}
|
|
226
|
+
for (let i = 0; i < 30; i++) oneIsland.step(DT);
|
|
227
|
+
const started = performance.now();
|
|
228
|
+
for (let i = 0; i < 120; i++) oneIsland.step(DT);
|
|
229
|
+
console.log(
|
|
230
|
+
` 4 workers on one island: ${(((performance.now() - started) / 120) * 1000).toFixed(0)} us a tick`,
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
console.log(` not running — ${oneIsland.parallelism.reason}`);
|
|
234
|
+
}
|
|
235
|
+
oneIsland.dispose();
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* That a world whose colliders stream is both fast and still correct.
|
|
3
|
+
*
|
|
4
|
+
* **The report.** A consumer moving to a 9.3 × 4.9 km world could not rebuild its `ColliderSet` at
|
|
5
|
+
* a region crossing: measured on their machine, a rebuild costs about 2.1 µs a box and is linear,
|
|
6
|
+
* which is 16.9 ms at the 6,076 colliders they have and 41.2 ms at the 15,000 they are moving to —
|
|
7
|
+
* a whole frame today and two and a half in the world coming, every few seconds, while somebody is
|
|
8
|
+
* driving. The alternative they shipped was one set per region and a query against the nine around
|
|
9
|
+
* the player, which measured 1.19 µs a query against 0.16 µs for a single set: affordable, and a
|
|
10
|
+
* 7.5x multiplier on every collision query in the game.
|
|
11
|
+
*
|
|
12
|
+
* **What this measures.** The engine's real sweep, in Node, against one set that is mutated as a
|
|
13
|
+
* body drives across region boundaries — groups added ahead of it and dropped behind it — at five
|
|
14
|
+
* frame rates: exactly sixty, sixty with the jitter a real clock has, 59.94 like a real panel, 120
|
|
15
|
+
* and 144.
|
|
16
|
+
*
|
|
17
|
+
* **Three of the six assertions are about speed and three are about whether removal works at
|
|
18
|
+
* all**, and the second three exist because the first three cannot see the defect this machinery
|
|
19
|
+
* can actually have. A `remove` that misses a bucket entry leaves the slot marked dead — so the
|
|
20
|
+
* fingerprint skips it and is right, and nothing walks it by index — while the stale entry goes on
|
|
21
|
+
* naming it: `query` returns it, and the sweep reads its stale bounds and collides with geometry
|
|
22
|
+
* that is gone. Frame time is unchanged, the query ratio is unchanged, the hash is unchanged, and
|
|
23
|
+
* a player drives into a wall that is not there.
|
|
24
|
+
*
|
|
25
|
+
* **It churns for hundreds of crossings before it measures.** Live slots reach free-list order
|
|
26
|
+
* only after that, and free-list order is unrelated to spatial order while the freshly built
|
|
27
|
+
* comparison set has slot order equal to input order — which is what assertion 2 is exposed to. A
|
|
28
|
+
* short run passes for a reason the field would not.
|
|
29
|
+
*
|
|
30
|
+
* If this ever fails at every rate except 59.94, the cause is not here: at that rate the steps per
|
|
31
|
+
* frame are exactly one, and a fault that hides there is a fault in the loop's stepping and not in
|
|
32
|
+
* the collider set.
|
|
33
|
+
*
|
|
34
|
+
* **The geometry is built before the clock starts, and the first draft of this check did not do
|
|
35
|
+
* that.** It called `boxCollider` inside the crossing frame and reported 6 to 7 ms, which read
|
|
36
|
+
* exactly like a broadphase that had not been fixed. Measured apart: generating 150 colliders is
|
|
37
|
+
* 2.086 ms, because `boxCollider` runs `hullShape` on eight points, while `add` is 0.164 ms and
|
|
38
|
+
* `remove` is 0.165 ms. So five sixths of that frame was the harness inventing a world. A streamer
|
|
39
|
+
* loads geometry from data or builds it off the main thread; what this track changed is the two
|
|
40
|
+
* calls that put it into the set and take it out, and those are what the clock is around.
|
|
41
|
+
*
|
|
42
|
+
* `npm run check:streaming`.
|
|
43
|
+
*/
|
|
44
|
+
import { AXIS_X, AXIS_Z } from '../src/collide/body';
|
|
45
|
+
import { moveAxis } from '../src/collide/sweep';
|
|
46
|
+
import type { Body } from '../src/collide/body';
|
|
47
|
+
import { ColliderSet, boxCollider } from '../src/colliderSet';
|
|
48
|
+
import type { Collider, ColliderGroup } from '../src/colliderSet';
|
|
49
|
+
import { fingerprintColliders } from '../src/fingerprint';
|
|
50
|
+
|
|
51
|
+
/** Metres a side. The consumer's own region size, so the numbers are about their world. */
|
|
52
|
+
const REGION = 500;
|
|
53
|
+
/** Colliders in a region, at the density the world being moved to has. */
|
|
54
|
+
const PER_REGION = 150;
|
|
55
|
+
/** How many regions stay resident around the player: the ring a streamer keeps. */
|
|
56
|
+
const RESIDENT = 9;
|
|
57
|
+
/** Crossings driven before anything is measured. See the header on free-list order. */
|
|
58
|
+
const WARMUP_CROSSINGS = 400;
|
|
59
|
+
/** Crossings measured after that. */
|
|
60
|
+
const MEASURED_CROSSINGS = 200;
|
|
61
|
+
/** Regions the drive will touch, generated once and shared by all five rates. */
|
|
62
|
+
const TOTAL_REGIONS = WARMUP_CROSSINGS + MEASURED_CROSSINGS + RESIDENT;
|
|
63
|
+
/** Metres a second. Ninety an hour, which is what the report was driving at. */
|
|
64
|
+
const SPEED = 25;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What a crossing may typically cost, in milliseconds.
|
|
68
|
+
*
|
|
69
|
+
* **Measured at 0.090 ms, and the rebuild it replaces costs 41 ms at this size.** So the threshold
|
|
70
|
+
* sits far closer to what is measured than to what it replaces, and a regression that matters here
|
|
71
|
+
* is an order of magnitude and not a few per cent. Five times the measurement is headroom for a
|
|
72
|
+
* slower machine, not slack for a change.
|
|
73
|
+
*
|
|
74
|
+
* **This is the median and not the maximum**, which is the split the first draft of this check got
|
|
75
|
+
* wrong. See `CROSSING_WORST_MS`.
|
|
76
|
+
*/
|
|
77
|
+
const CROSSING_MEDIAN_MS = 0.5;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a crossing may cost in the worst case, in milliseconds: a whole frame.
|
|
81
|
+
*
|
|
82
|
+
* **Because the maximum here is the runtime's and not the engine's.** Measured over two hundred
|
|
83
|
+
* crossings the median is 0.090 ms and the p99 is 0.38 ms, while the maximum lands between 1.6 and
|
|
84
|
+
* 4.4 ms at every rate — and it moves between runs on identical work, which is a garbage
|
|
85
|
+
* collection landing in the sample. Two hundred samples put the p99 on its last two, so no
|
|
86
|
+
* percentile at this sample size can tell a pause from a regression.
|
|
87
|
+
*
|
|
88
|
+
* So the two claims are asserted separately: the collider work is the median, and the claim a
|
|
89
|
+
* player would care about is that **no crossing drops a frame even with the runtime's pauses in
|
|
90
|
+
* it**, which is this one. A threshold on the maximum tight enough to catch a regression would be
|
|
91
|
+
* a check that fails at random, which is worse than no check.
|
|
92
|
+
*/
|
|
93
|
+
const CROSSING_WORST_MS = 16.7;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How much dearer a query on the streamed set may be than on a freshly built one.
|
|
97
|
+
*
|
|
98
|
+
* **Not 1.0, and the reason is cache locality rather than anything the structure does wrong.**
|
|
99
|
+
* After hundreds of cycles the live slots sit in free-list order, which is a function of load
|
|
100
|
+
* history, while the comparison set has slot order equal to input order — and the sweep indexes
|
|
101
|
+
* `data` at exactly those slots. The number that matters is that it is nowhere near the 7.5x the
|
|
102
|
+
* per-region workaround costs: this is the assertion that the workaround can be deleted. If it
|
|
103
|
+
* ever approaches 7.5 the answer is a compaction pass and not a bug hunt.
|
|
104
|
+
*
|
|
105
|
+
* Measured between 1.24 and 1.30 across the five rates, so this sits nearer the measurement than
|
|
106
|
+
* the thing it rules out.
|
|
107
|
+
*/
|
|
108
|
+
const QUERY_RATIO = 2.5;
|
|
109
|
+
|
|
110
|
+
const FRAME_RATES: readonly (readonly [string, number, number])[] = [
|
|
111
|
+
['60', 60, 0],
|
|
112
|
+
['60 jittered', 60, 0.3],
|
|
113
|
+
['59.94', 59.94, 0],
|
|
114
|
+
['120', 120, 0],
|
|
115
|
+
['144', 144, 0],
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/** A deterministic generator, so a failure is reproducible and a rerun is comparable. */
|
|
119
|
+
function rng(seed: number): () => number {
|
|
120
|
+
let state = seed >>> 0;
|
|
121
|
+
return () => {
|
|
122
|
+
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
|
|
123
|
+
return state / 4294967296;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One region's colliders, laid along the drive so the body meets them.
|
|
129
|
+
*
|
|
130
|
+
* **Seeded from the region index**, so every rate drives the same world and a failure at one rate
|
|
131
|
+
* and not another is about the loop and not about the geometry.
|
|
132
|
+
*/
|
|
133
|
+
function region(index: number): Collider[] {
|
|
134
|
+
const random = rng(0x9e37 + index * 2654435761);
|
|
135
|
+
const boxes: Collider[] = [];
|
|
136
|
+
const originX = index * REGION;
|
|
137
|
+
for (let i = 0; i < PER_REGION; i++) {
|
|
138
|
+
const x = originX + random() * REGION;
|
|
139
|
+
const z = (random() - 0.5) * 60;
|
|
140
|
+
/* Kept clear of the driving line at z = 0 so the body meets walls without being trapped by
|
|
141
|
+
them: this measures a broadphase, not a pathfinder. */
|
|
142
|
+
const offset = z >= 0 ? z + 6 : z - 6;
|
|
143
|
+
boxes.push(boxCollider(x, 1.5, offset, 1 + random() * 3, 1.5, 1 + random() * 3));
|
|
144
|
+
}
|
|
145
|
+
return boxes;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/*
|
|
149
|
+
* **Every region built once, before any clock starts.** See the header: generating a region is
|
|
150
|
+
* 2.086 ms of `hullShape` and is the consumer's cost, not the engine's, and leaving it inside the
|
|
151
|
+
* timed frame made a fixed broadphase read as a broken one.
|
|
152
|
+
*/
|
|
153
|
+
const WORLD: readonly Collider[][] = Array.from({ length: TOTAL_REGIONS }, (_, i) => region(i));
|
|
154
|
+
|
|
155
|
+
interface Rig {
|
|
156
|
+
readonly set: ColliderSet;
|
|
157
|
+
readonly resident: Map<number, ColliderGroup>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function newRig(): Rig {
|
|
161
|
+
return { set: new ColliderSet([]), resident: new Map() };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Load what the player can see and drop what they cannot, which is what a streamer does. */
|
|
165
|
+
function stream(rig: Rig, centre: number): void {
|
|
166
|
+
const half = (RESIDENT - 1) / 2;
|
|
167
|
+
for (let r = centre - half; r <= centre + half; r++) {
|
|
168
|
+
if (r < 0 || r >= WORLD.length || rig.resident.has(r)) continue;
|
|
169
|
+
rig.resident.set(r, rig.set.add(WORLD[r] as Collider[]));
|
|
170
|
+
}
|
|
171
|
+
for (const [r, group] of [...rig.resident]) {
|
|
172
|
+
if (Math.abs(r - centre) <= half) continue;
|
|
173
|
+
rig.set.remove(group);
|
|
174
|
+
rig.resident.delete(r);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Every live collider in the set, as a fresh dense array, for the comparison build. */
|
|
179
|
+
function liveColliders(set: ColliderSet): Collider[] {
|
|
180
|
+
const out: Collider[] = [];
|
|
181
|
+
for (let slot = 0; slot < set.capacity; slot++) {
|
|
182
|
+
if (!set.liveAt(slot)) continue;
|
|
183
|
+
const o = slot * 6;
|
|
184
|
+
out.push({
|
|
185
|
+
minX: set.data[o] as number,
|
|
186
|
+
minY: set.data[o + 1] as number,
|
|
187
|
+
minZ: set.data[o + 2] as number,
|
|
188
|
+
maxX: set.data[o + 3] as number,
|
|
189
|
+
maxY: set.data[o + 4] as number,
|
|
190
|
+
maxZ: set.data[o + 5] as number,
|
|
191
|
+
shape: set.shapeAt(slot),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Microseconds a query, averaged over a sweep of the resident band. */
|
|
198
|
+
function queryCostUs(set: ColliderSet, centre: number, samples: number): number {
|
|
199
|
+
const hits = new Int32Array(ColliderSet.MAX_HITS);
|
|
200
|
+
const from = (centre - 4) * REGION;
|
|
201
|
+
const started = performance.now();
|
|
202
|
+
for (let i = 0; i < samples; i++) {
|
|
203
|
+
const x = from + ((i * 977) % (REGION * RESIDENT));
|
|
204
|
+
set.query(x - 0.5, 0, -1.5, x + 0.5, 2, 1.5, hits);
|
|
205
|
+
}
|
|
206
|
+
return ((performance.now() - started) * 1000) / samples;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const failures: string[] = [];
|
|
210
|
+
function check(ok: boolean, message: string): void {
|
|
211
|
+
console.log(`${ok ? ' ok ' : ' FAIL'} ${message}`);
|
|
212
|
+
if (!ok) failures.push(message);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ------------------------------------------------------------------ 3, 4, 5, 6 */
|
|
216
|
+
|
|
217
|
+
console.log('\ncorrectness');
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
* **Assertion 3.** A set built in one shot hashes to the string it hashed to before any of this
|
|
221
|
+
* existed. The literal is the same one `fingerprint.test.ts` pins, and it is here as well as there
|
|
222
|
+
* because a recording is compared against a *running* engine and not against a unit test.
|
|
223
|
+
*/
|
|
224
|
+
{
|
|
225
|
+
const one = new ColliderSet([
|
|
226
|
+
{ minX: -1, minY: -2, minZ: -3, maxX: 1, maxY: 2, maxZ: 3 },
|
|
227
|
+
{ minX: 9.5, minY: -4.5, minZ: 6.5, maxX: 10.5, maxY: -3.5, maxZ: 7.5 },
|
|
228
|
+
]);
|
|
229
|
+
check(
|
|
230
|
+
fingerprintColliders(one) === '8e0eb495365aade7',
|
|
231
|
+
`a one-shot set fingerprints as it always did (${fingerprintColliders(one)})`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/*
|
|
236
|
+
* **Assertion 4.** After a group is dropped, a query over the volume it occupied returns none of
|
|
237
|
+
* its slots, and a body driven through where it stood passes. The second half is the one a player
|
|
238
|
+
* would notice, and it is the reason this is not just a query test.
|
|
239
|
+
*/
|
|
240
|
+
{
|
|
241
|
+
const set = new ColliderSet([]);
|
|
242
|
+
const wall = set.add([boxCollider(10, 1.5, 0, 0.5, 1.5, 8)]);
|
|
243
|
+
const body: Body = { x: 0, y: 1, z: 0, hx: 0.4, hy: 1, hz: 0.4 };
|
|
244
|
+
let travelled = 0;
|
|
245
|
+
for (let i = 0; i < 400; i++) travelled += moveAxis(body, set, AXIS_X, 0.05);
|
|
246
|
+
check(
|
|
247
|
+
travelled < 12,
|
|
248
|
+
`a wall stops the body while it is there (travelled ${travelled.toFixed(2)} m)`,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
set.remove(wall);
|
|
252
|
+
const hits = new Int32Array(ColliderSet.MAX_HITS);
|
|
253
|
+
check(set.query(9, 0, -8, 11, 3, 8, hits) === 0, 'a query over the dropped volume finds nothing');
|
|
254
|
+
|
|
255
|
+
const after: Body = { x: 0, y: 1, z: 0, hx: 0.4, hy: 1, hz: 0.4 };
|
|
256
|
+
let free = 0;
|
|
257
|
+
for (let i = 0; i < 400; i++) free += moveAxis(after, set, AXIS_X, 0.05);
|
|
258
|
+
check(free > 19.9, `and the body passes through where it was (travelled ${free.toFixed(2)} m)`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/*
|
|
262
|
+
* **Assertion 5.** The inverse, which catches an insert that ran late or not at all: a group put
|
|
263
|
+
* down ahead of the body is solid by the time the body reaches it.
|
|
264
|
+
*/
|
|
265
|
+
{
|
|
266
|
+
const set = new ColliderSet([]);
|
|
267
|
+
const body: Body = { x: 0, y: 1, z: 0, hx: 0.4, hy: 1, hz: 0.4 };
|
|
268
|
+
for (let i = 0; i < 100; i++) moveAxis(body, set, AXIS_X, 0.05);
|
|
269
|
+
set.add([boxCollider(10, 1.5, 0, 0.5, 1.5, 8)]);
|
|
270
|
+
let travelled = body.x;
|
|
271
|
+
for (let i = 0; i < 400; i++) moveAxis(body, set, AXIS_X, 0.05);
|
|
272
|
+
travelled = body.x;
|
|
273
|
+
check(
|
|
274
|
+
travelled < 12,
|
|
275
|
+
`a group added ahead is solid when the body arrives (stopped at ${travelled.toFixed(2)} m)`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ------------------------------------------------------------------ 1, 2, 6 */
|
|
280
|
+
|
|
281
|
+
console.log('\nthe drive, at five rates');
|
|
282
|
+
|
|
283
|
+
for (const [name, hz, jitter] of FRAME_RATES) {
|
|
284
|
+
const rig = newRig();
|
|
285
|
+
const random = rng(hz * 1000);
|
|
286
|
+
const body: Body = { x: 0, y: 1, z: 0, hx: 0.4, hy: 1, hz: 0.4 };
|
|
287
|
+
const crossingMs: number[] = [];
|
|
288
|
+
let crossings = 0;
|
|
289
|
+
let region0 = -1;
|
|
290
|
+
|
|
291
|
+
const total = WARMUP_CROSSINGS + MEASURED_CROSSINGS;
|
|
292
|
+
while (crossings < total) {
|
|
293
|
+
const dt = (1 / hz) * (1 + (jitter === 0 ? 0 : (random() - 0.5) * 2 * jitter));
|
|
294
|
+
body.x += SPEED * dt;
|
|
295
|
+
moveAxis(body, rig.set, AXIS_X, 0);
|
|
296
|
+
moveAxis(body, rig.set, AXIS_Z, 0);
|
|
297
|
+
|
|
298
|
+
const here = Math.floor(body.x / REGION);
|
|
299
|
+
if (here === region0) continue;
|
|
300
|
+
|
|
301
|
+
const started = performance.now();
|
|
302
|
+
stream(rig, here);
|
|
303
|
+
const ms = performance.now() - started;
|
|
304
|
+
region0 = here;
|
|
305
|
+
crossings++;
|
|
306
|
+
/* Warm-up crossings are driven but not judged: the first ones allocate, and the free list has
|
|
307
|
+
not reached the order assertion 2 is exposed to. */
|
|
308
|
+
if (crossings > WARMUP_CROSSINGS) crossingMs.push(ms);
|
|
309
|
+
}
|
|
310
|
+
crossingMs.sort((a, b) => a - b);
|
|
311
|
+
const at = (q: number): number =>
|
|
312
|
+
crossingMs[Math.min(crossingMs.length - 1, Math.floor(crossingMs.length * q))] as number;
|
|
313
|
+
const medianMs = at(0.5);
|
|
314
|
+
const p99Ms = at(0.99);
|
|
315
|
+
const worstMs = crossingMs[crossingMs.length - 1] as number;
|
|
316
|
+
|
|
317
|
+
const centre = Math.floor(body.x / REGION);
|
|
318
|
+
const comparison = new ColliderSet(liveColliders(rig.set));
|
|
319
|
+
/* Discarded: the first timed loop of the process pays for JIT that every later one inherits, and
|
|
320
|
+
charging it to whichever set went first made that set look 40% dearer than it is. */
|
|
321
|
+
queryCostUs(rig.set, centre, 20000);
|
|
322
|
+
queryCostUs(comparison, centre, 20000);
|
|
323
|
+
const streamed = queryCostUs(rig.set, centre, 40000);
|
|
324
|
+
const fresh = queryCostUs(comparison, centre, 40000);
|
|
325
|
+
const ratio = streamed / fresh;
|
|
326
|
+
const dead = rig.set.auditCells();
|
|
327
|
+
const cost = rig.set.bytes();
|
|
328
|
+
|
|
329
|
+
console.log(
|
|
330
|
+
`\n ${name} Hz — ${rig.set.count} live in ${rig.set.capacity} slots, ` +
|
|
331
|
+
`${(cost.total / 1048576).toFixed(2)} MB resident`,
|
|
332
|
+
);
|
|
333
|
+
console.log(
|
|
334
|
+
` crossing: median ${medianMs.toFixed(3)} ms, p99 ${p99Ms.toFixed(3)} ms, worst ${worstMs.toFixed(3)} ms`,
|
|
335
|
+
);
|
|
336
|
+
check(
|
|
337
|
+
medianMs < CROSSING_MEDIAN_MS,
|
|
338
|
+
`a crossing costs ${medianMs.toFixed(3)} ms < ${CROSSING_MEDIAN_MS} ms (a rebuild here is 41 ms)`,
|
|
339
|
+
);
|
|
340
|
+
check(
|
|
341
|
+
worstMs < CROSSING_WORST_MS,
|
|
342
|
+
`and the worst of ${crossingMs.length} drops no frame: ${worstMs.toFixed(3)} ms < ${CROSSING_WORST_MS} ms`,
|
|
343
|
+
);
|
|
344
|
+
check(
|
|
345
|
+
ratio < QUERY_RATIO,
|
|
346
|
+
`query ${streamed.toFixed(3)} µs vs ${fresh.toFixed(3)} µs fresh — ${ratio.toFixed(2)}x < ${QUERY_RATIO}x ` +
|
|
347
|
+
'(the per-region workaround costs 7.5x)',
|
|
348
|
+
);
|
|
349
|
+
check(dead === 0, `no bucket names a dead slot (${dead})`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log('');
|
|
353
|
+
if (failures.length > 0) {
|
|
354
|
+
console.error(`${failures.length} failed:`);
|
|
355
|
+
for (const f of failures) console.error(` ${f}`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
console.log('all assertions passed');
|