@series-inc/rundot-syncplay 5.23.0-beta.7 → 5.23.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/README.md +116 -15
- package/dist/browser.d.ts +87 -55
- package/dist/browser.js +47 -52
- package/dist/certification.d.ts +32 -3
- package/dist/certification.js +88 -3
- package/dist/cjs/browser.js +259 -87
- package/dist/cjs/certification.js +87 -2
- package/dist/cjs/collision.js +58 -0
- package/dist/cjs/creator.js +2 -1
- package/dist/cjs/engine-complete.js +213 -3
- package/dist/cjs/engine-parity-matrix.js +19 -4
- package/dist/cjs/index.js +199 -132
- package/dist/cjs/math.js +342 -15
- package/dist/cjs/movement3d.js +230 -1
- package/dist/cjs/node.js +5 -1
- package/dist/cjs/noise.js +58 -0
- package/dist/cjs/physics-cert.js +147 -6
- package/dist/cjs/physics2d.js +10 -4
- package/dist/cjs/physics3d-joints.js +637 -0
- package/dist/cjs/physics3d-shared.js +288 -0
- package/dist/cjs/physics3d-solver.js +1351 -0
- package/dist/cjs/physics3d-vehicle.js +467 -0
- package/dist/cjs/physics3d.js +1057 -223
- package/dist/cjs/random.js +49 -0
- package/dist/cjs/replay-bundle.js +127 -20
- package/dist/cjs/sample-scenes.js +3 -0
- package/dist/cjs/sdk-offline.js +6 -32
- package/dist/cjs/sdk-session.js +156 -0
- package/dist/cjs/session-build.js +42 -0
- package/dist/cjs/testing.js +107 -0
- package/dist/cli.js +40 -12
- package/dist/collision.d.ts +12 -0
- package/dist/collision.js +52 -0
- package/dist/creator.d.ts +1 -1
- package/dist/creator.js +1 -1
- package/dist/engine-complete.js +214 -4
- package/dist/engine-parity-matrix.js +19 -4
- package/dist/index.d.ts +94 -89
- package/dist/index.js +45 -56
- package/dist/math.d.ts +59 -6
- package/dist/math.js +342 -15
- package/dist/movement3d.d.ts +73 -0
- package/dist/movement3d.js +229 -1
- package/dist/node.d.ts +4 -0
- package/dist/node.js +2 -0
- package/dist/noise.d.ts +7 -0
- package/dist/noise.js +51 -0
- package/dist/physics-cert.d.ts +1 -1
- package/dist/physics-cert.js +147 -6
- package/dist/physics2d.js +10 -4
- package/dist/physics3d-joints.d.ts +94 -0
- package/dist/physics3d-joints.js +634 -0
- package/dist/physics3d-shared.d.ts +72 -0
- package/dist/physics3d-shared.js +257 -0
- package/dist/physics3d-solver.d.ts +197 -0
- package/dist/physics3d-solver.js +1346 -0
- package/dist/physics3d-vehicle.d.ts +84 -0
- package/dist/physics3d-vehicle.js +463 -0
- package/dist/physics3d.d.ts +108 -8
- package/dist/physics3d.js +1006 -177
- package/dist/random.d.ts +7 -0
- package/dist/random.js +49 -0
- package/dist/replay-bundle.d.ts +40 -1
- package/dist/replay-bundle.js +126 -20
- package/dist/sample-scenes.js +3 -0
- package/dist/sdk-offline.js +6 -32
- package/dist/sdk-session.d.ts +47 -0
- package/dist/sdk-session.js +153 -0
- package/dist/session-build.d.ts +21 -0
- package/dist/session-build.js +39 -0
- package/dist/testing.d.ts +52 -0
- package/dist/testing.js +45 -0
- package/dist/tools/vite-plugin.d.ts +8 -0
- package/dist/tools/vite-plugin.js +63 -9
- package/package.json +11 -3
package/dist/physics3d.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { cloneCanonical, defaultChecksum } from './canonical.js';
|
|
2
2
|
import { combineDeterministicPhysicsMaterial } from './deterministic-primitives.js';
|
|
3
|
+
import { deterministicSqrt, mulMat3Vec, quantizeAngle, quantizeDistance, quaternionFromQuarterTurns, quatToMat3, rotatePointByQuatInverse, worldInvInertia, } from './physics3d-shared.js';
|
|
4
|
+
import { generateManifold, solveRigidStep, WARM_KEY_BASE, } from './physics3d-solver.js';
|
|
5
|
+
import { createSolverJoints, } from './physics3d-joints.js';
|
|
3
6
|
export const deterministicPhysicsCallback3D = {
|
|
4
7
|
collisionEnter: 1,
|
|
5
8
|
collisionStay: 2,
|
|
@@ -18,32 +21,268 @@ export function createDeterministicPhysicsWorld3D(bodies) {
|
|
|
18
21
|
bodies: normalizeBodies(bodies),
|
|
19
22
|
activePairs: [],
|
|
20
23
|
activePairMetadata: [],
|
|
24
|
+
contactCache: [],
|
|
25
|
+
jointCache: [],
|
|
21
26
|
};
|
|
22
27
|
}
|
|
28
|
+
function worldInverseInertiaForBody(body) {
|
|
29
|
+
if (body.kind !== 'dynamic' || body.orientation === undefined) {
|
|
30
|
+
return [0, 0, 0, 0, 0, 0, 0, 0, 0];
|
|
31
|
+
}
|
|
32
|
+
const invInertiaLocal = deriveInverseInertia(body.shape, body.mass ?? 1);
|
|
33
|
+
const base = worldInvInertia(quatToMat3(body.orientation), invInertiaLocal);
|
|
34
|
+
const lock = body.lockAngular ?? {};
|
|
35
|
+
const lockArr = [lock.x === true ? 0 : 1, lock.y === true ? 0 : 1, lock.z === true ? 0 : 1];
|
|
36
|
+
const out = new Array(9).fill(0);
|
|
37
|
+
for (let row = 0; row < 3; row += 1) {
|
|
38
|
+
for (let col = 0; col < 3; col += 1) {
|
|
39
|
+
out[row * 3 + col] = base[row * 3 + col] * lockArr[row] * lockArr[col];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Bodies in the same contact island as `bodyId` (union-find over active pairs).
|
|
46
|
+
* Islands chain only through dynamic bodies — a static/kinematic body is a
|
|
47
|
+
* boundary, so wake does not leak across the floor to unrelated piles.
|
|
48
|
+
*/
|
|
49
|
+
function contactIslandFor(world, bodyId) {
|
|
50
|
+
const dynamicIds = new Set(world.bodies.filter((body) => body.kind === 'dynamic').map((body) => body.id));
|
|
51
|
+
const parent = new Map();
|
|
52
|
+
const find = (id) => {
|
|
53
|
+
if (!parent.has(id)) {
|
|
54
|
+
parent.set(id, id);
|
|
55
|
+
}
|
|
56
|
+
let root = id;
|
|
57
|
+
while (parent.get(root) !== root) {
|
|
58
|
+
root = parent.get(root);
|
|
59
|
+
}
|
|
60
|
+
return root;
|
|
61
|
+
};
|
|
62
|
+
const union = (a, b) => {
|
|
63
|
+
parent.set(find(a), find(b));
|
|
64
|
+
};
|
|
65
|
+
for (const pair of world.activePairMetadata ?? []) {
|
|
66
|
+
if (dynamicIds.has(pair.a) && dynamicIds.has(pair.b)) {
|
|
67
|
+
union(pair.a, pair.b);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const targetRoot = find(bodyId);
|
|
71
|
+
const island = new Set([bodyId]);
|
|
72
|
+
for (const id of parent.keys()) {
|
|
73
|
+
if (find(id) === targetRoot) {
|
|
74
|
+
island.add(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return island;
|
|
78
|
+
}
|
|
79
|
+
function assertFinitePoint(point, label) {
|
|
80
|
+
if (!isFiniteNumber(point.x) || !isFiniteNumber(point.y) || !isFiniteNumber(point.z)) {
|
|
81
|
+
throw new Error(`${label} must have finite components`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Apply a batch of deterministic impulses in one canonical-world rebuild.
|
|
86
|
+
* Semantically the impulses accumulate in list order (same math as repeated
|
|
87
|
+
* `applyDeterministicImpulse3D`, but velocities are quantized once per body at
|
|
88
|
+
* the end rather than per impulse), and every touched body's contact island is
|
|
89
|
+
* woken. One `O(bodies)` rebuild instead of one per impulse — this is the
|
|
90
|
+
* fast path for per-frame controller output (vehicles, KCC pushes).
|
|
91
|
+
*/
|
|
92
|
+
export function applyDeterministicImpulses3D(world, impulses) {
|
|
93
|
+
if (impulses.length === 0) {
|
|
94
|
+
return world;
|
|
95
|
+
}
|
|
96
|
+
const bodyById = new Map(world.bodies.map((body) => [body.id, body]));
|
|
97
|
+
const linearDelta = new Map();
|
|
98
|
+
const angularDelta = new Map();
|
|
99
|
+
const invInertiaByBody = new Map();
|
|
100
|
+
for (const entry of impulses) {
|
|
101
|
+
assertFinitePoint(entry.impulse, 'applyDeterministicImpulses3D: impulse');
|
|
102
|
+
if (entry.worldPoint !== undefined) {
|
|
103
|
+
assertFinitePoint(entry.worldPoint, 'applyDeterministicImpulses3D: worldPoint');
|
|
104
|
+
}
|
|
105
|
+
const target = bodyById.get(entry.bodyId);
|
|
106
|
+
if (target === undefined) {
|
|
107
|
+
throw new Error(`applyDeterministicImpulses3D: unknown body ${entry.bodyId}`);
|
|
108
|
+
}
|
|
109
|
+
const mass = target.mass ?? 1;
|
|
110
|
+
const baseInvMass = target.kind === 'dynamic' ? 1 / mass : 0;
|
|
111
|
+
const lockLinear = target.lockLinear ?? {};
|
|
112
|
+
const previousLinear = linearDelta.get(entry.bodyId) ?? { x: 0, y: 0, z: 0 };
|
|
113
|
+
linearDelta.set(entry.bodyId, {
|
|
114
|
+
x: previousLinear.x + entry.impulse.x * (lockLinear.x === true ? 0 : baseInvMass),
|
|
115
|
+
y: previousLinear.y + entry.impulse.y * (lockLinear.y === true ? 0 : baseInvMass),
|
|
116
|
+
z: previousLinear.z + entry.impulse.z * (lockLinear.z === true ? 0 : baseInvMass),
|
|
117
|
+
});
|
|
118
|
+
if (entry.worldPoint !== undefined && target.orientation !== undefined && target.kind === 'dynamic') {
|
|
119
|
+
let invInertia = invInertiaByBody.get(entry.bodyId);
|
|
120
|
+
if (invInertia === undefined) {
|
|
121
|
+
invInertia = worldInverseInertiaForBody(target);
|
|
122
|
+
invInertiaByBody.set(entry.bodyId, invInertia);
|
|
123
|
+
}
|
|
124
|
+
const lever = { x: entry.worldPoint.x - target.x, y: entry.worldPoint.y - target.y, z: entry.worldPoint.z - target.z };
|
|
125
|
+
const angularKick = mulMat3Vec(invInertia, cross(lever, entry.impulse));
|
|
126
|
+
const previousAngular = angularDelta.get(entry.bodyId) ?? { x: 0, y: 0, z: 0 };
|
|
127
|
+
angularDelta.set(entry.bodyId, {
|
|
128
|
+
x: previousAngular.x + angularKick.x,
|
|
129
|
+
y: previousAngular.y + angularKick.y,
|
|
130
|
+
z: previousAngular.z + angularKick.z,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const woken = new Set();
|
|
135
|
+
for (const bodyId of linearDelta.keys()) {
|
|
136
|
+
for (const member of contactIslandFor(world, bodyId)) {
|
|
137
|
+
woken.add(member);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const bodies = world.bodies.map((body) => {
|
|
141
|
+
const dv = linearDelta.get(body.id);
|
|
142
|
+
if (dv === undefined) {
|
|
143
|
+
if (woken.has(body.id) && body.kind === 'dynamic') {
|
|
144
|
+
return { ...body, sleeping: false, lowVelFrames: 0 };
|
|
145
|
+
}
|
|
146
|
+
return body;
|
|
147
|
+
}
|
|
148
|
+
const updated = {
|
|
149
|
+
...body,
|
|
150
|
+
vx: quantizeDistance(body.vx + dv.x),
|
|
151
|
+
vy: quantizeDistance(body.vy + dv.y),
|
|
152
|
+
vz: quantizeDistance(body.vz + dv.z),
|
|
153
|
+
sleeping: body.kind === 'dynamic' ? false : body.sleeping,
|
|
154
|
+
lowVelFrames: 0,
|
|
155
|
+
};
|
|
156
|
+
const dw = angularDelta.get(body.id);
|
|
157
|
+
if (dw === undefined) {
|
|
158
|
+
return updated;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
...updated,
|
|
162
|
+
angularVel: {
|
|
163
|
+
x: quantizeDistance((body.angularVel?.x ?? 0) + dw.x),
|
|
164
|
+
y: quantizeDistance((body.angularVel?.y ?? 0) + dw.y),
|
|
165
|
+
z: quantizeDistance((body.angularVel?.z ?? 0) + dw.z),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
return { ...world, bodies: normalizeBodies(bodies, 'trusted') };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Apply a deterministic impulse to a body (linear + angular via lever arm),
|
|
173
|
+
* waking the body and its contact island. Returns a new canonical world.
|
|
174
|
+
*/
|
|
175
|
+
export function applyDeterministicImpulse3D(world, bodyId, impulse, worldPoint) {
|
|
176
|
+
assertFinitePoint(impulse, 'applyDeterministicImpulse3D: impulse');
|
|
177
|
+
if (worldPoint !== undefined) {
|
|
178
|
+
assertFinitePoint(worldPoint, 'applyDeterministicImpulse3D: worldPoint');
|
|
179
|
+
}
|
|
180
|
+
const target = world.bodies.find((body) => body.id === bodyId);
|
|
181
|
+
if (target === undefined) {
|
|
182
|
+
throw new Error(`applyDeterministicImpulse3D: unknown body ${bodyId}`);
|
|
183
|
+
}
|
|
184
|
+
const island = contactIslandFor(world, bodyId);
|
|
185
|
+
const mass = target.mass ?? 1;
|
|
186
|
+
const baseInvMass = target.kind === 'dynamic' ? 1 / mass : 0;
|
|
187
|
+
const lockLinear = target.lockLinear ?? {};
|
|
188
|
+
const dv = {
|
|
189
|
+
x: impulse.x * (lockLinear.x === true ? 0 : baseInvMass),
|
|
190
|
+
y: impulse.y * (lockLinear.y === true ? 0 : baseInvMass),
|
|
191
|
+
z: impulse.z * (lockLinear.z === true ? 0 : baseInvMass),
|
|
192
|
+
};
|
|
193
|
+
let angular = target.angularVel;
|
|
194
|
+
if (worldPoint !== undefined && target.orientation !== undefined && target.kind === 'dynamic') {
|
|
195
|
+
const r = { x: worldPoint.x - target.x, y: worldPoint.y - target.y, z: worldPoint.z - target.z };
|
|
196
|
+
const angImpulse = cross(r, impulse);
|
|
197
|
+
const dw = mulMat3Vec(worldInverseInertiaForBody(target), angImpulse);
|
|
198
|
+
angular = {
|
|
199
|
+
x: (target.angularVel?.x ?? 0) + dw.x,
|
|
200
|
+
y: (target.angularVel?.y ?? 0) + dw.y,
|
|
201
|
+
z: (target.angularVel?.z ?? 0) + dw.z,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const bodies = world.bodies.map((body) => {
|
|
205
|
+
if (body.id === bodyId) {
|
|
206
|
+
const updated = {
|
|
207
|
+
...body,
|
|
208
|
+
vx: quantizeDistance(body.vx + dv.x),
|
|
209
|
+
vy: quantizeDistance(body.vy + dv.y),
|
|
210
|
+
vz: quantizeDistance(body.vz + dv.z),
|
|
211
|
+
sleeping: body.kind === 'dynamic' ? false : body.sleeping,
|
|
212
|
+
lowVelFrames: 0,
|
|
213
|
+
};
|
|
214
|
+
return angular !== undefined
|
|
215
|
+
? { ...updated, angularVel: { x: quantizeDistance(angular.x), y: quantizeDistance(angular.y), z: quantizeDistance(angular.z) } }
|
|
216
|
+
: updated;
|
|
217
|
+
}
|
|
218
|
+
if (island.has(body.id) && body.kind === 'dynamic') {
|
|
219
|
+
return { ...body, sleeping: false, lowVelFrames: 0 };
|
|
220
|
+
}
|
|
221
|
+
return body;
|
|
222
|
+
});
|
|
223
|
+
return { ...world, bodies: normalizeBodies(bodies, 'trusted') };
|
|
224
|
+
}
|
|
225
|
+
/** Set a body's linear (and optional angular) velocity deterministically, waking its island. */
|
|
226
|
+
export function setDeterministicBodyVelocity3D(world, bodyId, velocity, angularVelocity) {
|
|
227
|
+
assertFinitePoint(velocity, 'setDeterministicBodyVelocity3D: velocity');
|
|
228
|
+
if (angularVelocity !== undefined) {
|
|
229
|
+
assertFinitePoint(angularVelocity, 'setDeterministicBodyVelocity3D: angularVelocity');
|
|
230
|
+
}
|
|
231
|
+
const target = world.bodies.find((body) => body.id === bodyId);
|
|
232
|
+
if (target === undefined) {
|
|
233
|
+
throw new Error(`setDeterministicBodyVelocity3D: unknown body ${bodyId}`);
|
|
234
|
+
}
|
|
235
|
+
const island = contactIslandFor(world, bodyId);
|
|
236
|
+
const bodies = world.bodies.map((body) => {
|
|
237
|
+
if (body.id === bodyId) {
|
|
238
|
+
const updated = {
|
|
239
|
+
...body,
|
|
240
|
+
vx: quantizeDistance(velocity.x),
|
|
241
|
+
vy: quantizeDistance(velocity.y),
|
|
242
|
+
vz: quantizeDistance(velocity.z),
|
|
243
|
+
sleeping: body.kind === 'dynamic' ? false : body.sleeping,
|
|
244
|
+
lowVelFrames: 0,
|
|
245
|
+
};
|
|
246
|
+
return angularVelocity !== undefined
|
|
247
|
+
? { ...updated, angularVel: { x: quantizeDistance(angularVelocity.x), y: quantizeDistance(angularVelocity.y), z: quantizeDistance(angularVelocity.z) } }
|
|
248
|
+
: updated;
|
|
249
|
+
}
|
|
250
|
+
if (island.has(body.id) && body.kind === 'dynamic') {
|
|
251
|
+
return { ...body, sleeping: false, lowVelFrames: 0 };
|
|
252
|
+
}
|
|
253
|
+
return body;
|
|
254
|
+
});
|
|
255
|
+
return { ...world, bodies: normalizeBodies(bodies, 'trusted') };
|
|
256
|
+
}
|
|
23
257
|
export function stepDeterministicPhysicsWorld3D(world, options = {}) {
|
|
24
258
|
const scheduledQueries = sortScheduledQueries(options.scheduledQueries ?? []);
|
|
25
259
|
const prePhysicsScheduledQueryResults = scheduledQueries
|
|
26
260
|
.filter((query) => query.phase === 'pre-physics')
|
|
27
261
|
.map((query) => ({ id: query.id, hits: runScheduledQuery(world, query.query) }));
|
|
262
|
+
if (options.dtTicks !== undefined && options.dtTicks <= 0) {
|
|
263
|
+
throw new Error(`physics3d step: dtTicks must be a positive finite number, received ${options.dtTicks}`);
|
|
264
|
+
}
|
|
265
|
+
const velocityIterations = options.velocityIterations ?? DEFAULT_VELOCITY_ITERATIONS;
|
|
266
|
+
if (!Number.isInteger(velocityIterations) || velocityIterations <= 0) {
|
|
267
|
+
throw new Error(`physics3d step: velocityIterations must be a positive integer, received ${velocityIterations}`);
|
|
268
|
+
}
|
|
28
269
|
const dt = options.dtTicks ?? 1;
|
|
29
270
|
const gravityY = options.gravityY ?? 0;
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const solved = solvePenetrations(constrained.bodies);
|
|
46
|
-
const contacts = detectContacts(solved.bodies);
|
|
271
|
+
const rigid = runRigidSolver(world, dt, gravityY, velocityIterations, options.joints ?? []);
|
|
272
|
+
const constrained = solveConstraints3D(rigid.bodies, options.constraints ?? []);
|
|
273
|
+
const solvedBodies = constrained.bodies;
|
|
274
|
+
// Dynamic-involved solid-collision events come from the solver's manifolds (so
|
|
275
|
+
// an enter event coincides with the frame the arresting impulse is applied and
|
|
276
|
+
// carries its normalImpulse). Trigger/sensor pairs, and solid pairs the solver
|
|
277
|
+
// skips (no dynamic body — e.g. static-vs-static), come from overlap detection
|
|
278
|
+
// on the solved positions, preserving their existing event semantics.
|
|
279
|
+
const solidPairs = new Set(rigid.solidContacts.map((contact) => contact.pair));
|
|
280
|
+
// Only trigger/sensor pairs and fully non-dynamic pairs can survive the
|
|
281
|
+
// event filter, so reject everything else before the exact overlap test —
|
|
282
|
+
// dynamic solid pairs already got their events from the solver manifolds.
|
|
283
|
+
const overlapContacts = detectContacts(solvedBodies, (left, right) => left.trigger === true || left.sensor === true || right.trigger === true || right.sensor === true
|
|
284
|
+
|| (left.kind !== 'dynamic' && right.kind !== 'dynamic')).filter((contact) => !solidPairs.has(contact.pair));
|
|
285
|
+
const contacts = [...rigid.solidContacts, ...overlapContacts].sort((left, right) => compareStrings(left.pair, right.pair));
|
|
47
286
|
const activePairs = contacts.map((contact) => contact.pair).sort(compareStrings);
|
|
48
287
|
const activePairMetadata = contacts.map((contact) => ({
|
|
49
288
|
pair: contact.pair,
|
|
@@ -55,9 +294,13 @@ export function stepDeterministicPhysicsWorld3D(world, options = {}) {
|
|
|
55
294
|
const previousPairMetadata = new Map((world.activePairMetadata ?? []).map((pair) => [pair.pair, pair]));
|
|
56
295
|
const previousPairs = new Set(world.activePairs);
|
|
57
296
|
const currentPairs = new Set(activePairs);
|
|
58
|
-
const bodyById = new Map(
|
|
297
|
+
const bodyById = new Map(solvedBodies.map((body) => [body.id, body]));
|
|
59
298
|
const events = [
|
|
60
|
-
...contacts.map((contact) => ({
|
|
299
|
+
...contacts.map((contact) => ({
|
|
300
|
+
...contact,
|
|
301
|
+
type: previousPairs.has(contact.pair) ? 'stay' : 'enter',
|
|
302
|
+
normalImpulse: rigid.impulseByPair.get(contact.pair) ?? 0,
|
|
303
|
+
})),
|
|
61
304
|
...world.activePairs
|
|
62
305
|
.filter((pair) => !currentPairs.has(pair))
|
|
63
306
|
.sort(compareStrings)
|
|
@@ -71,26 +314,39 @@ export function stepDeterministicPhysicsWorld3D(world, options = {}) {
|
|
|
71
314
|
b: previous?.b ?? b,
|
|
72
315
|
trigger: previous?.trigger ?? false,
|
|
73
316
|
sensor: previous?.sensor ?? false,
|
|
317
|
+
normalImpulse: 0,
|
|
74
318
|
};
|
|
75
319
|
}),
|
|
76
320
|
]
|
|
77
321
|
.filter((event) => contactEventEnabled(event, bodyById))
|
|
78
322
|
.sort(compareContactEvents);
|
|
79
|
-
const nextWorld = {
|
|
323
|
+
const nextWorld = {
|
|
324
|
+
frame: world.frame + 1,
|
|
325
|
+
bodies: solvedBodies,
|
|
326
|
+
activePairs,
|
|
327
|
+
activePairMetadata,
|
|
328
|
+
contactCache: rigid.contactCache,
|
|
329
|
+
jointCache: rigid.jointCache,
|
|
330
|
+
};
|
|
80
331
|
const postPhysicsScheduledQueryResults = scheduledQueries
|
|
81
332
|
.filter((query) => query.phase === 'post-physics')
|
|
82
333
|
.map((query) => ({ id: query.id, hits: runScheduledQuery(nextWorld, query.query) }));
|
|
83
334
|
const scheduledQueryResults = [...prePhysicsScheduledQueryResults, ...postPhysicsScheduledQueryResults];
|
|
335
|
+
const solverCorrections = rigid.narrowphaseContacts + constrained.corrections;
|
|
84
336
|
return {
|
|
85
|
-
|
|
337
|
+
// nextWorld is assembled from freshly built, already-canonical parts this
|
|
338
|
+
// step, so the cloneCanonical round trip it used to pass through was
|
|
339
|
+
// value-identical — and measured as the single largest per-step cost.
|
|
340
|
+
world: nextWorld,
|
|
86
341
|
contacts: events,
|
|
342
|
+
brokenJointIds: rigid.brokenJointIds,
|
|
87
343
|
scheduledQueryResults,
|
|
88
344
|
prePhysicsScheduledQueryResults,
|
|
89
345
|
postPhysicsScheduledQueryResults,
|
|
90
|
-
broadphasePairs: countCandidatePairs(
|
|
91
|
-
narrowphaseContacts:
|
|
92
|
-
solverCorrections
|
|
93
|
-
checksum: options.computeChecksum === false ? '' : defaultChecksum({ nextWorld, scheduledQueryResults, events, solverCorrections
|
|
346
|
+
broadphasePairs: countCandidatePairs(solvedBodies),
|
|
347
|
+
narrowphaseContacts: rigid.narrowphaseContacts,
|
|
348
|
+
solverCorrections,
|
|
349
|
+
checksum: options.computeChecksum === false ? '' : defaultChecksum({ nextWorld, scheduledQueryResults, events, solverCorrections }),
|
|
94
350
|
};
|
|
95
351
|
}
|
|
96
352
|
function fastStressStepPhysicsWorld3D(world, scheduledQueries) {
|
|
@@ -114,6 +370,8 @@ function fastStressStepPhysicsWorld3D(world, scheduledQueries) {
|
|
|
114
370
|
}).sort((left, right) => compareStrings(left.id, right.id)),
|
|
115
371
|
activePairs: [],
|
|
116
372
|
activePairMetadata: [],
|
|
373
|
+
contactCache: world.contactCache ?? [],
|
|
374
|
+
jointCache: world.jointCache ?? [],
|
|
117
375
|
};
|
|
118
376
|
const postPhysicsScheduledQueryResults = sortedScheduledQueries
|
|
119
377
|
.filter((query) => query.phase === 'post-physics')
|
|
@@ -129,42 +387,279 @@ function fastStressStepPhysicsWorld3D(world, scheduledQueries) {
|
|
|
129
387
|
solverCorrections: 0,
|
|
130
388
|
};
|
|
131
389
|
}
|
|
390
|
+
const QUERY_INDEX_LEAF_SIZE = 4;
|
|
391
|
+
const queryIndexCache = new WeakMap();
|
|
392
|
+
/**
|
|
393
|
+
* Conservative world-space bounds as the narrow phase sees the body: bodies
|
|
394
|
+
* with a quaternion `orientation` use the |R|-transformed local AABB (the
|
|
395
|
+
* narrow phase ignores legacy quarter turns for them), everything else uses
|
|
396
|
+
* the exact quarter-turn-aware bounds.
|
|
397
|
+
*/
|
|
398
|
+
const queryIndexBoundsCache = new WeakMap();
|
|
399
|
+
function queryIndexBoundsForBody(body) {
|
|
400
|
+
if (body.orientation === undefined) {
|
|
401
|
+
return toBounds(body);
|
|
402
|
+
}
|
|
403
|
+
const cached = queryIndexBoundsCache.get(body);
|
|
404
|
+
if (cached !== undefined) {
|
|
405
|
+
return cached;
|
|
406
|
+
}
|
|
407
|
+
const local = shapePoseBounds({ x: 0, y: 0, z: 0, shape: body.shape });
|
|
408
|
+
const centerX = (local.minX + local.maxX) / 2;
|
|
409
|
+
const centerY = (local.minY + local.maxY) / 2;
|
|
410
|
+
const centerZ = (local.minZ + local.maxZ) / 2;
|
|
411
|
+
const halfX = (local.maxX - local.minX) / 2;
|
|
412
|
+
const halfY = (local.maxY - local.minY) / 2;
|
|
413
|
+
const halfZ = (local.maxZ - local.minZ) / 2;
|
|
414
|
+
const m = quatToMat3(body.orientation);
|
|
415
|
+
const worldCenterX = body.x + m[0] * centerX + m[1] * centerY + m[2] * centerZ;
|
|
416
|
+
const worldCenterY = body.y + m[3] * centerX + m[4] * centerY + m[5] * centerZ;
|
|
417
|
+
const worldCenterZ = body.z + m[6] * centerX + m[7] * centerY + m[8] * centerZ;
|
|
418
|
+
const extentX = Math.abs(m[0]) * halfX + Math.abs(m[1]) * halfY + Math.abs(m[2]) * halfZ;
|
|
419
|
+
const extentY = Math.abs(m[3]) * halfX + Math.abs(m[4]) * halfY + Math.abs(m[5]) * halfZ;
|
|
420
|
+
const extentZ = Math.abs(m[6]) * halfX + Math.abs(m[7]) * halfY + Math.abs(m[8]) * halfZ;
|
|
421
|
+
const bounds = {
|
|
422
|
+
minX: worldCenterX - extentX,
|
|
423
|
+
maxX: worldCenterX + extentX,
|
|
424
|
+
minY: worldCenterY - extentY,
|
|
425
|
+
maxY: worldCenterY + extentY,
|
|
426
|
+
minZ: worldCenterZ - extentZ,
|
|
427
|
+
maxZ: worldCenterZ + extentZ,
|
|
428
|
+
};
|
|
429
|
+
queryIndexBoundsCache.set(body, bounds);
|
|
430
|
+
return bounds;
|
|
431
|
+
}
|
|
432
|
+
function buildQueryIndexNode(entries, order, start, end, nodes) {
|
|
433
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
434
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
435
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
436
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
437
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
438
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
439
|
+
for (let index = start; index < end; index += 1) {
|
|
440
|
+
const bounds = entries[order[index]].bounds;
|
|
441
|
+
minX = Math.min(minX, bounds.minX);
|
|
442
|
+
maxX = Math.max(maxX, bounds.maxX);
|
|
443
|
+
minY = Math.min(minY, bounds.minY);
|
|
444
|
+
maxY = Math.max(maxY, bounds.maxY);
|
|
445
|
+
minZ = Math.min(minZ, bounds.minZ);
|
|
446
|
+
maxZ = Math.max(maxZ, bounds.maxZ);
|
|
447
|
+
}
|
|
448
|
+
const nodeIndex = nodes.length;
|
|
449
|
+
if (end - start <= QUERY_INDEX_LEAF_SIZE) {
|
|
450
|
+
nodes.push({ minX, maxX, minY, maxY, minZ, maxZ, left: -1, right: -1, start, count: end - start });
|
|
451
|
+
return nodeIndex;
|
|
452
|
+
}
|
|
453
|
+
// split on the widest centroid axis; deterministic tiebreak order x → y → z
|
|
454
|
+
let axisMinX = Number.POSITIVE_INFINITY;
|
|
455
|
+
let axisMaxX = Number.NEGATIVE_INFINITY;
|
|
456
|
+
let axisMinY = Number.POSITIVE_INFINITY;
|
|
457
|
+
let axisMaxY = Number.NEGATIVE_INFINITY;
|
|
458
|
+
let axisMinZ = Number.POSITIVE_INFINITY;
|
|
459
|
+
let axisMaxZ = Number.NEGATIVE_INFINITY;
|
|
460
|
+
for (let index = start; index < end; index += 1) {
|
|
461
|
+
const bounds = entries[order[index]].bounds;
|
|
462
|
+
const cx = (bounds.minX + bounds.maxX) / 2;
|
|
463
|
+
const cy = (bounds.minY + bounds.maxY) / 2;
|
|
464
|
+
const cz = (bounds.minZ + bounds.maxZ) / 2;
|
|
465
|
+
axisMinX = Math.min(axisMinX, cx);
|
|
466
|
+
axisMaxX = Math.max(axisMaxX, cx);
|
|
467
|
+
axisMinY = Math.min(axisMinY, cy);
|
|
468
|
+
axisMaxY = Math.max(axisMaxY, cy);
|
|
469
|
+
axisMinZ = Math.min(axisMinZ, cz);
|
|
470
|
+
axisMaxZ = Math.max(axisMaxZ, cz);
|
|
471
|
+
}
|
|
472
|
+
const spanX = axisMaxX - axisMinX;
|
|
473
|
+
const spanY = axisMaxY - axisMinY;
|
|
474
|
+
const spanZ = axisMaxZ - axisMinZ;
|
|
475
|
+
const axis = spanY > spanX
|
|
476
|
+
? (spanZ > spanY ? 2 : 1)
|
|
477
|
+
: (spanZ > spanX ? 2 : 0);
|
|
478
|
+
const centroidOnAxis = (entryIndex) => {
|
|
479
|
+
const bounds = entries[entryIndex].bounds;
|
|
480
|
+
if (axis === 0) {
|
|
481
|
+
return (bounds.minX + bounds.maxX) / 2;
|
|
482
|
+
}
|
|
483
|
+
return axis === 1 ? (bounds.minY + bounds.maxY) / 2 : (bounds.minZ + bounds.maxZ) / 2;
|
|
484
|
+
};
|
|
485
|
+
const slice = order.slice(start, end);
|
|
486
|
+
// full strict order (centroid, then body id) — never relies on sort stability
|
|
487
|
+
slice.sort((left, right) => centroidOnAxis(left) - centroidOnAxis(right)
|
|
488
|
+
|| compareStrings(entries[left].body.id, entries[right].body.id));
|
|
489
|
+
for (let index = 0; index < slice.length; index += 1) {
|
|
490
|
+
order[start + index] = slice[index];
|
|
491
|
+
}
|
|
492
|
+
const mid = start + ((end - start) >> 1);
|
|
493
|
+
// reserve this node's slot before recursing so child indexes are stable
|
|
494
|
+
nodes.push({ minX, maxX, minY, maxY, minZ, maxZ, left: -1, right: -1, start: 0, count: 0 });
|
|
495
|
+
const left = buildQueryIndexNode(entries, order, start, mid, nodes);
|
|
496
|
+
const right = buildQueryIndexNode(entries, order, mid, end, nodes);
|
|
497
|
+
nodes[nodeIndex] = { minX, maxX, minY, maxY, minZ, maxZ, left, right, start: 0, count: 0 };
|
|
498
|
+
return nodeIndex;
|
|
499
|
+
}
|
|
500
|
+
/** O(1) body lookup backed by the per-world query-index cache. */
|
|
501
|
+
export function deterministicPhysicsBodyById3D(world, bodyId) {
|
|
502
|
+
return queryIndexFor(world).bodyById.get(bodyId);
|
|
503
|
+
}
|
|
504
|
+
function buildQueryIndexTree(bodies) {
|
|
505
|
+
const entries = bodies.map((body) => ({ body, bounds: queryIndexBoundsForBody(body) }));
|
|
506
|
+
const order = entries.map((_, index) => index);
|
|
507
|
+
const nodes = [];
|
|
508
|
+
if (entries.length > 0) {
|
|
509
|
+
buildQueryIndexNode(entries, order, 0, entries.length, nodes);
|
|
510
|
+
}
|
|
511
|
+
return { entries, order, nodes };
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Static bodies keep object identity across world snapshots (the solver and
|
|
515
|
+
* the trusted normalize paths pass them through by reference), so their
|
|
516
|
+
* subtree — usually the bulk of a level — is reused frame over frame; only
|
|
517
|
+
* the dynamic subtree is rebuilt. Identity comparison makes reuse exact.
|
|
518
|
+
*/
|
|
519
|
+
let staticQueryTreeCache;
|
|
520
|
+
function sameBodyIdentities(left, right) {
|
|
521
|
+
if (left.length !== right.length) {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
525
|
+
if (left[index] !== right[index]) {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
function queryIndexFor(world) {
|
|
532
|
+
const cached = queryIndexCache.get(world);
|
|
533
|
+
if (cached !== undefined) {
|
|
534
|
+
return cached;
|
|
535
|
+
}
|
|
536
|
+
const statics = [];
|
|
537
|
+
const movers = [];
|
|
538
|
+
for (const body of world.bodies) {
|
|
539
|
+
if (body.kind === 'static') {
|
|
540
|
+
statics.push(body);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
movers.push(body);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
let staticTree;
|
|
547
|
+
if (staticQueryTreeCache !== undefined && sameBodyIdentities(staticQueryTreeCache.statics, statics)) {
|
|
548
|
+
staticTree = staticQueryTreeCache.tree;
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
staticTree = buildQueryIndexTree(statics);
|
|
552
|
+
staticQueryTreeCache = { statics, tree: staticTree };
|
|
553
|
+
}
|
|
554
|
+
const index = {
|
|
555
|
+
trees: movers.length > 0 ? [staticTree, buildQueryIndexTree(movers)] : [staticTree],
|
|
556
|
+
bodyById: new Map(world.bodies.map((body) => [body.id, body])),
|
|
557
|
+
};
|
|
558
|
+
queryIndexCache.set(world, index);
|
|
559
|
+
return index;
|
|
560
|
+
}
|
|
561
|
+
/** Collect entries whose bounds pass `nodeTest`, walking each tree iteratively. */
|
|
562
|
+
function collectQueryCandidates(index, nodeTest) {
|
|
563
|
+
const candidates = [];
|
|
564
|
+
for (const tree of index.trees) {
|
|
565
|
+
if (tree.nodes.length === 0) {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const stack = [0];
|
|
569
|
+
while (stack.length > 0) {
|
|
570
|
+
const node = tree.nodes[stack.pop()];
|
|
571
|
+
if (!nodeTest(node)) {
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (node.left === -1) {
|
|
575
|
+
for (let slot = node.start; slot < node.start + node.count; slot += 1) {
|
|
576
|
+
const entry = tree.entries[tree.order[slot]];
|
|
577
|
+
if (nodeTest(entry.bounds)) {
|
|
578
|
+
candidates.push(entry);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
stack.push(node.right);
|
|
584
|
+
stack.push(node.left);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return candidates;
|
|
588
|
+
}
|
|
589
|
+
function rayQueryCandidates(index, query, expandBy) {
|
|
590
|
+
return collectQueryCandidates(index, (bounds) => {
|
|
591
|
+
const target = expandBy === undefined ? bounds : expandBoundsByBounds(bounds, expandBy);
|
|
592
|
+
return rayAabbDistanceExact(target, query.x, query.y, query.z, query.dx, query.dy, query.dz, query.maxDistance) !== undefined;
|
|
593
|
+
});
|
|
594
|
+
}
|
|
132
595
|
export function raycastDeterministicPhysics3D(world, query) {
|
|
133
596
|
const normalizedQuery = normalizeRaycastQuery3D(query);
|
|
134
|
-
return world
|
|
135
|
-
.filter((
|
|
136
|
-
.map((
|
|
137
|
-
const hit = rayBodyDistance(body, normalizedQuery);
|
|
138
|
-
return hit === undefined ? undefined : { bodyId: body.id, distance: hit };
|
|
597
|
+
return rayQueryCandidates(queryIndexFor(world), normalizedQuery)
|
|
598
|
+
.filter((entry) => queryBodyAllowed(entry.body, normalizedQuery))
|
|
599
|
+
.map((entry) => {
|
|
600
|
+
const hit = rayBodyDistance(entry.body, normalizedQuery);
|
|
601
|
+
return hit === undefined ? undefined : { bodyId: entry.body.id, distance: hit };
|
|
139
602
|
})
|
|
140
603
|
.filter((hit) => hit !== undefined)
|
|
141
604
|
.sort(compareHits);
|
|
142
605
|
}
|
|
143
606
|
export function raycastDeterministicPhysics3DDetailed(world, query) {
|
|
144
607
|
const normalizedQuery = normalizeRaycastQuery3D(query);
|
|
608
|
+
const bodyById = queryIndexFor(world).bodyById;
|
|
145
609
|
return raycastDeterministicPhysics3D(world, query)
|
|
146
610
|
.map((hit) => {
|
|
147
|
-
const body =
|
|
611
|
+
const body = bodyById.get(hit.bodyId);
|
|
148
612
|
return detailedHitForBody(body, normalizedQuery, hit.distance, 0);
|
|
149
613
|
})
|
|
150
614
|
.sort(compareHits);
|
|
151
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Exact body-vs-query-shape overlap (the narrow phase of `overlapDeterministicPhysics3D`).
|
|
618
|
+
* Bodies with a full quaternion orientation use the exact solver geometry
|
|
619
|
+
* (SAT/closed-form) so overlap against tumbled props is correct; the legacy
|
|
620
|
+
* quarter-turn path is preserved for bodies without `orientation`.
|
|
621
|
+
*/
|
|
622
|
+
function bodyOverlapsQueryShape(body, shape, x, y, z, queryBounds) {
|
|
623
|
+
if (body.orientation !== undefined) {
|
|
624
|
+
return orientedBodyOverlapsShape(body, shape, x, y, z);
|
|
625
|
+
}
|
|
626
|
+
return boundsOverlap(toBounds(body), queryBounds)
|
|
627
|
+
&& shapesOverlap3D({ x, y, z, shape }, bodyPose(body));
|
|
628
|
+
}
|
|
152
629
|
export function overlapDeterministicPhysics3D(world, query) {
|
|
153
630
|
const queryBounds = shapeBounds(query.x, query.y, query.z, query.shape);
|
|
154
|
-
return world
|
|
155
|
-
.filter((
|
|
156
|
-
.filter((
|
|
157
|
-
.
|
|
158
|
-
.map((body) => body.id)
|
|
631
|
+
return collectQueryCandidates(queryIndexFor(world), (bounds) => boundsOverlap(bounds, queryBounds))
|
|
632
|
+
.filter((entry) => queryBodyAllowed(entry.body, query))
|
|
633
|
+
.filter((entry) => bodyOverlapsQueryShape(entry.body, query.shape, query.x, query.y, query.z, queryBounds))
|
|
634
|
+
.map((entry) => entry.body.id)
|
|
159
635
|
.sort(compareStrings);
|
|
160
636
|
}
|
|
637
|
+
/** Exact overlap between an oriented body and a query shape via the solver manifold geometry. */
|
|
638
|
+
function orientedBodyOverlapsShape(body, queryShape, qx, qy, qz) {
|
|
639
|
+
const bodyGeom = {
|
|
640
|
+
index: 0,
|
|
641
|
+
p: { x: body.x, y: body.y, z: body.z },
|
|
642
|
+
q: body.orientation ?? { x: 0, y: 0, z: 0, w: 1 },
|
|
643
|
+
shape: toSolverShape(body.shape),
|
|
644
|
+
};
|
|
645
|
+
const queryGeom = {
|
|
646
|
+
index: 1,
|
|
647
|
+
p: { x: qx, y: qy, z: qz },
|
|
648
|
+
q: { x: 0, y: 0, z: 0, w: 1 },
|
|
649
|
+
shape: toSolverShape(queryShape),
|
|
650
|
+
};
|
|
651
|
+
return generateManifold(bodyGeom, queryGeom) !== undefined;
|
|
652
|
+
}
|
|
161
653
|
export function shapeCastDeterministicPhysics3D(world, query) {
|
|
162
654
|
const normalizedQuery = normalizeShapeCastQuery3D(query);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
655
|
+
// prune with body bounds Minkowski-expanded by the cast shape's local bounds,
|
|
656
|
+
// so the cast reduces to a segment-vs-AABB test against the tree
|
|
657
|
+
const castExtents = shapeBounds(0, 0, 0, query.shape);
|
|
658
|
+
return rayQueryCandidates(queryIndexFor(world), normalizedQuery, castExtents)
|
|
659
|
+
.filter((entry) => queryBodyAllowed(entry.body, normalizedQuery))
|
|
660
|
+
.map((entry) => {
|
|
661
|
+
const distance = shapeCastBodyDistance(entry.body, normalizedQuery);
|
|
662
|
+
return distance === undefined ? undefined : { bodyId: entry.body.id, distance };
|
|
168
663
|
})
|
|
169
664
|
.filter((hit) => hit !== undefined)
|
|
170
665
|
.sort(compareHits);
|
|
@@ -172,9 +667,10 @@ export function shapeCastDeterministicPhysics3D(world, query) {
|
|
|
172
667
|
export function shapeCastDeterministicPhysics3DDetailed(world, query) {
|
|
173
668
|
const normalizedQuery = normalizeShapeCastQuery3D(query);
|
|
174
669
|
const castRadius = query.shape.type === 'sphere' ? query.shape.radius : 0;
|
|
670
|
+
const bodyById = queryIndexFor(world).bodyById;
|
|
175
671
|
return shapeCastDeterministicPhysics3D(world, query)
|
|
176
672
|
.map((hit) => {
|
|
177
|
-
const body =
|
|
673
|
+
const body = bodyById.get(hit.bodyId);
|
|
178
674
|
return detailedHitForBody(body, normalizedQuery, hit.distance, castRadius);
|
|
179
675
|
})
|
|
180
676
|
.sort(compareHits);
|
|
@@ -784,11 +1280,269 @@ function callbackFixtureBody(id, x, trigger, callbackMask) {
|
|
|
784
1280
|
callbackMask,
|
|
785
1281
|
};
|
|
786
1282
|
}
|
|
787
|
-
|
|
1283
|
+
const DEFAULT_LINEAR_DAMPING = 0.995;
|
|
1284
|
+
const DEFAULT_ANGULAR_DAMPING = 0.98;
|
|
1285
|
+
const DEFAULT_VELOCITY_ITERATIONS = 10;
|
|
1286
|
+
function toSolverPrimitiveShape(shape) {
|
|
1287
|
+
if (shape.type === 'box') {
|
|
1288
|
+
return { kind: 'box', half: { x: shape.halfX, y: shape.halfY, z: shape.halfZ } };
|
|
1289
|
+
}
|
|
1290
|
+
if (shape.type === 'sphere') {
|
|
1291
|
+
return { kind: 'sphere', radius: shape.radius };
|
|
1292
|
+
}
|
|
1293
|
+
return { kind: 'capsule', radius: shape.radius, halfHeight: shape.halfHeight };
|
|
1294
|
+
}
|
|
1295
|
+
function toSolverShape(shape) {
|
|
1296
|
+
if (shape.type === 'box' || shape.type === 'sphere' || shape.type === 'capsule') {
|
|
1297
|
+
return toSolverPrimitiveShape(shape);
|
|
1298
|
+
}
|
|
1299
|
+
if (shape.type === 'compound') {
|
|
1300
|
+
return {
|
|
1301
|
+
kind: 'compound',
|
|
1302
|
+
children: shape.children.map((child) => ({
|
|
1303
|
+
shape: toSolverPrimitiveShape(child.shape),
|
|
1304
|
+
offset: child.offset,
|
|
1305
|
+
q: quaternionFromQuarterTurns(child.quarterTurns ?? { x: 0, y: 0, z: 0 }),
|
|
1306
|
+
})),
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
// mesh: local triangle-list, grouped into triples (static geometry).
|
|
1310
|
+
const triangles = [];
|
|
1311
|
+
for (let index = 0; index + 2 < shape.vertices.length; index += 3) {
|
|
1312
|
+
triangles.push([shape.vertices[index], shape.vertices[index + 1], shape.vertices[index + 2]]);
|
|
1313
|
+
}
|
|
1314
|
+
return { kind: 'mesh', triangles };
|
|
1315
|
+
}
|
|
1316
|
+
export function deriveInverseInertia(shape, mass) {
|
|
1317
|
+
if (shape.type === 'box') {
|
|
1318
|
+
const sx = 2 * shape.halfX;
|
|
1319
|
+
const sy = 2 * shape.halfY;
|
|
1320
|
+
const sz = 2 * shape.halfZ;
|
|
1321
|
+
const ix = (mass / 12) * (sy * sy + sz * sz);
|
|
1322
|
+
const iy = (mass / 12) * (sx * sx + sz * sz);
|
|
1323
|
+
const iz = (mass / 12) * (sx * sx + sy * sy);
|
|
1324
|
+
return { x: 1 / ix, y: 1 / iy, z: 1 / iz };
|
|
1325
|
+
}
|
|
1326
|
+
if (shape.type === 'sphere') {
|
|
1327
|
+
const i = (2 / 5) * mass * shape.radius * shape.radius;
|
|
1328
|
+
return { x: 1 / i, y: 1 / i, z: 1 / i };
|
|
1329
|
+
}
|
|
1330
|
+
if (shape.type === 'capsule') {
|
|
1331
|
+
const r = shape.radius;
|
|
1332
|
+
const h = 2 * shape.halfHeight;
|
|
1333
|
+
const iAxial = 0.5 * mass * r * r;
|
|
1334
|
+
const iLateral = (mass / 12) * (3 * r * r + h * h);
|
|
1335
|
+
return { x: 1 / iLateral, y: 1 / iAxial, z: 1 / iLateral };
|
|
1336
|
+
}
|
|
1337
|
+
if (shape.type === 'compound') {
|
|
1338
|
+
// union-AABB box approximation of the compound inertia (v1).
|
|
1339
|
+
const bounds = shapePoseBounds({ x: 0, y: 0, z: 0, shape });
|
|
1340
|
+
const half = { x: (bounds.maxX - bounds.minX) / 2, y: (bounds.maxY - bounds.minY) / 2, z: (bounds.maxZ - bounds.minZ) / 2 };
|
|
1341
|
+
return deriveInverseInertia({ type: 'box', halfX: Math.max(half.x, 1e-3), halfY: Math.max(half.y, 1e-3), halfZ: Math.max(half.z, 1e-3) }, mass);
|
|
1342
|
+
}
|
|
1343
|
+
// mesh: static, never rotates.
|
|
1344
|
+
return { x: 0, y: 0, z: 0 };
|
|
1345
|
+
}
|
|
1346
|
+
function buildSolverBody(body, index) {
|
|
1347
|
+
const isDynamic = body.kind === 'dynamic';
|
|
1348
|
+
const mass = body.mass ?? 1;
|
|
1349
|
+
const baseInvMass = isDynamic ? 1 / mass : 0;
|
|
1350
|
+
const lockLinear = body.lockLinear ?? {};
|
|
1351
|
+
const lockAngular = body.lockAngular ?? {};
|
|
1352
|
+
const hasOrientation = body.orientation !== undefined;
|
|
1353
|
+
return {
|
|
1354
|
+
index,
|
|
1355
|
+
id: body.id,
|
|
1356
|
+
isDynamic,
|
|
1357
|
+
sleeping: body.sleeping === true,
|
|
1358
|
+
p: { x: body.x, y: body.y, z: body.z },
|
|
1359
|
+
q: body.orientation ?? { x: 0, y: 0, z: 0, w: 1 },
|
|
1360
|
+
v: { x: body.vx, y: body.vy, z: body.vz },
|
|
1361
|
+
w: body.angularVel ?? { x: 0, y: 0, z: 0 },
|
|
1362
|
+
vb: { x: 0, y: 0, z: 0 },
|
|
1363
|
+
wb: { x: 0, y: 0, z: 0 },
|
|
1364
|
+
invMassVec: {
|
|
1365
|
+
x: lockLinear.x === true ? 0 : baseInvMass,
|
|
1366
|
+
y: lockLinear.y === true ? 0 : baseInvMass,
|
|
1367
|
+
z: lockLinear.z === true ? 0 : baseInvMass,
|
|
1368
|
+
},
|
|
1369
|
+
invInertiaLocal: isDynamic ? deriveInverseInertia(body.shape, mass) : { x: 0, y: 0, z: 0 },
|
|
1370
|
+
angularLock: hasOrientation
|
|
1371
|
+
? { x: lockAngular.x === true ? 0 : 1, y: lockAngular.y === true ? 0 : 1, z: lockAngular.z === true ? 0 : 1 }
|
|
1372
|
+
: { x: 0, y: 0, z: 0 },
|
|
1373
|
+
matFriction: (body.material?.friction ?? 0) / 10,
|
|
1374
|
+
matRestitution: (body.material?.restitution ?? 0) / 10,
|
|
1375
|
+
linearDamping: body.linearDamping ?? DEFAULT_LINEAR_DAMPING,
|
|
1376
|
+
angularDamping: body.angularDamping ?? DEFAULT_ANGULAR_DAMPING,
|
|
1377
|
+
gravityScale: body.gravityScale ?? 1,
|
|
1378
|
+
hasOrientation,
|
|
1379
|
+
shape: toSolverShape(body.shape),
|
|
1380
|
+
lowVelFrames: body.lowVelFrames ?? 0,
|
|
1381
|
+
wakeForced: false,
|
|
1382
|
+
ccd: body.ccd === true,
|
|
1383
|
+
layer: body.layer,
|
|
1384
|
+
mask: body.mask,
|
|
1385
|
+
noCollide: body.trigger === true || body.sensor === true,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
function warmCacheToMap(cache, indexById) {
|
|
1389
|
+
const warm = new Map();
|
|
1390
|
+
// Cache entries are sorted by pair, so resolve ids to indices once per run
|
|
1391
|
+
// of equal pair strings instead of splitting every entry.
|
|
1392
|
+
let runPair;
|
|
1393
|
+
let a;
|
|
1394
|
+
let b;
|
|
1395
|
+
for (const entry of cache) {
|
|
1396
|
+
if (entry.pair !== runPair) {
|
|
1397
|
+
runPair = entry.pair;
|
|
1398
|
+
const [aId, bId] = entry.pair.split('|');
|
|
1399
|
+
a = indexById.get(aId);
|
|
1400
|
+
b = indexById.get(bId);
|
|
1401
|
+
}
|
|
1402
|
+
if (a === undefined || b === undefined) {
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
const key = a * WARM_KEY_BASE + b;
|
|
1406
|
+
let list = warm.get(key);
|
|
1407
|
+
if (list === undefined) {
|
|
1408
|
+
list = [];
|
|
1409
|
+
warm.set(key, list);
|
|
1410
|
+
}
|
|
1411
|
+
list.push({
|
|
1412
|
+
anchor: { x: entry.anchorX, y: entry.anchorY, z: entry.anchorZ },
|
|
1413
|
+
normalImpulse: entry.normalImpulse,
|
|
1414
|
+
tangentImpulse1: entry.tangentImpulse1,
|
|
1415
|
+
tangentImpulse2: entry.tangentImpulse2,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
return warm;
|
|
1419
|
+
}
|
|
1420
|
+
function runRigidSolver(world, dt, gravityY, velocityIterations, joints) {
|
|
1421
|
+
const source = world.bodies;
|
|
1422
|
+
const indexById = new Map(source.map((body, index) => [body.id, index]));
|
|
1423
|
+
const solverBodies = source.map((body, index) => buildSolverBody(body, index));
|
|
1424
|
+
const warm = warmCacheToMap(world.contactCache ?? [], indexById);
|
|
1425
|
+
const previouslyBroken = new Set((world.jointCache ?? []).filter((entry) => entry.broken === true).map((entry) => entry.jointId));
|
|
1426
|
+
const solverJoints = createSolverJoints(joints, indexById, world.jointCache ?? []);
|
|
1427
|
+
// Suppress contacts for intact jointed pairs flagged disableCollision. A joint
|
|
1428
|
+
// that breaks this step suppresses one final step (it is not yet in
|
|
1429
|
+
// previouslyBroken), resuming collision next step.
|
|
1430
|
+
const noCollideKeys = joints
|
|
1431
|
+
.filter((joint) => joint.type !== 'grab' && 'disableCollision' in joint && joint.disableCollision === true && !previouslyBroken.has(joint.id))
|
|
1432
|
+
.map((joint) => {
|
|
1433
|
+
const jointBodies = joint;
|
|
1434
|
+
const aIndex = indexById.get(jointBodies.a);
|
|
1435
|
+
const bIndex = indexById.get(jointBodies.b);
|
|
1436
|
+
if (aIndex === undefined || bIndex === undefined) {
|
|
1437
|
+
return undefined; // createSolverJoints throws the canonical unknown-body error
|
|
1438
|
+
}
|
|
1439
|
+
return Math.min(aIndex, bIndex) * WARM_KEY_BASE + Math.max(aIndex, bIndex);
|
|
1440
|
+
})
|
|
1441
|
+
.filter((key) => key !== undefined);
|
|
1442
|
+
const noCollidePairs = new Set(noCollideKeys);
|
|
1443
|
+
const result = solveRigidStep(solverBodies, {
|
|
1444
|
+
dt,
|
|
1445
|
+
gravityY,
|
|
1446
|
+
velocityIterations,
|
|
1447
|
+
noCollidePairs: noCollidePairs.size > 0 ? noCollidePairs : undefined,
|
|
1448
|
+
warm,
|
|
1449
|
+
joints: solverJoints,
|
|
1450
|
+
});
|
|
1451
|
+
const bodies = source.map((body, index) => {
|
|
1452
|
+
if (body.kind !== 'dynamic') {
|
|
1453
|
+
return body;
|
|
1454
|
+
}
|
|
1455
|
+
const sb = solverBodies[index];
|
|
1456
|
+
const mapped = {
|
|
1457
|
+
...body,
|
|
1458
|
+
x: quantizeDistance(sb.p.x),
|
|
1459
|
+
y: quantizeDistance(sb.p.y),
|
|
1460
|
+
z: quantizeDistance(sb.p.z),
|
|
1461
|
+
vx: quantizeDistance(sb.v.x),
|
|
1462
|
+
vy: quantizeDistance(sb.v.y),
|
|
1463
|
+
vz: quantizeDistance(sb.v.z),
|
|
1464
|
+
sleeping: sb.sleeping,
|
|
1465
|
+
lowVelFrames: sb.lowVelFrames,
|
|
1466
|
+
};
|
|
1467
|
+
if (body.orientation !== undefined) {
|
|
1468
|
+
return {
|
|
1469
|
+
...mapped,
|
|
1470
|
+
orientation: {
|
|
1471
|
+
x: quantizeDistance(sb.q.x),
|
|
1472
|
+
y: quantizeDistance(sb.q.y),
|
|
1473
|
+
z: quantizeDistance(sb.q.z),
|
|
1474
|
+
w: quantizeDistance(sb.q.w),
|
|
1475
|
+
},
|
|
1476
|
+
angularVel: {
|
|
1477
|
+
x: quantizeDistance(sb.w.x),
|
|
1478
|
+
y: quantizeDistance(sb.w.y),
|
|
1479
|
+
z: quantizeDistance(sb.w.z),
|
|
1480
|
+
},
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
// legacy quarter-turn decoration for bodies without a quaternion orientation
|
|
1484
|
+
return { ...mapped, ...integrateAngularState(body, dt) };
|
|
1485
|
+
});
|
|
1486
|
+
const contactCache = warmMapToCache(result.warm, solverBodies);
|
|
1487
|
+
const jointCache = result.jointCache
|
|
1488
|
+
.map((entry) => ({
|
|
1489
|
+
jointId: entry.jointId,
|
|
1490
|
+
impulseX: quantizeDistance(entry.impulseX),
|
|
1491
|
+
impulseY: quantizeDistance(entry.impulseY),
|
|
1492
|
+
impulseZ: quantizeDistance(entry.impulseZ),
|
|
1493
|
+
axialImpulse: quantizeDistance(entry.axialImpulse),
|
|
1494
|
+
hingeAngle: quantizeDistance(entry.hingeAngle),
|
|
1495
|
+
...(entry.broken === true ? { broken: true } : {}),
|
|
1496
|
+
}))
|
|
1497
|
+
.sort((left, right) => compareStrings(left.jointId, right.jointId));
|
|
1498
|
+
const brokenJointIds = jointCache
|
|
1499
|
+
.filter((entry) => entry.broken === true && !previouslyBroken.has(entry.jointId))
|
|
1500
|
+
.map((entry) => entry.jointId);
|
|
1501
|
+
const impulseByPair = new Map();
|
|
1502
|
+
const solidContacts = [];
|
|
1503
|
+
for (const impulse of result.contactImpulses) {
|
|
1504
|
+
impulseByPair.set(impulse.pair, quantizeDistance(impulse.normalImpulse));
|
|
1505
|
+
solidContacts.push({
|
|
1506
|
+
pair: impulse.pair,
|
|
1507
|
+
a: solverBodies[impulse.a].id,
|
|
1508
|
+
b: solverBodies[impulse.b].id,
|
|
1509
|
+
trigger: false,
|
|
1510
|
+
sensor: false,
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
return { bodies, contactCache, jointCache, impulseByPair, solidContacts, narrowphaseContacts: result.manifolds.length, brokenJointIds };
|
|
1514
|
+
}
|
|
1515
|
+
function warmMapToCache(warm, bodies) {
|
|
1516
|
+
const entries = [];
|
|
1517
|
+
for (const [key, points] of warm.entries()) {
|
|
1518
|
+
const aIndex = Math.floor(key / WARM_KEY_BASE);
|
|
1519
|
+
const bIndex = key % WARM_KEY_BASE;
|
|
1520
|
+
const pair = `${bodies[aIndex].id}|${bodies[bIndex].id}`;
|
|
1521
|
+
for (const point of points) {
|
|
1522
|
+
entries.push({
|
|
1523
|
+
pair,
|
|
1524
|
+
anchorX: quantizeDistance(point.anchor.x),
|
|
1525
|
+
anchorY: quantizeDistance(point.anchor.y),
|
|
1526
|
+
anchorZ: quantizeDistance(point.anchor.z),
|
|
1527
|
+
normalImpulse: quantizeDistance(point.normalImpulse),
|
|
1528
|
+
tangentImpulse1: quantizeDistance(point.tangentImpulse1),
|
|
1529
|
+
tangentImpulse2: quantizeDistance(point.tangentImpulse2),
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return entries.sort((left, right) => compareStrings(left.pair, right.pair)
|
|
1534
|
+
|| left.anchorX - right.anchorX
|
|
1535
|
+
|| left.anchorY - right.anchorY
|
|
1536
|
+
|| left.anchorZ - right.anchorZ);
|
|
1537
|
+
}
|
|
1538
|
+
function detectContacts(bodies, keepPair) {
|
|
788
1539
|
const contacts = [];
|
|
789
1540
|
for (const pair of broadphaseCandidatePairs(bodies)) {
|
|
790
1541
|
const left = pair.left;
|
|
791
1542
|
const right = pair.right;
|
|
1543
|
+
if (keepPair !== undefined && !keepPair(left, right)) {
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
792
1546
|
if ((left.mask & right.layer) === 0 ||
|
|
793
1547
|
(right.mask & left.layer) === 0 ||
|
|
794
1548
|
!boundsOverlap(pair.leftBounds, pair.rightBounds) ||
|
|
@@ -800,99 +1554,6 @@ function detectContacts(bodies) {
|
|
|
800
1554
|
}
|
|
801
1555
|
return contacts.sort((left, right) => compareStrings(left.pair, right.pair));
|
|
802
1556
|
}
|
|
803
|
-
function solvePenetrations(bodies) {
|
|
804
|
-
const next = bodies.map((body) => ({ ...body }));
|
|
805
|
-
let corrections = 0;
|
|
806
|
-
for (const pair of broadphaseCandidatePairs(next)) {
|
|
807
|
-
if (pair.left.trigger === true ||
|
|
808
|
-
pair.right.trigger === true ||
|
|
809
|
-
pair.left.sensor === true ||
|
|
810
|
-
pair.right.sensor === true ||
|
|
811
|
-
(pair.left.mask & pair.right.layer) === 0 ||
|
|
812
|
-
(pair.right.mask & pair.left.layer) === 0) {
|
|
813
|
-
continue;
|
|
814
|
-
}
|
|
815
|
-
const leftIndex = next.findIndex((body) => body.id === pair.left.id);
|
|
816
|
-
const rightIndex = next.findIndex((body) => body.id === pair.right.id);
|
|
817
|
-
const resolved = resolvePair(next[leftIndex], next[rightIndex]);
|
|
818
|
-
if (resolved.left !== next[leftIndex] || resolved.right !== next[rightIndex]) {
|
|
819
|
-
next[leftIndex] = resolved.left;
|
|
820
|
-
next[rightIndex] = resolved.right;
|
|
821
|
-
corrections += resolved.corrections;
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
return { bodies: normalizeBodies(next), corrections };
|
|
825
|
-
}
|
|
826
|
-
function resolvePair(leftBody, rightBody) {
|
|
827
|
-
if (leftBody.kind !== 'dynamic' && rightBody.kind !== 'dynamic') {
|
|
828
|
-
return { left: leftBody, right: rightBody, corrections: 0 };
|
|
829
|
-
}
|
|
830
|
-
const left = toBounds(leftBody);
|
|
831
|
-
const right = toBounds(rightBody);
|
|
832
|
-
if (!boundsOverlap(left, right) ||
|
|
833
|
-
!shapesOverlap3D(bodyPose(leftBody), bodyPose(rightBody))) {
|
|
834
|
-
return { left: leftBody, right: rightBody, corrections: 0 };
|
|
835
|
-
}
|
|
836
|
-
const pushLeft = right.minX - left.maxX;
|
|
837
|
-
const pushRight = right.maxX - left.minX;
|
|
838
|
-
const pushDown = right.minY - left.maxY;
|
|
839
|
-
const pushUp = right.maxY - left.minY;
|
|
840
|
-
const pushBack = right.minZ - left.maxZ;
|
|
841
|
-
const pushForward = right.maxZ - left.minZ;
|
|
842
|
-
const correction = [
|
|
843
|
-
{ axis: 'x', delta: nearestAxisDelta(pushLeft, pushRight) },
|
|
844
|
-
{ axis: 'y', delta: nearestAxisDelta(pushDown, pushUp) },
|
|
845
|
-
{ axis: 'z', delta: nearestAxisDelta(pushBack, pushForward) },
|
|
846
|
-
].sort((leftCandidate, rightCandidate) => Math.abs(leftCandidate.delta) - Math.abs(rightCandidate.delta) ||
|
|
847
|
-
compareStrings(leftCandidate.axis, rightCandidate.axis))[0];
|
|
848
|
-
const leftDynamic = leftBody.kind === 'dynamic';
|
|
849
|
-
const rightDynamic = rightBody.kind === 'dynamic';
|
|
850
|
-
const leftShare = leftDynamic && rightDynamic ? 0.5 : leftDynamic ? 1 : 0;
|
|
851
|
-
const rightShare = leftDynamic && rightDynamic ? -0.5 : rightDynamic ? -1 : 0;
|
|
852
|
-
const material = combineDeterministicPhysicsMaterial(leftBody.material ?? defaultPhysicsMaterial3D, rightBody.material ?? defaultPhysicsMaterial3D);
|
|
853
|
-
const leftResolved = applyAxisCorrection(leftBody, correction.axis, correction.delta * leftShare, material);
|
|
854
|
-
const rightResolved = applyAxisCorrection(rightBody, correction.axis, correction.delta * rightShare, material);
|
|
855
|
-
return { left: leftResolved, right: rightResolved, corrections: 1 };
|
|
856
|
-
}
|
|
857
|
-
function applyAxisCorrection(body, axis, delta, material) {
|
|
858
|
-
if (delta === 0) {
|
|
859
|
-
return body;
|
|
860
|
-
}
|
|
861
|
-
const quantized = quantizeDistance(delta);
|
|
862
|
-
const normalVelocity = (velocity) => Math.trunc((-velocity * material.restitution) / 10);
|
|
863
|
-
const tangentVelocity = (velocity) => Math.trunc((velocity * Math.max(0, 10 - material.friction)) / 10);
|
|
864
|
-
if (axis === 'x') {
|
|
865
|
-
return {
|
|
866
|
-
...body,
|
|
867
|
-
x: quantizeDistance(body.x + quantized),
|
|
868
|
-
vx: normalVelocity(body.vx),
|
|
869
|
-
vy: tangentVelocity(body.vy),
|
|
870
|
-
vz: tangentVelocity(body.vz),
|
|
871
|
-
};
|
|
872
|
-
}
|
|
873
|
-
if (axis === 'y') {
|
|
874
|
-
return {
|
|
875
|
-
...body,
|
|
876
|
-
y: quantizeDistance(body.y + quantized),
|
|
877
|
-
vx: tangentVelocity(body.vx),
|
|
878
|
-
vy: normalVelocity(body.vy),
|
|
879
|
-
vz: tangentVelocity(body.vz),
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
return {
|
|
883
|
-
...body,
|
|
884
|
-
x: body.x,
|
|
885
|
-
y: body.y,
|
|
886
|
-
z: quantizeDistance(body.z + quantized),
|
|
887
|
-
vx: tangentVelocity(body.vx),
|
|
888
|
-
vy: tangentVelocity(body.vy),
|
|
889
|
-
vz: normalVelocity(body.vz),
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
function nearestAxisDelta(negativeDelta, positiveDelta) {
|
|
893
|
-
const useNegative = Number(Math.abs(negativeDelta) <= Math.abs(positiveDelta));
|
|
894
|
-
return negativeDelta * useNegative + positiveDelta * (1 - useNegative);
|
|
895
|
-
}
|
|
896
1557
|
function solveConstraints3D(bodies, constraints) {
|
|
897
1558
|
const next = bodies.map((body) => ({ ...body }));
|
|
898
1559
|
let corrections = 0;
|
|
@@ -941,7 +1602,7 @@ function solveConstraints3D(bodies, constraints) {
|
|
|
941
1602
|
corrections += 1;
|
|
942
1603
|
}
|
|
943
1604
|
}
|
|
944
|
-
return { bodies: normalizeBodies(next), corrections };
|
|
1605
|
+
return { bodies: normalizeBodies(next, 'trusted'), corrections };
|
|
945
1606
|
}
|
|
946
1607
|
function moveBodyBy(body, dx, dy, dz) {
|
|
947
1608
|
if (body.kind !== 'dynamic') {
|
|
@@ -992,14 +1653,109 @@ function broadphaseCandidatePairs(bodies) {
|
|
|
992
1653
|
}
|
|
993
1654
|
return pairs.sort((left, right) => compareStrings(left.left.id, right.left.id) || compareStrings(left.right.id, right.right.id));
|
|
994
1655
|
}
|
|
995
|
-
|
|
996
|
-
|
|
1656
|
+
/**
|
|
1657
|
+
* Canonicalize a body list. `strict` (the user-input boundary) deep-clones and
|
|
1658
|
+
* validates every body; `trusted` skips both for bodies the engine itself
|
|
1659
|
+
* built — they are already validated, already canonical values (the clone
|
|
1660
|
+
* round-trip preserves -0/NaN/undefined exactly, so skipping it is
|
|
1661
|
+
* value-identical), and immutable by repo-wide discipline, so sharing their
|
|
1662
|
+
* identity across world snapshots is safe (and lets derived-data caches keyed
|
|
1663
|
+
* on body identity persist across frames).
|
|
1664
|
+
*/
|
|
1665
|
+
function normalizeBodies(bodies, mode = 'strict') {
|
|
1666
|
+
const source = mode === 'strict' ? bodies.map((body) => cloneCanonical(body)) : [...bodies];
|
|
1667
|
+
const sorted = source
|
|
1668
|
+
.map((body) => (body.kind === 'dynamic' && body.sleeping === undefined ? { ...body, sleeping: false } : body))
|
|
1669
|
+
.sort((left, right) => compareStrings(left.id, right.id));
|
|
997
1670
|
const duplicate = sorted.find((body, index) => index > 0 && body.id === sorted[index - 1].id);
|
|
998
1671
|
if (duplicate !== undefined) {
|
|
999
1672
|
throw new Error(`Duplicate deterministic physics body id ${duplicate.id}`);
|
|
1000
1673
|
}
|
|
1674
|
+
if (mode === 'strict') {
|
|
1675
|
+
for (const body of sorted) {
|
|
1676
|
+
validatePhysicsBody3D(body);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1001
1679
|
return sorted;
|
|
1002
1680
|
}
|
|
1681
|
+
function isFiniteNumber(value) {
|
|
1682
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
1683
|
+
}
|
|
1684
|
+
function validatePhysicsBody3D(body) {
|
|
1685
|
+
const id = body.id;
|
|
1686
|
+
for (const [name, value] of [['x', body.x], ['y', body.y], ['z', body.z], ['vx', body.vx], ['vy', body.vy], ['vz', body.vz]]) {
|
|
1687
|
+
if (!isFiniteNumber(value)) {
|
|
1688
|
+
throw new Error(`physics3d body ${id}: ${name} must be a finite number`);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
if (body.mass !== undefined && (!isFiniteNumber(body.mass) || body.mass <= 0)) {
|
|
1692
|
+
throw new Error(`physics3d body ${id}: mass must be a positive finite number`);
|
|
1693
|
+
}
|
|
1694
|
+
if (body.angularVel !== undefined) {
|
|
1695
|
+
for (const [name, value] of [['angularVel.x', body.angularVel.x], ['angularVel.y', body.angularVel.y], ['angularVel.z', body.angularVel.z]]) {
|
|
1696
|
+
if (!isFiniteNumber(value)) {
|
|
1697
|
+
throw new Error(`physics3d body ${id}: ${name} must be a finite number`);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
for (const [name, value] of [['linearDamping', body.linearDamping], ['angularDamping', body.angularDamping], ['gravityScale', body.gravityScale]]) {
|
|
1702
|
+
if (value !== undefined && !isFiniteNumber(value)) {
|
|
1703
|
+
throw new Error(`physics3d body ${id}: ${name} must be a finite number`);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
if (body.orientation !== undefined) {
|
|
1707
|
+
const q = body.orientation;
|
|
1708
|
+
for (const [name, value] of [['orientation.x', q.x], ['orientation.y', q.y], ['orientation.z', q.z], ['orientation.w', q.w]]) {
|
|
1709
|
+
if (!isFiniteNumber(value)) {
|
|
1710
|
+
throw new Error(`physics3d body ${id}: ${name} must be a finite number`);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w === 0) {
|
|
1714
|
+
throw new Error(`physics3d body ${id}: orientation quaternion must have non-zero length`);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
validatePhysicsShape3D(id, body.shape);
|
|
1718
|
+
}
|
|
1719
|
+
function validatePhysicsShape3D(id, shape) {
|
|
1720
|
+
if (shape.type === 'box') {
|
|
1721
|
+
for (const [name, value] of [['halfX', shape.halfX], ['halfY', shape.halfY], ['halfZ', shape.halfZ]]) {
|
|
1722
|
+
if (!isFiniteNumber(value) || value <= 0) {
|
|
1723
|
+
throw new Error(`physics3d body ${id}: box ${name} must be a positive finite number`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (shape.type === 'sphere') {
|
|
1729
|
+
if (!isFiniteNumber(shape.radius) || shape.radius <= 0) {
|
|
1730
|
+
throw new Error(`physics3d body ${id}: sphere radius must be a positive finite number`);
|
|
1731
|
+
}
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
if (shape.type === 'capsule') {
|
|
1735
|
+
if (!isFiniteNumber(shape.radius) || shape.radius <= 0) {
|
|
1736
|
+
throw new Error(`physics3d body ${id}: capsule radius must be a positive finite number`);
|
|
1737
|
+
}
|
|
1738
|
+
if (!isFiniteNumber(shape.halfHeight) || shape.halfHeight <= 0) {
|
|
1739
|
+
throw new Error(`physics3d body ${id}: capsule halfHeight must be a positive finite number`);
|
|
1740
|
+
}
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (shape.type === 'compound') {
|
|
1744
|
+
if (shape.children.length === 0) {
|
|
1745
|
+
throw new Error(`physics3d body ${id}: compound collider must have at least one child`);
|
|
1746
|
+
}
|
|
1747
|
+
for (const child of shape.children) {
|
|
1748
|
+
for (const [name, value] of [['offset.x', child.offset.x], ['offset.y', child.offset.y], ['offset.z', child.offset.z]]) {
|
|
1749
|
+
if (!isFiniteNumber(value)) {
|
|
1750
|
+
throw new Error(`physics3d body ${id}: compound child ${name} must be a finite number`);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
validatePhysicsShape3D(id, child.shape);
|
|
1754
|
+
}
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
// mesh: triangle-list validation happens in the geometry paths (meshTriangles).
|
|
1758
|
+
}
|
|
1003
1759
|
function queryBodyAllowed(body, query) {
|
|
1004
1760
|
if (query.layerMask !== undefined && (body.layer & query.layerMask) === 0) {
|
|
1005
1761
|
return false;
|
|
@@ -1031,8 +1787,20 @@ function queryKindAllowsBody3D(body, query) {
|
|
|
1031
1787
|
}
|
|
1032
1788
|
return query.hitKinematics === true;
|
|
1033
1789
|
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Per-body bounds memo. Bodies are immutable and, on trusted normalize paths,
|
|
1792
|
+
* keep their identity across world snapshots while untouched (static props,
|
|
1793
|
+
* sleeping bodies) — so their bounds are derived once, not once per frame.
|
|
1794
|
+
*/
|
|
1795
|
+
const bodyBoundsCache = new WeakMap();
|
|
1034
1796
|
function toBounds(body) {
|
|
1035
|
-
|
|
1797
|
+
const cached = bodyBoundsCache.get(body);
|
|
1798
|
+
if (cached !== undefined) {
|
|
1799
|
+
return cached;
|
|
1800
|
+
}
|
|
1801
|
+
const bounds = shapePoseBounds(bodyPose(body));
|
|
1802
|
+
bodyBoundsCache.set(body, bounds);
|
|
1803
|
+
return bounds;
|
|
1036
1804
|
}
|
|
1037
1805
|
function shapeBounds(x, y, z, shape) {
|
|
1038
1806
|
return shapePoseBounds({ x, y, z, shape });
|
|
@@ -1043,6 +1811,18 @@ function bodyPose(body) {
|
|
|
1043
1811
|
function shapePoseBounds(pose) {
|
|
1044
1812
|
const { x, y, z, shape } = pose;
|
|
1045
1813
|
if (shape.type === 'box') {
|
|
1814
|
+
// unrotated boxes (the overwhelmingly common case) skip the 8-corner
|
|
1815
|
+
// expansion; x + (-half) === x - half in IEEE, so values are identical
|
|
1816
|
+
if ((pose.rx ?? 0) === 0 && (pose.ry ?? 0) === 0 && (pose.rz ?? 0) === 0) {
|
|
1817
|
+
return {
|
|
1818
|
+
minX: x - shape.halfX,
|
|
1819
|
+
maxX: x + shape.halfX,
|
|
1820
|
+
minY: y - shape.halfY,
|
|
1821
|
+
maxY: y + shape.halfY,
|
|
1822
|
+
minZ: z - shape.halfZ,
|
|
1823
|
+
maxZ: z + shape.halfZ,
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1046
1826
|
return boundsFromPoints(boxLocalCorners(shape).map((point) => translateRotatedPoint(point, pose)));
|
|
1047
1827
|
}
|
|
1048
1828
|
if (shape.type === 'mesh') {
|
|
@@ -1051,6 +1831,21 @@ function shapePoseBounds(pose) {
|
|
|
1051
1831
|
}
|
|
1052
1832
|
return boundsFromPoints(shape.vertices.map((vertex) => translateRotatedPoint(vertex, pose)));
|
|
1053
1833
|
}
|
|
1834
|
+
if (shape.type === 'compound') {
|
|
1835
|
+
const childBounds = shape.children.map((child) => {
|
|
1836
|
+
const center = translateRotatedPoint(child.offset, pose);
|
|
1837
|
+
return shapePoseBounds({
|
|
1838
|
+
x: center.x,
|
|
1839
|
+
y: center.y,
|
|
1840
|
+
z: center.z,
|
|
1841
|
+
rx: child.quarterTurns?.x,
|
|
1842
|
+
ry: child.quarterTurns?.y,
|
|
1843
|
+
rz: child.quarterTurns?.z,
|
|
1844
|
+
shape: child.shape,
|
|
1845
|
+
});
|
|
1846
|
+
});
|
|
1847
|
+
return unionBounds3D(childBounds);
|
|
1848
|
+
}
|
|
1054
1849
|
const radius = shape.type === 'sphere' ? shape.radius : shape.radius;
|
|
1055
1850
|
const halfY = shape.type === 'capsule' ? shape.halfHeight + radius : radius;
|
|
1056
1851
|
return { minX: x - radius, maxX: x + radius, minY: y - halfY, maxY: y + halfY, minZ: z - radius, maxZ: z + radius };
|
|
@@ -1156,6 +1951,12 @@ function angularGeometryProof() {
|
|
|
1156
1951
|
&& overlap.includes('rotated-box'));
|
|
1157
1952
|
}
|
|
1158
1953
|
function shapesOverlap3D(left, right) {
|
|
1954
|
+
if (left.shape.type === 'compound') {
|
|
1955
|
+
return compoundChildPoses(left).some((childPose) => shapesOverlap3D(childPose, right));
|
|
1956
|
+
}
|
|
1957
|
+
if (right.shape.type === 'compound') {
|
|
1958
|
+
return compoundChildPoses(right).some((childPose) => shapesOverlap3D(left, childPose));
|
|
1959
|
+
}
|
|
1159
1960
|
if (left.shape.type === 'mesh') {
|
|
1160
1961
|
return meshOverlapsShape(left, right);
|
|
1161
1962
|
}
|
|
@@ -1222,7 +2023,29 @@ function meshOverlapsShape(mesh, other) {
|
|
|
1222
2023
|
const bounds = shapeBounds(other.x, other.y, other.z, otherShape);
|
|
1223
2024
|
return triangles.some((triangle) => triangleBoxOverlap(triangle, bounds));
|
|
1224
2025
|
}
|
|
1225
|
-
|
|
2026
|
+
if (otherShape.type === 'mesh') {
|
|
2027
|
+
return triangles.some((left) => meshTriangles({ ...other, shape: otherShape }).some((right) => trianglesOverlap(left, right)));
|
|
2028
|
+
}
|
|
2029
|
+
// compound-vs-mesh: compound children are dynamic-only primitives; mesh colliders are static-only.
|
|
2030
|
+
return compoundChildPoses(other).some((childPose) => meshOverlapsShape(mesh, childPose));
|
|
2031
|
+
}
|
|
2032
|
+
function compoundChildPoses(pose) {
|
|
2033
|
+
const compound = pose.shape;
|
|
2034
|
+
if (compound.type !== 'compound') {
|
|
2035
|
+
return [];
|
|
2036
|
+
}
|
|
2037
|
+
return compound.children.map((child) => {
|
|
2038
|
+
const center = translateRotatedPoint(child.offset, pose);
|
|
2039
|
+
return {
|
|
2040
|
+
x: center.x,
|
|
2041
|
+
y: center.y,
|
|
2042
|
+
z: center.z,
|
|
2043
|
+
rx: (pose.rx ?? 0) + (child.quarterTurns?.x ?? 0),
|
|
2044
|
+
ry: (pose.ry ?? 0) + (child.quarterTurns?.y ?? 0),
|
|
2045
|
+
rz: (pose.rz ?? 0) + (child.quarterTurns?.z ?? 0),
|
|
2046
|
+
shape: child.shape,
|
|
2047
|
+
};
|
|
2048
|
+
});
|
|
1226
2049
|
}
|
|
1227
2050
|
function meshTriangles(pose) {
|
|
1228
2051
|
const shape = pose.shape;
|
|
@@ -1487,6 +2310,19 @@ function pointInBounds(point, bounds) {
|
|
|
1487
2310
|
&& point.y >= bounds.minY && point.y <= bounds.maxY
|
|
1488
2311
|
&& point.z >= bounds.minZ && point.z <= bounds.maxZ;
|
|
1489
2312
|
}
|
|
2313
|
+
function unionBounds3D(boundsList) {
|
|
2314
|
+
if (boundsList.length === 0) {
|
|
2315
|
+
throw new Error('unionBounds3D requires at least one bounds');
|
|
2316
|
+
}
|
|
2317
|
+
return boundsList.reduce((accumulator, bounds) => ({
|
|
2318
|
+
minX: Math.min(accumulator.minX, bounds.minX),
|
|
2319
|
+
maxX: Math.max(accumulator.maxX, bounds.maxX),
|
|
2320
|
+
minY: Math.min(accumulator.minY, bounds.minY),
|
|
2321
|
+
maxY: Math.max(accumulator.maxY, bounds.maxY),
|
|
2322
|
+
minZ: Math.min(accumulator.minZ, bounds.minZ),
|
|
2323
|
+
maxZ: Math.max(accumulator.maxZ, bounds.maxZ),
|
|
2324
|
+
}));
|
|
2325
|
+
}
|
|
1490
2326
|
function subtractPoint(left, right) {
|
|
1491
2327
|
return { x: left.x - right.x, y: left.y - right.y, z: left.z - right.z };
|
|
1492
2328
|
}
|
|
@@ -1531,6 +2367,19 @@ function rayAabbDistance(bounds, query) {
|
|
|
1531
2367
|
return distance === undefined ? undefined : quantizeDistance(distance);
|
|
1532
2368
|
}
|
|
1533
2369
|
function rayBodyDistance(body, query) {
|
|
2370
|
+
if (body.orientation !== undefined) {
|
|
2371
|
+
// transform the ray into the body's local frame (isometry preserves the hit
|
|
2372
|
+
// distance for a unit-direction ray), then raycast the axis-aligned shape.
|
|
2373
|
+
const localOrigin = rotatePointByQuatInverse(body.orientation, { x: query.x - body.x, y: query.y - body.y, z: query.z - body.z });
|
|
2374
|
+
const localDir = rotatePointByQuatInverse(body.orientation, { x: query.dx, y: query.dy, z: query.dz });
|
|
2375
|
+
const localQuery = {
|
|
2376
|
+
...query,
|
|
2377
|
+
x: localOrigin.x, y: localOrigin.y, z: localOrigin.z,
|
|
2378
|
+
dx: localDir.x, dy: localDir.y, dz: localDir.z,
|
|
2379
|
+
};
|
|
2380
|
+
const localBody = { ...body, x: 0, y: 0, z: 0, orientation: undefined, rx: undefined, ry: undefined, rz: undefined };
|
|
2381
|
+
return rayBodyDistance(localBody, localQuery);
|
|
2382
|
+
}
|
|
1534
2383
|
if (body.shape.type === 'sphere') {
|
|
1535
2384
|
return raySphereDistance(query.x, query.y, query.z, query.dx, query.dy, query.dz, body.x, body.y, body.z, body.shape.radius, query.maxDistance);
|
|
1536
2385
|
}
|
|
@@ -1543,7 +2392,22 @@ function rayBodyDistance(body, query) {
|
|
|
1543
2392
|
return rayAabbDistance(toBounds(body), query);
|
|
1544
2393
|
}
|
|
1545
2394
|
function shapeCastBodyDistance(body, query) {
|
|
1546
|
-
if (
|
|
2395
|
+
if (body.orientation !== undefined) {
|
|
2396
|
+
// Transform the cast into the body's local frame (isometry preserves the hit
|
|
2397
|
+
// distance for a unit-direction cast), then cast against the axis-aligned shape.
|
|
2398
|
+
const localOrigin = rotatePointByQuatInverse(body.orientation, { x: query.x - body.x, y: query.y - body.y, z: query.z - body.z });
|
|
2399
|
+
const localDir = rotatePointByQuatInverse(body.orientation, { x: query.dx, y: query.dy, z: query.dz });
|
|
2400
|
+
const localQuery = {
|
|
2401
|
+
...query,
|
|
2402
|
+
x: localOrigin.x, y: localOrigin.y, z: localOrigin.z,
|
|
2403
|
+
dx: localDir.x, dy: localDir.y, dz: localDir.z,
|
|
2404
|
+
};
|
|
2405
|
+
const localBody = { ...body, x: 0, y: 0, z: 0, orientation: undefined, rx: undefined, ry: undefined, rz: undefined };
|
|
2406
|
+
return shapeCastBodyDistance(localBody, localQuery);
|
|
2407
|
+
}
|
|
2408
|
+
// exact initial-overlap check without constructing a throwaway single-body
|
|
2409
|
+
// world (normalizeBodies clones + validates per call — a measured hot spot)
|
|
2410
|
+
if (bodyOverlapsQueryShape(body, query.shape, query.x, query.y, query.z, shapeBounds(query.x, query.y, query.z, query.shape))) {
|
|
1547
2411
|
return 0;
|
|
1548
2412
|
}
|
|
1549
2413
|
if (query.shape.type === 'sphere') {
|
|
@@ -1790,41 +2654,6 @@ function expandBoundsByBounds(bounds, extents) {
|
|
|
1790
2654
|
maxZ: bounds.maxZ - extents.minZ,
|
|
1791
2655
|
};
|
|
1792
2656
|
}
|
|
1793
|
-
function quantizeDistance(distance) {
|
|
1794
|
-
// Pure integer rounding, not Number.prototype.toFixed — see physics2d
|
|
1795
|
-
// quantizeDistance (cross-engine half-way divergence on checksummed state).
|
|
1796
|
-
const quantized = Math.round(distance * 1e6) / 1e6;
|
|
1797
|
-
return quantized === 0 ? 0 : quantized;
|
|
1798
|
-
}
|
|
1799
|
-
function quantizeAngle(value) {
|
|
1800
|
-
const wrapped = value - Math.floor(value);
|
|
1801
|
-
const quantizedAngle = Math.round(wrapped * 1e6) / 1e6;
|
|
1802
|
-
return quantizedAngle === 0 ? 0 : quantizedAngle;
|
|
1803
|
-
}
|
|
1804
|
-
function deterministicSqrt(value) {
|
|
1805
|
-
if (value <= 0) {
|
|
1806
|
-
return 0;
|
|
1807
|
-
}
|
|
1808
|
-
// Seed the Newton iteration from the argument's magnitude (2^ceil(bits/2)):
|
|
1809
|
-
// starting from `value` itself needs ~0.5*log2(value) halving steps before
|
|
1810
|
-
// the quadratic regime, so 8 iterations only converged for tiny inputs —
|
|
1811
|
-
// at realistic fixed-point magnitudes (dx^2+dy^2 ~ 1e10) the old seed
|
|
1812
|
-
// returned results off by orders of magnitude. Every operation here is
|
|
1813
|
-
// exact integer/IEEE-deterministic arithmetic, so all peers still agree.
|
|
1814
|
-
let estimate = 1;
|
|
1815
|
-
let magnitude = value;
|
|
1816
|
-
while (magnitude >= 4) {
|
|
1817
|
-
magnitude /= 4;
|
|
1818
|
-
estimate *= 2;
|
|
1819
|
-
}
|
|
1820
|
-
if (magnitude > 1) {
|
|
1821
|
-
estimate *= 2;
|
|
1822
|
-
}
|
|
1823
|
-
for (let iteration = 0; iteration < 8; iteration += 1) {
|
|
1824
|
-
estimate = (estimate + value / estimate) / 2;
|
|
1825
|
-
}
|
|
1826
|
-
return quantizeDistance(estimate);
|
|
1827
|
-
}
|
|
1828
2657
|
function countCandidatePairs(bodies) {
|
|
1829
2658
|
return broadphaseCandidatePairs(bodies).length;
|
|
1830
2659
|
}
|