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:
- Main Thread (UI/Renderer): Initializes the Web Worker, renders the visual scene, captures user inputs, and sends these inputs to the worker.
- 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);
}
}
};