Pinch-to-Zoom and Accurate Mouse Picking in Matter.js

This article explains how to implement pinch-to-zoom camera controls in a Matter.js canvas while keeping mouse and touch picking completely synchronized with physics bodies. You will learn how to capture multi-touch gestures, dynamically adjust the viewport using Matter's native render bounds, and recalibrate the Matter.Mouse scale and offset vectors so physics interactions like dragging remain pixel-accurate at any zoom level.

The Coordinate Desynchronization Problem

By default, Matter.js maps screen-space pixel coordinates directly to world-space coordinates in the physics engine. When you implement a camera zoom, the visual representation of the world scales, but the default MouseConstraint continues listening to raw screen coordinates.

Without adjusting the pointer mapping, touching a body on screen sends raw pixel coordinates to the physics engine instead of the scaled world coordinates, causing touches and drags to miss their targets entirely.

Implementing Camera Zoom via Render Bounds

The cleanest way to zoom in Matter.js without breaking engine internals is by manipulating render.bounds. By scaling the viewport's bounding box around a focal point, the renderer handles the camera transformation natively.

function zoomAt(render, zoomFactor, center) {
    const { min, max } = render.bounds;
    const width = max.x - min.x;
    const height = max.y - min.y;

    const newWidth = width * zoomFactor;
    const newHeight = height * zoomFactor;

    // Calculate ratio of center point relative to current viewport
    const factorX = (center.x - min.x) / width;
    const factorY = (center.y - min.y) / height;

    // Update bounds around the focal point
    render.bounds.min.x = center.x - newWidth * factorX;
    render.bounds.min.y = center.y - newHeight * factorY;
    render.bounds.max.x = render.bounds.min.x + newWidth;
    render.bounds.max.y = render.bounds.min.y + newHeight;
}

Handling Touch Pinch Gestures

To detect pinch-to-zoom, monitor active touch points using standard DOM touch events. Calculate the Euclidean distance between two touches on touchmove and compare it to the initial distance to compute the zoom ratio.

let initialPinchDistance = null;

canvas.addEventListener('touchstart', (e) => {
    if (e.touches.length === 2) {
        initialPinchDistance = Math.hypot(
            e.touches[0].clientX - e.touches[1].clientX,
            e.touches[0].clientY - e.touches[1].clientY
        );
    }
});

canvas.addEventListener('touchmove', (e) => {
    if (e.touches.length === 2 && initialPinchDistance) {
        const currentDistance = Math.hypot(
            e.touches[0].clientX - e.touches[1].clientX,
            e.touches[0].clientY - e.touches[1].clientY
        );

        const zoomFactor = initialPinchDistance / currentDistance;
        
        // Midpoint between the two fingers in client coordinates
        const center = {
            x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
            y: (e.touches[0].clientY + e.touches[1].clientY) / 2
        };

        // Convert center point to world coordinates
        const worldCenter = screenToWorld(center, render);

        zoomAt(render, zoomFactor, worldCenter);
        updateMouseMapping(mouse, render);

        initialPinchDistance = currentDistance;
    }
});

canvas.addEventListener('touchend', (e) => {
    if (e.touches.length < 2) {
        initialPinchDistance = null;
    }
});

Preserving Mouse and Touch Pick Accuracy

To restore pick accuracy, update the Matter.Mouse object using Mouse.setScale and Mouse.setOffset. This synchronizes the input coordinates with the modified render.bounds.

function updateMouseMapping(mouse, render) {
    const width = render.bounds.max.x - render.bounds.min.x;
    const height = render.bounds.max.y - render.bounds.min.y;

    // Calculate scale relative to canvas dimensions
    const scaleX = width / render.options.width;
    const scaleY = height / render.options.height;

    // Update the mouse scale and offset
    Matter.Mouse.setScale(mouse, { x: scaleX, y: scaleY });
    Matter.Mouse.setOffset(mouse, render.bounds.min);
}

function screenToWorld(screenPoint, render) {
    const rect = render.canvas.getBoundingClientRect();
    const scaleX = (render.bounds.max.x - render.bounds.min.x) / render.options.width;
    const scaleY = (render.bounds.max.y - render.bounds.min.y) / render.options.height;

    return {
        x: render.bounds.min.x + (screenPoint.x - rect.left) * scaleX,
        y: render.bounds.min.y + (screenPoint.y - rect.top) * scaleY
    };
}

Enabling Viewport Rendering

By default, Matter.js does not clip or transform the view based on render.bounds. Ensure bounds tracking is explicitly enabled in your render configuration:

const render = Matter.Render.create({
    element: document.body,
    engine: engine,
    options: {
        width: window.innerWidth,
        height: window.innerHeight,
        hasBounds: true,
        wireframes: false
    }
});

Whenever the bounds change during a pinch gesture, calling Mouse.setScale and Mouse.setOffset forces the underlying MouseConstraint to evaluate hover and drag states against transformed world coordinates, ensuring interaction accuracy remains intact.