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);
}