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:

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:

  1. Allocate two ArrayBuffer instances.
  2. The worker sends Buffer A to the main thread.
  3. The main thread processes Buffer A and transfers Buffer A back to the worker.
  4. Meanwhile, the worker writes the next frame into Buffer B.
  5. 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.