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.