@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type DeterministicPhysicsWorld3D } from './physics3d.js';
|
|
2
|
+
import { type Point3D } from './physics3d-shared.js';
|
|
3
|
+
export interface DeterministicVehicleWheel3D {
|
|
4
|
+
readonly localAnchor: Point3D;
|
|
5
|
+
/** Local-space suspension ray direction (typically {0,-1,0}); normalized internally. */
|
|
6
|
+
readonly suspensionDir: Point3D;
|
|
7
|
+
readonly suspensionRestLength: number;
|
|
8
|
+
readonly suspensionMaxTravel: number;
|
|
9
|
+
readonly radius: number;
|
|
10
|
+
/** Suspension force per unit compression. */
|
|
11
|
+
readonly stiffness: number;
|
|
12
|
+
/** Suspension force per unit compression-velocity. */
|
|
13
|
+
readonly damping: number;
|
|
14
|
+
/** Dimensionless tire-friction factor combined with ground material friction. */
|
|
15
|
+
readonly grip: number;
|
|
16
|
+
/** Max steer as a float turn fraction in [0, 0.25]. */
|
|
17
|
+
readonly maxSteerAngleTurns: number;
|
|
18
|
+
readonly isSteered: boolean;
|
|
19
|
+
readonly isDriven: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface DeterministicVehicle3D {
|
|
22
|
+
readonly chassisId: string;
|
|
23
|
+
readonly wheels: readonly DeterministicVehicleWheel3D[];
|
|
24
|
+
/** Full-throttle drive force for the whole vehicle, split across driven wheels. */
|
|
25
|
+
readonly driveForce: number;
|
|
26
|
+
/** Full-brake force for the whole vehicle, split across all wheels. */
|
|
27
|
+
readonly brakeForce: number;
|
|
28
|
+
}
|
|
29
|
+
/** Integer deci-units for determinism: throttle/steer in -10..10, brake in 0..10. */
|
|
30
|
+
export interface DeterministicVehicleInput3D {
|
|
31
|
+
readonly throttle: number;
|
|
32
|
+
readonly steer: number;
|
|
33
|
+
readonly brake: number;
|
|
34
|
+
}
|
|
35
|
+
export interface DeterministicVehicleWheelTelemetry3D {
|
|
36
|
+
/** Load-bearing (checksummed): contact found and suspension engaged. */
|
|
37
|
+
readonly grounded: boolean;
|
|
38
|
+
/** Load-bearing (checksummed): suspension compression in [0, maxTravel], quantized. */
|
|
39
|
+
readonly compression: number;
|
|
40
|
+
/** Applied steer angle in turns (quantized). */
|
|
41
|
+
readonly steerAngleTurns: number;
|
|
42
|
+
/** Cosmetic render-only telemetry — NOT checksummed. */
|
|
43
|
+
readonly contactBodyId?: string;
|
|
44
|
+
readonly contactPoint?: Point3D;
|
|
45
|
+
readonly contactNormal?: Point3D;
|
|
46
|
+
/** Cosmetic wheel-spin delta for this step, in turns (accumulate game-side). */
|
|
47
|
+
readonly spinDeltaTurns: number;
|
|
48
|
+
}
|
|
49
|
+
export interface DeterministicVehicleImpulse3D {
|
|
50
|
+
readonly bodyId: string;
|
|
51
|
+
readonly impulse: Point3D;
|
|
52
|
+
readonly point: Point3D;
|
|
53
|
+
}
|
|
54
|
+
export interface DeterministicVehicleStepResult3D {
|
|
55
|
+
/** Apply via `applyDeterministicImpulse3D` before the next physics step. */
|
|
56
|
+
readonly impulses: readonly DeterministicVehicleImpulse3D[];
|
|
57
|
+
readonly wheels: readonly DeterministicVehicleWheelTelemetry3D[];
|
|
58
|
+
readonly events: readonly string[];
|
|
59
|
+
readonly checksum: string;
|
|
60
|
+
}
|
|
61
|
+
export interface DeterministicVehicleStepOptions3D {
|
|
62
|
+
readonly dtTicks?: number;
|
|
63
|
+
}
|
|
64
|
+
export declare function stepDeterministicVehicle3D(vehicle: DeterministicVehicle3D, input: DeterministicVehicleInput3D, world: DeterministicPhysicsWorld3D, options?: DeterministicVehicleStepOptions3D): DeterministicVehicleStepResult3D;
|
|
65
|
+
export interface DeterministicVehicle3DFixtureResult {
|
|
66
|
+
readonly checksum: string;
|
|
67
|
+
readonly restoredChecksum: string;
|
|
68
|
+
readonly permutedChecksum: string;
|
|
69
|
+
readonly rollbackEquivalent: boolean;
|
|
70
|
+
readonly stableUnderAuthoringOrderPermutation: boolean;
|
|
71
|
+
readonly frames: number;
|
|
72
|
+
readonly vehicles: number;
|
|
73
|
+
readonly impulseSamples: number;
|
|
74
|
+
readonly groundedWheelSamples: number;
|
|
75
|
+
/** Every emitted impulse component was finite and never negative zero. */
|
|
76
|
+
readonly impulsesQuantizedClean: boolean;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Determinism fixture for the raycast vehicle: the full read→apply→step loop is
|
|
80
|
+
* bit-identical across a mid-run snapshot/restore and across world body /
|
|
81
|
+
* vehicle authoring-order permutation (wheel arrays are authored and fixed —
|
|
82
|
+
* wheel-order permutation is intentionally NOT part of the contract).
|
|
83
|
+
*/
|
|
84
|
+
export declare function runDeterministicVehicle3DFixture(frames?: number): DeterministicVehicle3DFixtureResult;
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic raycast-wheel vehicle controller on the rigid-body solver.
|
|
3
|
+
*
|
|
4
|
+
* A vehicle is one dynamic chassis body plus N suspension raycasts (wheels are
|
|
5
|
+
* rays, not bodies — Bullet/Godot raycast-vehicle lineage). Per step the
|
|
6
|
+
* stateless controller reads the chassis pose/velocity from the world, casts
|
|
7
|
+
* each wheel, computes spring-damper suspension + friction-circle tire
|
|
8
|
+
* impulses, and returns them for the game to apply via
|
|
9
|
+
* `applyDeterministicImpulse3D` before the next physics step. v1 is specified
|
|
10
|
+
* against static ground: the controller emits chassis-only impulses (no
|
|
11
|
+
* moving-ground relative velocity, no reaction impulse on the ground body).
|
|
12
|
+
*/
|
|
13
|
+
import { cloneCanonical, defaultChecksum } from './canonical.js';
|
|
14
|
+
import { createDeterministicMath } from './math.js';
|
|
15
|
+
import { applyDeterministicImpulses3D, createDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, raycastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D, } from './physics3d.js';
|
|
16
|
+
import { addVec3, crossVec3, dotVec3, mulMat3Vec, normalizeVec3, quantizeDistance, quatToMat3, rotatePointByQuat, scaleVec3, solverSqrt, subVec3, worldInvInertia, } from './physics3d-shared.js';
|
|
17
|
+
const vehicleMath = createDeterministicMath();
|
|
18
|
+
/** Fraction of the suspension impulse lost to rolling resistance each step. */
|
|
19
|
+
const ROLLING_RESISTANCE = 0.02;
|
|
20
|
+
/** Gauss-Seidel passes over the wheels for the tire (drive/brake/grip) solve. */
|
|
21
|
+
const TIRE_ITERATIONS = 6;
|
|
22
|
+
const TWO_PI = 2 * Math.PI;
|
|
23
|
+
/** Chassis-local forward axis: vehicles drive along +z. */
|
|
24
|
+
const LOCAL_FORWARD = { x: 0, y: 0, z: 1 };
|
|
25
|
+
/** Rodrigues rotation of `v` about unit axis `axis` given cos/sin of the angle. */
|
|
26
|
+
function rotateAboutAxis(v, axis, cos, sin) {
|
|
27
|
+
const term1 = scaleVec3(v, cos);
|
|
28
|
+
const term2 = scaleVec3(crossVec3(axis, v), sin);
|
|
29
|
+
const term3 = scaleVec3(axis, dotVec3(axis, v) * (1 - cos));
|
|
30
|
+
return addVec3(addVec3(term1, term2), term3);
|
|
31
|
+
}
|
|
32
|
+
function isFiniteNumber(value) {
|
|
33
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
34
|
+
}
|
|
35
|
+
function assertIntegerInput(value, label, min, max) {
|
|
36
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
37
|
+
throw new Error(`stepDeterministicVehicle3D: ${label} must be an integer in [${min}, ${max}], received ${value}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function assertPositiveWheelField(value, wheelLabel, name) {
|
|
41
|
+
if (!isFiniteNumber(value) || value <= 0) {
|
|
42
|
+
throw new Error(`${wheelLabel}: ${name} must be a positive finite number`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function validateVehicle3D(vehicle, input, world, options) {
|
|
46
|
+
const chassis = deterministicPhysicsBodyById3D(world, vehicle.chassisId);
|
|
47
|
+
if (chassis === undefined) {
|
|
48
|
+
throw new Error(`stepDeterministicVehicle3D: unknown chassis body ${vehicle.chassisId}`);
|
|
49
|
+
}
|
|
50
|
+
// Point-impulse yaw is silently dropped by applyDeterministicImpulse3D for
|
|
51
|
+
// non-dynamic or orientation-less bodies, so steering would be a no-op.
|
|
52
|
+
if (chassis.kind !== 'dynamic' || chassis.orientation === undefined) {
|
|
53
|
+
throw new Error(`vehicle ${vehicle.chassisId}: chassis must be a dynamic body with an orientation quaternion`);
|
|
54
|
+
}
|
|
55
|
+
if (vehicle.wheels.length === 0) {
|
|
56
|
+
throw new Error(`vehicle ${vehicle.chassisId}: wheels must not be empty`);
|
|
57
|
+
}
|
|
58
|
+
for (const [name, value] of [['driveForce', vehicle.driveForce], ['brakeForce', vehicle.brakeForce]]) {
|
|
59
|
+
if (!isFiniteNumber(value) || value < 0) {
|
|
60
|
+
throw new Error(`vehicle ${vehicle.chassisId}: ${name} must be a non-negative finite number`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (let index = 0; index < vehicle.wheels.length; index += 1) {
|
|
64
|
+
const wheel = vehicle.wheels[index];
|
|
65
|
+
const wheelLabel = `vehicle ${vehicle.chassisId} wheel ${index}`;
|
|
66
|
+
for (const [name, point] of [['localAnchor', wheel.localAnchor], ['suspensionDir', wheel.suspensionDir]]) {
|
|
67
|
+
if (!isFiniteNumber(point.x) || !isFiniteNumber(point.y) || !isFiniteNumber(point.z)) {
|
|
68
|
+
throw new Error(`${wheelLabel}: ${name} must have finite components`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (wheel.suspensionDir.x === 0 && wheel.suspensionDir.y === 0 && wheel.suspensionDir.z === 0) {
|
|
72
|
+
throw new Error(`${wheelLabel}: suspensionDir must be a non-zero vector`);
|
|
73
|
+
}
|
|
74
|
+
assertPositiveWheelField(wheel.radius, wheelLabel, 'radius');
|
|
75
|
+
assertPositiveWheelField(wheel.suspensionRestLength, wheelLabel, 'suspensionRestLength');
|
|
76
|
+
assertPositiveWheelField(wheel.suspensionMaxTravel, wheelLabel, 'suspensionMaxTravel');
|
|
77
|
+
assertPositiveWheelField(wheel.stiffness, wheelLabel, 'stiffness');
|
|
78
|
+
if (wheel.suspensionMaxTravel > wheel.suspensionRestLength) {
|
|
79
|
+
throw new Error(`${wheelLabel}: suspensionMaxTravel must not exceed suspensionRestLength`);
|
|
80
|
+
}
|
|
81
|
+
for (const [name, value] of [['damping', wheel.damping], ['grip', wheel.grip]]) {
|
|
82
|
+
if (!isFiniteNumber(value) || value < 0) {
|
|
83
|
+
throw new Error(`${wheelLabel}: ${name} must be a non-negative finite number`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!isFiniteNumber(wheel.maxSteerAngleTurns) || wheel.maxSteerAngleTurns < 0 || wheel.maxSteerAngleTurns > 0.25) {
|
|
87
|
+
throw new Error(`${wheelLabel}: maxSteerAngleTurns must be within [0, 0.25]`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
assertIntegerInput(input.throttle, 'throttle', -10, 10);
|
|
91
|
+
assertIntegerInput(input.steer, 'steer', -10, 10);
|
|
92
|
+
assertIntegerInput(input.brake, 'brake', 0, 10);
|
|
93
|
+
if (options.dtTicks !== undefined && (!isFiniteNumber(options.dtTicks) || options.dtTicks <= 0)) {
|
|
94
|
+
throw new Error(`stepDeterministicVehicle3D: dtTicks must be a positive finite number, received ${options.dtTicks}`);
|
|
95
|
+
}
|
|
96
|
+
return chassis;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Effective mass of the chassis at lever arm `r` along direction `dir`
|
|
100
|
+
* (1 / (1/m + d·((I⁻¹(r×d))×r))) — the impulse that cancels the point velocity
|
|
101
|
+
* exactly, including the rotational coupling a plain mass share would ignore.
|
|
102
|
+
*/
|
|
103
|
+
function effectiveMassAt(invMass, invInertiaWorld, r, dir) {
|
|
104
|
+
// k = invMass + quadratic form of the SPD inverse inertia: always > 0.
|
|
105
|
+
const rCrossDir = crossVec3(r, dir);
|
|
106
|
+
const k = invMass + dotVec3(crossVec3(mulMat3Vec(invInertiaWorld, rCrossDir), r), dir);
|
|
107
|
+
return 1 / k;
|
|
108
|
+
}
|
|
109
|
+
function castWheel(world, chassis, wheel) {
|
|
110
|
+
const orientation = chassis.orientation;
|
|
111
|
+
const chassisPos = { x: chassis.x, y: chassis.y, z: chassis.z };
|
|
112
|
+
const anchor = addVec3(chassisPos, rotatePointByQuat(orientation, wheel.localAnchor));
|
|
113
|
+
const dir = normalizeVec3(rotatePointByQuat(orientation, wheel.suspensionDir));
|
|
114
|
+
const suspensionUp = scaleVec3(dir, -1);
|
|
115
|
+
const hits = raycastDeterministicPhysics3DDetailed(world, {
|
|
116
|
+
x: anchor.x,
|
|
117
|
+
y: anchor.y,
|
|
118
|
+
z: anchor.z,
|
|
119
|
+
dx: dir.x,
|
|
120
|
+
dy: dir.y,
|
|
121
|
+
dz: dir.z,
|
|
122
|
+
maxDistance: wheel.suspensionRestLength + wheel.radius,
|
|
123
|
+
hitTriggers: false,
|
|
124
|
+
hitSensors: false,
|
|
125
|
+
hitSolids: true,
|
|
126
|
+
});
|
|
127
|
+
// No exclude-id query knob exists; post-filter the distance-sorted hits.
|
|
128
|
+
const hit = hits.find((candidate) => candidate.bodyId !== chassis.id);
|
|
129
|
+
if (hit === undefined) {
|
|
130
|
+
return { grounded: false, compression: 0, groundFriction: 0, anchor, suspensionUp };
|
|
131
|
+
}
|
|
132
|
+
const groundBody = deterministicPhysicsBodyById3D(world, hit.bodyId);
|
|
133
|
+
// hit.distance <= restLength + radius (the ray's maxDistance), so the raw
|
|
134
|
+
// compression is never negative — only the maxTravel clamp is needed.
|
|
135
|
+
const rawCompression = wheel.suspensionRestLength - (hit.distance - wheel.radius);
|
|
136
|
+
const compression = quantizeDistance(rawCompression > wheel.suspensionMaxTravel ? wheel.suspensionMaxTravel : rawCompression);
|
|
137
|
+
return {
|
|
138
|
+
grounded: compression > 0,
|
|
139
|
+
compression,
|
|
140
|
+
bodyId: hit.bodyId,
|
|
141
|
+
point: hit.point,
|
|
142
|
+
normal: hit.normal,
|
|
143
|
+
groundFriction: groundBody.material?.friction ?? 0,
|
|
144
|
+
anchor,
|
|
145
|
+
suspensionUp,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
|
|
149
|
+
const chassis = validateVehicle3D(vehicle, input, world, options);
|
|
150
|
+
const dt = options.dtTicks ?? 1;
|
|
151
|
+
const contacts = vehicle.wheels.map((wheel) => castWheel(world, chassis, wheel));
|
|
152
|
+
const chassisPos = { x: chassis.x, y: chassis.y, z: chassis.z };
|
|
153
|
+
// Internal velocity state, advanced as impulses accumulate so each solve
|
|
154
|
+
// stage sees the effect of the previous ones.
|
|
155
|
+
let chassisVel = { x: chassis.vx, y: chassis.vy, z: chassis.vz };
|
|
156
|
+
let angularVel = chassis.angularVel ?? { x: 0, y: 0, z: 0 };
|
|
157
|
+
const orientation = chassis.orientation;
|
|
158
|
+
const forwardWorld = rotatePointByQuat(orientation, LOCAL_FORWARD);
|
|
159
|
+
const chassisMass = chassis.mass ?? 1;
|
|
160
|
+
const wheelCount = vehicle.wheels.length;
|
|
161
|
+
const drivenCount = vehicle.wheels.filter((wheel) => wheel.isDriven).length;
|
|
162
|
+
const invMass = 1 / chassisMass;
|
|
163
|
+
const invInertiaWorld = worldInvInertia(quatToMat3(orientation), deriveInverseInertia(chassis.shape, chassisMass));
|
|
164
|
+
const throttleFraction = input.throttle / 10;
|
|
165
|
+
const brakeFraction = input.brake / 10;
|
|
166
|
+
const steerFraction = input.steer / 10;
|
|
167
|
+
const impulses = [];
|
|
168
|
+
const applyDelta = (impulseVec, point) => {
|
|
169
|
+
chassisVel = addVec3(chassisVel, scaleVec3(impulseVec, invMass));
|
|
170
|
+
angularVel = addVec3(angularVel, mulMat3Vec(invInertiaWorld, crossVec3(subVec3(point, chassisPos), impulseVec)));
|
|
171
|
+
};
|
|
172
|
+
/** Quantize and emit an impulse; returns the quantized impulse (undefined if it rounds to zero). */
|
|
173
|
+
const emitImpulse = (impulseVec, point) => {
|
|
174
|
+
const quantized = {
|
|
175
|
+
x: quantizeDistance(impulseVec.x),
|
|
176
|
+
y: quantizeDistance(impulseVec.y),
|
|
177
|
+
z: quantizeDistance(impulseVec.z),
|
|
178
|
+
};
|
|
179
|
+
if (quantized.x === 0 && quantized.y === 0 && quantized.z === 0) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
impulses.push({ bodyId: vehicle.chassisId, impulse: quantized, point });
|
|
183
|
+
return quantized;
|
|
184
|
+
};
|
|
185
|
+
// Phase A — suspension for every wheel before any tire force, so tire slip is
|
|
186
|
+
// measured against the settled post-suspension state (interleaving lets a
|
|
187
|
+
// wheel's slip cancel sample the lopsided half-applied suspension state and
|
|
188
|
+
// steers the car under symmetric input).
|
|
189
|
+
const suspensionImpulseMags = vehicle.wheels.map((wheel, index) => {
|
|
190
|
+
const contact = contacts[index];
|
|
191
|
+
if (!contact.grounded || contact.point === undefined || contact.normal === undefined) {
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const anchorVel = addVec3(chassisVel, crossVec3(angularVel, subVec3(contact.anchor, chassisPos)));
|
|
195
|
+
const compressionVelocity = -dotVec3(anchorVel, contact.suspensionUp);
|
|
196
|
+
const rawForce = wheel.stiffness * contact.compression + wheel.damping * compressionVelocity;
|
|
197
|
+
const suspensionForce = rawForce > 0 ? rawForce : 0;
|
|
198
|
+
const suspensionImpulseMag = suspensionForce * dt;
|
|
199
|
+
const emitted = emitImpulse(scaleVec3(contact.normal, suspensionImpulseMag), contact.point);
|
|
200
|
+
if (emitted !== undefined) {
|
|
201
|
+
applyDelta(emitted, contact.point);
|
|
202
|
+
}
|
|
203
|
+
return suspensionImpulseMag;
|
|
204
|
+
});
|
|
205
|
+
const tireStates = [];
|
|
206
|
+
const spinDeltas = vehicle.wheels.map(() => 0);
|
|
207
|
+
for (let index = 0; index < vehicle.wheels.length; index += 1) {
|
|
208
|
+
const wheel = vehicle.wheels[index];
|
|
209
|
+
const contact = contacts[index];
|
|
210
|
+
if (!contact.grounded || contact.point === undefined || contact.normal === undefined) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Steer angle through the deterministic fixed-point trig instance: float
|
|
214
|
+
// turn fraction → fixed-point turns → sin/cos → quantized float.
|
|
215
|
+
const steerFixedTurns = wheel.isSteered
|
|
216
|
+
? Math.round(steerFraction * wheel.maxSteerAngleTurns * vehicleMath.fixedScale)
|
|
217
|
+
: 0;
|
|
218
|
+
const sinSteer = quantizeDistance(vehicleMath.sinTurns(steerFixedTurns) / vehicleMath.fixedScale);
|
|
219
|
+
const cosSteer = quantizeDistance(vehicleMath.cosTurns(steerFixedTurns) / vehicleMath.fixedScale);
|
|
220
|
+
const steeredForward = steerFixedTurns === 0
|
|
221
|
+
? forwardWorld
|
|
222
|
+
: rotateAboutAxis(forwardWorld, contact.suspensionUp, cosSteer, sinSteer);
|
|
223
|
+
const forwardPlanar = subVec3(steeredForward, scaleVec3(contact.normal, dotVec3(steeredForward, contact.normal)));
|
|
224
|
+
const forwardDir = normalizeVec3(forwardPlanar);
|
|
225
|
+
if (forwardDir.x === 0 && forwardDir.y === 0 && forwardDir.z === 0) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Tire impulses act at the contact point projected into the COM plane along
|
|
229
|
+
// the normal: the lever arm is then ⊥ normal, so lateral grip yields pure
|
|
230
|
+
// yaw with zero roll/pitch injection. Applying side friction at ground
|
|
231
|
+
// level couples into the (small-inertia) roll axis and the per-wheel slip
|
|
232
|
+
// cancels ping-pong into a self-propelling limit cycle — the same reason
|
|
233
|
+
// Bullet's raycast vehicle raises its side-impulse point (rollInfluence).
|
|
234
|
+
const tirePoint = addVec3(contact.point, scaleVec3(contact.normal, dotVec3(subVec3(chassisPos, contact.point), contact.normal)));
|
|
235
|
+
const sideDir = normalizeVec3(crossVec3(contact.normal, forwardDir));
|
|
236
|
+
const leverArm = subVec3(tirePoint, chassisPos);
|
|
237
|
+
const suspensionImpulseMag = suspensionImpulseMags[index];
|
|
238
|
+
tireStates.push({
|
|
239
|
+
index,
|
|
240
|
+
forwardDir,
|
|
241
|
+
sideDir,
|
|
242
|
+
tirePoint,
|
|
243
|
+
effLateralMass: effectiveMassAt(invMass, invInertiaWorld, leverArm, sideDir),
|
|
244
|
+
effForwardMass: effectiveMassAt(invMass, invInertiaWorld, leverArm, forwardDir),
|
|
245
|
+
maxTireImpulse: ((wheel.grip * contact.groundFriction) / 10) * suspensionImpulseMag,
|
|
246
|
+
longitudinal: 0,
|
|
247
|
+
lateral: 0,
|
|
248
|
+
// brake + rolling resistance oppose the forward speed, spent from a fixed
|
|
249
|
+
// per-frame budget so iterations cannot over-apply them
|
|
250
|
+
resistBudget: (brakeFraction * vehicle.brakeForce * dt) / wheelCount
|
|
251
|
+
+ ROLLING_RESISTANCE * suspensionImpulseMag,
|
|
252
|
+
driveBudget: wheel.isDriven ? (throttleFraction * vehicle.driveForce * dt) / drivenCount : 0,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
for (let iteration = 0; iteration < TIRE_ITERATIONS; iteration += 1) {
|
|
256
|
+
for (const tire of tireStates) {
|
|
257
|
+
const contactVel = addVec3(chassisVel, crossVec3(angularVel, subVec3(tire.tirePoint, chassisPos)));
|
|
258
|
+
const forwardSpeed = dotVec3(contactVel, tire.forwardDir);
|
|
259
|
+
const lateralSpeed = dotVec3(contactVel, tire.sideDir);
|
|
260
|
+
if (iteration === 0) {
|
|
261
|
+
spinDeltas[tire.index] = quantizeDistance((forwardSpeed * dt) / (TWO_PI * vehicle.wheels[tire.index].radius));
|
|
262
|
+
}
|
|
263
|
+
const drive = tire.driveBudget;
|
|
264
|
+
tire.driveBudget = 0;
|
|
265
|
+
const absForwardSpeed = forwardSpeed < 0 ? -forwardSpeed : forwardSpeed;
|
|
266
|
+
const resistCap = absForwardSpeed * tire.effForwardMass;
|
|
267
|
+
const resistWanted = tire.resistBudget > resistCap ? resistCap : tire.resistBudget;
|
|
268
|
+
const resist = forwardSpeed > 0 ? resistWanted : forwardSpeed < 0 ? -resistWanted : 0;
|
|
269
|
+
tire.resistBudget -= resistWanted;
|
|
270
|
+
let longitudinal = tire.longitudinal + drive - resist;
|
|
271
|
+
let lateral = tire.lateral - lateralSpeed * tire.effLateralMass;
|
|
272
|
+
// friction circle in impulse units on the ACCUMULATED tire impulse:
|
|
273
|
+
// sqrt(long² + lat²) ≤ μ · suspension impulse
|
|
274
|
+
const tireMag = solverSqrt(longitudinal * longitudinal + lateral * lateral);
|
|
275
|
+
if (tireMag > tire.maxTireImpulse) {
|
|
276
|
+
// tireMag > maxTireImpulse >= 0 implies tireMag > 0
|
|
277
|
+
const scale = tire.maxTireImpulse / tireMag;
|
|
278
|
+
longitudinal *= scale;
|
|
279
|
+
lateral *= scale;
|
|
280
|
+
}
|
|
281
|
+
const delta = addVec3(scaleVec3(tire.forwardDir, longitudinal - tire.longitudinal), scaleVec3(tire.sideDir, lateral - tire.lateral));
|
|
282
|
+
applyDelta(delta, tire.tirePoint);
|
|
283
|
+
tire.longitudinal = longitudinal;
|
|
284
|
+
tire.lateral = lateral;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const tire of tireStates) {
|
|
288
|
+
emitImpulse(addVec3(scaleVec3(tire.forwardDir, tire.longitudinal), scaleVec3(tire.sideDir, tire.lateral)), tire.tirePoint);
|
|
289
|
+
}
|
|
290
|
+
const wheels = vehicle.wheels.map((wheel, index) => {
|
|
291
|
+
const contact = contacts[index];
|
|
292
|
+
const steerFixedTurns = wheel.isSteered
|
|
293
|
+
? Math.round(steerFraction * wheel.maxSteerAngleTurns * vehicleMath.fixedScale)
|
|
294
|
+
: 0;
|
|
295
|
+
return {
|
|
296
|
+
grounded: contact.grounded,
|
|
297
|
+
compression: contact.compression,
|
|
298
|
+
steerAngleTurns: quantizeDistance(steerFixedTurns / vehicleMath.fixedScale),
|
|
299
|
+
contactBodyId: contact.grounded ? contact.bodyId : undefined,
|
|
300
|
+
contactPoint: contact.grounded ? contact.point : undefined,
|
|
301
|
+
contactNormal: contact.grounded ? contact.normal : undefined,
|
|
302
|
+
spinDeltaTurns: spinDeltas[index],
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
const events = [];
|
|
306
|
+
if (contacts.every((contact) => !contact.grounded)) {
|
|
307
|
+
events.push('airborne');
|
|
308
|
+
}
|
|
309
|
+
// Checksum covers only the load-bearing outputs — impulses, grounded flags,
|
|
310
|
+
// compressions, events. Cosmetic telemetry (contact point, spin) is excluded
|
|
311
|
+
// so it cannot false-diverge a replay.
|
|
312
|
+
const checksum = defaultChecksum({
|
|
313
|
+
impulses,
|
|
314
|
+
grounded: wheels.map((wheel) => wheel.grounded),
|
|
315
|
+
compressions: wheels.map((wheel) => wheel.compression),
|
|
316
|
+
events,
|
|
317
|
+
});
|
|
318
|
+
return { impulses, wheels, events, checksum };
|
|
319
|
+
}
|
|
320
|
+
const FIXTURE_GRAVITY_Y = -0.02;
|
|
321
|
+
function fixtureWheel(localAnchor, steered) {
|
|
322
|
+
return {
|
|
323
|
+
localAnchor,
|
|
324
|
+
suspensionDir: { x: 0, y: -1, z: 0 },
|
|
325
|
+
suspensionRestLength: 0.6,
|
|
326
|
+
suspensionMaxTravel: 0.3,
|
|
327
|
+
radius: 0.3,
|
|
328
|
+
stiffness: 60,
|
|
329
|
+
damping: 220,
|
|
330
|
+
grip: 1,
|
|
331
|
+
maxSteerAngleTurns: 0.1,
|
|
332
|
+
isSteered: steered,
|
|
333
|
+
isDriven: steered,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function fixtureVehicle(chassisId) {
|
|
337
|
+
return {
|
|
338
|
+
chassisId,
|
|
339
|
+
driveForce: 12,
|
|
340
|
+
brakeForce: 40,
|
|
341
|
+
wheels: [
|
|
342
|
+
fixtureWheel({ x: -0.9, y: -0.25, z: 1.5 }, true),
|
|
343
|
+
fixtureWheel({ x: 0.9, y: -0.25, z: 1.5 }, true),
|
|
344
|
+
fixtureWheel({ x: -0.9, y: -0.25, z: -1.5 }, false),
|
|
345
|
+
fixtureWheel({ x: 0.9, y: -0.25, z: -1.5 }, false),
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function fixtureBodies() {
|
|
350
|
+
const chassis = (id, x) => ({
|
|
351
|
+
id,
|
|
352
|
+
kind: 'dynamic',
|
|
353
|
+
x,
|
|
354
|
+
y: 1.55,
|
|
355
|
+
z: 0,
|
|
356
|
+
vx: 0,
|
|
357
|
+
vy: 0,
|
|
358
|
+
vz: 0,
|
|
359
|
+
mass: 1200,
|
|
360
|
+
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
|
361
|
+
shape: { type: 'box', halfX: 1, halfY: 0.25, halfZ: 2 },
|
|
362
|
+
layer: 1,
|
|
363
|
+
mask: 1,
|
|
364
|
+
});
|
|
365
|
+
return [
|
|
366
|
+
{
|
|
367
|
+
id: 'vehicle-ground',
|
|
368
|
+
kind: 'static',
|
|
369
|
+
x: 0,
|
|
370
|
+
y: 0,
|
|
371
|
+
z: 0,
|
|
372
|
+
vx: 0,
|
|
373
|
+
vy: 0,
|
|
374
|
+
vz: 0,
|
|
375
|
+
shape: { type: 'box', halfX: 60, halfY: 0.5, halfZ: 60 },
|
|
376
|
+
layer: 1,
|
|
377
|
+
mask: 1,
|
|
378
|
+
material: { friction: 8, restitution: 0 },
|
|
379
|
+
},
|
|
380
|
+
chassis('vehicle-car-a', -4),
|
|
381
|
+
chassis('vehicle-car-b', 4),
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
function fixtureInput(frame, chassisId) {
|
|
385
|
+
const phase = chassisId === 'vehicle-car-a' ? 0 : 1;
|
|
386
|
+
return {
|
|
387
|
+
throttle: frame < 80 ? 10 : 0,
|
|
388
|
+
steer: (Math.trunc(frame / 20) + phase) % 2 === 0 ? 10 : -10,
|
|
389
|
+
brake: frame >= 100 ? 10 : 0,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function impulseComponentClean(value) {
|
|
393
|
+
return Number.isFinite(value) && !Object.is(value, -0);
|
|
394
|
+
}
|
|
395
|
+
function runVehicleFixtureLoop(initialWorld, vehicleOrder, frames, priorFrameChecksums = []) {
|
|
396
|
+
let world = initialWorld;
|
|
397
|
+
const frameChecksums = [...priorFrameChecksums];
|
|
398
|
+
const snapshotFrame = initialWorld.frame + Math.trunc(frames / 2);
|
|
399
|
+
let snapshot = initialWorld;
|
|
400
|
+
let impulseSamples = 0;
|
|
401
|
+
let groundedWheelSamples = 0;
|
|
402
|
+
let impulsesQuantizedClean = true;
|
|
403
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
404
|
+
if (world.frame === snapshotFrame) {
|
|
405
|
+
snapshot = cloneCanonical(world);
|
|
406
|
+
}
|
|
407
|
+
// Step vehicles in the given (possibly permuted) order; each step is a pure
|
|
408
|
+
// read of the same world. Impulses are applied in canonical chassis-id
|
|
409
|
+
// order so authoring order cannot leak into the outcome.
|
|
410
|
+
const results = vehicleOrder.map((vehicle) => ({
|
|
411
|
+
vehicle,
|
|
412
|
+
result: stepDeterministicVehicle3D(vehicle, fixtureInput(world.frame, vehicle.chassisId), world, { dtTicks: 1 }),
|
|
413
|
+
}));
|
|
414
|
+
// chassis ids are unique (normalizeBodies enforces world-wide uniqueness)
|
|
415
|
+
const canonical = [...results].sort((left, right) => (left.vehicle.chassisId < right.vehicle.chassisId ? -1 : 1));
|
|
416
|
+
for (const entry of canonical) {
|
|
417
|
+
impulseSamples += entry.result.impulses.length;
|
|
418
|
+
groundedWheelSamples += entry.result.wheels.filter((wheel) => wheel.grounded).length;
|
|
419
|
+
impulsesQuantizedClean = impulsesQuantizedClean && entry.result.impulses.every((impulse) => impulseComponentClean(impulse.impulse.x)
|
|
420
|
+
&& impulseComponentClean(impulse.impulse.y)
|
|
421
|
+
&& impulseComponentClean(impulse.impulse.z));
|
|
422
|
+
world = applyDeterministicImpulses3D(world, entry.result.impulses.map((impulse) => ({
|
|
423
|
+
bodyId: impulse.bodyId,
|
|
424
|
+
impulse: impulse.impulse,
|
|
425
|
+
worldPoint: impulse.point,
|
|
426
|
+
})));
|
|
427
|
+
}
|
|
428
|
+
world = stepDeterministicPhysicsWorld3D(world, { gravityY: FIXTURE_GRAVITY_Y, computeChecksum: false }).world;
|
|
429
|
+
frameChecksums.push(defaultChecksum({
|
|
430
|
+
frame: world.frame,
|
|
431
|
+
vehicles: canonical.map((entry) => entry.result.checksum),
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
return { world, frameChecksums, snapshot, snapshotFrame, impulseSamples, groundedWheelSamples, impulsesQuantizedClean };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Determinism fixture for the raycast vehicle: the full read→apply→step loop is
|
|
438
|
+
* bit-identical across a mid-run snapshot/restore and across world body /
|
|
439
|
+
* vehicle authoring-order permutation (wheel arrays are authored and fixed —
|
|
440
|
+
* wheel-order permutation is intentionally NOT part of the contract).
|
|
441
|
+
*/
|
|
442
|
+
export function runDeterministicVehicle3DFixture(frames = 120) {
|
|
443
|
+
const bodies = fixtureBodies();
|
|
444
|
+
const vehicles = [fixtureVehicle('vehicle-car-a'), fixtureVehicle('vehicle-car-b')];
|
|
445
|
+
const run = runVehicleFixtureLoop(createDeterministicPhysicsWorld3D(bodies), vehicles, frames);
|
|
446
|
+
const restored = runVehicleFixtureLoop(run.snapshot, vehicles, frames - run.snapshotFrame, run.frameChecksums.slice(0, run.snapshotFrame));
|
|
447
|
+
const permuted = runVehicleFixtureLoop(createDeterministicPhysicsWorld3D([...bodies].reverse()), [...vehicles].reverse(), frames);
|
|
448
|
+
const checksum = defaultChecksum({ world: run.world, frameChecksums: run.frameChecksums });
|
|
449
|
+
const restoredChecksum = defaultChecksum({ world: restored.world, frameChecksums: restored.frameChecksums });
|
|
450
|
+
const permutedChecksum = defaultChecksum({ world: permuted.world, frameChecksums: permuted.frameChecksums });
|
|
451
|
+
return {
|
|
452
|
+
checksum,
|
|
453
|
+
restoredChecksum,
|
|
454
|
+
permutedChecksum,
|
|
455
|
+
rollbackEquivalent: checksum === restoredChecksum,
|
|
456
|
+
stableUnderAuthoringOrderPermutation: checksum === permutedChecksum,
|
|
457
|
+
frames,
|
|
458
|
+
vehicles: vehicles.length,
|
|
459
|
+
impulseSamples: run.impulseSamples,
|
|
460
|
+
groundedWheelSamples: run.groundedWheelSamples,
|
|
461
|
+
impulsesQuantizedClean: run.impulsesQuantizedClean,
|
|
462
|
+
};
|
|
463
|
+
}
|