Detect Fast Bullet Collision in Ammo.js

Fast-moving objects like bullets often pass through walls in physics simulations due to a phenomenon called “tunneling,” where an object’s speed exceeds its size relative to the simulation’s time step. This article explains how to prevent tunneling and accurately detect when a fast-moving bullet penetrates a wall in ammo.js by combining Continuous Collision Detection (CCD) with collision callbacks.

Step 1: Enable Continuous Collision Detection (CCD)

By default, ammo.js uses discrete collision detection, checking for overlaps only at the end of each physics step. For fast objects, you must enable CCD, which sweeps a sphere along the object’s travel path to detect collisions between frames.

Configure your bullet’s rigid body with CCD settings after creating it:

// Set the motion threshold. CCD triggers if the bullet moves further than this distance in one frame.
// A value of 1e-7 or the bullet's radius is ideal.
bulletBody.setCcdMotionThreshold(0.01);

// Set the swept sphere radius. This should be slightly smaller than the bullet's actual radius.
bulletBody.setCcdSweptSphereRadius(0.2);

Step 2: Implement the Collision Callback

To execute code the exact moment the bullet hits a wall, register an internal tick callback with the physics world. This callback runs after every physics substep, allowing you to inspect active contact manifolds.

Here is how to set up the post-tick callback to detect collisions:

// Register the tick callback
physicsWorld.setInternalTickCallback(Ammo.addFunction((worldPtr, timeStep) => {
    const dispatcher = physicsWorld.getDispatcher();
    const numManifolds = dispatcher.getNumManifolds();

    for (let i = 0; i < numManifolds; i++) {
        const contactManifold = dispatcher.getManifoldByIndexInternal(i);
        const numContacts = contactManifold.getNumContacts();

        if (numContacts > 0) {
            const body0 = Ammo.castObject(contactManifold.getBody0(), Ammo.btRigidBody);
            const body1 = Ammo.castObject(contactManifold.getBody1(), Ammo.btRigidBody);

            // Identify your bullet and wall using custom properties (e.g., userPointer)
            const userPointer0 = body0.getUserPointer();
            const userPointer1 = body1.getUserPointer();

            if (isBulletAndWall(userPointer0, userPointer1)) {
                handleBulletPenetration(body0, body1, contactManifold);
            }
        }
    }
}), Ammo.kbtInternalTickCallbackObject);

Step 3: Handle the Penetration Event

Inside the collision handler, you can extract the exact collision points, penetration depth, and normal vectors from the contact manifold to determine how deep the bullet has penetrated.

function handleBulletPenetration(body0, body1, manifold) {
    const numContacts = manifold.getNumContacts();
    
    for (let j = 0; j < numContacts; j++) {
        const contactPoint = manifold.getContactPoint(j);
        const distance = contactPoint.getDistance(); // Negative values indicate penetration depth

        if (distance < 0) {
            const hitPointWorld = contactPoint.getPositionWorldOnA(); // Collision point on Object A
            const normalWorld = contactPoint.m_normalWorldOnB; // Collision normal vector

            console.log(`Bullet hit wall! Penetration depth: ${Math.abs(distance)} meters.`);
            
            // Trigger your game logic here (e.g., spawn particles, apply damage, destroy bullet)
            destroyBullet(body0); 
            break;
        }
    }
}

By configuring setCcdMotionThreshold and setCcdSweptSphereRadius on your bullet rigid bodies, you ensure they never phase through walls. Combining this with the setInternalTickCallback allows you to immediately capture the collision data and execute game logic upon impact.