How to Interpolate Rendering in Matter.js

Decoupling your physics update rate from your display refresh rate is essential for achieving smooth visual motion in Matter.js. When physics runs at a fixed delta time (such as 60Hz) while the screen renders at variable or higher refresh rates (such as 120Hz or 144Hz), bodies can appear to jitter. This article outlines how to implement a fixed-timestep game loop, track previous body states, and compute an interpolation factor to smoothly render Matter.js bodies between physics updates.

The Accumulator Pattern

To interpolate rendering, you must run physics at a deterministic, fixed interval while letting the render loop run as fast as the monitor refreshes via requestAnimationFrame.

An accumulator stores elapsed real-world time. Whenever the accumulator exceeds your fixed physics step duration, you advance the physics engine by that exact interval and subtract the duration from the accumulator. Any remaining time represents the fractional progress toward the next physics step—this fraction is your interpolation factor (\(\alpha\)).

Tracking Body States

Interpolation requires knowing where a body was at the previous physics step and where it is currently. Matter.js does not store previous positions automatically for rendering purposes, so you must store them manually before updating the engine.

Attach a custom property to each body:

function trackBodyState(body) {
    body.renderState = {
        previousPosition: { x: body.position.x, y: body.position.y },
        previousAngle: body.angle,
        currentPosition: { x: body.position.x, y: body.position.y },
        currentAngle: body.angle
    };
}

Implementing the Loop

Replace the default Matter.Runner with a custom requestAnimationFrame loop.

const engine = Matter.Engine.create();
const fixedDelta = 1000 / 60; // 60 updates per second (approx. 16.66ms)
let accumulator = 0;
let lastTime = performance.now();

function gameLoop(currentTime) {
    requestAnimationFrame(gameLoop);

    let frameTime = currentTime - lastTime;
    lastTime = currentTime;

    // Prevent spiral of death on lag spikes
    if (frameTime > 250) {
        frameTime = 250;
    }

    accumulator += frameTime;

    // Fixed physics updates
    while (accumulator >= fixedDelta) {
        // Save current positions as previous before updating
        const bodies = Matter.Composite.allBodies(engine.world);
        for (let i = 0; i < bodies.length; i++) {
            const body = bodies[i];
            if (!body.renderState) trackBodyState(body);

            body.renderState.previousPosition.x = body.position.x;
            body.renderState.previousPosition.y = body.position.y;
            body.renderState.previousAngle = body.angle;
        }

        // Advance simulation
        Matter.Engine.update(engine, fixedDelta);

        // Record the new current positions
        for (let i = 0; i < bodies.length; i++) {
            const body = bodies[i];
            body.renderState.currentPosition.x = body.position.x;
            body.renderState.currentPosition.y = body.position.y;
            body.renderState.currentAngle = body.angle;
        }

        accumulator -= fixedDelta;
    }

    // Alpha is the progress toward the next frame [0.0, 1.0)
    const alpha = accumulator / fixedDelta;

    render(alpha);
}

requestAnimationFrame(gameLoop);

Calculating Render Transforms

When rendering with a custom Canvas, Pixi.js, or Three.js pipeline, calculate the visual position using linear interpolation (lerp):

\[\text{Render Position} = \text{Previous} + (\text{Current} - \text{Previous}) \times \alpha\]

function lerp(start, end, alpha) {
    return start + (end - start) * alpha;
}

function render(alpha) {
    const context = canvas.getContext('2d');
    context.clearRect(0, 0, canvas.width, canvas.height);

    const bodies = Matter.Composite.allBodies(engine.world);

    for (let i = 0; i < bodies.length; i++) {
        const body = bodies[i];
        if (!body.renderState) continue;

        // Calculate interpolated values
        const renderX = lerp(body.renderState.previousPosition.x, body.renderState.currentPosition.x, alpha);
        const renderY = lerp(body.renderState.previousPosition.y, body.renderState.currentPosition.y, alpha);
        const renderAngle = lerp(body.renderState.previousAngle, body.renderState.currentAngle, alpha);

        // Draw the body using interpolated values
        context.save();
        context.translate(renderX, renderY);
        context.rotate(renderAngle);

        // Draw body geometry relative to its origin
        context.beginPath();
        const vertices = body.vertices;
        context.moveTo(vertices[0].x - body.position.x, vertices[0].y - body.position.y);
        for (let j = 1; j < vertices.length; j++) {
            context.lineTo(vertices[j].x - body.position.x, vertices[j].y - body.position.y);
        }
        context.closePath();
        context.fillStyle = '#2c3e50';
        context.fill();

        context.restore();
    }
}

Important Considerations