Transfer Ammo.js Transforms From Web Worker Efficiently

Running an Ammo.js physics simulation inside a Web Worker keeps the main thread responsive, but transferring high-frequency transform data (position and rotation) back to the main thread for rendering can quickly become a performance bottleneck. The most efficient way to transfer this data is by writing the transform coordinates into a flat, typed float array and passing it back using Transferable Objects or sharing it directly via a SharedArrayBuffer. This approach eliminates the heavy overhead of structured cloning and object serialization, ensuring 60 FPS rendering.


The Bottleneck: Avoid Structured Cloning

By default, sending JavaScript objects via postMessage() uses the structured clone algorithm. Copying thousands of individual position and quaternion objects from a Web Worker to the main thread every frame introduces severe garbage collection overhead and CPU latency.

To achieve maximum efficiency, you must serialize your Ammo.js transform data into a single, flat Float32Array.


Method 1: SharedArrayBuffer (Zero-Copy, Fastest)

If your hosting environment supports secure contexts (with Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers enabled), SharedArrayBuffer is the absolute fastest method. It allows both the Web Worker and the main thread to access the exact same memory space without any copying or transferring.

1. Setup the Shared Buffer

On the main thread, allocate a shared memory buffer large enough to hold the transform data of all your dynamic physics bodies. Each body requires 8 floats: 1 for the unique body ID, 3 for position (X, Y, Z), and 4 for rotation/quaternion (X, Y, Z, W).

const maxBodies = 1000;
const floatsPerBody = 8; // [ID, X, Y, Z, QX, QY, QZ, QW]
const sharedBuffer = new SharedArrayBuffer(maxBodies * floatsPerBody * Float32Array.BYTES_PER_ELEMENT);
const sharedArray = new Float32Array(sharedBuffer);

// Pass the buffer to the Web Worker during initialization
physicsWorker.postMessage({ type: 'INIT', buffer: sharedBuffer });

2. Write Data in the Web Worker

Inside the Web Worker, iterate through your active Ammo.js rigid bodies, extract their transforms using getMotionState().getWorldTransform(), and write them directly into the shared array.

// Inside Web Worker loop
let offset = 0;
for (let i = 0; i < activeBodies.length; i++) {
    const body = activeBodies[i];
    body.getMotionState().getWorldTransform(tempTransform);
    const origin = tempTransform.getOrigin();
    const rotation = tempTransform.getRotation();

    sharedArray[offset++] = body.userIndex; // Unique ID mapped to Main Thread mesh
    sharedArray[offset++] = origin.x();
    sharedArray[offset++] = origin.y();
    sharedArray[offset++] = origin.z();
    sharedArray[offset++] = rotation.x();
    sharedArray[offset++] = rotation.y();
    sharedArray[offset++] = rotation.z();
    sharedArray[offset++] = rotation.w();
}

// Notify the main thread that new data is ready
postMessage({ type: 'TICK', activeCount: activeBodies.length });

3. Read Data on the Main Thread

Because the memory is shared, the main thread can instantly read the updated coordinates from its local sharedArray reference as soon as it receives the ‘TICK’ signal.


Method 2: Transferable Objects (High Compatibility)

If you cannot configure the required COOP/COEP headers for SharedArrayBuffer, use Transferable Objects. Transferring an ArrayBuffer passes ownership of the memory instantly from the worker to the main thread. This is a zero-copy operation, but it renders the array inaccessible in the worker after transfer, requiring you to utilize a double-buffering strategy.

1. Web Worker Double-Buffering Implementation

To avoid reallocating memory every frame, maintain two buffers in the worker. While the main thread reads one buffer, the worker writes to the other.

let bufferA = new Float32Array(maxBodies * 8);
let bufferB = new Float32Array(maxBodies * 8);
let currentBuffer = bufferA;

function tickPhysics() {
    let offset = 0;
    
    // ... Fill currentBuffer with Ammo.js transform data ...

    // Transfer ownership of the underlying buffer to the main thread
    postMessage({ 
        type: 'TRANSFORMS', 
        data: currentBuffer, 
        count: activeCount 
    }, [currentBuffer.buffer]);

    // Swap buffers to write to the other one next frame
    currentBuffer = (currentBuffer === bufferA) ? bufferB : bufferA;
}

// Receive the exhausted buffer back from the main thread for reuse
self.onmessage = function(e) {
    if (e.data.type === 'RETURN_BUFFER') {
        if (currentBuffer === bufferA) {
            bufferB = new Float32Array(e.data.buffer);
        } else {
            bufferA = new Float32Array(e.data.buffer);
        }
    }
};

2. Main Thread Processing and Buffer Return

Once the main thread receives the transferred array, it updates the visual scene graph (e.g., Three.js meshes) and immediately transfers the empty buffer back to the worker.

physicsWorker.onmessage = function(e) {
    if (e.data.type === 'TRANSFORMS') {
        const data = e.data.data;
        const count = e.data.count;

        for (let i = 0; i < count; i++) {
            const offset = i * 8;
            const id = data[offset];
            const mesh = sceneMeshes[id];

            if (mesh) {
                mesh.position.set(data[offset + 1], data[offset + 2], data[offset + 3]);
                mesh.quaternion.set(data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]);
            }
        }

        // Return the buffer back to the worker to prevent garbage collection allocation
        physicsWorker.postMessage({ type: 'RETURN_BUFFER', buffer: data.buffer }, [data.buffer]);
    }
};

Summary of Best Practices