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.
- Create a SharedArrayBuffer on the main thread to hold the input
values (e.g., using a
Float32Array). - Pass the buffer to the worker during initialization.
- The main thread writes to the shared array on input events using
atomic operations (
Atomics.store). - The worker reads from the shared array directly in its physics loop
using
Atomics.load, completely bypassing thepostMessagequeue for input updates.