Sync Input with Ammo.js in a Web Worker

Running a heavy physics engine like ammo.js in an asynchronous Web Worker keeps the main rendering thread responsive, but it introduces the challenge of input synchronization. Because Web Workers do not have access to the DOM, they cannot directly listen to keyboard, mouse, or gamepad events. This article explains how to capture user inputs on the main thread, stream them efficiently to the physics worker, and apply them to Ammo.js rigid bodies without causing simulation lag.

The Architecture

To sync inputs with a physics worker, you must establish a one-way data flow: 1. Main Thread: Captures DOM input events, updates an input state object, and sends this state to the worker. 2. Worker Thread: Receives the input state, applies corresponding forces or velocities to Ammo.js rigid bodies, steps the physics simulation, and sends the updated entity transforms back to the main thread for rendering.

Step 1: Capturing Input on the Main Thread

On the main thread, maintain a clean representation of the current input state. Avoid sending raw DOM events directly to the worker; instead, serialize only the necessary directional values or action states.

const inputState = {
    forward: 0,
    backward: 0,
    left: 0,
    right: 0,
    jump: false
};

window.addEventListener('keydown', (e) => {
    switch (e.code) {
        case 'KeyW': inputState.forward = 1; break;
        case 'KeyS': inputState.backward = 1; break;
        case 'KeyA': inputState.left = 1; break;
        case 'KeyD': inputState.right = 1; break;
        case 'Space': inputState.jump = true; break;
    }
    sendInputToWorker();
});

window.addEventListener('keyup', (e) => {
    switch (e.code) {
        case 'KeyW': inputState.forward = 0; break;
        case 'KeyS': inputState.backward = 0; break;
        case 'KeyA': inputState.left = 0; break;
        case 'KeyD': inputState.right = 0; break;
        case 'Space': inputState.jump = false; break;
    }
    sendInputToWorker();
});

function sendInputToWorker() {
    physicsWorker.postMessage({
        type: 'INPUT_UPDATE',
        inputs: { ...inputState }
    });
}

Step 2: Handling Inputs in the Physics Worker

Inside the Web Worker, listen for the incoming input messages and update a local copy of the input state. During the physics step loop, apply these inputs to the corresponding Ammo.js rigid bodies as forces, impulses, or direct velocity changes.

let localInputState = { forward: 0, backward: 0, left: 0, right: 0, jump: false };
let playerBody = null; // Assigned when Ammo.js initializes the player

self.onmessage = function(e) {
    if (e.data.type === 'INPUT_UPDATE') {
        localInputState = e.data.inputs;
    }
};

// Inside the physics tick loop
function updatePhysics(deltaTime) {
    if (playerBody) {
        const speed = 10;
        const moveVector = new Ammo.btVector3(
            (localInputState.right - localInputState.left) * speed,
            0,
            (localInputState.backward - localInputState.forward) * speed
        );

        // Fetch current linear velocity to preserve gravity (Y axis)
        const currentVelocity = playerBody.getLinearVelocity();
        moveVector.setY(currentVelocity.y());

        if (localInputState.jump && Math.abs(currentVelocity.y()) < 0.01) {
            moveVector.setY(5); // Apply upward jump impulse
        }

        playerBody.setLinearVelocity(moveVector);
        Ammo.destroy(moveVector);
    }

    // Step simulation
    physicsWorld.stepSimulation(deltaTime, 10);
    
    // Send updated transforms back to main thread
    sendTransformsToMainThread();
}

Step 3: Optimization with SharedArrayBuffer (Optional)

For high-performance scenarios where postMessage serialization overhead becomes a bottleneck, use a SharedArrayBuffer. This allows both the main thread and the worker thread to read and write to the same memory space instantly.

  1. Create a SharedArrayBuffer on the main thread to hold the input values (e.g., using a Float32Array).
  2. Pass the buffer to the worker during initialization.
  3. The main thread writes to the shared array on input events using atomic operations (Atomics.store).
  4. The worker reads from the shared array directly in its physics loop using Atomics.load, completely bypassing the postMessage queue for input updates.