Custom btMotionState Visual Interpolation in Ammo.js

This article explains how to implement a custom btMotionState in Ammo.js to decouple your physics simulation tick rate from your rendering frame rate. By capturing both the previous and current physics states, you can perform visual interpolation (LERP and SLERP) during render frames, eliminating jitter and ensuring buttery-smooth animations even when the physics engine runs at a fixed, lower frequency.

The Problem: Physics vs. Render Rates

Physics simulations like Ammo.js run most stably at a fixed time step (typically 60Hz). However, modern screens render at variable rates (such as 90Hz, 120Hz, or 144Hz). If you map the visual mesh position directly to the physics body position on every frame, you will notice a subtle jitter. This occurs because the rendering loop runs more frequently than the physics ticks, causing some rendering frames to render identical physics states.

To solve this, we must track the state of the physics body at the end of the previous tick and the current tick, then interpolate between them based on the time remaining in the physics accumulator.

Step 1: Implement the Custom btMotionState

Because Ammo.js is a WebAssembly port of the C++ Bullet physics engine, you cannot inherit from C++ classes using standard JavaScript classes. Instead, you must use Ammo.implement to create a custom implementation of the abstract btMotionState class.

Below is the code to implement a custom motion state that stores both the previous and current transforms:

// Define the constructor for your custom motion state
const InterpolatedMotionState = function(initialTransform) {
    // We must manually cache the transforms
    this.currentTransform = new Ammo.btTransform(initialTransform);
    this.previousTransform = new Ammo.btTransform(initialTransform);
    
    // A flag to check if the physics engine updated this state during the step
    this.hasUpdated = false;
};

// Implement the btMotionState interface methods
InterpolatedMotionState.prototype = {
    // Bullet calls this to set the initial transform of the rigid body
    getWorldTransform: function(worldTrans) {
        worldTrans.setOrigin(this.currentTransform.getOrigin());
        worldTrans.setRotation(this.currentTransform.getRotation());
    },

    // Bullet calls this every time the physics simulation updates the body's position
    setWorldTransform: function(worldTrans) {
        // Shift current transform to previous transform
        this.previousTransform.setOrigin(this.currentTransform.getOrigin());
        this.previousTransform.setRotation(this.currentTransform.getRotation());

        // Update current transform with the new physics state
        this.currentTransform.setOrigin(worldTrans.getOrigin());
        this.currentTransform.setRotation(worldTrans.getRotation());
        
        this.hasUpdated = true;
    }
};

To instantiate this in Ammo.js, wrap your prototype using Ammo.implement:

const startTransform = new Ammo.btTransform();
startTransform.setIdentity();
startTransform.setOrigin(new Ammo.btVector3(0, 10, 0));

// Instantiate the implemented motion state
const myMotionStateInstance = new Ammo.implement(
    new InterpolatedMotionState(startTransform), 
    Ammo.btMotionState
);

Step 2: Track the Interpolation Alpha in your Loop

To interpolate correctly, you need to calculate the “remainder” time left over after stepping the physics world. This remainder is represented as alpha, a normalized value between 0.0 and 1.0.

let physicsAccumulator = 0;
const fixedTimeStep = 1 / 60; // 60Hz physics step

function tick(deltaTime) {
    physicsAccumulator += deltaTime;

    // Step the physics simulation
    // This will call setWorldTransform() on your motion state if a step occurs
    dynamicsWorld.stepSimulation(deltaTime, 10, fixedTimeStep);

    // Calculate the interpolation factor
    // alpha represents how far we are between the previous tick and the current tick
    const alpha = (physicsAccumulator % fixedTimeStep) / fixedTimeStep;

    // Update your visual meshes
    updateVisualMeshes(alpha);
}

Step 3: Interpolate and Update Visual Meshes

During your render step, query the custom motion state of each rigid body. Use the alpha value to linearly interpolate (LERP) the position and spherically linear interpolate (SLERP) the rotation between previousTransform and currentTransform.

Here is an implementation example assuming you are using Three.js for rendering:

function updateVisualMeshes(alpha) {
    // Access your custom motion state instance (unwrap from Ammo.implement if necessary)
    const state = myMotionStateInstance; 

    const prevPos = state.previousTransform.getOrigin();
    const currPos = state.currentTransform.getOrigin();
    const prevRot = state.previousTransform.getRotation();
    const currRot = state.currentTransform.getRotation();

    // Interpolate Position (LERP)
    const x = prevPos.x() + alpha * (currPos.x() - prevPos.x());
    const y = prevPos.y() + alpha * (currPos.y() - prevPos.y());
    const z = prevPos.z() + alpha * (currPos.z() - prevPos.z());
    threeMesh.position.set(x, y, z);

    // Interpolate Rotation (SLERP)
    const qPrev = new THREE.Quaternion(prevRot.x(), prevRot.y(), prevRot.z(), prevRot.w());
    const qCurr = new THREE.Quaternion(currRot.x(), currRot.y(), currRot.z(), currRot.w());
    
    // Smoothly interpolate between the two orientations
    threeMesh.quaternion.copy(qPrev).slerp(qCurr, alpha);
}