Ammo.js Swept Sphere Test for High-Speed Collision

In fast-paced 3D applications, fast-moving character controllers often pass right through thin walls or obstacles—a physics bug known as tunneling or clipping. This article provides a practical guide on how to implement a swept sphere test using Ammo.js (the Emscripten port of the Bullet Physics engine) to predict collisions along a character’s movement path. By calculating collisions continuously rather than discretely, you can entirely prevent character clipping during high-speed movement.

The Problem: Tunneling at High Speeds

Standard physics engines evaluate collisions at discrete time steps. If a character moves 5 units per frame and a wall is only 1 unit thick, the character’s position in Frame A might be in front of the wall, and in Frame B, it might be behind the wall. Because the engine never detects an overlap at either discrete step, the character passes through the wall.

To solve this, we use Continuous Collision Detection (CCD). Specifically, a swept sphere test projects a sphere shape from the character’s starting position to their destination, identifying any intersections with geometry along that trajectory.

How to Implement Swept Sphere in Ammo.js

Ammo.js provides this functionality through the convexSweepTest method on the dynamics world. Below are the steps to implement this test.

Step 1: Define Start and End Transforms

You must define where the sweep starts (the character’s current position) and where it is projected to end (the desired next position).

// Create transforms
const transformFrom = new Ammo.btTransform();
transformFrom.setIdentity();
transformFrom.setOrigin(new Ammo.btVector3(currentX, currentY, currentZ));

const transformTo = new Ammo.btTransform();
transformTo.setIdentity();
transformTo.setOrigin(new Ammo.btVector3(targetX, targetY, targetZ));

Step 2: Create the Swept Shape

Use a btSphereShape to represent the character’s collision volume. The radius should match your character’s physical size.

const sphereRadius = 0.5; // Match your character's radius
const sweptShape = new Ammo.btSphereShape(sphereRadius);

Step 3: Set Up the Callback

To capture the collision results, use ClosestConvexResultCallback. This callback determines the closest object hit along the sweep path.

const callback = new Ammo.btCollisionWorld_ClosestConvexResultCallback(
    transformFrom.getOrigin(),
    transformTo.getOrigin()
);

// Optional: Filter collision groups to avoid self-collision with the character's own rigid body
callback.m_collisionFilterGroup = collisionGroup;
callback.m_collisionFilterMask = collisionMask;

Step 4: Perform the Sweep Test

Run the sweep test on your Ammo.js dynamics world instance.

dynamicsWorld.convexSweepTest(
    sweptShape,
    transformFrom,
    transformTo,
    callback,
    0 // Allowed penetration depth (usually 0)
);

Step 5: Process the Results

After running the test, check if the callback detected a hit. If a hit is registered, you must stop the character at the point of impact.

if (callback.hasHit()) {
    // Get the fraction of the distance traveled before collision (0.0 to 1.0)
    const hitFraction = callback.m_closestHitFraction;
    
    // Calculate the safe stop position slightly before the hit point
    const safeFraction = Math.max(0, hitFraction - 0.01); 
    
    const finalX = currentX + (targetX - currentX) * safeFraction;
    const finalY = currentY + (targetY - currentY) * safeFraction;
    const finalZ = currentZ + (targetZ - currentZ) * safeFraction;
    
    // Position character at the safe stop position
    character.setPosition(finalX, finalY, finalZ);
    
    // Optional: Get collision normal to calculate sliding vector
    const hitNormal = callback.m_hitNormalWorld;
} else {
    // No collision: Move character to the target position safely
    character.setPosition(targetX, targetY, targetZ);
}

Step 6: Clean Up Memory

Ammo.js does not automatically garbage-collect C++ objects allocated in JavaScript. You must manually destroy the created objects to prevent memory leaks.

Ammo.destroy(transformFrom);
Ammo.destroy(transformTo);
Ammo.destroy(sweptShape);
Ammo.destroy(callback);

Summary of Best Practices