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