Fast Matter.js Physics in Workers Using ArrayBuffers
Offloading Matter.js physics calculations to a Web Worker prevents
heavy simulation loops from blocking UI interactions and rendering on
the main thread. By default, communication via postMessage
uses structured cloning, which introduces serialization latency and
garbage collection pressure when synchronizing dozens or hundreds of
bodies at 60 frames per second. This article explains how to pack
Matter.js simulation coordinates into transferable
ArrayBuffer objects to achieve zero-copy, high-performance
state synchronization between a worker and the main thread.
The Zero-Copy Concept
Standard postMessage(object) duplicates data in memory.
Transferable objects, such as the underlying ArrayBuffer of
a Float32Array, transfer memory ownership instantly from
the worker context to the main thread without copying data. Once
transferred, the buffer becomes inaccessible to the sender, eliminating
memory duplication and reducing frame time overhead.
1. Structure the Physics Data
Determine the properties required by the renderer for each body. A standard 2D render typically needs:
xposition (1 float)yposition (1 float)anglerotation (1 float)
Using 3 floats per entity, an array of \(N\) bodies requires \(N \times 3\) floats, or \(N \times 12\) bytes.
2. Implement the Web Worker
In the worker, run the Matter.js engine inside a fixed loop or
requestAnimationFrame polyfill. Pack the transform
properties of each dynamic body into a Float32Array, then
transfer its buffer to the main thread.
// physics.worker.js
import Matter from 'matter-js';
const { Engine, Bodies, Composite } = Matter;
const engine = Engine.create();
const bodies = [];
const BODY_COUNT = 500;
// Create bodies
for (let i = 0; i < BODY_COUNT; i++) {
const body = Bodies.circle(Math.random() * 800, Math.random() * 600, 10);
bodies.push(body);
Composite.add(engine.world, body);
}
// Stride length: x, y, angle
const STRIDE = 3;
function updatePhysics() {
Engine.update(engine, 1000 / 60);
// Allocate typed array for physics data
const data = new Float32Array(bodies.length * STRIDE);
for (let i = 0; i < bodies.length; i++) {
const offset = i * STRIDE;
data[offset] = bodies[i].position.x;
data[offset + 1] = bodies[i].position.y;
data[offset + 2] = bodies[i].angle;
}
// Transfer the underlying ArrayBuffer
self.postMessage(data.buffer, [data.buffer]);
setTimeout(updatePhysics, 1000 / 60);
}
updatePhysics();3. Read Data on the Main Thread
On the main thread, listen for messages, construct a
Float32Array view over the incoming buffer, and iterate
through the stride offsets to update visual representations (DOM nodes,
Canvas, Pixi.js, or Three.js objects).
// main.js
const worker = new Worker('physics.worker.js', { type: 'module' });
const STRIDE = 3;
// Example visual representations
const renderNodes = initializeRenderNodes();
worker.onmessage = (event) => {
const positions = new Float32Array(event.data);
for (let i = 0; i < renderNodes.length; i++) {
const offset = i * STRIDE;
const x = positions[offset];
const y = positions[offset + 1];
const angle = positions[offset + 2];
renderNodes[i].update(x, y, angle);
}
};4. Preventing Memory Allocation via Buffer Ping-Ponging
Creating a new Float32Array on every frame still
triggers garbage collection. To eliminate allocation entirely, establish
a buffer ping-pong architecture:
- Allocate two
ArrayBufferinstances. - The worker sends Buffer A to the main thread.
- The main thread processes Buffer A and transfers Buffer A back to the worker.
- Meanwhile, the worker writes the next frame into Buffer B.
- Once the worker receives Buffer A back, it reuses it for the subsequent frame.
By cycling pre-allocated buffers across the worker boundary via transfer lists, Matter.js simulations run at high frame rates with zero serialization latency and zero memory allocations during runtime.