@aethervtt/shard-physics 0.0.1 → 0.1.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/dist/index.d.ts +3 -2
- package/dist/index.js +8 -2
- package/dist/methods.js +2 -1
- package/dist/plugin.d.ts +9 -3
- package/dist/plugin.js +26 -261
- package/dist/rapier.d.ts +22 -0
- package/dist/rapier.js +27 -0
- package/dist/render.d.ts +5 -0
- package/dist/render.js +264 -0
- package/dist/track/format.d.ts +58 -0
- package/dist/track/format.js +171 -0
- package/dist/track/golden.json +238 -0
- package/dist/track/hash.d.ts +12 -0
- package/dist/track/hash.js +41 -0
- package/dist/track/index.d.ts +3 -0
- package/dist/track/index.js +44 -0
- package/dist/track/record.d.ts +79 -0
- package/dist/track/record.js +286 -0
- package/dist/track/scene.d.ts +74 -0
- package/dist/track/scene.js +153 -0
- package/dist/track/test-scenes.d.ts +6 -0
- package/dist/track/test-scenes.js +113 -0
- package/dist/worker/client.d.ts +44 -0
- package/dist/worker/client.js +163 -0
- package/dist/worker/index.d.ts +8 -0
- package/dist/worker/index.js +13 -0
- package/dist/worker/protocol.d.ts +38 -0
- package/dist/worker/protocol.js +0 -0
- package/dist/worker/serve.d.ts +34 -0
- package/dist/worker/serve.js +111 -0
- package/dist/worker/test-worker.d.ts +1 -0
- package/dist/worker/test-worker.js +18 -0
- package/dist/worker/track-worker.d.ts +1 -0
- package/dist/worker/track-worker.js +2 -0
- package/dist/world.d.ts +13 -6
- package/dist/world.js +9 -13
- package/package.json +22 -8
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { loadDeterministic3d } from "../rapier.js";
|
|
3
|
+
import { TRACK_VERSION } from "./format.js";
|
|
4
|
+
import {
|
|
5
|
+
checkTrackScene,
|
|
6
|
+
sceneHash
|
|
7
|
+
} from "./scene.js";
|
|
8
|
+
const settleWhenAsleep = (view) => {
|
|
9
|
+
for (let i = 0; i < view.bodyCount; i++) if (view.sleeping[i] === 0) return "continue";
|
|
10
|
+
return "done";
|
|
11
|
+
};
|
|
12
|
+
const builtinSettleRules = {
|
|
13
|
+
sleep: () => settleWhenAsleep
|
|
14
|
+
};
|
|
15
|
+
const TRACK_CHUNK_STEPS = 64;
|
|
16
|
+
const TRACK_CHUNK_MS = 4;
|
|
17
|
+
function trackCancelled() {
|
|
18
|
+
return new ShardError("physics/track-cancelled", "The track recording was cancelled", {
|
|
19
|
+
hint: "Its signal was aborted or its client disposed: nothing to fix, record again when needed."
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const host = globalThis;
|
|
23
|
+
let channel;
|
|
24
|
+
const waiting = [];
|
|
25
|
+
function yieldTask() {
|
|
26
|
+
if (host.setImmediate) return new Promise((resolve) => host.setImmediate(resolve));
|
|
27
|
+
if (!channel) {
|
|
28
|
+
channel = new host.MessageChannel();
|
|
29
|
+
channel.port1.onmessage = () => waiting.shift()?.();
|
|
30
|
+
}
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
waiting.push(resolve);
|
|
33
|
+
channel.port2.postMessage(0);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const ALL_GROUPS = 4294967295;
|
|
37
|
+
const packGroups = (g) => ((g.layers & 65535) << 16 | g.mask & 65535) >>> 0;
|
|
38
|
+
function colliderDesc(R, c, path) {
|
|
39
|
+
const D = R.ColliderDesc;
|
|
40
|
+
let desc;
|
|
41
|
+
if (c.shape === "ball") desc = D.ball(c.radius);
|
|
42
|
+
else if (c.shape === "cuboid")
|
|
43
|
+
desc = D.cuboid(c.halfExtents[0], c.halfExtents[1], c.halfExtents[2]);
|
|
44
|
+
else desc = D.convexHull(c.points);
|
|
45
|
+
if (!desc) {
|
|
46
|
+
throw new ShardError(
|
|
47
|
+
"physics/track-scene",
|
|
48
|
+
`${path} has no volume: its convex points all lie in one plane`,
|
|
49
|
+
{ path, hint: "See TrackScene in @aethervtt/shard-physics/track." }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (c.translation) desc.setTranslation(c.translation[0], c.translation[1], c.translation[2]);
|
|
53
|
+
if (c.rotation) {
|
|
54
|
+
const [x, y, z, w] = c.rotation;
|
|
55
|
+
desc.setRotation({ x, y, z, w });
|
|
56
|
+
}
|
|
57
|
+
desc.setFriction(c.friction);
|
|
58
|
+
desc.setRestitution(c.restitution);
|
|
59
|
+
desc.setDensity(c.density);
|
|
60
|
+
return desc;
|
|
61
|
+
}
|
|
62
|
+
async function recordTrack(scene, options = {}) {
|
|
63
|
+
const { signal } = options;
|
|
64
|
+
checkTrackScene(scene);
|
|
65
|
+
if (signal?.aborted) throw trackCancelled();
|
|
66
|
+
const R = await loadDeterministic3d();
|
|
67
|
+
if (signal?.aborted) throw trackCancelled();
|
|
68
|
+
const settle = options.settle ?? settleWhenAsleep;
|
|
69
|
+
const n = scene.bodies.length;
|
|
70
|
+
const maxSteps = scene.maxSteps;
|
|
71
|
+
const g = scene.gravity;
|
|
72
|
+
const world = new R.World({ x: g[0], y: g[1], z: g[2] });
|
|
73
|
+
const events = new R.EventQueue(true);
|
|
74
|
+
try {
|
|
75
|
+
world.timestep = scene.step;
|
|
76
|
+
const groups = /* @__PURE__ */ new Map();
|
|
77
|
+
const inGroup = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const [name, group] of Object.entries(scene.groups ?? {})) {
|
|
79
|
+
groups.set(name, packGroups(group));
|
|
80
|
+
inGroup.set(name, []);
|
|
81
|
+
}
|
|
82
|
+
const addCollider = (c, path, body) => {
|
|
83
|
+
const desc = colliderDesc(R, c, path);
|
|
84
|
+
desc.setCollisionGroups(c.group === void 0 ? ALL_GROUPS : groups.get(c.group));
|
|
85
|
+
if (body && options.contacts) {
|
|
86
|
+
desc.setActiveEvents(R.ActiveEvents.CONTACT_FORCE_EVENTS);
|
|
87
|
+
desc.setContactForceEventThreshold(options.contacts.minForce);
|
|
88
|
+
}
|
|
89
|
+
const collider = world.createCollider(desc, body);
|
|
90
|
+
if (c.group !== void 0) inGroup.get(c.group).push(collider);
|
|
91
|
+
return collider;
|
|
92
|
+
};
|
|
93
|
+
scene.fixed.forEach((c, i) => {
|
|
94
|
+
addCollider(c, `fixed[${i}]`);
|
|
95
|
+
});
|
|
96
|
+
const bodies = [];
|
|
97
|
+
const bodyOf = /* @__PURE__ */ new Map();
|
|
98
|
+
scene.bodies.forEach((b, i) => {
|
|
99
|
+
const [x, y, z, w] = b.rotation;
|
|
100
|
+
const desc = R.RigidBodyDesc.dynamic().setTranslation(b.translation[0], b.translation[1], b.translation[2]).setRotation({ x, y, z, w }).setLinvel(b.linear[0], b.linear[1], b.linear[2]).setAngvel({ x: b.angular[0], y: b.angular[1], z: b.angular[2] }).setCcdEnabled(b.ccd ?? false).setCanSleep(b.canSleep ?? true).setLinearDamping(b.linearDamping ?? 0).setAngularDamping(b.angularDamping ?? 0);
|
|
101
|
+
const body = world.createRigidBody(desc);
|
|
102
|
+
bodies.push(body);
|
|
103
|
+
b.colliders.forEach((c, j) => {
|
|
104
|
+
bodyOf.set(addCollider(c, `bodies[${i}].colliders[${j}]`, body).handle, i);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
let capacity = Math.min(maxSteps + 1, 256);
|
|
108
|
+
let positions = new Float32Array(capacity * n * 3);
|
|
109
|
+
let rotations = new Float32Array(capacity * n * 4);
|
|
110
|
+
const v3 = { x: 0, y: 0, z: 0 };
|
|
111
|
+
const q4 = { x: 0, y: 0, z: 0, w: 1 };
|
|
112
|
+
const writePoses = (s) => {
|
|
113
|
+
if (s >= capacity) {
|
|
114
|
+
capacity = Math.min(maxSteps + 1, capacity * 2);
|
|
115
|
+
const p = new Float32Array(capacity * n * 3);
|
|
116
|
+
p.set(positions);
|
|
117
|
+
positions = p;
|
|
118
|
+
const r = new Float32Array(capacity * n * 4);
|
|
119
|
+
r.set(rotations);
|
|
120
|
+
rotations = r;
|
|
121
|
+
}
|
|
122
|
+
for (let i = 0; i < n; i++) {
|
|
123
|
+
const body = bodies[i];
|
|
124
|
+
const t = body.translation(v3);
|
|
125
|
+
const r = body.rotation(q4);
|
|
126
|
+
const o3 = (s * n + i) * 3;
|
|
127
|
+
const o4 = (s * n + i) * 4;
|
|
128
|
+
positions[o3] = t.x;
|
|
129
|
+
positions[o3 + 1] = t.y;
|
|
130
|
+
positions[o3 + 2] = t.z;
|
|
131
|
+
rotations[o4] = r.x;
|
|
132
|
+
rotations[o4 + 1] = r.y;
|
|
133
|
+
rotations[o4 + 2] = r.z;
|
|
134
|
+
rotations[o4 + 3] = r.w;
|
|
135
|
+
const bad = t.x - t.x + (t.y - t.y) + (t.z - t.z);
|
|
136
|
+
if (bad + (r.x - r.x) + (r.y - r.y) + (r.z - r.z) + (r.w - r.w) !== 0) {
|
|
137
|
+
throw new ShardError(
|
|
138
|
+
"physics/track-diverged",
|
|
139
|
+
`Body "${scene.bodies[i].id}" left finite numbers at step ${s}`,
|
|
140
|
+
{
|
|
141
|
+
path: `bodies[${i}]`,
|
|
142
|
+
hint: "Check its speed, mass and colliders; a smaller step or ccd can help."
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
let step = 0;
|
|
149
|
+
const sleeping = new Uint8Array(n);
|
|
150
|
+
const view = {
|
|
151
|
+
get step() {
|
|
152
|
+
return step;
|
|
153
|
+
},
|
|
154
|
+
bodyCount: n,
|
|
155
|
+
sleeping,
|
|
156
|
+
pose(body, outPos, outRot) {
|
|
157
|
+
const o3 = (step * n + body) * 3;
|
|
158
|
+
const o4 = (step * n + body) * 4;
|
|
159
|
+
outPos[0] = positions[o3];
|
|
160
|
+
outPos[1] = positions[o3 + 1];
|
|
161
|
+
outPos[2] = positions[o3 + 2];
|
|
162
|
+
outRot[0] = rotations[o4];
|
|
163
|
+
outRot[1] = rotations[o4 + 1];
|
|
164
|
+
outRot[2] = rotations[o4 + 2];
|
|
165
|
+
outRot[3] = rotations[o4 + 3];
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const applyPhase = (phase) => {
|
|
169
|
+
const changed = /* @__PURE__ */ new Set();
|
|
170
|
+
for (const [name, group] of Object.entries(phase.groups ?? {})) {
|
|
171
|
+
if (!groups.has(name)) throw unknownGroup(name, step);
|
|
172
|
+
groups.set(name, packGroups(group));
|
|
173
|
+
changed.add(name);
|
|
174
|
+
}
|
|
175
|
+
for (const name of phase.disableGroups ?? []) {
|
|
176
|
+
if (!groups.has(name)) throw unknownGroup(name, step);
|
|
177
|
+
groups.set(name, 0);
|
|
178
|
+
changed.add(name);
|
|
179
|
+
}
|
|
180
|
+
for (const name of changed) {
|
|
181
|
+
for (const c of inGroup.get(name)) c.setCollisionGroups(groups.get(name));
|
|
182
|
+
}
|
|
183
|
+
for (const d of phase.damping ?? []) {
|
|
184
|
+
const body = bodies[d.body];
|
|
185
|
+
if (!body || !Number.isInteger(d.body)) {
|
|
186
|
+
throw new ShardError(
|
|
187
|
+
"physics/track-scene",
|
|
188
|
+
`A settle phase at step ${step} damps body ${d.body}; the track has ${n}`,
|
|
189
|
+
{ path: "bodies" }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (d.linear !== void 0) body.setLinearDamping(d.linear);
|
|
193
|
+
if (d.angular !== void 0) body.setAngularDamping(d.angular);
|
|
194
|
+
}
|
|
195
|
+
if (phase.wake) for (const body of bodies) body.wakeUp();
|
|
196
|
+
};
|
|
197
|
+
const contacts = options.contacts;
|
|
198
|
+
const maxContacts = contacts?.max ?? 1024;
|
|
199
|
+
const dedupe = contacts?.dedupeSteps ?? 3;
|
|
200
|
+
const lastSeen = /* @__PURE__ */ new Map();
|
|
201
|
+
const cSteps = [];
|
|
202
|
+
const cA = [];
|
|
203
|
+
const cB = [];
|
|
204
|
+
const cForce = [];
|
|
205
|
+
const onForce = (e) => {
|
|
206
|
+
let a = bodyOf.get(e.collider1()) ?? -1;
|
|
207
|
+
let b = bodyOf.get(e.collider2()) ?? -1;
|
|
208
|
+
if (a < 0 || b >= 0 && b < a) {
|
|
209
|
+
const swap = a;
|
|
210
|
+
a = b;
|
|
211
|
+
b = swap;
|
|
212
|
+
}
|
|
213
|
+
if (a < 0) return;
|
|
214
|
+
const key = a * 65536 + b + 1;
|
|
215
|
+
const last = lastSeen.get(key);
|
|
216
|
+
lastSeen.set(key, step);
|
|
217
|
+
if (last !== void 0 && step - last <= dedupe || cSteps.length >= maxContacts) return;
|
|
218
|
+
cSteps.push(step);
|
|
219
|
+
cA.push(a);
|
|
220
|
+
cB.push(b);
|
|
221
|
+
cForce.push(e.totalForceMagnitude());
|
|
222
|
+
};
|
|
223
|
+
writePoses(0);
|
|
224
|
+
let settled = false;
|
|
225
|
+
let simulationMs = 0;
|
|
226
|
+
while (!settled && step < maxSteps) {
|
|
227
|
+
const start = performance.now();
|
|
228
|
+
for (let k = 0; k < TRACK_CHUNK_STEPS && step < maxSteps; k++) {
|
|
229
|
+
world.step(events);
|
|
230
|
+
step++;
|
|
231
|
+
if (contacts) events.drainContactForceEvents(onForce);
|
|
232
|
+
writePoses(step);
|
|
233
|
+
for (let i = 0; i < n; i++) sleeping[i] = bodies[i].isSleeping() ? 1 : 0;
|
|
234
|
+
const result = settle(view);
|
|
235
|
+
if (result === "done") {
|
|
236
|
+
settled = true;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
if (typeof result === "object") applyPhase(result);
|
|
240
|
+
if (performance.now() - start >= TRACK_CHUNK_MS) break;
|
|
241
|
+
}
|
|
242
|
+
simulationMs += performance.now() - start;
|
|
243
|
+
if (settled || step >= maxSteps) break;
|
|
244
|
+
await yieldTask();
|
|
245
|
+
if (signal?.aborted) throw trackCancelled();
|
|
246
|
+
}
|
|
247
|
+
const poses = (step + 1) * n;
|
|
248
|
+
return {
|
|
249
|
+
version: TRACK_VERSION,
|
|
250
|
+
engine: `rapier3d-deterministic@${R.version()}`,
|
|
251
|
+
sceneHash: sceneHash(scene),
|
|
252
|
+
step: scene.step,
|
|
253
|
+
steps: step,
|
|
254
|
+
bodyCount: n,
|
|
255
|
+
settled,
|
|
256
|
+
maxStepsHit: !settled,
|
|
257
|
+
positions: positions.slice(0, poses * 3),
|
|
258
|
+
rotations: rotations.slice(0, poses * 4),
|
|
259
|
+
contacts: {
|
|
260
|
+
steps: Uint16Array.from(cSteps),
|
|
261
|
+
a: Int16Array.from(cA),
|
|
262
|
+
b: Int16Array.from(cB),
|
|
263
|
+
force: Float32Array.from(cForce)
|
|
264
|
+
},
|
|
265
|
+
simulationMs
|
|
266
|
+
};
|
|
267
|
+
} finally {
|
|
268
|
+
world.free();
|
|
269
|
+
events.free();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function unknownGroup(name, step) {
|
|
273
|
+
return new ShardError(
|
|
274
|
+
"physics/track-scene",
|
|
275
|
+
`A settle phase at step ${step} names group "${name}", which isn't in scene.groups`,
|
|
276
|
+
{ path: "groups" }
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
export {
|
|
280
|
+
TRACK_CHUNK_MS,
|
|
281
|
+
TRACK_CHUNK_STEPS,
|
|
282
|
+
builtinSettleRules,
|
|
283
|
+
recordTrack,
|
|
284
|
+
settleWhenAsleep,
|
|
285
|
+
trackCancelled
|
|
286
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type Quat, type Vec3 } from '@aethervtt/shard-core';
|
|
2
|
+
/**
|
|
3
|
+
* What a track records: fixed colliders, and bodies with their starting poses and velocities. Plain
|
|
4
|
+
* data, so it crosses a worker boundary and hashes the same everywhere. The caller computes every
|
|
5
|
+
* number (dice draw theirs from a seeded Rng); the recorder never draws random numbers.
|
|
6
|
+
*/
|
|
7
|
+
export interface TrackScene {
|
|
8
|
+
version: 1;
|
|
9
|
+
dim: 3;
|
|
10
|
+
/** Seconds per step, e.g. 1 / 60. */
|
|
11
|
+
step: number;
|
|
12
|
+
/** The longest the track can be, in steps (at most 65535). */
|
|
13
|
+
maxSteps: number;
|
|
14
|
+
gravity: Vec3;
|
|
15
|
+
/** Floor, walls: colliders without a body. */
|
|
16
|
+
fixed: TrackCollider[];
|
|
17
|
+
/** Recorded, in this order: body i of the track is `bodies[i]`. */
|
|
18
|
+
bodies: TrackBody[];
|
|
19
|
+
/** Collision groups colliders name; a collider without one collides with everything. */
|
|
20
|
+
groups?: {
|
|
21
|
+
[name: string]: TrackGroup;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface TrackGroup {
|
|
25
|
+
/** Bits of the 16 collision layers this group is in. */
|
|
26
|
+
layers: number;
|
|
27
|
+
/** The layers it collides with. Two colliders touch when each one's layers meet the other's mask. */
|
|
28
|
+
mask: number;
|
|
29
|
+
}
|
|
30
|
+
export interface TrackBody {
|
|
31
|
+
/** Names the body in errors (`physics/track-diverged`); unique in the scene. */
|
|
32
|
+
id: string;
|
|
33
|
+
translation: Vec3;
|
|
34
|
+
rotation: Quat;
|
|
35
|
+
linear: Vec3;
|
|
36
|
+
angular: Vec3;
|
|
37
|
+
colliders: TrackCollider[];
|
|
38
|
+
ccd?: boolean;
|
|
39
|
+
/** Default true. */
|
|
40
|
+
canSleep?: boolean;
|
|
41
|
+
linearDamping?: number;
|
|
42
|
+
angularDamping?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface TrackCollider {
|
|
45
|
+
shape: 'ball' | 'cuboid' | 'convex';
|
|
46
|
+
radius?: number;
|
|
47
|
+
halfExtents?: Vec3;
|
|
48
|
+
/** Convex hull points, x y z each, already scaled. */
|
|
49
|
+
points?: Float32Array;
|
|
50
|
+
/** Offset from the body (default none). Fixed colliders: their place in the world. */
|
|
51
|
+
translation?: Vec3;
|
|
52
|
+
rotation?: Quat;
|
|
53
|
+
friction: number;
|
|
54
|
+
restitution: number;
|
|
55
|
+
density: number;
|
|
56
|
+
/** A key of `TrackScene.groups`. */
|
|
57
|
+
group?: string;
|
|
58
|
+
}
|
|
59
|
+
export declare const TRACK_SCENE_VERSION = 1;
|
|
60
|
+
/** Contacts store step indices as u16. */
|
|
61
|
+
export declare const MAX_TRACK_STEPS = 65535;
|
|
62
|
+
/** Contacts store body indices as i16. */
|
|
63
|
+
export declare const MAX_TRACK_BODIES = 32767;
|
|
64
|
+
/** Throws `physics/track-scene` naming the first thing wrong with a scene. */
|
|
65
|
+
export declare function checkTrackScene(scene: TrackScene): void;
|
|
66
|
+
/** A scene as JSON can hold it: convex points as plain arrays. */
|
|
67
|
+
export declare function trackSceneToJson(scene: TrackScene): unknown;
|
|
68
|
+
/** A scene from JSON (`trackSceneToJson`, a file): points become Float32Arrays, then it's checked. */
|
|
69
|
+
export declare function trackSceneFromJson(json: unknown): TrackScene;
|
|
70
|
+
/**
|
|
71
|
+
* The hash of a scene's canonical form: every field in a fixed order, defaults filled in, groups
|
|
72
|
+
* sorted by name. Two scenes that record the same track hash the same.
|
|
73
|
+
*/
|
|
74
|
+
export declare function sceneHash(scene: TrackScene): number;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { Hasher } from "./hash.js";
|
|
3
|
+
const TRACK_SCENE_VERSION = 1;
|
|
4
|
+
const MAX_TRACK_STEPS = 65535;
|
|
5
|
+
const MAX_TRACK_BODIES = 32767;
|
|
6
|
+
function invalid(message, path) {
|
|
7
|
+
return new ShardError("physics/track-scene", message, {
|
|
8
|
+
path,
|
|
9
|
+
hint: "See TrackScene in @aethervtt/shard-physics/track."
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function finite(v, path) {
|
|
13
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
14
|
+
throw invalid(`${path} is not a finite number`, path);
|
|
15
|
+
}
|
|
16
|
+
function vector(v, n, path) {
|
|
17
|
+
if (!Array.isArray(v) || v.length !== n) throw invalid(`${path} needs ${n} numbers`, path);
|
|
18
|
+
for (let i = 0; i < n; i++) finite(v[i], `${path}[${i}]`);
|
|
19
|
+
}
|
|
20
|
+
function checkCollider(c, path, groups) {
|
|
21
|
+
if (c.shape === "ball") {
|
|
22
|
+
finite(c.radius, `${path}.radius`);
|
|
23
|
+
if (c.radius <= 0) throw invalid(`${path}.radius must be positive`, `${path}.radius`);
|
|
24
|
+
} else if (c.shape === "cuboid") {
|
|
25
|
+
vector(c.halfExtents, 3, `${path}.halfExtents`);
|
|
26
|
+
if (c.halfExtents.some((h) => h <= 0))
|
|
27
|
+
throw invalid(`${path}.halfExtents must be positive`, `${path}.halfExtents`);
|
|
28
|
+
} else if (c.shape === "convex") {
|
|
29
|
+
const p = c.points;
|
|
30
|
+
if (!(p instanceof Float32Array) || p.length < 12 || p.length % 3 !== 0)
|
|
31
|
+
throw invalid(
|
|
32
|
+
`${path}.points needs 4 or more x y z points in a Float32Array`,
|
|
33
|
+
`${path}.points`
|
|
34
|
+
);
|
|
35
|
+
for (let i = 0; i < p.length; i++) finite(p[i], `${path}.points[${i}]`);
|
|
36
|
+
} else {
|
|
37
|
+
throw invalid(
|
|
38
|
+
`${path}.shape "${String(c.shape)}" isn't ball, cuboid or convex`,
|
|
39
|
+
`${path}.shape`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (c.translation !== void 0) vector(c.translation, 3, `${path}.translation`);
|
|
43
|
+
if (c.rotation !== void 0) vector(c.rotation, 4, `${path}.rotation`);
|
|
44
|
+
finite(c.friction, `${path}.friction`);
|
|
45
|
+
finite(c.restitution, `${path}.restitution`);
|
|
46
|
+
finite(c.density, `${path}.density`);
|
|
47
|
+
if (c.group !== void 0 && !groups?.[c.group])
|
|
48
|
+
throw invalid(`${path}.group "${c.group}" isn't in scene.groups`, `${path}.group`);
|
|
49
|
+
}
|
|
50
|
+
function checkTrackScene(scene) {
|
|
51
|
+
if (scene.version !== TRACK_SCENE_VERSION)
|
|
52
|
+
throw invalid(`Track scene version ${scene.version} isn't 1`, "version");
|
|
53
|
+
if (scene.dim !== 3) throw invalid("Only 3D tracks exist (dim: 3)", "dim");
|
|
54
|
+
finite(scene.step, "step");
|
|
55
|
+
if (scene.step <= 0) throw invalid("step must be positive", "step");
|
|
56
|
+
if (!Number.isInteger(scene.maxSteps) || scene.maxSteps < 1 || scene.maxSteps > MAX_TRACK_STEPS)
|
|
57
|
+
throw invalid(`maxSteps must be an integer from 1 to ${MAX_TRACK_STEPS}`, "maxSteps");
|
|
58
|
+
vector(scene.gravity, 3, "gravity");
|
|
59
|
+
for (const [name, g] of Object.entries(scene.groups ?? {})) {
|
|
60
|
+
for (const key of ["layers", "mask"]) {
|
|
61
|
+
const v = g[key];
|
|
62
|
+
if (!Number.isInteger(v) || v < 0 || v > 65535)
|
|
63
|
+
throw invalid(`groups.${name}.${key} must be a 16-bit mask`, `groups.${name}.${key}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
scene.fixed.forEach((c, i) => {
|
|
67
|
+
checkCollider(c, `fixed[${i}]`, scene.groups);
|
|
68
|
+
});
|
|
69
|
+
if (scene.bodies.length > MAX_TRACK_BODIES)
|
|
70
|
+
throw invalid(`A track holds at most ${MAX_TRACK_BODIES} bodies`, "bodies");
|
|
71
|
+
const ids = /* @__PURE__ */ new Set();
|
|
72
|
+
scene.bodies.forEach((b, i) => {
|
|
73
|
+
const path = `bodies[${i}]`;
|
|
74
|
+
if (typeof b.id !== "string" || ids.has(b.id))
|
|
75
|
+
throw invalid(`${path}.id must be a string unique in the scene`, `${path}.id`);
|
|
76
|
+
ids.add(b.id);
|
|
77
|
+
vector(b.translation, 3, `${path}.translation`);
|
|
78
|
+
vector(b.rotation, 4, `${path}.rotation`);
|
|
79
|
+
vector(b.linear, 3, `${path}.linear`);
|
|
80
|
+
vector(b.angular, 3, `${path}.angular`);
|
|
81
|
+
if (b.linearDamping !== void 0) finite(b.linearDamping, `${path}.linearDamping`);
|
|
82
|
+
if (b.angularDamping !== void 0) finite(b.angularDamping, `${path}.angularDamping`);
|
|
83
|
+
if (b.colliders.length === 0) throw invalid(`${path} has no colliders`, `${path}.colliders`);
|
|
84
|
+
b.colliders.forEach((c, j) => {
|
|
85
|
+
checkCollider(c, `${path}.colliders[${j}]`, scene.groups);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function trackSceneToJson(scene) {
|
|
90
|
+
const collider = (c) => c.points ? { ...c, points: Array.from(c.points) } : c;
|
|
91
|
+
return {
|
|
92
|
+
...scene,
|
|
93
|
+
fixed: scene.fixed.map(collider),
|
|
94
|
+
bodies: scene.bodies.map((b) => ({ ...b, colliders: b.colliders.map(collider) }))
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function trackSceneFromJson(json) {
|
|
98
|
+
if (typeof json !== "object" || json === null || !Array.isArray(json.bodies)) {
|
|
99
|
+
throw invalid("A track scene is an object with fixed and bodies", "");
|
|
100
|
+
}
|
|
101
|
+
const s = json;
|
|
102
|
+
const collider = (c) => Array.isArray(c.points) ? { ...c, points: Float32Array.from(c.points) } : c;
|
|
103
|
+
const scene = {
|
|
104
|
+
...s,
|
|
105
|
+
fixed: (s.fixed ?? []).map(collider),
|
|
106
|
+
bodies: s.bodies.map((b) => ({ ...b, colliders: (b.colliders ?? []).map(collider) }))
|
|
107
|
+
};
|
|
108
|
+
checkTrackScene(scene);
|
|
109
|
+
return scene;
|
|
110
|
+
}
|
|
111
|
+
function hashCollider(h, c) {
|
|
112
|
+
h.str(c.shape).f64(c.radius ?? 0);
|
|
113
|
+
const he = c.halfExtents ?? [0, 0, 0];
|
|
114
|
+
h.f64(he[0]).f64(he[1]).f64(he[2]);
|
|
115
|
+
h.array(c.points ?? new Float32Array(0));
|
|
116
|
+
const t = c.translation ?? [0, 0, 0];
|
|
117
|
+
const r = c.rotation ?? [0, 0, 0, 1];
|
|
118
|
+
h.f64(t[0]).f64(t[1]).f64(t[2]);
|
|
119
|
+
h.f64(r[0]).f64(r[1]).f64(r[2]).f64(r[3]);
|
|
120
|
+
h.f64(c.friction).f64(c.restitution).f64(c.density).str(c.group ?? "");
|
|
121
|
+
}
|
|
122
|
+
function sceneHash(scene) {
|
|
123
|
+
const h = new Hasher();
|
|
124
|
+
h.u32(scene.version).u32(scene.dim).f64(scene.step).u32(scene.maxSteps);
|
|
125
|
+
h.f64(scene.gravity[0]).f64(scene.gravity[1]).f64(scene.gravity[2]);
|
|
126
|
+
const groups = Object.keys(scene.groups ?? {}).sort();
|
|
127
|
+
h.u32(groups.length);
|
|
128
|
+
for (const name of groups) {
|
|
129
|
+
const g = scene.groups[name];
|
|
130
|
+
h.str(name).u32(g.layers).u32(g.mask);
|
|
131
|
+
}
|
|
132
|
+
h.u32(scene.fixed.length);
|
|
133
|
+
for (const c of scene.fixed) hashCollider(h, c);
|
|
134
|
+
h.u32(scene.bodies.length);
|
|
135
|
+
for (const b of scene.bodies) {
|
|
136
|
+
h.str(b.id);
|
|
137
|
+
for (const v of [b.translation, b.rotation, b.linear, b.angular]) for (const x of v) h.f64(x);
|
|
138
|
+
h.bool(b.ccd ?? false).bool(b.canSleep ?? true);
|
|
139
|
+
h.f64(b.linearDamping ?? 0).f64(b.angularDamping ?? 0);
|
|
140
|
+
h.u32(b.colliders.length);
|
|
141
|
+
for (const c of b.colliders) hashCollider(h, c);
|
|
142
|
+
}
|
|
143
|
+
return h.digest();
|
|
144
|
+
}
|
|
145
|
+
export {
|
|
146
|
+
MAX_TRACK_BODIES,
|
|
147
|
+
MAX_TRACK_STEPS,
|
|
148
|
+
TRACK_SCENE_VERSION,
|
|
149
|
+
checkTrackScene,
|
|
150
|
+
sceneHash,
|
|
151
|
+
trackSceneFromJson,
|
|
152
|
+
trackSceneToJson
|
|
153
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TrackScene } from './scene.js';
|
|
2
|
+
export declare const CUBE_POINTS: Float32Array<ArrayBufferLike>;
|
|
3
|
+
export declare const OCTAHEDRON_POINTS: Float32Array<ArrayBufferLike>;
|
|
4
|
+
export declare const ICOSAHEDRON_POINTS: Float32Array<ArrayBufferLike>;
|
|
5
|
+
/** `count` convex dice thrown from the tray's right side, velocities and spins from `seed`. */
|
|
6
|
+
export declare function trayScene(count: number, seed: number, maxSteps?: number): TrackScene;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { quat, Rng } from "@aethervtt/shard-core";
|
|
2
|
+
const PHI = (1 + Math.sqrt(5)) / 2;
|
|
3
|
+
function hull(points, radius) {
|
|
4
|
+
const out = new Float32Array(points.length * 3);
|
|
5
|
+
points.forEach((p, i) => {
|
|
6
|
+
const len = Math.sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]);
|
|
7
|
+
for (let k = 0; k < 3; k++) out[i * 3 + k] = p[k] / len * radius;
|
|
8
|
+
});
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
const signs = [-1, 1];
|
|
12
|
+
const CUBE_POINTS = hull(
|
|
13
|
+
signs.flatMap((x) => signs.flatMap((y) => signs.map((z) => [x, y, z]))),
|
|
14
|
+
0.52
|
|
15
|
+
);
|
|
16
|
+
const OCTAHEDRON_POINTS = hull(
|
|
17
|
+
[
|
|
18
|
+
[1, 0, 0],
|
|
19
|
+
[-1, 0, 0],
|
|
20
|
+
[0, 1, 0],
|
|
21
|
+
[0, -1, 0],
|
|
22
|
+
[0, 0, 1],
|
|
23
|
+
[0, 0, -1]
|
|
24
|
+
],
|
|
25
|
+
0.42
|
|
26
|
+
);
|
|
27
|
+
const ICOSAHEDRON_POINTS = hull(
|
|
28
|
+
signs.flatMap(
|
|
29
|
+
(a) => signs.flatMap((b) => [
|
|
30
|
+
[0, a, b * PHI],
|
|
31
|
+
[a, b * PHI, 0],
|
|
32
|
+
[b * PHI, 0, a]
|
|
33
|
+
])
|
|
34
|
+
),
|
|
35
|
+
0.4
|
|
36
|
+
);
|
|
37
|
+
const SHAPES = [CUBE_POINTS, OCTAHEDRON_POINTS, ICOSAHEDRON_POINTS];
|
|
38
|
+
function wall(x, z, hx, hz) {
|
|
39
|
+
return {
|
|
40
|
+
shape: "cuboid",
|
|
41
|
+
halfExtents: [hx, 2, hz],
|
|
42
|
+
translation: [x, 2, z],
|
|
43
|
+
friction: 0.3,
|
|
44
|
+
restitution: 0.4,
|
|
45
|
+
density: 1,
|
|
46
|
+
group: "walls"
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function trayScene(count, seed, maxSteps = 480) {
|
|
50
|
+
const rng = new Rng(seed);
|
|
51
|
+
const bodies = [];
|
|
52
|
+
for (let i = 0; i < count; i++) {
|
|
53
|
+
const rotation = quat.fromEuler(
|
|
54
|
+
[0, 0, 0, 1],
|
|
55
|
+
rng.range(0, 360),
|
|
56
|
+
rng.range(0, 360),
|
|
57
|
+
rng.range(0, 360)
|
|
58
|
+
);
|
|
59
|
+
bodies.push({
|
|
60
|
+
id: `die-${i}`,
|
|
61
|
+
// A grid, so no two start inside each other.
|
|
62
|
+
translation: [1 + i % 4 * 1.1, 1.5 + Math.floor(i / 16) * 1.1, (i >> 2) % 4 * 1.1 - 1.6],
|
|
63
|
+
rotation,
|
|
64
|
+
linear: [rng.range(-7, -4), rng.range(1, 3), rng.range(-2, 2)],
|
|
65
|
+
angular: [rng.range(-15, 15), rng.range(-15, 15), rng.range(-15, 15)],
|
|
66
|
+
colliders: [
|
|
67
|
+
{
|
|
68
|
+
shape: "convex",
|
|
69
|
+
points: SHAPES[i % SHAPES.length],
|
|
70
|
+
friction: 0.6,
|
|
71
|
+
restitution: 0.3,
|
|
72
|
+
density: 1,
|
|
73
|
+
group: "dice"
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
ccd: true
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
version: 1,
|
|
81
|
+
dim: 3,
|
|
82
|
+
step: 1 / 60,
|
|
83
|
+
maxSteps,
|
|
84
|
+
gravity: [0, -9.81, 0],
|
|
85
|
+
groups: {
|
|
86
|
+
dice: { layers: 1, mask: 1 | 2 | 4 },
|
|
87
|
+
walls: { layers: 2, mask: 1 },
|
|
88
|
+
floor: { layers: 4, mask: 1 }
|
|
89
|
+
},
|
|
90
|
+
fixed: [
|
|
91
|
+
{
|
|
92
|
+
shape: "cuboid",
|
|
93
|
+
halfExtents: [8, 0.5, 8],
|
|
94
|
+
translation: [0, -0.5, 0],
|
|
95
|
+
friction: 0.6,
|
|
96
|
+
restitution: 0.3,
|
|
97
|
+
density: 1,
|
|
98
|
+
group: "floor"
|
|
99
|
+
},
|
|
100
|
+
wall(0, -4, 6, 0.2),
|
|
101
|
+
wall(0, 3, 6, 0.2),
|
|
102
|
+
wall(-5.5, 0, 0.2, 5),
|
|
103
|
+
wall(5.5, 0, 0.2, 5)
|
|
104
|
+
],
|
|
105
|
+
bodies
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export {
|
|
109
|
+
CUBE_POINTS,
|
|
110
|
+
ICOSAHEDRON_POINTS,
|
|
111
|
+
OCTAHEDRON_POINTS,
|
|
112
|
+
trayScene
|
|
113
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Track } from '../track/format.js';
|
|
2
|
+
import { type SettleRule, type TrackContactOptions } from '../track/record.js';
|
|
3
|
+
import type { TrackScene } from '../track/scene.js';
|
|
4
|
+
import type { SettleRuleRef, TrackRequest } from './protocol.js';
|
|
5
|
+
/** What the client needs of a worker. A DOM `Worker` is one; Node's `worker_threads` needs an adapter. */
|
|
6
|
+
export interface TrackWorkerLike {
|
|
7
|
+
postMessage(message: TrackRequest, transfer?: Transferable[]): void;
|
|
8
|
+
addEventListener(type: 'message', listener: (event: MessageEvent) => void): void;
|
|
9
|
+
addEventListener(type: 'error' | 'messageerror', listener: (event: Event) => void): void;
|
|
10
|
+
terminate(): void;
|
|
11
|
+
}
|
|
12
|
+
export interface TrackClientOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Makes the worker: `trackWorker` from this module, a host's own entry (a package with its own
|
|
15
|
+
* settle rules), or `'inline'` to record on the calling thread (Node, tests).
|
|
16
|
+
*/
|
|
17
|
+
spawn: (() => TrackWorkerLike) | 'inline';
|
|
18
|
+
/** With `'inline'`: settle rules besides `sleep`. A worker gets its rules from its entry. */
|
|
19
|
+
rules?: {
|
|
20
|
+
[name: string]: SettleRule;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export interface TrackRecordOptions {
|
|
24
|
+
/** Aborting it rejects at once with `physics/track-cancelled`, and stops the worker's recording. */
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
/** A rule the worker registered, and its parameters (default `sleep`). */
|
|
27
|
+
settle?: SettleRuleRef;
|
|
28
|
+
contacts?: TrackContactOptions;
|
|
29
|
+
}
|
|
30
|
+
export interface TrackClient {
|
|
31
|
+
/** Starts the worker and loads Rapier's WASM. Call it early to have it warm for the first roll. */
|
|
32
|
+
ready(): Promise<void>;
|
|
33
|
+
record(scene: TrackScene, options?: TrackRecordOptions): Promise<Track>;
|
|
34
|
+
/** Stops the worker; pending recordings reject with `physics/track-cancelled`. */
|
|
35
|
+
dispose(): void;
|
|
36
|
+
readonly disposed: boolean;
|
|
37
|
+
/** Workers spawned so far (a crash respawns on the next call). */
|
|
38
|
+
readonly spawns: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Records tracks off the main thread. One worker, started on first use; a crash rejects what's
|
|
42
|
+
* pending with `physics/worker-crashed`, and the next call starts another.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createTrackClient(options: TrackClientOptions): TrackClient;
|