How to Fix Ammo.js NaN Physics Explosions
In 3D web development, sudden physics “explosions” where objects
disappear or fly off-screen are often caused by NaN (Not a
Number) values propagating through the Ammo.js physics engine. This
article explains why these errors occur, how to systematically isolate
the root cause of the calculation breakdown, and how to implement
defensive coding practices to prevent simulation crashes.
Common Causes of NaN Values in Ammo.js
Ammo.js is a direct WebAssembly/asm.js port of the C++ Bullet Physics
engine. When a mathematical operation results in an undefined state, the
underlying engine outputs NaN. Once a single coordinate,
velocity, or rotation vector becomes NaN, it rapidly
infects the entire physics pipeline, causing immediate simulation
collapse. The most common triggers include:
- Zero Mass or Inertia Misconfigurations: Setting a dynamic body’s mass to zero without properly calculating its local inertia.
- Zero-Length Vector Operations: Normalizing a vector with a length of zero (e.g., applying a force direction based on two objects occupying the exact same coordinate).
- Extremely Large Forces or Impulses: Applying a massive force in a single simulation step, causing the velocity to exceed floating-point limits.
- Time Step Spikes: Passing a massive delta time
(
dt) to the step simulation function during frame drops or background tab switching.
Step 1: Isolate the Triggering Object
To find which object is causing the crash, write a utility function
to monitor your physics bodies. In your main update loop, right before
and after calling stepSimulation(), traverse your active
rigid bodies and check their transform matrices and linear/angular
velocities for NaN values.
function checkNaN(rigidBody) {
const transform = rigidBody.getWorldTransform();
const origin = transform.getOrigin();
if (isNaN(origin.x()) || isNaN(origin.y()) || isNaN(origin.z())) {
return true;
}
const linVel = rigidBody.getLinearVelocity();
if (isNaN(linVel.x()) || isNaN(linVel.y()) || isNaN(linVel.z())) {
return true;
}
return false;
}Print a warning and pause the rendering loop the exact millisecond
checkNaN returns true. This allows you to
inspect the call stack and identify the offending object.
Step 2: Sanitize Inputs to the Physics World
Never pass unverified JavaScript data directly to Ammo.js bindings. Ensure that:
- All rotation quaternions are normalized using
.normalize()before being applied to abtTransform. - User-input forces or impulses are clamped to a safe maximum limit.
- Vector calculations (such as custom gravitational pull or wind forces) check for division-by-zero errors before dividing by the distance.
Step 3: Implement Safe Time Stepping
If a browser tab goes out of focus, the time delta can spike from 16ms to several seconds. When passed to Ammo.js, this causes objects to move massive distances in a single frame, resulting in deep interpenetration and explosive ejection forces.
Fix this by clamping your maximum step size and using fixed substepping:
const maxSubSteps = 10;
const fixedTimeStep = 1 / 60; // 16.67ms
// Clamp delta time to a maximum of 0.1 seconds to prevent spikes
let dt = Math.min(clock.getDelta(), 0.1);
physicsWorld.stepSimulation(dt, maxSubSteps, fixedTimeStep);Step 4: Clamp Velocities
To prevent runaway numerical integration, limit the maximum velocity of your rigid bodies. This stops exponential force accumulation before it leads to a math overflow.
const maxVelocity = 100; // Define a safe speed limit for your world
const velocity = body.getLinearVelocity();
const speed = Math.sqrt(
velocity.x() * velocity.x() +
velocity.y() * velocity.y() +
velocity.z() * velocity.z()
);
if (speed > maxVelocity) {
const ratio = maxVelocity / speed;
velocity.setValue(
velocity.x() * ratio,
velocity.y() * ratio,
velocity.z() * ratio
);
body.setLinearVelocity(velocity);
}Implementing these checks creates a robust boundary around the WebAssembly memory space, ensuring Ammo.js remains stable even under extreme gameplay conditions.