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.