Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/dev/src/demo/walkFeelTuning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ describe("walk feel demo characters", () => {
expect(weighty.jumpHeight).toBeLessThan(0.6);
expect(weighty.airTime).toBeLessThan(0.4);
expect(weighty.airControlReach).toBeLessThan(0.5);
expect(weighty.airTime - weighty.apexTime).toBeLessThan(weighty.apexTime);
// At 60 Hz the fall and the rise land on the same tick (both 0.1833 s), so this comparison sits on
// its own boundary. Sample at 240 Hz, where the 1.6x fall gravity resolves to 0.1667 s fall against
// a 0.1875 s rise. The tolerance is one 240 Hz tick — the sampling resolution — because anything
// smaller fails a legitimate next-tick rounding and anything larger (1/60) would swallow the signal.
const fine = measureMovement(WEIGHTY_FEEL, { dt: 1 / 240 });
const rise = fine.apexTime;
const fall = fine.airTime - fine.apexTime;
expect(fall, `fall ${fall.toFixed(4)}s against rise ${rise.toFixed(4)}s at 240 Hz`).toBeLessThan(rise - 1 / 240);
expect(weighty.timeToTopSpeed).toBeGreaterThan(0.2);
expect(weighty.stopDistance).toBeGreaterThan(0.2);
});
Expand Down
5 changes: 5 additions & 0 deletions changes/physics-frame-rate-invariance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed

- `@jgengine/rapier` now accumulates `step(dt)` into its fixed substeps instead of stepping once per call with `fixedDt` as the timestep. A 1 s move covered 0.5 s of simulated time at 30 fps and 2.4 s at 144 fps; it now covers 1 s at either rate, matching `PhysicsWorld` and the `PhysicsBackend` contract.
- `PhysicsWorld` bounds-floor friction is Coulomb (`friction × g × dt`) like contact friction, not a 60 Hz multiplicative brake: a crate sliding at 4 m/s travelled 0.165 m on the world floor and 0.970 m on a floor collider, and now travels 0.967 m on both.
- Player and character-controller gravity integrate trapezoidally, so a jump reaches `v²/2g` at any frame rate: the default jump peaked at 0.933 m at 30 fps and 1.026 m at 144 fps, and now at 1.050 m for both.
15 changes: 14 additions & 1 deletion packages/core/src/movement/characterController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export interface CharacterMoveInput {
crouch?: boolean;
/** Body the controller itself is represented by, excluded from its own casts. */
self?: BodyHandle;
/**
* Vertical displacement for this move when the caller already integrated it (a movement model that
* owns jump/gravity feel). Overrides the controller's own `verticalVelocity * dt` for the sweep.
*/
verticalStep?: number;
}

/** What one move did. */
Expand Down Expand Up @@ -259,13 +264,21 @@ export function createCharacterController(initial: CharacterControllerConfig): C
remaining = slid;
}

const verticalBefore = state.verticalVelocity;
if (input.gravity !== undefined) state.verticalVelocity -= input.gravity * dt;
let jumped = false;
if (input.jumpVelocity !== undefined && state.grounded) {
state.verticalVelocity = input.jumpVelocity;
state.grounded = false;
jumped = true;
}
const wasGrounded = state.grounded;
let vertical = state.verticalVelocity * dt + carry[1];
// Trapezoidal displacement: the step moves at the average of this step's start and end vertical
// speed, so a jump reaches its analytic apex (v²/2g) at any frame rate. A caller that already
// integrated the vertical motion passes the step explicitly.
const verticalStep =
input.verticalStep ?? (jumped ? state.verticalVelocity : (verticalBefore + state.verticalVelocity) / 2) * dt;
let vertical = verticalStep + carry[1];
if (wasGrounded && vertical <= 0) vertical -= cfg.snapDistance;
const { hit: verticalHit } = sweep(backend, pos, height, [0, vertical, 0], self);
if (verticalHit !== null && vertical > 0) {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/movement/movementModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,9 +369,12 @@ export function advancePlayerMotion(
: motion.verticalVelocity < 0
? (tuning?.fallGravityScale ?? 1)
: 1;
const risingVelocity = motion.verticalVelocity;
motion.verticalVelocity -= gravityAcceleration * gravityScale * deltaSeconds;
if (motion.verticalVelocity <= 0) motion.jumpRising = false;
motion.jumpOffset += motion.verticalVelocity * deltaSeconds;
// Trapezoidal, not Euler: a jump integrates to v²/2g whatever the frame rate. First-order Euler
// undershot the apex by 11% at 30 fps and 2% at 144 fps, so the same jump reached different heights.
motion.jumpOffset += ((risingVelocity + motion.verticalVelocity) / 2) * deltaSeconds;
if (motion.jumpOffset <= 0 && options?.externalGrounding !== true) {
motion.jumpOffset = 0;
motion.verticalVelocity = 0;
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/movement/movementProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ describe("measureMovement", () => {
expect(measureMovement(FLOATY_PLATFORMER)).toEqual(measureMovement(FLOATY_PLATFORMER));
});

test("the same jump reaches the same height at 30, 60, 144 and 240 fps", () => {
// Euler gravity over-estimates at large dt: before the trapezoidal step the same jump peaked at
// 0.933 m at 30 fps and 1.026 m at 144 fps — an 11% band from frame rate alone.
const heights = [1 / 30, 1 / 60, 1 / 144, 1 / 240].map((dt) => measureMovement({}, { dt }).jumpHeight);
const spread = Math.max(...heights) - Math.min(...heights);
expect(heights[1]).toBeCloseTo((7.1 * 7.1) / (2 * 24), 2);
expect(spread).toBeLessThan(0.01);
});

test("same fields, two distinct feels: floaty platformer vs weighty shooter", () => {
const floaty = measureMovement(FLOATY_PLATFORMER);
const weighty = measureMovement(WEIGHTY_SHOOTER);
Expand All @@ -51,7 +60,13 @@ describe("measureMovement", () => {
expect(floaty.tapJumpHeight).toBeLessThan(floaty.jumpHeight * 0.6);
expect(weighty.tapJumpHeight).toBe(weighty.jumpHeight);
expect(floaty.airTime - floaty.apexTime).toBeCloseTo(floaty.apexTime, 1);
expect(weighty.airTime - weighty.apexTime).toBeLessThan(weighty.apexTime);
// At 60 Hz tick resolution the 1.6× fall gravity is under one tick; sample finer to see it. The
// tolerance is one 240 Hz tick (the sampling resolution), so a fall slower than the rise by more
// than a tick still fails while a next-tick rounding does not.
const weightyFine = measureMovement(WEIGHTY_SHOOTER, { dt: 1 / 240 });
const rise = weightyFine.apexTime;
const fall = weightyFine.airTime - weightyFine.apexTime;
expect(fall, `fall ${fall.toFixed(4)}s against rise ${rise.toFixed(4)}s at 240 Hz`).toBeLessThan(rise - 1 / 240);
});

test("sprint raises top speed by the run multiplier", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/movement/playerMovement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1243,7 +1243,7 @@ describe("capsule jump feel", () => {
expect(capsuleState.motion!.clockMs).toBe(authoritativeStep ? 200 : 50);
expect(capsuleState.motion!.clockMs).toBe(heightfieldState.motion!.clockMs);
expect(capsuleState.motion!.verticalVelocity).toBe(heightfieldState.motion!.verticalVelocity);
expect(capsule.scene.entity.get("capsule")!.position[1]).toBeCloseTo(authoritativeStep ? 0.8 : 0.35, 8);
expect(capsule.scene.entity.get("capsule")!.position[1]).toBeCloseTo(authoritativeStep ? 1.2 : 0.375, 8);
expect(capsule.scene.entity.get("capsule")!.position[1]).toBeCloseTo(heightfield.scene.entity.get("heightfield")!.position[1], 8);
}
});
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/movement/playerMovement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,9 @@ export function stepPlayerMovement(
{ buffer: state.jumpBuffer, externalGrounding: true, authoritativeStep: tuning.authoritativeStep },
);
state.controllerJumpHeld = intent.jumping;
// `jumpOffset` is this step's vertical displacement under the movement model's trapezoidal
// integration; the controller sweeps that instead of re-deriving `v * dt` from the end velocity.
const verticalStep = horizontal.jumpOffset;
controller.restore({
...controller.state(),
verticalVelocity: horizontal.verticalVelocity,
Expand All @@ -752,6 +755,7 @@ export function stepPlayerMovement(
const result = controller.move(tuning.controller.backend, {
motion: [horizontalStep.stepX, 0, horizontalStep.stepZ],
dt: motionStepSeconds(dt, tuning.authoritativeStep),
verticalStep,
});
horizontal.verticalVelocity = controller.state().verticalVelocity;
horizontal.jumpOffset = 0;
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/physics/physicsBackendConformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ export function runPhysicsBackendConformance(create: () => PhysicsBackend, harne
expect(Math.abs(state.velocity[1])).toBeLessThan(0.5);
});

test("the same wall-clock time simulates the same distance at 30, 60 and 144 fps", () => {
// A backend that honours `fixedDt` as one step per call simulates `fixedDt * fps` seconds per
// second of real time — half speed at 30 fps, 2.4x at 144 fps. Horizontal travel ignores the
// integrator, so the distance is exactly `v * realSeconds` for any backend that accumulates.
const backend = create();
const travelled: number[] = [];
for (const fps of [30, 60, 144]) {
const ball = backend.addBody({ shape: { kind: "sphere", radius: 0.2 }, position: [0, 8, 0], velocity: [10, 0, 0] });
for (let i = 0; i < fps; i += 1) backend.step(1 / fps);
travelled.push(backend.body(ball)!.position[0]);
backend.removeBody(ball);
}
const spread = Math.max(...travelled) - Math.min(...travelled);
expect(travelled[1]).toBeGreaterThan(8);
expect(spread).toBeLessThan(0.2);
});

test("raycast reports the floor with an upward normal and the right distance", () => {
const backend = create();
const ground = floor(backend);
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/physics/physicsWorld.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,3 +558,46 @@ describe("collision events", () => {
expect(fired).toBe(settled);
});
});

describe("resting bodies and floor friction", () => {
function floorWorld(floorIsBounds: boolean): { w: PhysicsWorld; box: number } {
const w = new PhysicsWorld({
capacity: 16,
bounds: { min: [-50, floorIsBounds ? 0 : -5, -50], max: [50, 50, 50] },
gravity: -20,
});
if (!floorIsBounds) w.addBody({ shape: "box", halfExtents: [20, 0.5, 20], position: [0, -0.5, 0], static: true });
return { w, box: w.addBody({ shape: "box", halfExtents: [0.5, 0.5, 0.5], position: [0, 3, 0] }) };
}

test("a resting body stays put: velocity 0, no drift, penetration under 12 mm", () => {
const { w, box } = floorWorld(false);
for (let i = 0; i < 120; i += 1) w.step(1 / 60);
const restY = w.posY[box]!;
const restX = w.posX[box]!;
const penetration = 0.5 - restY;
expect(w.isSleeping(box)).toBe(true);
expect(w.velY[box]).toBe(0);
expect(Math.abs(w.velX[box]!)).toBeLessThan(1e-6);
expect(penetration).toBeGreaterThan(-1e-6);
expect(penetration).toBeLessThan(0.012);
for (let i = 0; i < 240; i += 1) w.step(1 / 60);
expect(w.posY[box]).toBe(restY);
expect(w.posX[box]).toBe(restX);
});

test("a body sliding on the world floor stops as fast as on a floor collider", () => {
const travelled: number[] = [];
for (const floorIsBounds of [true, false]) {
const { w, box } = floorWorld(floorIsBounds);
// Settle first: this compares steady sliding friction, not the impact impulse's friction budget.
for (let i = 0; i < 120; i += 1) w.step(1 / 60);
w.setVelocity(box, 4, 0, 0);
for (let i = 0; i < 60; i += 1) w.step(1 / 60);
travelled.push(w.posX[box]!);
}
const [bounds, collider] = travelled as [number, number];
expect(collider).toBeGreaterThan(0.8);
expect(Math.abs(bounds - collider)).toBeLessThan(collider * 0.15);
});
});
32 changes: 26 additions & 6 deletions packages/core/src/physics/physicsWorld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,19 +736,19 @@ export class PhysicsWorld {
this.posX[i]! += this.velX[i]! * dt;
this.posY[i]! += this.velY[i]! * dt;
this.posZ[i]! += this.velZ[i]! * dt;
this.constrainBounds(i);
this.constrainBounds(i, dt, true);
}
this.buildGrid();
const iterations = this.solverIterations;
for (let it = 0; it < iterations; it += 1) {
this.solve(it === 0, it === iterations - 1);
if (this.jointActiveCount > 0) this.solveJoints(dt);
}
for (let a = 0; a < this.awakeCount; a += 1) this.constrainBounds(this.awakeList[a]!);
for (let a = 0; a < this.awakeCount; a += 1) this.constrainBounds(this.awakeList[a]!, dt, false);
this.updateSleep();
}

private constrainBounds(i: number): void {
private constrainBounds(i: number, dt: number, frictionPass: boolean): void {
const min = this.bounds.min;
const max = this.bounds.max;
const e = this.restitution;
Expand Down Expand Up @@ -776,15 +776,35 @@ export class PhysicsWorld {
if (this.posY[i]! < loY) {
this.posY[i] = loY;
if (this.velY[i]! < 0) this.velY[i]! = -this.velY[i]! < rt ? 0 : this.velY[i]! * -e;
const tf = 1 - this.friction;
this.velX[i]! *= tf;
this.velZ[i]! *= tf;
if (frictionPass) this.applyFloorFriction(i, dt);
} else if (this.posY[i]! > hiY) {
this.posY[i] = hiY;
if (this.velY[i]! > 0) this.velY[i]! = this.velY[i]! < rt ? 0 : -this.velY[i]! * e;
}
}

/**
* Coulomb friction against the bounds floor, using the same `friction` coefficient a contact
* applies: horizontal speed bleeds by `friction * |gravity| * dt` per substep. The old
* multiplicative form (`* (1 - friction)` every substep) removed 60 × 40% per second, so a body
* on the world floor stopped ~6× shorter than the same body on a static floor collider.
*/
private applyFloorFriction(i: number, dt: number): void {
const decel = this.friction * Math.abs(this.gravity) * dt;
if (!(decel > 0)) return;
const vx = this.velX[i]!;
const vz = this.velZ[i]!;
const speed = Math.sqrt(vx * vx + vz * vz);
if (speed <= decel) {
this.velX[i] = 0;
this.velZ[i] = 0;
return;
}
const keep = (speed - decel) / speed;
this.velX[i] = vx * keep;
this.velZ[i] = vz * keep;
}

/**
* Walk every broadphase cell body `i`'s AABB overlaps, invoking `visit(cell)` once per cell. The
* cell-range math (six `cellCoord` calls plus the row-major triple loop) lives here so the count
Expand Down
15 changes: 15 additions & 0 deletions packages/rapier/src/createRapierBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ describe("createRapierBackend", () => {
}
});

test("fixedDt accumulates real time: 30 fps and 144 fps cover the same distance", async () => {
// The backend used to set `world.timestep = fixedDt` and step once per call, so 1 s of real time
// at 30 fps simulated 0.5 s and at 144 fps simulated 2.4 s — speed scaled with frame rate.
const travelled: number[] = [];
for (const fps of [30, 144]) {
const backend = await createRapierBackend({ gravity: [0, 0, 0], fixedDt: 1 / 60 });
const ball = backend.addBody({ shape: { kind: "sphere", radius: 0.2 }, position: [0, 0, 0], velocity: [10, 0, 0] });
for (let i = 0; i < fps; i += 1) backend.step(1 / fps);
travelled.push(backend.body(ball)!.position[0]);
backend.dispose();
}
expect(travelled[0]).toBeCloseTo(10, 0);
expect(Math.abs(travelled[0]! - travelled[1]!)).toBeLessThan(0.2);
});

test("character controller lands and walks", async () => {
const backend = await world();
const c = controller();
Expand Down
22 changes: 19 additions & 3 deletions packages/rapier/src/rapierBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export async function createRapierBackend(options: RapierBackendOptions = {}): P
let hasStepped = false;
let listener: ((event: ContactEvent) => void) | null = null;
const forced = new Set<number>();
// `step(dt)` is accumulated into equal fixed slices, as the backend contract promises; the
// default matches PhysicsWorld so the two backends simulate the same real time per frame.
const fixedDt = options.fixedDt ?? 1 / 60;
const maxSubsteps = Math.max(1, options.maxSubsteps ?? 4);
let accumulator = 0;

const shape = (s: BodyShape): any => {
switch (s.kind) {
Expand Down Expand Up @@ -122,10 +127,21 @@ export async function createRapierBackend(options: RapierBackendOptions = {}): P
},
removeJoint(h) { const j = joints.get(h); if (j) { world.removeImpulseJoint(j.joint, true); joints.delete(h); } },
step(dt) {
if (dt <= 0) return; world.timestep = options.fixedDt ?? dt; world.step(events); hasStepped = true;
// Rapier keeps added forces until reset; the backend contract is one step per applyForce call.
if (!(dt > 0)) return;
const advance = (): void => {
world.step(events);
hasStepped = true;
events.drainCollisionEvents((rawX: number, rawY: number, started: boolean) => { if (!listener || !started) return; const x = eventHandle(rawX), y = eventHandle(rawY), ca = world.getCollider(x), cb = world.getCollider(y), ha = find(colliders, x), hb = find(colliders, y); if (!ca || !cb || ha === undefined || hb === undefined) return; const m = world.contactPair(ca, cb)?.manifolds?.()[0], n = vec(m?.normal?.() ?? { x: 0, y: 1, z: 0 }), va = ca.parent()?.linvel?.() ?? { x: 0, y: 0, z: 0 }, vb = cb.parent()?.linvel?.() ?? { x: 0, y: 0, z: 0 }; listener({ a: ha, b: hb, normal: n, approachSpeed: Math.max(0, -((vb.x - va.x) * n[0] + (vb.y - va.y) * n[1] + (vb.z - va.z) * n[2])), impulse: Number(m?.totalImpulse?.() ?? 0) }); });
};
accumulator += dt;
const cap = fixedDt * maxSubsteps;
if (accumulator > cap) accumulator = cap;
world.timestep = fixedDt;
while (accumulator >= fixedDt) {
advance();
accumulator -= fixedDt;
} // Rapier keeps added forces until reset; the backend contract is one step per applyForce call.
for (const h of forced) { const b = bodies.get(h); if (b) { b.resetForces(false); b.resetTorques(false); } } forced.clear();
events.drainCollisionEvents((rawX: number, rawY: number, started: boolean) => { if (!listener || !started) return; const x = eventHandle(rawX), y = eventHandle(rawY), ca = world.getCollider(x), cb = world.getCollider(y), ha = find(colliders, x), hb = find(colliders, y); if (!ca || !cb || ha === undefined || hb === undefined) return; const m = world.contactPair(ca, cb)?.manifolds?.()[0], n = vec(m?.normal?.() ?? { x: 0, y: 1, z: 0 }), va = ca.parent()?.linvel?.() ?? { x: 0, y: 0, z: 0 }, vb = cb.parent()?.linvel?.() ?? { x: 0, y: 0, z: 0 }; listener({ a: ha, b: hb, normal: n, approachSpeed: Math.max(0, -((vb.x - va.x) * n[0] + (vb.y - va.y) * n[1] + (vb.z - va.z) * n[2])), impulse: Number(m?.totalImpulse?.() ?? 0) }); });
},
raycast(d: RayDesc) {
const n = Math.hypot(...d.direction); if (!n) return null;
Expand Down
Loading