Can SharedArrayBuffer Eliminate ammo.js Message Latency?
High-performance web applications often offload physics engines like
ammo.js to Web Workers to keep the main thread responsive. However,
standard message passing using postMessage introduces
latency and garbage collection overhead that can bottleneck rendering.
This article explores how SharedArrayBuffer can be used to
share physics states between threads in real-time, effectively
eliminating message-passing latency.
The Problem with standard Web Workers and ammo.js
When running ammo.js (a WebAssembly port of the Bullet physics engine) in a Web Worker, the main thread and the worker thread must constantly communicate. To update the visual positions and rotations of 3D objects, the worker must send the transform data of every active rigid body back to the main thread on every frame.
Using standard postMessage requires either copying the
data (which creates CPU overhead and garbage collection pressure) or
transferring ownership of the underlying ArrayBuffer.
Transferring buffers avoids copying but requires a constant
back-and-forth allocation cycle, which still introduces minor overhead
and complex synchronization logic.
How SharedArrayBuffer Solves Latency
SharedArrayBuffer allows you to allocate a single memory
buffer that is accessible by both the main execution thread and the Web
Worker thread simultaneously. By writing physics state data directly to
this shared memory, you eliminate the need to serialize, copy, or
transfer data across threads.
Instead of waiting for a message event, the main thread reads the
physical transforms directly from the shared memory space during its
render loop (e.g., inside requestAnimationFrame). This
results in near-zero latency and zero garbage collection overhead.
Implementing SharedArrayBuffer with ammo.js
Because ammo.js operates on its own WebAssembly memory heap, you
cannot directly expose the entire ammo.js memory space as a
SharedArrayBuffer without complex WebAssembly rebuilding
configurations. Instead, the most practical approach is to use a
dedicated “state buffer.”
1. Allocate the Shared Buffer
On the main thread, allocate a SharedArrayBuffer large
enough to store the positions (3 floats) and rotations (4 floats for
quaternions) of all your physics bodies.
// Example: 1000 bodies, each needing 7 floats (3 position, 4 rotation)
const totalBodies = 1000;
const floatSize = 4; // 4 bytes per Float32
const bufferSize = totalBodies * 7 * floatSize;
const sharedBuffer = new SharedArrayBuffer(bufferSize);
const sharedArray = new Float32Array(sharedBuffer);
// Pass the buffer to the Web Worker once during initialization
physicsWorker.postMessage({ type: 'INIT_BUFFER', buffer: sharedBuffer });2. Update the Buffer in the Physics Worker
Inside your ammo.js Web Worker, wrap the received buffer in a
Float32Array. On every physics step, iterate through your
active rigid bodies, extract their transforms using ammo.js APIs, and
write the values directly into the shared array.
// Inside the Worker step loop
let offset = 0;
for (let i = 0; i < rigidBodies.length; i++) {
const body = rigidBodies[i];
const motionState = body.getMotionState();
if (motionState) {
motionState.getWorldTransform(transform);
const origin = transform.getOrigin();
const rotation = transform.getRotation();
// Write directly to shared memory
sharedArray[offset] = origin.x();
sharedArray[offset + 1] = origin.y();
sharedArray[offset + 2] = origin.z();
sharedArray[offset + 3] = rotation.x();
sharedArray[offset + 4] = rotation.y();
sharedArray[offset + 5] = rotation.z();
sharedArray[offset + 6] = rotation.w();
}
offset += 7;
}3. Read the Buffer on the Main Thread
In your main thread’s render loop (e.g., using Three.js), read the
values directly from the sharedArray and apply them to your
3D meshes.
function animate() {
requestAnimationFrame(animate);
let offset = 0;
for (let i = 0; i < meshes.length; i++) {
const mesh = meshes[i];
// Read directly from shared memory with zero latency
mesh.position.set(
sharedArray[offset],
sharedArray[offset + 1],
sharedArray[offset + 2]
);
mesh.quaternion.set(
sharedArray[offset + 3],
sharedArray[offset + 4],
sharedArray[offset + 5],
sharedArray[offset + 6]
);
offset += 7;
}
renderer.render(scene, camera);
}Synchronization and Safety
Because the main thread and worker thread are accessing the same memory, there is a theoretical risk of “dirty reads” (the main thread reading a transform while the worker is halfway through writing it).
For standard 3D rendering, minor visual tearing is rarely noticeable,
and the raw performance gain of avoiding locks is usually preferred.
However, if strict thread synchronization is required, you can use the
JavaScript Atomics API to implement basic spinlocks or
state flags within the shared buffer to ensure the main thread only
reads fully updated frames.
Security Constraints to Keep in Mind
To use SharedArrayBuffer in modern web browsers, your
hosting server must serve your website with specific HTTP headers to
enable Cross-Origin Isolation. Without these headers, browsers will
block the instantiation of SharedArrayBuffer for security
reasons:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp