Implement Matter.js Canvas Zoom with Renderer Bounds

Controlling the camera zoom in a Matter.js simulation is best accomplished by manipulating the bounds object of the built-in renderer rather than applying manual 2D canvas context transformations. By enabling renderer bounds, Matter.js automatically maps a virtual rectangular viewport to the canvas element's dimensions, allowing you to zoom in and out by shrinking or expanding this bounding box. This guide covers how to activate this feature, calculate zoom scaling relative to the cursor position, and implement mouse wheel zoom controls.

Enable Bounds on the Renderer

By default, the Matter.js renderer uses the canvas width and height directly without tracking custom view boundaries. You must enable hasBounds when instantiating the renderer:

const render = Matter.Render.create({
    element: document.body,
    engine: engine,
    options: {
        width: 800,
        height: 600,
        wireframes: false,
        hasBounds: true // Required for custom bounds and zooming
    }
});

When hasBounds is set to true, Matter.js uses render.bounds.min and render.bounds.max to determine which region of the physics world is drawn onto the canvas.

Understanding the Bounds Property

The render.bounds object defines the visible rectangular area in world coordinates:

To zoom in, decrease the distance between min and max (making the rendered area smaller). To zoom out, increase the distance between min and max.

Implementing Mouse Wheel Zoom Controls

To provide an intuitive user experience, zoom operations should scale around the user's cursor position rather than the top-left corner or canvas center.

Here is the complete implementation attaching to the wheel event:

const canvas = render.canvas;

// Configure zoom boundaries and speed
const ZOOM_FACTOR = 0.1;
const MIN_WIDTH = 200;   // Maximum zoom in
const MAX_WIDTH = 4000;  // Maximum zoom out

canvas.addEventListener('wheel', (event) => {
    event.preventDefault();

    // 1. Get current mouse position relative to canvas
    const rect = canvas.getBoundingClientRect();
    const mouseCanvasX = event.clientX - rect.left;
    const mouseCanvasY = event.clientY - rect.top;

    // 2. Normalize mouse position (0.0 to 1.0)
    const normalizedX = mouseCanvasX / canvas.width;
    const normalizedY = mouseCanvasY / canvas.height;

    // 3. Get current world dimensions
    const currentBounds = render.bounds;
    const currentWidth = currentBounds.max.x - currentBounds.min.x;
    const currentHeight = currentBounds.max.y - currentBounds.min.y;

    // 4. Calculate scale factor based on wheel direction
    const zoomDirection = event.deltaY < 0 ? 1 : -1;
    const scale = 1 - (zoomDirection * ZOOM_FACTOR);

    // 5. Calculate new width and apply constraints
    let newWidth = currentWidth * scale;
    if (newWidth < MIN_WIDTH) newWidth = MIN_WIDTH;
    if (newWidth > MAX_WIDTH) newWidth = MAX_WIDTH;

    // Maintain canvas aspect ratio
    const aspectRatio = canvas.height / canvas.width;
    const newHeight = newWidth * aspectRatio;

    // 6. Calculate new bounds centered on the cursor position
    const mouseWorldX = currentBounds.min.x + (currentWidth * normalizedX);
    const mouseWorldY = currentBounds.min.y + (currentHeight * normalizedY);

    render.bounds.min.x = mouseWorldX - (newWidth * normalizedX);
    render.bounds.min.y = mouseWorldY - (newHeight * normalizedY);
    render.bounds.max.x = render.bounds.min.x + newWidth;
    render.bounds.max.y = render.bounds.min.y + newHeight;
});

Handling Mouse Constraints with Zoom

If your simulation utilizes Matter.MouseConstraint for object dragging, updating render.bounds without syncing the mouse module will cause a mismatch between cursor clicks and object interactions.

Ensure the mouse offset and scale are updated whenever the bounds change:

const mouse = Matter.Mouse.create(render.canvas);
const mouseConstraint = Matter.MouseConstraint.create(engine, {
    mouse: mouse
});

// Update the mouse scale and offset during the render loop or directly after zooming
Matter.Events.on(render, 'beforeRender', () => {
    Matter.Mouse.setScale(mouse, {
        x: (render.bounds.max.x - render.bounds.min.x) / render.canvas.width,
        y: (render.bounds.max.y - render.bounds.min.y) / render.canvas.height
    });
    Matter.Mouse.setOffset(mouse, render.bounds.min);
});

This synchronization ensures physics interactions remain accurate regardless of the current zoom level.