Run Ammo.js Physics inside a Web Worker

This article explains how to offload resource-intensive physics calculations from your main browser thread to a Web Worker using ammo.js (the Emscripten port of the Bullet physics engine). You will learn how to initialize the physics engine inside a worker, set up a high-performance simulation loop, and efficiently synchronize transformation data back to the main rendering thread.

Why Run Physics in a Web Worker?

Running physics on the main thread can cause frame rate drops (jank) because rendering, user input, and physics calculations all compete for the same single-threaded CPU cycles. By moving ammo.js to a Web Worker, you isolate the physics simulation on a separate OS-level thread. This ensures your rendering loop (e.g., Three.js) remains highly responsive and runs at a consistent 60+ FPS.


Architecture Overview

To run physics completely inside a Web Worker, you must establish a clear separation of concerns:

  1. Main Thread (UI/Renderer): Initializes the Web Worker, renders the visual scene, captures user inputs, and sends these inputs to the worker.
  2. Worker Thread (Physics): Loads ammo.js, builds the physics world, runs the simulation loop, and streams the positions and rotations of rigid bodies back to the main thread.

Because Web Workers do not have access to the DOM or direct memory sharing by default, synchronization is achieved via message passing (postMessage) using highly efficient Transferable Objects (such as TypedArrays).


Step 1: Loading Ammo.js in the Worker

Because Web Workers do not have access to the window object, you must load ammo.js using the importScripts function inside the worker script.

// physics-worker.js
importScripts('path/to/ammo.js');

let Ammo;
let physicsWorld;
let rigidBodies = []; // Keep track of bodies to sync

// Initialize Ammo
Ammo().then((AmmoLib) => {
    Ammo = AmmoLib;
    initPhysics();
    initLoop();
});

Step 2: Initializing the Physics World

Inside the worker, set up the standard bullet physics world components:

function initPhysics() {
    const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
    const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
    const overlappingPairCache = new Ammo.btDbvtBroadphase();
    const solver = new Ammo.btSequentialImpulseConstraintSolver();
    
    physicsWorld = new Ammo.btDiscreteDynamicsWorld(
        dispatcher, 
        overlappingPairCache, 
        solver, 
        collisionConfiguration
    );
    physicsWorld.setGravity(new Ammo.btVector3(0, -9.81, 0));
}

Step 3: Structuring the Physics Loop

The physics loop inside the worker needs to run at a consistent interval. Since modern Web Workers support requestAnimationFrame, you can use it for timing. If it is unavailable in your target environments, fall back to a high-precision setTimeout loop.

let lastTime = performance.now();

function initLoop() {
    tick();
}

function tick() {
    const now = performance.now();
    const deltaTime = (now - lastTime) / 1000;
    lastTime = now;

    // Step the physics simulation
    // Max sub-steps = 10, fixed time step = 1/60
    physicsWorld.stepSimulation(deltaTime, 10, 1/60);

    // Synchronize data back to the main thread
    sendTransformData();

    // Call the next frame
    requestAnimationFrame(tick);
}

Step 4: High-Performance Data Transfer

Passing JSON objects via postMessage causes serialization overhead that can ruin the performance benefits of using a worker. Instead, pack your translation and rotation data into a single flat Float32Array and transfer it to the main thread.

Inside the Worker:

Create a reusable array to store coordinates (e.g., [id, x, y, z, qx, qy, qz, qw, ...]).

// Assuming each body needs 8 floats: 1 ID, 3 Position, 4 Rotation (quaternion)
const STRIDE = 8; 

function sendTransformData() {
    const numBodies = rigidBodies.length;
    const transformData = new Float32Array(numBodies * STRIDE);
    const transform = new Ammo.btTransform();

    for (let i = 0; i < numBodies; i++) {
        const body = rigidBodies[i];
        const motionState = body.getMotionState();
        
        if (motionState) {
            motionState.getWorldTransform(transform);
            const origin = transform.getOrigin();
            const rotation = transform.getRotation();

            const index = i * STRIDE;
            transformData[index]     = body.userIndex; // Unique identifier linked to visual mesh
            transformData[index + 1] = origin.x();
            transformData[index + 2] = origin.y();
            transformData[index + 3] = origin.z();
            transformData[index + 4] = rotation.x();
            transformData[index + 5] = rotation.y();
            transformData[index + 6] = rotation.z();
            transformData[index + 7] = rotation.w();
        }
    }

    // Transfer the buffer memory without copying it
    self.postMessage({
        type: 'TRANSFORMS',
        data: transformData
    }, [transformData.buffer]);
}

Step 5: Handling Messages on the Main Thread

On the main thread, instantiate the worker, listen for the packed data, and apply it directly to your visual objects (like Three.js meshes).

// main.js
const worker = new Worker('physics-worker.js');
const meshMap = new Map(); // Map matching userIndex to Three.js Meshes

worker.onmessage = function(event) {
    const { type, data } = event.data;

    if (type === 'TRANSFORMS') {
        const transformData = data;
        const STRIDE = 8;

        for (let i = 0; i < transformData.length; i += STRIDE) {
            const id = transformData[i];
            const mesh = meshMap.get(id);

            if (mesh) {
                // Update position
                mesh.position.set(
                    transformData[i + 1],
                    transformData[i + 2],
                    transformData[i + 3]
                );
                // Update rotation
                mesh.quaternion.set(
                    transformData[i + 4],
                    transformData[i + 5],
                    transformData[i + 6],
                    transformData[i + 7]
                );
            }
        }
    }
};

Handling User Inputs and State Changes

When the player interacts with the game (e.g., applying a force to a ball), you must send a command message to the worker:

// Main thread sending an impulse
worker.postMessage({
    type: 'APPLY_IMPULSE',
    bodyId: 42,
    force: [0, 10, 0]
});

The worker should listen for these events, find the corresponding btRigidBody in its internal registry, and apply the physics methods natively:

// Inside physics-worker.js
self.onmessage = function(event) {
    const { type, bodyId, force } = event.data;

    if (type === 'APPLY_IMPULSE') {
        const body = findRigidBodyById(bodyId);
        if (body) {
            body.activate();
            const impulse = new Ammo.btVector3(force[0], force[1], force[2]);
            body.applyCentralImpulse(impulse);
        }
    }
};