diff --git a/apps/dev/src/demo/walkFeelTuning.test.ts b/apps/dev/src/demo/walkFeelTuning.test.ts index 957115846..376ad7433 100644 --- a/apps/dev/src/demo/walkFeelTuning.test.ts +++ b/apps/dev/src/demo/walkFeelTuning.test.ts @@ -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); }); diff --git a/changes/physics-frame-rate-invariance.md b/changes/physics-frame-rate-invariance.md new file mode 100644 index 000000000..26a257d9e --- /dev/null +++ b/changes/physics-frame-rate-invariance.md @@ -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. diff --git a/packages/core/src/movement/characterController.ts b/packages/core/src/movement/characterController.ts index 1fea6bfa0..3ede541f9 100644 --- a/packages/core/src/movement/characterController.ts +++ b/packages/core/src/movement/characterController.ts @@ -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. */ @@ -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) { diff --git a/packages/core/src/movement/movementModel.ts b/packages/core/src/movement/movementModel.ts index a08ec2607..d650ecc21 100644 --- a/packages/core/src/movement/movementModel.ts +++ b/packages/core/src/movement/movementModel.ts @@ -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; diff --git a/packages/core/src/movement/movementProbe.test.ts b/packages/core/src/movement/movementProbe.test.ts index 6ab925169..aa44f9840 100644 --- a/packages/core/src/movement/movementProbe.test.ts +++ b/packages/core/src/movement/movementProbe.test.ts @@ -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); @@ -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", () => { diff --git a/packages/core/src/movement/playerMovement.test.ts b/packages/core/src/movement/playerMovement.test.ts index 69f6e55dd..ba66e0dac 100644 --- a/packages/core/src/movement/playerMovement.test.ts +++ b/packages/core/src/movement/playerMovement.test.ts @@ -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); } }); diff --git a/packages/core/src/movement/playerMovement.ts b/packages/core/src/movement/playerMovement.ts index 2a7493e02..40273ea10 100644 --- a/packages/core/src/movement/playerMovement.ts +++ b/packages/core/src/movement/playerMovement.ts @@ -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, @@ -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; diff --git a/packages/core/src/physics/physicsBackendConformance.ts b/packages/core/src/physics/physicsBackendConformance.ts index 8722602bd..c60a07567 100644 --- a/packages/core/src/physics/physicsBackendConformance.ts +++ b/packages/core/src/physics/physicsBackendConformance.ts @@ -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); diff --git a/packages/core/src/physics/physicsWorld.test.ts b/packages/core/src/physics/physicsWorld.test.ts index 7f5d5034e..b8a2912e3 100644 --- a/packages/core/src/physics/physicsWorld.test.ts +++ b/packages/core/src/physics/physicsWorld.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/physics/physicsWorld.ts b/packages/core/src/physics/physicsWorld.ts index a565b443c..793b68cf7 100644 --- a/packages/core/src/physics/physicsWorld.ts +++ b/packages/core/src/physics/physicsWorld.ts @@ -736,7 +736,7 @@ 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; @@ -744,11 +744,11 @@ export class PhysicsWorld { 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; @@ -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 diff --git a/packages/rapier/src/createRapierBackend.test.ts b/packages/rapier/src/createRapierBackend.test.ts index 2afa904a9..6940fd385 100644 --- a/packages/rapier/src/createRapierBackend.test.ts +++ b/packages/rapier/src/createRapierBackend.test.ts @@ -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(); diff --git a/packages/rapier/src/rapierBackend.ts b/packages/rapier/src/rapierBackend.ts index 50b728517..80565124f 100644 --- a/packages/rapier/src/rapierBackend.ts +++ b/packages/rapier/src/rapierBackend.ts @@ -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(); + // `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) { @@ -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;