Frame Rate Independence in Ammo.js Physics

Achieving consistent physics simulations across different devices and frame rates is a crucial aspect of web-based 3D development. This article explains how to implement frame rate independence in an ammo.js physics simulation, ensuring that gravity, collisions, and forces behave identically whether a user is running the application at 30, 60, or 144 frames per second. You will learn how to configure the core simulation stepping function and structure your main loop to decouple physics updates from the rendering rate.

The Problem with Variable Frame Rates

When physics updates are tied directly to the rendering frame rate (using a simple variable delta time), the simulation becomes unstable. On faster machines, physics will run too quickly, while on slower machines, fast-moving objects may pass through walls because the time step between frames is too large for the collision detection engine to register the impact.

To solve this, ammo.js uses a fixed internal time step, decoupling the rendering rate from the physics simulation rate.

Using the stepSimulation Function

The key to frame rate independence in ammo.js is the dynamicsWorld.stepSimulation method. This function automatically handles variable frame rates by taking the elapsed real-world time and splitting it into a fixed number of smaller, consistent internal steps.

The method signature is:

dynamicsWorld.stepSimulation(timeStep, maxSubSteps, fixedTimeStep);

Understanding the Parameters

  1. timeStep (seconds): The actual amount of time that has passed since the last frame. This is typically calculated using a high-resolution clock in your render loop.
  2. maxSubSteps (integer): The maximum number of internal physics steps ammo.js is allowed to take during a single render frame to catch up to the real-time clock.
  3. fixedTimeStep (seconds): The size of the internal physics step. The default value is 1 / 60 (approximately 0.0166 seconds). It is highly recommended to keep this constant.

Implementing the Game Loop

To implement this correctly, you must calculate the delta time in your animation loop and pass it to the physics world. Here is a standard implementation template:

let physicsWorld; // Your Ammo.btDiscreteDynamicsWorld instance
let lastTime = performance.now();

function update(currentTime) {
    requestAnimationFrame(update);

    // Calculate delta time in seconds
    const deltaTime = (currentTime - lastTime) / 1000;
    lastTime = currentTime;

    // Step the simulation
    // 1/60 is the fixed time step (60Hz)
    // 10 is the maximum substeps allowed to catch up
    physicsWorld.stepSimulation(deltaTime, 10, 1 / 60);

    // Update your graphical representation (e.g., Three.js meshes)
    updateVisuals();

    // Render the scene
    renderer.render(scene, camera);
}

requestAnimationFrame(update);

Tuning Substeps to Avoid Performance Issues

If a device lags heavily, the delta time becomes very large. If your maxSubSteps value is set too high, ammo.js will attempt to calculate too many physics steps in a single frame. This slows down the CPU even further, causing the next frame to take even longer, creating a performance bottleneck known as the “spiral of death.”

Conversely, if maxSubSteps is set too low (e.g., 1), and the frame rate drops below 60 FPS, the physics simulation will appear to run in slow motion because it is not allowed to take enough internal steps to catch up with real time.

A safe rule of thumb is to ensure that maxSubSteps multiplied by fixedTimeStep is always greater than the maximum expected frame time. For example, 10 substeps multiplied by 0.0166 seconds allows the physics simulation to handle frame drops down to 6 FPS without slowing down the simulation speed.