@forgeax/engine-physics-rapier3d 0.1.28 → 0.1.30
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/__tests__/contact-observation.unit.test.d.ts +2 -0
- package/dist/__tests__/contact-observation.unit.test.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +512 -321
- package/dist/index.mjs.map +1 -1
- package/dist/rapier-physics-world-3d.d.ts +4 -2
- package/dist/rapier-physics-world-3d.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/contact-observation.unit.test.ts +113 -0
- package/src/__tests__/derived-physics.unit.test.ts +482 -1
- package/src/index.ts +4 -4
- package/src/rapier-physics-world-3d.ts +643 -384
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
} from '@forgeax/engine-physics';
|
|
61
61
|
import { ChildOf } from '@forgeax/engine-scene';
|
|
62
62
|
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
63
|
-
import type { Rapier3DModule } from './wasm-loader';
|
|
63
|
+
import type { Rapier3DModule } from './wasm-loader.js';
|
|
64
64
|
|
|
65
65
|
interface Rapier3DKinematicControllerState {
|
|
66
66
|
readonly entity: number;
|
|
@@ -231,6 +231,12 @@ interface DerivedCandidateRecord {
|
|
|
231
231
|
readonly bytes: number;
|
|
232
232
|
state: DerivedPhysicsCandidateState;
|
|
233
233
|
commitGeometry?: () => Result<void, Error>;
|
|
234
|
+
batch?: DerivedAdmissionBatch;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface DerivedAdmissionBatch {
|
|
238
|
+
readonly records: readonly DerivedCandidateRecord[];
|
|
239
|
+
readonly commitGeometry?: () => Result<void, Error>;
|
|
234
240
|
}
|
|
235
241
|
|
|
236
242
|
interface DerivedConstraintRecord {
|
|
@@ -536,8 +542,10 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
536
542
|
|
|
537
543
|
step(deltaTime: number): void {
|
|
538
544
|
this.assertActive('step');
|
|
539
|
-
|
|
545
|
+
if (!Number.isFinite(deltaTime) || deltaTime <= 0 || deltaTime > PHYSICS_DT_MAX) return;
|
|
540
546
|
try {
|
|
547
|
+
// Native integration must consume the same fixed delta as force controllers.
|
|
548
|
+
(this.raw as { timestep: number }).timestep = deltaTime;
|
|
541
549
|
this.processDerivedCandidates();
|
|
542
550
|
// A failed native rollback is an explicit rebuild boundary. Physics does
|
|
543
551
|
// not advance or publish a mixed state after this point.
|
|
@@ -625,6 +633,8 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
625
633
|
): void {
|
|
626
634
|
let point: PhysicsVector | undefined;
|
|
627
635
|
let normal: PhysicsVector | undefined;
|
|
636
|
+
let geometricPoint: PhysicsVector | undefined;
|
|
637
|
+
let geometricNormal: PhysicsVector | undefined;
|
|
628
638
|
try {
|
|
629
639
|
const colliderA = (this.raw as RapierWorld).getCollider(handleA);
|
|
630
640
|
const colliderB = (this.raw as RapierWorld).getCollider(handleB);
|
|
@@ -633,6 +643,44 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
633
643
|
colliderA,
|
|
634
644
|
colliderB,
|
|
635
645
|
(manifold: RapierWorld, flipped: boolean) => {
|
|
646
|
+
// A contact manifold can retain geometric points without solver
|
|
647
|
+
// points. Composite local points are subshape-local, so only
|
|
648
|
+
// transform points from the ordinary primitive collider surface.
|
|
649
|
+
if (
|
|
650
|
+
manifold.numSolverContacts() === 0 &&
|
|
651
|
+
geometricPoint === undefined &&
|
|
652
|
+
manifold.numContacts() > 0
|
|
653
|
+
) {
|
|
654
|
+
const first = flipped ? colliderB : colliderA;
|
|
655
|
+
const second = flipped ? colliderA : colliderB;
|
|
656
|
+
const types = this.rapierModule.ShapeType;
|
|
657
|
+
for (const [collider, local] of [
|
|
658
|
+
[first, manifold.localContactPoint1(0)],
|
|
659
|
+
[second, manifold.localContactPoint2(0)],
|
|
660
|
+
]) {
|
|
661
|
+
if (
|
|
662
|
+
local == null ||
|
|
663
|
+
![types.Ball, types.Cuboid, types.Capsule].includes(collider.shapeType())
|
|
664
|
+
)
|
|
665
|
+
continue;
|
|
666
|
+
const rotation = collider.rotation();
|
|
667
|
+
const rotated = quat.transformVec3(
|
|
668
|
+
vec3.create(),
|
|
669
|
+
[rotation.x, rotation.y, rotation.z, rotation.w],
|
|
670
|
+
[local.x, local.y, local.z],
|
|
671
|
+
);
|
|
672
|
+
const position = collider.translation();
|
|
673
|
+
geometricPoint = [
|
|
674
|
+
rotated[0] + position.x,
|
|
675
|
+
rotated[1] + position.y,
|
|
676
|
+
rotated[2] + position.z,
|
|
677
|
+
];
|
|
678
|
+
const n = manifold.normal();
|
|
679
|
+
const direction = flipped ? -1 : 1;
|
|
680
|
+
geometricNormal = [n.x * direction, n.y * direction, n.z * direction];
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
636
684
|
if (manifold.numSolverContacts?.() > 0) {
|
|
637
685
|
const contact = manifold.solverContactPoint(0);
|
|
638
686
|
const n = manifold.normal();
|
|
@@ -651,6 +699,10 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
651
699
|
// Contact events remain useful without optional manifold sampling. The
|
|
652
700
|
// public type makes point/normal optional rather than inventing values.
|
|
653
701
|
}
|
|
702
|
+
if (point === undefined && geometricPoint !== undefined) {
|
|
703
|
+
point = geometricPoint;
|
|
704
|
+
normal = geometricNormal;
|
|
705
|
+
}
|
|
654
706
|
this.derivedContacts.push({
|
|
655
707
|
...observation,
|
|
656
708
|
...(point === undefined ? {} : { point }),
|
|
@@ -906,14 +958,85 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
906
958
|
return admitted;
|
|
907
959
|
}
|
|
908
960
|
|
|
961
|
+
admitDerivedShapeCandidates(
|
|
962
|
+
candidates: readonly DerivedPhysicsCandidate[],
|
|
963
|
+
commitGeometry?: () => Result<void, Error>,
|
|
964
|
+
): ReturnType<NonNullable<PhysicsWorld['admitDerivedShapeCandidates']>> {
|
|
965
|
+
this.assertActive('admitDerivedShapeCandidates');
|
|
966
|
+
const records: DerivedCandidateRecord[] = [];
|
|
967
|
+
const sources = new Map<number, DerivedBodySource>();
|
|
968
|
+
const constraints = new Set<string>();
|
|
969
|
+
const invalid = (reason: string) =>
|
|
970
|
+
new DerivedPhysicsError(
|
|
971
|
+
'derived-candidate-invalid',
|
|
972
|
+
'one bounded admission contains distinct prepared bodies and constraint updates',
|
|
973
|
+
'prepare one candidate per body and submit the complete replacement together',
|
|
974
|
+
{ reason },
|
|
975
|
+
);
|
|
976
|
+
if (candidates.length === 0 || candidates.length > DERIVED_PHYSICS_LIMITS.maxCandidates)
|
|
977
|
+
return err(invalid('invalid batch size'));
|
|
978
|
+
// Resolve credentials before mutating the queue. Never use caller input as
|
|
979
|
+
// the source projection or invalidate another owner's matching token ID.
|
|
980
|
+
for (const candidate of candidates) {
|
|
981
|
+
const record = this.derivedCandidates.get(candidate.candidateId);
|
|
982
|
+
if (
|
|
983
|
+
record === undefined ||
|
|
984
|
+
candidate.owner !== this.physicsOwner ||
|
|
985
|
+
candidate.generation !== this.backendGeneration ||
|
|
986
|
+
record.state !== 'ready'
|
|
987
|
+
)
|
|
988
|
+
return err(invalid('batch member is not a current prepared candidate'));
|
|
989
|
+
if (sources.has(record.input.entity)) return err(invalid('duplicate body'));
|
|
990
|
+
for (const constraint of record.input.constraints ?? []) {
|
|
991
|
+
if (constraints.has(constraint.id)) return err(invalid('duplicate constraint update'));
|
|
992
|
+
constraints.add(constraint.id);
|
|
993
|
+
}
|
|
994
|
+
records.push(record);
|
|
995
|
+
sources.set(record.input.entity, {
|
|
996
|
+
sourceKey: record.input.sourceKey,
|
|
997
|
+
revision: record.input.revision,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
for (const candidate of candidates) {
|
|
1001
|
+
const checked = this.admitDerivedShapeCandidateInternal(candidate, sources, true);
|
|
1002
|
+
if (!checked.ok) {
|
|
1003
|
+
for (const record of records) {
|
|
1004
|
+
if (this.derivedCandidates.has(record.token.candidateId))
|
|
1005
|
+
this.rejectPreparedCandidate(record, checked.error);
|
|
1006
|
+
}
|
|
1007
|
+
return checked;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const batch: DerivedAdmissionBatch = Object.freeze({
|
|
1011
|
+
records: Object.freeze(records),
|
|
1012
|
+
...(commitGeometry === undefined ? {} : { commitGeometry }),
|
|
1013
|
+
});
|
|
1014
|
+
for (const record of records) record.batch = batch;
|
|
1015
|
+
const queued: DerivedPhysicsCandidate[] = [];
|
|
1016
|
+
for (const candidate of candidates) {
|
|
1017
|
+
const admitted = this.admitDerivedShapeCandidateInternal(candidate, sources);
|
|
1018
|
+
if (!admitted.ok) {
|
|
1019
|
+
const first = records[0];
|
|
1020
|
+
if (first !== undefined) this.rejectPreparedCandidate(first, admitted.error);
|
|
1021
|
+
return admitted;
|
|
1022
|
+
}
|
|
1023
|
+
queued.push(admitted.value);
|
|
1024
|
+
}
|
|
1025
|
+
return ok(Object.freeze(queued));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
909
1028
|
getDerivedAdmission(
|
|
910
1029
|
entity?: number,
|
|
911
1030
|
):
|
|
912
1031
|
| { readonly entity: number; readonly revision: number; readonly fixedStep: number }
|
|
913
1032
|
| undefined {
|
|
914
|
-
const
|
|
915
|
-
|
|
916
|
-
|
|
1033
|
+
const active = this.activeDerivedAdmission;
|
|
1034
|
+
const record =
|
|
1035
|
+
entity === undefined
|
|
1036
|
+
? active
|
|
1037
|
+
: (active?.batch?.records.find((item) => item.input.entity === entity) ??
|
|
1038
|
+
(active?.input.entity === entity ? active : undefined));
|
|
1039
|
+
if (record === undefined) return undefined;
|
|
917
1040
|
return {
|
|
918
1041
|
entity: record.input.entity,
|
|
919
1042
|
revision: record.input.revision,
|
|
@@ -924,6 +1047,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
924
1047
|
private admitDerivedShapeCandidateInternal(
|
|
925
1048
|
candidate: DerivedPhysicsCandidate,
|
|
926
1049
|
sourceOverrides?: ReadonlyMap<number, DerivedBodySource>,
|
|
1050
|
+
validateOnly = false,
|
|
927
1051
|
): ReturnType<NonNullable<PhysicsWorld['admitDerivedShapeCandidate']>> {
|
|
928
1052
|
this.assertActive('admitDerivedShapeCandidate');
|
|
929
1053
|
const record = this.derivedCandidates.get(candidate.candidateId);
|
|
@@ -1012,6 +1136,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1012
1136
|
this.rejectPreparedCandidate(record, stale);
|
|
1013
1137
|
return err(stale);
|
|
1014
1138
|
}
|
|
1139
|
+
if (validateOnly) return ok(record.token);
|
|
1015
1140
|
record.state = 'queued';
|
|
1016
1141
|
this.pendingDerivedCandidates.add(candidate.candidateId);
|
|
1017
1142
|
const queued = Object.freeze({ ...record.token, state: 'queued' as const });
|
|
@@ -1084,10 +1209,12 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1084
1209
|
),
|
|
1085
1210
|
);
|
|
1086
1211
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1212
|
+
for (const member of record.batch?.records ?? [record]) {
|
|
1213
|
+
this.pendingDerivedCandidates.delete(member.token.candidateId);
|
|
1214
|
+
for (const collider of member.nativeColliders) this.removeNativeCollider(collider.handle);
|
|
1215
|
+
member.state = 'cancelled';
|
|
1216
|
+
this.releaseDerivedCandidate(member.token.candidateId);
|
|
1217
|
+
}
|
|
1091
1218
|
return ok(undefined);
|
|
1092
1219
|
}
|
|
1093
1220
|
|
|
@@ -1143,13 +1270,83 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1143
1270
|
const com = body.worldCom();
|
|
1144
1271
|
const linear = body.linvel();
|
|
1145
1272
|
const angular = body.angvel();
|
|
1273
|
+
const rotation = body.rotation();
|
|
1146
1274
|
return Object.freeze({
|
|
1147
1275
|
centerOfMass: [com.x, com.y, com.z] as PhysicsVector,
|
|
1148
1276
|
linearVelocity: [linear.x, linear.y, linear.z] as PhysicsVector,
|
|
1149
1277
|
angularVelocity: [angular.x, angular.y, angular.z] as PhysicsVector,
|
|
1278
|
+
rotation: [rotation.x, rotation.y, rotation.z, rotation.w] as PhysicsQuaternion,
|
|
1150
1279
|
});
|
|
1151
1280
|
}
|
|
1152
1281
|
|
|
1282
|
+
applyDerivedImpulse(
|
|
1283
|
+
input: Parameters<NonNullable<PhysicsWorld['applyDerivedImpulse']>>[0],
|
|
1284
|
+
): Result<void, DerivedPhysicsError> {
|
|
1285
|
+
this.assertActive('applyDerivedImpulse');
|
|
1286
|
+
const refuse = (code: DerivedPhysicsError['code'], expected: string) =>
|
|
1287
|
+
err(
|
|
1288
|
+
new DerivedPhysicsError(
|
|
1289
|
+
code,
|
|
1290
|
+
expected,
|
|
1291
|
+
'read the committed dynamic body and submit a finite impulse outside pending admission',
|
|
1292
|
+
{ entity: input.entity },
|
|
1293
|
+
),
|
|
1294
|
+
);
|
|
1295
|
+
if (this.recoveryBlocked())
|
|
1296
|
+
return refuse('derived-recovery-invalid', 'PhysicsWorld is healthy');
|
|
1297
|
+
const record = this.derivedBodies.get(input.entity);
|
|
1298
|
+
const body = this.bodyForEntity(input.entity);
|
|
1299
|
+
if (record === undefined || body === undefined)
|
|
1300
|
+
return refuse('derived-body-not-found', 'a committed derived body exists');
|
|
1301
|
+
if (record.sourceKey !== input.sourceKey || record.revision !== input.revision)
|
|
1302
|
+
return refuse(
|
|
1303
|
+
'derived-candidate-stale',
|
|
1304
|
+
'impulse identity matches the committed body revision',
|
|
1305
|
+
);
|
|
1306
|
+
if (
|
|
1307
|
+
this.derivedPublicationPending ||
|
|
1308
|
+
[...this.pendingDerivedCandidates].some(
|
|
1309
|
+
(id) => this.derivedCandidates.get(id)?.input.entity === input.entity,
|
|
1310
|
+
)
|
|
1311
|
+
)
|
|
1312
|
+
return refuse('derived-candidate-pending', 'the body has no unpublished native mutation');
|
|
1313
|
+
if (
|
|
1314
|
+
rapierBodyTypeToString(this.rapierModule, body.bodyType()) !== 'dynamic' ||
|
|
1315
|
+
![input.impulse, input.point].every(
|
|
1316
|
+
(vector) =>
|
|
1317
|
+
Array.isArray(vector) &&
|
|
1318
|
+
vector.length === 3 &&
|
|
1319
|
+
vector.every((value) => Number.isFinite(value) && Number.isFinite(Math.fround(value))),
|
|
1320
|
+
)
|
|
1321
|
+
)
|
|
1322
|
+
return refuse(
|
|
1323
|
+
'derived-candidate-invalid',
|
|
1324
|
+
'a dynamic body receives finite Float32 world vectors',
|
|
1325
|
+
);
|
|
1326
|
+
try {
|
|
1327
|
+
body.applyImpulseAtPoint(
|
|
1328
|
+
{ x: input.impulse[0], y: input.impulse[1], z: input.impulse[2] },
|
|
1329
|
+
{ x: input.point[0], y: input.point[1], z: input.point[2] },
|
|
1330
|
+
true,
|
|
1331
|
+
);
|
|
1332
|
+
const linear = body.linvel(),
|
|
1333
|
+
angular = body.angvel();
|
|
1334
|
+
if (![linear.x, linear.y, linear.z, angular.x, angular.y, angular.z].every(Number.isFinite))
|
|
1335
|
+
throw new Error('native impulse produced non-finite motion');
|
|
1336
|
+
return ok(undefined);
|
|
1337
|
+
} catch (cause) {
|
|
1338
|
+
this.derivedPoisonedEntities.add(input.entity);
|
|
1339
|
+
return err(
|
|
1340
|
+
new DerivedPhysicsError(
|
|
1341
|
+
'derived-backend-failed',
|
|
1342
|
+
'native impulse completes with finite motion',
|
|
1343
|
+
'rebuild the World from a previously committed snapshot',
|
|
1344
|
+
{ entity: input.entity, reason: cause instanceof Error ? cause.message : String(cause) },
|
|
1345
|
+
),
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1153
1350
|
getDerivedRecoveryState(): 'ready' | 'rebuild-required' {
|
|
1154
1351
|
return this.recoveryBlocked() ? 'rebuild-required' : 'ready';
|
|
1155
1352
|
}
|
|
@@ -1251,20 +1448,8 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1251
1448
|
}
|
|
1252
1449
|
preparedCandidates.push(prepared.value);
|
|
1253
1450
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const admitted = this.admitDerivedShapeCandidateInternal(prepared, snapshotSources);
|
|
1257
|
-
if (!admitted.ok) {
|
|
1258
|
-
// Admission may have created disabled native colliders before a later
|
|
1259
|
-
// body/constraint row fails. Cancel every prepared credential, not
|
|
1260
|
-
// only rows that reached the local `candidates` list: the rejected
|
|
1261
|
-
// row and any rows after it also own native staging state.
|
|
1262
|
-
for (const candidate of preparedCandidates) this.cancelDerivedShapeCandidate(candidate);
|
|
1263
|
-
return admitted;
|
|
1264
|
-
}
|
|
1265
|
-
candidates.push(admitted.value);
|
|
1266
|
-
}
|
|
1267
|
-
return ok(candidates);
|
|
1451
|
+
if (preparedCandidates.length === 0) return ok([]);
|
|
1452
|
+
return this.admitDerivedShapeCandidates(preparedCandidates);
|
|
1268
1453
|
}
|
|
1269
1454
|
|
|
1270
1455
|
createDerivedConstraint(
|
|
@@ -1424,8 +1609,11 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1424
1609
|
record: DerivedCandidateRecord,
|
|
1425
1610
|
error: DerivedPhysicsError,
|
|
1426
1611
|
): void {
|
|
1427
|
-
for (const
|
|
1428
|
-
|
|
1612
|
+
for (const member of record.batch?.records ?? [record]) {
|
|
1613
|
+
if (!this.derivedCandidates.has(member.token.candidateId)) continue;
|
|
1614
|
+
for (const collider of member.nativeColliders) this.removeNativeCollider(collider.handle);
|
|
1615
|
+
this.rememberDerivedFailure(member, error, 'old-state-retained');
|
|
1616
|
+
}
|
|
1429
1617
|
}
|
|
1430
1618
|
|
|
1431
1619
|
private processDerivedCandidates(): void {
|
|
@@ -1444,389 +1632,432 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1444
1632
|
// endpoint. This turns a queue-time source projection into a final
|
|
1445
1633
|
// committed-source check: if the endpoint fails natively, its dependent
|
|
1446
1634
|
// candidate sees the old source on the next iteration and is rejected.
|
|
1447
|
-
const ordered: DerivedCandidateRecord[] = [];
|
|
1635
|
+
const ordered: (readonly DerivedCandidateRecord[])[] = [];
|
|
1448
1636
|
const visiting = new Set<string>();
|
|
1449
1637
|
const visited = new Set<string>();
|
|
1450
1638
|
const visit = (record: DerivedCandidateRecord): void => {
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
for (const endpoint of [
|
|
1456
|
-
[constraint.bodyA, constraint.bodyASource],
|
|
1457
|
-
[constraint.bodyB, constraint.bodyBSource],
|
|
1458
|
-
] as const) {
|
|
1459
|
-
const dependency = endpoint[1];
|
|
1460
|
-
const target = pendingByEntity.get(endpoint[0]);
|
|
1461
|
-
if (
|
|
1462
|
-
target !== undefined &&
|
|
1463
|
-
target.input.sourceKey === dependency.sourceKey &&
|
|
1464
|
-
target.input.revision === dependency.revision
|
|
1465
|
-
) {
|
|
1466
|
-
visit(target);
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
visiting.delete(record.token.candidateId);
|
|
1471
|
-
visited.add(record.token.candidateId);
|
|
1472
|
-
ordered.push(record);
|
|
1473
|
-
};
|
|
1474
|
-
for (const record of pendingRecords) visit(record);
|
|
1475
|
-
|
|
1476
|
-
for (const record of ordered) {
|
|
1477
|
-
const id = record.token.candidateId;
|
|
1478
|
-
if (!this.pendingDerivedCandidates.has(id) || record.state !== 'queued') continue;
|
|
1479
|
-
const pendingSources = this.pendingDerivedSources();
|
|
1480
|
-
const projected = pendingSources.get(record.input.entity);
|
|
1481
|
-
if (projected !== undefined && record.input.revision < projected.revision) {
|
|
1639
|
+
const group = record.batch?.records ?? [record];
|
|
1640
|
+
const key = group[0]?.token.candidateId ?? record.token.candidateId;
|
|
1641
|
+
if (visited.has(key)) return;
|
|
1642
|
+
if (visiting.has(key)) {
|
|
1482
1643
|
this.rejectPreparedCandidate(
|
|
1483
1644
|
record,
|
|
1484
1645
|
new DerivedPhysicsError(
|
|
1485
|
-
'derived-
|
|
1486
|
-
'
|
|
1487
|
-
'
|
|
1488
|
-
{
|
|
1489
|
-
entity: record.input.entity,
|
|
1490
|
-
candidateId: record.token.candidateId,
|
|
1491
|
-
expected: `>=${projected.revision}`,
|
|
1492
|
-
actual: record.input.revision,
|
|
1493
|
-
},
|
|
1646
|
+
'derived-constraint-invalid',
|
|
1647
|
+
'cyclic endpoint updates share one batch',
|
|
1648
|
+
'submit mutually dependent body replacements together',
|
|
1649
|
+
{ candidateId: key },
|
|
1494
1650
|
),
|
|
1495
1651
|
);
|
|
1496
|
-
|
|
1652
|
+
return;
|
|
1497
1653
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1654
|
+
visiting.add(key);
|
|
1655
|
+
for (const member of group) {
|
|
1656
|
+
for (const constraint of member.input.constraints ?? []) {
|
|
1657
|
+
for (const [entity, dependency] of [
|
|
1658
|
+
[constraint.bodyA, constraint.bodyASource],
|
|
1659
|
+
[constraint.bodyB, constraint.bodyBSource],
|
|
1660
|
+
] as const) {
|
|
1661
|
+
const target = pendingByEntity.get(entity);
|
|
1662
|
+
if (
|
|
1663
|
+
target !== undefined &&
|
|
1664
|
+
!group.includes(target) &&
|
|
1665
|
+
target.input.sourceKey === dependency.sourceKey &&
|
|
1666
|
+
target.input.revision === dependency.revision
|
|
1667
|
+
)
|
|
1668
|
+
visit(target);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1502
1671
|
}
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1672
|
+
visiting.delete(key);
|
|
1673
|
+
visited.add(key);
|
|
1674
|
+
ordered.push(group);
|
|
1675
|
+
};
|
|
1676
|
+
for (const record of pendingRecords) visit(record);
|
|
1677
|
+
|
|
1678
|
+
for (const group of ordered) {
|
|
1679
|
+
if (this.derivedPoisonedEntities.size > 0) break;
|
|
1680
|
+
const first = group[0];
|
|
1681
|
+
if (first === undefined) continue;
|
|
1682
|
+
if (
|
|
1683
|
+
group.some(
|
|
1684
|
+
(record) =>
|
|
1685
|
+
record.state !== 'queued' ||
|
|
1686
|
+
!this.pendingDerivedCandidates.has(record.token.candidateId),
|
|
1687
|
+
)
|
|
1688
|
+
)
|
|
1516
1689
|
continue;
|
|
1517
|
-
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
new DerivedPhysicsError(
|
|
1690
|
+
// Validate the whole final projection before any native body changes.
|
|
1691
|
+
const sources = this.pendingDerivedSources();
|
|
1692
|
+
let rejected: DerivedPhysicsError | undefined;
|
|
1693
|
+
for (const record of group) {
|
|
1694
|
+
const projected = sources.get(record.input.entity);
|
|
1695
|
+
if (projected !== undefined && record.input.revision < projected.revision) {
|
|
1696
|
+
rejected = new DerivedPhysicsError(
|
|
1697
|
+
'derived-candidate-stale',
|
|
1698
|
+
'fixed-step admission publishes the final queued body revisions',
|
|
1699
|
+
'discard the older group and prepare a complete replacement',
|
|
1700
|
+
{ entity: record.input.entity },
|
|
1701
|
+
);
|
|
1702
|
+
} else if (this.derivedPoisonedEntities.has(record.input.entity)) {
|
|
1703
|
+
rejected = new DerivedPhysicsError(
|
|
1704
|
+
'derived-recovery-invalid',
|
|
1705
|
+
'every batch body has recoverable native state',
|
|
1706
|
+
'rebuild the PhysicsWorld before retrying',
|
|
1707
|
+
{ entity: record.input.entity },
|
|
1708
|
+
);
|
|
1709
|
+
} else if (this.bodyForEntity(record.input.entity) === undefined) {
|
|
1710
|
+
rejected = new DerivedPhysicsError(
|
|
1524
1711
|
'derived-body-not-found',
|
|
1525
|
-
'
|
|
1526
|
-
'reconcile
|
|
1527
|
-
{ entity: record.input.entity
|
|
1528
|
-
)
|
|
1529
|
-
|
|
1530
|
-
);
|
|
1712
|
+
'every batch body remains live through admission',
|
|
1713
|
+
'reconcile entities and prepare again',
|
|
1714
|
+
{ entity: record.input.entity },
|
|
1715
|
+
);
|
|
1716
|
+
} else rejected = this.validateDerivedAdmission(record.input, sources);
|
|
1717
|
+
if (rejected !== undefined) break;
|
|
1718
|
+
}
|
|
1719
|
+
if (rejected !== undefined) {
|
|
1720
|
+
this.rejectPreparedCandidate(first, rejected);
|
|
1531
1721
|
continue;
|
|
1532
1722
|
}
|
|
1533
|
-
const
|
|
1534
|
-
const
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
collider,
|
|
1550
|
-
density: typeof collider.density === 'function' ? collider.density() : undefined,
|
|
1551
|
-
enabled: typeof collider.isEnabled === 'function' ? collider.isEnabled() : true,
|
|
1552
|
-
}));
|
|
1553
|
-
const oldConstraints = new Map(this.derivedConstraints);
|
|
1554
|
-
const stagedConstraints = new Map<string, DerivedConstraintRecord>();
|
|
1555
|
-
let geometryCommitUncertain = false;
|
|
1556
|
-
try {
|
|
1557
|
-
for (const constraint of record.input.constraints ?? []) {
|
|
1558
|
-
const created = this.createNativeConstraint(constraint);
|
|
1559
|
-
if (!created.ok) throw created.error;
|
|
1560
|
-
stagedConstraints.set(constraint.id, {
|
|
1561
|
-
input: { ...constraint },
|
|
1562
|
-
handle: created.value.handle,
|
|
1563
|
-
});
|
|
1564
|
-
}
|
|
1565
|
-
if (record.input.bodyType !== undefined) {
|
|
1566
|
-
const RAPIER = this.rapierModule as RapierWorld;
|
|
1567
|
-
const bodyType =
|
|
1568
|
-
record.input.bodyType === 'static'
|
|
1569
|
-
? RAPIER.RigidBodyType.Fixed
|
|
1570
|
-
: record.input.bodyType === 'kinematic'
|
|
1571
|
-
? RAPIER.RigidBodyType.KinematicPositionBased
|
|
1572
|
-
: RAPIER.RigidBodyType.Dynamic;
|
|
1573
|
-
body.setBodyType(bodyType, true);
|
|
1574
|
-
}
|
|
1575
|
-
for (const native of record.nativeColliders) {
|
|
1576
|
-
const collider = (this.raw as RapierWorld).getCollider(native.handle);
|
|
1577
|
-
if (collider === null || collider === undefined)
|
|
1578
|
-
throw new Error('candidate collider disappeared');
|
|
1579
|
-
collider.setEnabled(true);
|
|
1580
|
-
}
|
|
1581
|
-
const nativeById = new Map(
|
|
1582
|
-
record.input.shapes.map((shape: VoxelShapeInput, index: number) => [
|
|
1583
|
-
shape.id,
|
|
1584
|
-
record.nativeColliders[index]?.handle as number,
|
|
1585
|
-
]),
|
|
1586
|
-
);
|
|
1587
|
-
for (const seam of record.input.seams ?? []) {
|
|
1588
|
-
const firstHandle = nativeById.get(seam.shapeA);
|
|
1589
|
-
const secondHandle = nativeById.get(seam.shapeB);
|
|
1590
|
-
const first =
|
|
1591
|
-
firstHandle === undefined
|
|
1592
|
-
? undefined
|
|
1593
|
-
: (this.raw as RapierWorld).getCollider(firstHandle);
|
|
1594
|
-
const second =
|
|
1595
|
-
secondHandle === undefined
|
|
1596
|
-
? undefined
|
|
1597
|
-
: (this.raw as RapierWorld).getCollider(secondHandle);
|
|
1598
|
-
if (first === undefined || second === undefined || first === null || second === null) {
|
|
1599
|
-
throw new Error(`derived seam references missing shape ${seam.shapeA}`);
|
|
1600
|
-
}
|
|
1601
|
-
first.combineVoxelStates(second, seam.offset[0], seam.offset[1], seam.offset[2]);
|
|
1602
|
-
}
|
|
1603
|
-
this.applyDerivedMass(
|
|
1604
|
-
body,
|
|
1605
|
-
record.input.massProperties,
|
|
1606
|
-
oldCom,
|
|
1607
|
-
record.input.velocityPolicy ?? 'preserve',
|
|
1608
|
-
this.entityMap.get(record.input.entity)?.additionalMass ?? 0,
|
|
1723
|
+
const undos: (() => boolean)[] = [];
|
|
1724
|
+
for (const record of group) {
|
|
1725
|
+
const id = record.token.candidateId;
|
|
1726
|
+
const old = this.derivedBodies.get(record.input.entity);
|
|
1727
|
+
const body = this.bodyForEntity(record.input.entity);
|
|
1728
|
+
const oldBodyType = body.bodyType();
|
|
1729
|
+
const oldBodyEnabled = body.isEnabled();
|
|
1730
|
+
const oldVelocity = body.linvel();
|
|
1731
|
+
const oldAngularVelocity = body.angvel();
|
|
1732
|
+
const oldTranslation = body.translation();
|
|
1733
|
+
const oldRotation = body.rotation();
|
|
1734
|
+
const oldCom = body.worldCom();
|
|
1735
|
+
const oldMass = body.mass();
|
|
1736
|
+
const oldAutomaticAdditionalMass =
|
|
1737
|
+
this.entityMap.get(record.input.entity)?.automaticAdditionalMass ?? 0;
|
|
1738
|
+
const oldAutomaticRecordMass = this.entityMap.get(
|
|
1609
1739
|
record.input.entity,
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
);
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
body.
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
)
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
const committedSources = new Map(this.derivedBodySources);
|
|
1648
|
-
committedSources.set(record.input.entity, {
|
|
1649
|
-
sourceKey: record.input.sourceKey,
|
|
1650
|
-
revision: record.input.revision,
|
|
1651
|
-
});
|
|
1652
|
-
const replacementConstraintIds = new Set(
|
|
1653
|
-
(record.input.constraints ?? []).map((constraint) => constraint.id),
|
|
1654
|
-
);
|
|
1655
|
-
for (const [constraintId, current] of [...this.derivedConstraints]) {
|
|
1656
|
-
if (
|
|
1657
|
-
replacementConstraintIds.has(constraintId) ||
|
|
1658
|
-
this.constraintDependenciesMatch(current.input, committedSources)
|
|
1659
|
-
)
|
|
1660
|
-
continue;
|
|
1661
|
-
this.removeNativeConstraint(current.handle);
|
|
1662
|
-
this.derivedConstraints.delete(constraintId);
|
|
1663
|
-
}
|
|
1664
|
-
if (old !== undefined) {
|
|
1665
|
-
for (const shape of old.shapes) {
|
|
1666
|
-
const collider = (this.raw as RapierWorld).getCollider(shape.colliderHandle);
|
|
1740
|
+
)?.automaticAdditionalMass;
|
|
1741
|
+
const oldSource = this.derivedBodySources.get(record.input.entity);
|
|
1742
|
+
const oldPublication = this.derivedPublications.get(record.input.entity);
|
|
1743
|
+
const oldDensities = this.bodyColliders(body).map((collider) => ({
|
|
1744
|
+
collider,
|
|
1745
|
+
density: typeof collider.density === 'function' ? collider.density() : undefined,
|
|
1746
|
+
enabled: typeof collider.isEnabled === 'function' ? collider.isEnabled() : true,
|
|
1747
|
+
}));
|
|
1748
|
+
const oldConstraints = new Map(this.derivedConstraints);
|
|
1749
|
+
const stagedConstraints = new Map<string, DerivedConstraintRecord>();
|
|
1750
|
+
let geometryCommitUncertain = false;
|
|
1751
|
+
const rollback = (): boolean => {
|
|
1752
|
+
for (const staged of stagedConstraints.values())
|
|
1753
|
+
this.removeNativeConstraint(staged.handle);
|
|
1754
|
+
// The commit path may already have removed/replaced a native joint
|
|
1755
|
+
// before a later body mutation fails. Clear every current native joint
|
|
1756
|
+
// now; the old records are recreated after the body state is restored
|
|
1757
|
+
// instead of leaving a stale JS handle that no longer exists in
|
|
1758
|
+
// Rapier.
|
|
1759
|
+
for (const current of this.derivedConstraints.values())
|
|
1760
|
+
this.removeNativeConstraint(current.handle);
|
|
1761
|
+
this.derivedConstraints.clear();
|
|
1762
|
+
if (old !== undefined) {
|
|
1763
|
+
for (const shape of old.shapes) {
|
|
1764
|
+
const collider = (this.raw as RapierWorld).getCollider(shape.colliderHandle);
|
|
1765
|
+
if (collider !== null && collider !== undefined) collider.setEnabled(true);
|
|
1766
|
+
}
|
|
1767
|
+
const retiredIndex = this.retiredDerivedBodies.indexOf(old);
|
|
1768
|
+
if (retiredIndex >= 0) this.retiredDerivedBodies.splice(retiredIndex, 1);
|
|
1769
|
+
this.derivedBodies.set(record.input.entity, old);
|
|
1770
|
+
this.derivedBodySources.set(record.input.entity, {
|
|
1771
|
+
sourceKey: old.sourceKey,
|
|
1772
|
+
revision: old.revision,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
for (const native of record.nativeColliders) {
|
|
1776
|
+
const collider = (this.raw as RapierWorld).getCollider(native.handle);
|
|
1667
1777
|
if (collider !== null && collider !== undefined) collider.setEnabled(false);
|
|
1668
1778
|
}
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1779
|
+
// Remove staged colliders before restoring mass. Rapier defers some
|
|
1780
|
+
// mass-property recomputation until a collider mutation; restoring
|
|
1781
|
+
// while a disabled candidate is still attached can leave an explicit
|
|
1782
|
+
// override behind for the next fixed step.
|
|
1783
|
+
for (const native of record.nativeColliders) this.removeNativeCollider(native.handle);
|
|
1784
|
+
for (const { collider, density, enabled } of oldDensities) {
|
|
1785
|
+
if (this.raw.getCollider(collider.handle) === null) continue;
|
|
1786
|
+
if (density !== undefined && typeof collider.setDensity === 'function')
|
|
1787
|
+
collider.setDensity(density);
|
|
1788
|
+
if (typeof collider.setEnabled === 'function') collider.setEnabled(enabled);
|
|
1789
|
+
}
|
|
1790
|
+
let restored = true;
|
|
1791
|
+
try {
|
|
1792
|
+
body.setBodyType(oldBodyType, true);
|
|
1793
|
+
this.restoreCommittedMass(body, old, oldAutomaticAdditionalMass);
|
|
1794
|
+
body.setTranslation(oldTranslation, true);
|
|
1795
|
+
body.setRotation(oldRotation, true);
|
|
1796
|
+
body.setLinvel(oldVelocity, true);
|
|
1797
|
+
body.setAngvel(oldAngularVelocity, true);
|
|
1798
|
+
body.setEnabled(oldBodyEnabled);
|
|
1799
|
+
} catch {
|
|
1800
|
+
restored = false;
|
|
1801
|
+
}
|
|
1802
|
+
if (restored && !this.restoreNativeConstraints(oldConstraints)) restored = false;
|
|
1803
|
+
if (restored) {
|
|
1804
|
+
const currentMass = body.mass();
|
|
1805
|
+
const currentCom = body.worldCom();
|
|
1806
|
+
const currentVelocity = body.linvel();
|
|
1807
|
+
const currentAngularVelocity = body.angvel();
|
|
1808
|
+
const currentRotation = body.rotation();
|
|
1809
|
+
restored =
|
|
1810
|
+
Number.isFinite(currentMass) &&
|
|
1811
|
+
Math.abs(currentMass - oldMass) <= 1e-6 * Math.max(1, Math.abs(oldMass)) &&
|
|
1812
|
+
Math.abs(currentCom.x - oldCom.x) <= 1e-6 &&
|
|
1813
|
+
Math.abs(currentCom.y - oldCom.y) <= 1e-6 &&
|
|
1814
|
+
Math.abs(currentCom.z - oldCom.z) <= 1e-6 &&
|
|
1815
|
+
Math.abs(currentVelocity.x - oldVelocity.x) <= 1e-6 &&
|
|
1816
|
+
Math.abs(currentVelocity.y - oldVelocity.y) <= 1e-6 &&
|
|
1817
|
+
Math.abs(currentVelocity.z - oldVelocity.z) <= 1e-6 &&
|
|
1818
|
+
Math.abs(currentAngularVelocity.x - oldAngularVelocity.x) <= 1e-6 &&
|
|
1819
|
+
Math.abs(currentAngularVelocity.y - oldAngularVelocity.y) <= 1e-6 &&
|
|
1820
|
+
Math.abs(currentAngularVelocity.z - oldAngularVelocity.z) <= 1e-6 &&
|
|
1821
|
+
Math.abs(
|
|
1822
|
+
currentRotation.x * oldRotation.x +
|
|
1823
|
+
currentRotation.y * oldRotation.y +
|
|
1824
|
+
currentRotation.z * oldRotation.z +
|
|
1825
|
+
currentRotation.w * oldRotation.w,
|
|
1826
|
+
) >=
|
|
1827
|
+
1 - 1e-6 &&
|
|
1828
|
+
body.bodyType() === oldBodyType &&
|
|
1829
|
+
body.isEnabled() === oldBodyEnabled;
|
|
1830
|
+
}
|
|
1831
|
+
if (old === undefined) this.derivedBodies.delete(record.input.entity);
|
|
1832
|
+
else this.derivedBodies.set(record.input.entity, old);
|
|
1833
|
+
if (oldSource === undefined) this.derivedBodySources.delete(record.input.entity);
|
|
1834
|
+
else this.derivedBodySources.set(record.input.entity, oldSource);
|
|
1835
|
+
if (oldPublication === undefined) this.derivedPublications.delete(record.input.entity);
|
|
1836
|
+
else this.derivedPublications.set(record.input.entity, oldPublication);
|
|
1837
|
+
const entityRecord = this.entityMap.get(record.input.entity);
|
|
1838
|
+
if (entityRecord !== undefined && oldAutomaticRecordMass !== undefined)
|
|
1839
|
+
entityRecord.automaticAdditionalMass = oldAutomaticRecordMass;
|
|
1840
|
+
return restored;
|
|
1695
1841
|
};
|
|
1696
|
-
|
|
1697
|
-
// follows a successful geometry commit before recording this body.
|
|
1698
|
-
// A refused commit takes the same complete native rollback below.
|
|
1699
|
-
if (record.commitGeometry !== undefined) {
|
|
1700
|
-
this.activeDerivedAdmission = record;
|
|
1842
|
+
const restore = (undo: () => boolean): boolean => {
|
|
1701
1843
|
try {
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
if (!geometry.ok) throw geometry.error;
|
|
1706
|
-
} finally {
|
|
1707
|
-
this.activeDerivedAdmission = undefined;
|
|
1844
|
+
return undo();
|
|
1845
|
+
} catch {
|
|
1846
|
+
return false;
|
|
1708
1847
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1848
|
+
};
|
|
1849
|
+
try {
|
|
1850
|
+
for (const constraint of record.input.constraints ?? []) {
|
|
1851
|
+
const created = this.createNativeConstraint(constraint);
|
|
1852
|
+
if (!created.ok) throw created.error;
|
|
1853
|
+
stagedConstraints.set(constraint.id, {
|
|
1854
|
+
input: { ...constraint },
|
|
1855
|
+
handle: created.value.handle,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
if (record.input.bodyType !== undefined) {
|
|
1859
|
+
const RAPIER = this.rapierModule as RapierWorld;
|
|
1860
|
+
const bodyType =
|
|
1861
|
+
record.input.bodyType === 'static'
|
|
1862
|
+
? RAPIER.RigidBodyType.Fixed
|
|
1863
|
+
: record.input.bodyType === 'kinematic'
|
|
1864
|
+
? RAPIER.RigidBodyType.KinematicPositionBased
|
|
1865
|
+
: RAPIER.RigidBodyType.Dynamic;
|
|
1866
|
+
body.setBodyType(bodyType, true);
|
|
1867
|
+
}
|
|
1868
|
+
for (const native of record.nativeColliders) {
|
|
1869
|
+
const collider = (this.raw as RapierWorld).getCollider(native.handle);
|
|
1870
|
+
if (collider === null || collider === undefined)
|
|
1871
|
+
throw new Error('candidate collider disappeared');
|
|
1872
|
+
collider.setEnabled(true);
|
|
1873
|
+
}
|
|
1874
|
+
const nativeById = new Map(
|
|
1875
|
+
record.input.shapes.map((shape: VoxelShapeInput, index: number) => [
|
|
1876
|
+
shape.id,
|
|
1877
|
+
record.nativeColliders[index]?.handle as number,
|
|
1878
|
+
]),
|
|
1879
|
+
);
|
|
1880
|
+
for (const seam of record.input.seams ?? []) {
|
|
1881
|
+
const firstHandle = nativeById.get(seam.shapeA);
|
|
1882
|
+
const secondHandle = nativeById.get(seam.shapeB);
|
|
1883
|
+
const first =
|
|
1884
|
+
firstHandle === undefined
|
|
1885
|
+
? undefined
|
|
1886
|
+
: (this.raw as RapierWorld).getCollider(firstHandle);
|
|
1887
|
+
const second =
|
|
1888
|
+
secondHandle === undefined
|
|
1889
|
+
? undefined
|
|
1890
|
+
: (this.raw as RapierWorld).getCollider(secondHandle);
|
|
1891
|
+
if (first === undefined || second === undefined || first === null || second === null) {
|
|
1892
|
+
throw new Error(`derived seam references missing shape ${seam.shapeA}`);
|
|
1893
|
+
}
|
|
1894
|
+
first.combineVoxelStates(second, seam.offset[0], seam.offset[1], seam.offset[2]);
|
|
1895
|
+
}
|
|
1896
|
+
if (record.input.motion?.rotation !== undefined) {
|
|
1897
|
+
const [x, y, z, w] = record.input.motion.rotation;
|
|
1898
|
+
body.setRotation({ x, y, z, w }, true);
|
|
1899
|
+
}
|
|
1900
|
+
this.applyDerivedMass(
|
|
1901
|
+
body,
|
|
1902
|
+
record.input.massProperties,
|
|
1903
|
+
oldCom,
|
|
1904
|
+
record.input.velocityPolicy ?? 'preserve',
|
|
1905
|
+
this.entityMap.get(record.input.entity)?.additionalMass ?? 0,
|
|
1906
|
+
record.input.entity,
|
|
1907
|
+
record.input.shapes,
|
|
1908
|
+
record.nativeColliders,
|
|
1909
|
+
);
|
|
1910
|
+
if (record.input.motion !== undefined) {
|
|
1911
|
+
const currentCom = body.worldCom();
|
|
1912
|
+
const targetCom = record.input.motion.centerOfMass;
|
|
1913
|
+
const translation = body.translation();
|
|
1914
|
+
body.setTranslation(
|
|
1915
|
+
{
|
|
1916
|
+
x: translation.x + targetCom[0] - currentCom.x,
|
|
1917
|
+
y: translation.y + targetCom[1] - currentCom.y,
|
|
1918
|
+
z: translation.z + targetCom[2] - currentCom.z,
|
|
1919
|
+
},
|
|
1920
|
+
true,
|
|
1921
|
+
);
|
|
1922
|
+
body.setLinvel(
|
|
1923
|
+
{
|
|
1924
|
+
x: record.input.motion.linearVelocity[0],
|
|
1925
|
+
y: record.input.motion.linearVelocity[1],
|
|
1926
|
+
z: record.input.motion.linearVelocity[2],
|
|
1927
|
+
},
|
|
1928
|
+
true,
|
|
1929
|
+
);
|
|
1930
|
+
body.setAngvel(
|
|
1931
|
+
{
|
|
1932
|
+
x: record.input.motion.angularVelocity[0],
|
|
1933
|
+
y: record.input.motion.angularVelocity[1],
|
|
1934
|
+
z: record.input.motion.angularVelocity[2],
|
|
1935
|
+
},
|
|
1936
|
+
true,
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
// A body revision invalidates any committed joint that still names
|
|
1940
|
+
// that body's previous source/revision. Keep this tied to the body
|
|
1941
|
+
// that is actually committing: a different queued endpoint may still
|
|
1942
|
+
// fail, in which case its old joint remains valid and must not be
|
|
1943
|
+
// removed speculatively.
|
|
1944
|
+
const committedSources = new Map(this.derivedBodySources);
|
|
1945
|
+
committedSources.set(record.input.entity, {
|
|
1946
|
+
sourceKey: record.input.sourceKey,
|
|
1947
|
+
revision: record.input.revision,
|
|
1948
|
+
});
|
|
1949
|
+
const replacementConstraintIds = new Set(
|
|
1950
|
+
(record.input.constraints ?? []).map((constraint) => constraint.id),
|
|
1951
|
+
);
|
|
1952
|
+
for (const [constraintId, current] of [...this.derivedConstraints]) {
|
|
1953
|
+
if (
|
|
1954
|
+
replacementConstraintIds.has(constraintId) ||
|
|
1955
|
+
this.constraintDependenciesMatch(current.input, committedSources)
|
|
1956
|
+
)
|
|
1957
|
+
continue;
|
|
1958
|
+
this.removeNativeConstraint(current.handle);
|
|
1959
|
+
this.derivedConstraints.delete(constraintId);
|
|
1960
|
+
}
|
|
1961
|
+
if (old !== undefined) {
|
|
1962
|
+
for (const shape of old.shapes) {
|
|
1963
|
+
const collider = (this.raw as RapierWorld).getCollider(shape.colliderHandle);
|
|
1964
|
+
if (collider !== null && collider !== undefined) collider.setEnabled(false);
|
|
1965
|
+
}
|
|
1966
|
+
this.retiredDerivedBodies.push(old);
|
|
1967
|
+
}
|
|
1968
|
+
for (const constraint of record.input.constraints ?? []) {
|
|
1969
|
+
const previous = this.derivedConstraints.get(constraint.id);
|
|
1970
|
+
if (previous !== undefined) this.removeNativeConstraint(previous.handle);
|
|
1971
|
+
const staged = stagedConstraints.get(constraint.id);
|
|
1972
|
+
if (staged !== undefined) this.derivedConstraints.set(constraint.id, staged);
|
|
1973
|
+
}
|
|
1974
|
+
const shapes: DerivedShapeRecord[] = record.input.shapes.map(
|
|
1975
|
+
(shape: VoxelShapeInput, index: number) => ({
|
|
1976
|
+
input: shape as DerivedShapeRecord['input'],
|
|
1977
|
+
colliderHandle: record.nativeColliders[index]?.handle as number,
|
|
1978
|
+
}),
|
|
1979
|
+
);
|
|
1980
|
+
const committed: DerivedBodyRecord = {
|
|
1981
|
+
entity: record.input.entity,
|
|
1982
|
+
sourceKey: record.input.sourceKey,
|
|
1983
|
+
generation: this.backendGeneration,
|
|
1984
|
+
revision: record.input.revision,
|
|
1985
|
+
bodyType: record.input.bodyType,
|
|
1986
|
+
velocityPolicy: record.input.velocityPolicy,
|
|
1987
|
+
candidateId: record.token.candidateId,
|
|
1988
|
+
shapes,
|
|
1989
|
+
seams: [...(record.input.seams ?? [])],
|
|
1990
|
+
massProperties: record.input.massProperties,
|
|
1991
|
+
constraints: [...(record.input.constraints ?? [])],
|
|
1992
|
+
};
|
|
1993
|
+
// The sole cross-domain observation boundary: no native operation
|
|
1994
|
+
// follows a successful geometry commit before recording this body.
|
|
1995
|
+
// A refused commit takes the same complete native rollback below.
|
|
1996
|
+
const commitGeometry =
|
|
1997
|
+
record === group[group.length - 1]
|
|
1998
|
+
? (record.batch?.commitGeometry ?? record.commitGeometry)
|
|
1999
|
+
: undefined;
|
|
2000
|
+
if (commitGeometry !== undefined) {
|
|
2001
|
+
this.activeDerivedAdmission = record;
|
|
2002
|
+
try {
|
|
2003
|
+
geometryCommitUncertain = true;
|
|
2004
|
+
const geometry = commitGeometry();
|
|
2005
|
+
geometryCommitUncertain = false;
|
|
2006
|
+
if (!geometry.ok) throw geometry.error;
|
|
2007
|
+
} finally {
|
|
2008
|
+
this.activeDerivedAdmission = undefined;
|
|
2009
|
+
}
|
|
2010
|
+
delete record.commitGeometry;
|
|
2011
|
+
}
|
|
2012
|
+
this.derivedBodies.set(record.input.entity, committed);
|
|
2013
|
+
const entityRecord = this.entityMap.get(record.input.entity);
|
|
2014
|
+
if (entityRecord !== undefined) {
|
|
2015
|
+
entityRecord.automaticAdditionalMass =
|
|
2016
|
+
record.input.massProperties?.mode === 'explicit' ? 0 : entityRecord.additionalMass;
|
|
1738
2017
|
}
|
|
1739
|
-
const retiredIndex = this.retiredDerivedBodies.indexOf(old);
|
|
1740
|
-
if (retiredIndex >= 0) this.retiredDerivedBodies.splice(retiredIndex, 1);
|
|
1741
|
-
this.derivedBodies.set(record.input.entity, old);
|
|
1742
2018
|
this.derivedBodySources.set(record.input.entity, {
|
|
1743
|
-
sourceKey:
|
|
1744
|
-
revision:
|
|
2019
|
+
sourceKey: record.input.sourceKey,
|
|
2020
|
+
revision: record.input.revision,
|
|
1745
2021
|
});
|
|
2022
|
+
this.derivedFailures.delete(record.input.entity);
|
|
2023
|
+
record.state = 'queued';
|
|
2024
|
+
this.pendingDerivedCandidates.delete(id);
|
|
2025
|
+
undos.push(rollback);
|
|
2026
|
+
} catch (cause) {
|
|
2027
|
+
let restored = restore(rollback);
|
|
2028
|
+
for (const undo of undos.reverse()) {
|
|
2029
|
+
if (!restore(undo)) restored = false;
|
|
2030
|
+
}
|
|
2031
|
+
const error =
|
|
2032
|
+
cause instanceof DerivedPhysicsError
|
|
2033
|
+
? cause
|
|
2034
|
+
: new DerivedPhysicsError(
|
|
2035
|
+
'derived-backend-failed',
|
|
2036
|
+
'derived admission either commits completely or preserves the prior body state',
|
|
2037
|
+
'inspect the failure receipt and rebuild the PhysicsWorld if recovery is required',
|
|
2038
|
+
{
|
|
2039
|
+
entity: record.input.entity,
|
|
2040
|
+
candidateId: record.token.candidateId,
|
|
2041
|
+
reason: cause instanceof Error ? cause.message : String(cause),
|
|
2042
|
+
},
|
|
2043
|
+
);
|
|
2044
|
+
// A thrown consumer callback can have changed ECS state. Native
|
|
2045
|
+
// rollback alone cannot certify any member of that combined group.
|
|
2046
|
+
if (geometryCommitUncertain) restored = false;
|
|
2047
|
+
for (const member of group) {
|
|
2048
|
+
if (!restored) {
|
|
2049
|
+
this.derivedPoisonedEntities.add(member.input.entity);
|
|
2050
|
+
this.derivedBodies.delete(member.input.entity);
|
|
2051
|
+
this.derivedPublications.delete(member.input.entity);
|
|
2052
|
+
}
|
|
2053
|
+
this.rememberDerivedFailure(
|
|
2054
|
+
member,
|
|
2055
|
+
error,
|
|
2056
|
+
restored ? 'old-state-retained' : 'rebuild-required',
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
break;
|
|
1746
2060
|
}
|
|
1747
|
-
for (const native of record.nativeColliders) {
|
|
1748
|
-
const collider = (this.raw as RapierWorld).getCollider(native.handle);
|
|
1749
|
-
if (collider !== null && collider !== undefined) collider.setEnabled(false);
|
|
1750
|
-
}
|
|
1751
|
-
// Remove staged colliders before restoring mass. Rapier defers some
|
|
1752
|
-
// mass-property recomputation until a collider mutation; restoring
|
|
1753
|
-
// while a disabled candidate is still attached can leave an explicit
|
|
1754
|
-
// override behind for the next fixed step.
|
|
1755
|
-
for (const native of record.nativeColliders) this.removeNativeCollider(native.handle);
|
|
1756
|
-
for (const { collider, density, enabled } of oldDensities) {
|
|
1757
|
-
if (this.raw.getCollider(collider.handle) === null) continue;
|
|
1758
|
-
if (density !== undefined && typeof collider.setDensity === 'function')
|
|
1759
|
-
collider.setDensity(density);
|
|
1760
|
-
if (typeof collider.setEnabled === 'function') collider.setEnabled(enabled);
|
|
1761
|
-
}
|
|
1762
|
-
let restored = true;
|
|
1763
|
-
try {
|
|
1764
|
-
body.setBodyType(oldBodyType, true);
|
|
1765
|
-
this.restoreCommittedMass(body, old, oldAutomaticAdditionalMass);
|
|
1766
|
-
body.setTranslation(oldTranslation, true);
|
|
1767
|
-
body.setRotation(oldRotation, true);
|
|
1768
|
-
body.setLinvel(oldVelocity, true);
|
|
1769
|
-
body.setAngvel(oldAngularVelocity, true);
|
|
1770
|
-
body.setEnabled(oldBodyEnabled);
|
|
1771
|
-
} catch {
|
|
1772
|
-
restored = false;
|
|
1773
|
-
}
|
|
1774
|
-
if (restored && !this.restoreNativeConstraints(oldConstraints)) restored = false;
|
|
1775
|
-
if (restored) {
|
|
1776
|
-
const currentMass = body.mass();
|
|
1777
|
-
const currentCom = body.worldCom();
|
|
1778
|
-
const currentVelocity = body.linvel();
|
|
1779
|
-
const currentAngularVelocity = body.angvel();
|
|
1780
|
-
restored =
|
|
1781
|
-
Number.isFinite(currentMass) &&
|
|
1782
|
-
Math.abs(currentMass - oldMass) <= 1e-6 * Math.max(1, Math.abs(oldMass)) &&
|
|
1783
|
-
Math.abs(currentCom.x - oldCom.x) <= 1e-6 &&
|
|
1784
|
-
Math.abs(currentCom.y - oldCom.y) <= 1e-6 &&
|
|
1785
|
-
Math.abs(currentCom.z - oldCom.z) <= 1e-6 &&
|
|
1786
|
-
Math.abs(currentVelocity.x - oldVelocity.x) <= 1e-6 &&
|
|
1787
|
-
Math.abs(currentVelocity.y - oldVelocity.y) <= 1e-6 &&
|
|
1788
|
-
Math.abs(currentVelocity.z - oldVelocity.z) <= 1e-6 &&
|
|
1789
|
-
Math.abs(currentAngularVelocity.x - oldAngularVelocity.x) <= 1e-6 &&
|
|
1790
|
-
Math.abs(currentAngularVelocity.y - oldAngularVelocity.y) <= 1e-6 &&
|
|
1791
|
-
Math.abs(currentAngularVelocity.z - oldAngularVelocity.z) <= 1e-6 &&
|
|
1792
|
-
body.bodyType() === oldBodyType &&
|
|
1793
|
-
body.isEnabled() === oldBodyEnabled;
|
|
1794
|
-
}
|
|
1795
|
-
if (old === undefined) this.derivedBodies.delete(record.input.entity);
|
|
1796
|
-
else this.derivedBodies.set(record.input.entity, old);
|
|
1797
|
-
if (oldSource === undefined) this.derivedBodySources.delete(record.input.entity);
|
|
1798
|
-
else this.derivedBodySources.set(record.input.entity, oldSource);
|
|
1799
|
-
if (oldPublication === undefined) this.derivedPublications.delete(record.input.entity);
|
|
1800
|
-
else this.derivedPublications.set(record.input.entity, oldPublication);
|
|
1801
|
-
const entityRecord = this.entityMap.get(record.input.entity);
|
|
1802
|
-
if (entityRecord !== undefined && oldAutomaticRecordMass !== undefined)
|
|
1803
|
-
entityRecord.automaticAdditionalMass = oldAutomaticRecordMass;
|
|
1804
|
-
const error =
|
|
1805
|
-
cause instanceof DerivedPhysicsError
|
|
1806
|
-
? cause
|
|
1807
|
-
: new DerivedPhysicsError(
|
|
1808
|
-
'derived-backend-failed',
|
|
1809
|
-
'derived admission either commits completely or preserves the prior body state',
|
|
1810
|
-
'inspect the failure receipt and rebuild the PhysicsWorld if recovery is required',
|
|
1811
|
-
{
|
|
1812
|
-
entity: record.input.entity,
|
|
1813
|
-
candidateId: record.token.candidateId,
|
|
1814
|
-
reason: cause instanceof Error ? cause.message : String(cause),
|
|
1815
|
-
},
|
|
1816
|
-
);
|
|
1817
|
-
// A thrown consumer callback may already have changed its ECS domain.
|
|
1818
|
-
// Native rollback alone cannot certify the combined state in that case.
|
|
1819
|
-
if (geometryCommitUncertain) restored = false;
|
|
1820
|
-
if (!restored) {
|
|
1821
|
-
this.derivedPoisonedEntities.add(record.input.entity);
|
|
1822
|
-
this.derivedBodies.delete(record.input.entity);
|
|
1823
|
-
this.derivedPublications.delete(record.input.entity);
|
|
1824
|
-
}
|
|
1825
|
-
this.rememberDerivedFailure(
|
|
1826
|
-
record,
|
|
1827
|
-
error,
|
|
1828
|
-
restored ? 'old-state-retained' : 'rebuild-required',
|
|
1829
|
-
);
|
|
1830
2061
|
}
|
|
1831
2062
|
}
|
|
1832
2063
|
}
|
|
@@ -1847,6 +2078,9 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1847
2078
|
}),
|
|
1848
2079
|
);
|
|
1849
2080
|
candidate.state = 'published';
|
|
2081
|
+
// Admission membership is transient. A long-lived sibling must not keep
|
|
2082
|
+
// a replaced member's private shape buffers or callback closure alive.
|
|
2083
|
+
delete candidate.batch;
|
|
1850
2084
|
}
|
|
1851
2085
|
}
|
|
1852
2086
|
|
|
@@ -1893,6 +2127,9 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1893
2127
|
{ x: frame[0], y: frame[1], z: frame[2], w: frame[3] },
|
|
1894
2128
|
true,
|
|
1895
2129
|
);
|
|
2130
|
+
// Rapier defers additional mass updates. Admission reads worldCom below
|
|
2131
|
+
// before stepping, so refresh now or COM restoration shifts the body twice.
|
|
2132
|
+
body.recomputeMassPropertiesFromColliders();
|
|
1896
2133
|
} else {
|
|
1897
2134
|
this.restoreAutomaticDensities(
|
|
1898
2135
|
entity,
|
|
@@ -1942,6 +2179,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
1942
2179
|
{ x: frame[0], y: frame[1], z: frame[2], w: frame[3] },
|
|
1943
2180
|
true,
|
|
1944
2181
|
);
|
|
2182
|
+
body.recomputeMassPropertiesFromColliders();
|
|
1945
2183
|
return;
|
|
1946
2184
|
}
|
|
1947
2185
|
this.restoreAutomaticMass(body, automaticAdditionalMass);
|
|
@@ -2958,6 +3196,10 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
2958
3196
|
);
|
|
2959
3197
|
if (collider === undefined) {
|
|
2960
3198
|
const record = this.entityMap.get(entity);
|
|
3199
|
+
// A new fragment can admit derived shapes in this same fixed step.
|
|
3200
|
+
// Resolve Rapier's deferred descriptor mass before rollback captures the
|
|
3201
|
+
// baseline, rather than comparing an uninitialized zero to restored mass.
|
|
3202
|
+
this.restoreAutomaticMass(body, record?.additionalMass ?? 0);
|
|
2961
3203
|
if (record !== undefined) record.automaticAdditionalMass = record.additionalMass;
|
|
2962
3204
|
return;
|
|
2963
3205
|
}
|
|
@@ -3190,6 +3432,23 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
|
3190
3432
|
removeEntity(entity: number): void {
|
|
3191
3433
|
const record = this.entityMap.get(entity);
|
|
3192
3434
|
if (!record) return;
|
|
3435
|
+
for (const candidate of this.derivedCandidates.values()) {
|
|
3436
|
+
if (
|
|
3437
|
+
candidate.input.entity !== entity ||
|
|
3438
|
+
candidate.batch === undefined ||
|
|
3439
|
+
(candidate.state !== 'ready' && candidate.state !== 'queued')
|
|
3440
|
+
)
|
|
3441
|
+
continue;
|
|
3442
|
+
this.rejectPreparedCandidate(
|
|
3443
|
+
candidate,
|
|
3444
|
+
new DerivedPhysicsError(
|
|
3445
|
+
'derived-body-not-found',
|
|
3446
|
+
'all grouped bodies remain live until admission',
|
|
3447
|
+
'prepare a new complete group after entity reconciliation',
|
|
3448
|
+
{ entity },
|
|
3449
|
+
),
|
|
3450
|
+
);
|
|
3451
|
+
}
|
|
3193
3452
|
const derived = this.derivedBodies.get(entity);
|
|
3194
3453
|
if (derived !== undefined) {
|
|
3195
3454
|
for (const shape of derived.shapes) this.removeNativeCollider(shape.colliderHandle);
|