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
- Keep Arrays Flat: Never transfer nested arrays or objects.
- Double Buffer: Always send buffers back to the
worker when using Transferables to avoid
new Float32Arrayallocations on every frame. - Use User Indexes: Assign an integer index to
body.setUserIndex(id)within Ammo.js to quickly map the flat array data back to your main-thread 3D meshes without string lookups.