Debug Ammo.js Broadphase Cache Phantom Collisions
Phantom collisions in ammo.js (the Emscripten port of the Bullet physics engine) occur when the broadphase collision detection phase falsely pairs objects that are no longer colliding, or fails to register that an object has moved. This article explains why these ghost collisions happen and outlines the exact steps to debug, clean, and rebuild the broadphase overlapping pair cache to restore accurate physics simulation.
Why Broadphase Cache Corruption Occurs
The broadphase collision step uses an accelerated spatial data
structure (like btDbvtBroadphase) to quickly identify
potential overlapping pairs of Axis-Aligned Bounding Boxes (AABBs). To
optimize performance, ammo.js caches these overlapping pairs.
Corruption or desynchronization in this cache typically happens due
to: * Direct Transformation Bypass: Manually modifying
a rigid body’s position or scale via its underlying graphics mesh (e.g.,
Three.js mesh.position.set()) without notifying the physics
engine. * Improper Body Removal: Removing rigid bodies
from the world without properly cleaning up their constraints, collision
shapes, or proxies. * Rapid Scaling or Teleportation:
Teleporting a body across a large distance in a single frame, leaving
stale overlapping pairs in the cache.
Step 1: Force a Broadphase Refresh (Remove and Re-add)
The most reliable, straightforward way to clear a corrupted broadphase cache for a specific object is to force-remove it from the dynamics world and immediately add it back. This forces ammo.js to destroy the object’s broadphase proxy and generate a fresh one.
// Function to safely reset a body's broadphase state
function resetBodyBroadphase(dynamicsWorld, rigidBody) {
// 1. Remove the body from the physics world
dynamicsWorld.removeRigidBody(rigidBody);
// 2. Clear any cached forces and velocity if necessary
rigidBody.setLinearVelocity(new Ammo.btVector3(0, 0, 0));
rigidBody.setAngularVelocity(new Ammo.btVector3(0, 0, 0));
rigidBody.clearForces();
// 3. Re-add the body to force-rebuild its broadphase proxy
dynamicsWorld.addRigidBody(rigidBody);
}Step 2: Manually Clean Stale Overlapping Pairs
If phantom collisions persist globally, you can manually instruct the broadphase controller to clean stale proxies from the overlapping pair cache. This is highly effective after bulk deletions or large teleportation operations.
function clearStaleBroadphasePairs(dynamicsWorld, rigidBody) {
const broadphase = dynamicsWorld.getBroadphase();
const dispatcher = dynamicsWorld.getDispatcher();
const proxy = rigidBody.getBroadphaseProxy();
if (proxy) {
// Clean all collision pairs associated with this specific proxy
broadphase.getOverlappingPairCache().cleanProxyFromPairs(proxy, dispatcher);
}
}Step 3: Use Motion States for Teleportation
Directly modifying a body’s transform matrix using
setWorldTransform() can desynchronize the broadphase. To
prevent cache corruption during teleportation, always use the object’s
btMotionState and clear the interpolation cache.
function teleportBody(dynamicsWorld, rigidBody, targetPosition, targetRotation) {
const transform = new Ammo.btTransform();
transform.setIdentity();
transform.setOrigin(targetPosition);
transform.setRotation(targetRotation);
// Update Motion State
const motionState = rigidBody.getMotionState();
if (motionState) {
motionState.setWorldTransform(transform);
}
// Update the Rigid Body transform directly
rigidBody.setWorldTransform(transform);
// Force the engine to update the bounding box in the broadphase
dynamicsWorld.updateSingleAabb(rigidBody);
// Activate the body so the solver evaluates it next frame
rigidBody.activate(true);
}Step 4: Verify the Collision with Narrowphase Filtering
Sometimes, the broadphase correctly pairs two objects, but a bug keeps them registered in the narrowphase contact manifolds. To verify if a collision is a true phantom (broadphase mismatch) or a persistent manifold issue, iterate through the dispatcher’s manifolds:
function verifyActiveCollisions(dynamicsWorld) {
const dispatcher = dynamicsWorld.getDispatcher();
const numManifolds = dispatcher.getNumManifolds();
for (let i = 0; i < numManifolds; i++) {
const contactManifold = dispatcher.getManifoldByIndexInternal(i);
const body0 = Ammo.castObject(contactManifold.getBody0(), Ammo.btRigidBody);
const body1 = Ammo.castObject(contactManifold.getBody1(), Ammo.btRigidBody);
let actualContacts = 0;
const numContacts = contactManifold.getNumContacts();
for (let j = 0; j < numContacts; j++) {
const pt = contactManifold.getContactPoint(j);
if (pt.getDistance() < 0) {
actualContacts++;
}
}
// If actualContacts is 0 but the manifold exists, it's a cached broadphase pair
if (numContacts > 0 && actualContacts === 0) {
console.warn("Potential phantom collision detected between", body0, body1);
}
}
}