Ammo.js Internal Tick Callback for Custom Drag Profiles

This article explains how to implement custom, velocity-dependent drag profiles on rigid bodies in Ammo.js by leveraging the physics world’s internal tick callback. You will learn why this callback is necessary for accurate physics, how to write a custom quadratic drag formula, and how to register the callback within the WebAssembly environment of Ammo.js without causing memory leaks.

Why Use the Internal Tick Callback?

Ammo.js (a JavaScript port of the Bullet physics engine) calculates physics in small, discrete time intervals called substeps. If you apply forces to a rigid body inside your main JavaScript game loop, those forces are only applied once per frame. This approach misses the internal substeps, leading to inconsistent physics behaviors, frame-rate dependency, and jitter.

By registering a callback via setInternalTickCallback, you run your custom drag logic directly before (isPreTick = true) or after (isPreTick = false) each internal physics simulation substep. For applying forces like aerodynamic drag, you must use a pre-tick callback so the forces are integrated into the current physics step.

Step 1: Define the Drag Logic

Standard linear damping in Ammo.js is a simple exponential decay. Real-world fluid resistance (drag) is typically quadratic, meaning the force increases with the square of the velocity (\(F_d = \frac{1}{2} \rho v^2 C_d A\)).

To apply this custom drag, you need to: 1. Retrieve the rigid body’s current linear velocity. 2. Calculate the speed (magnitude of the velocity vector). 3. Compute the drag force vector acting in the opposite direction of the velocity. 4. Apply the resulting force to the body.

Step 2: Implement the Callback Code

Below is the implementation of the drag calculation inside the internal tick callback.

// Array to keep track of rigid bodies requiring custom drag profiles
const activeDragBodies = [];

function customDragCallback(worldPtr, timeStep) {
    for (let i = 0; i < activeDragBodies.length; i++) {
        const body = activeDragBodies[i];
        
        // Skip calculation if the body is sleeping to save performance
        if (!body.isActive()) continue;

        const velocity = body.getLinearVelocity();
        const vx = velocity.x();
        const vy = velocity.y();
        const vz = velocity.z();
        const speed = Math.sqrt(vx * vx + vy * vy + vz * vz);

        // Avoid division by zero for stationary bodies
        if (speed > 0.0001) {
            // Drag coefficient combining fluid density, drag coefficient, and reference area
            const dragCoeff = body.customDragCoeff || 0.47; 
            
            // Quadratic drag formula: F = dragCoeff * speed^2
            const dragMagnitude = dragCoeff * speed * speed;

            // Calculate directional forces (opposite to velocity direction)
            const forceX = -(vx / speed) * dragMagnitude;
            const forceY = -(vy / speed) * dragMagnitude;
            const forceZ = -(vz / speed) * dragMagnitude;

            // Apply force to the center of mass
            const dragForce = new Ammo.btVector3(forceX, forceY, forceZ);
            body.applyCentralForce(dragForce);

            // Clean up WebAssembly memory allocation
            Ammo.destroy(dragForce);
        }
    }
}

Step 3: Register the Callback with Ammo.js

Because Ammo.js runs in WebAssembly/C++, you cannot pass a standard JavaScript function directly to the engine. You must wrap the JavaScript function using Ammo.addFunction to create a functional pointer that the WebAssembly runtime can execute.

// 1. Wrap the JS function for WebAssembly
const tickCallbackPointer = Ammo.addFunction(customDragCallback);

// 2. Register the callback with the dynamics world
// The third parameter (true) registers this as a "pre-tick" callback
physicsWorld.setInternalTickCallback(tickCallbackPointer, 0, true);

Memory Management and Performance

When implementing custom drag profiles, keep the following performance considerations in mind: