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: