Responsive Canvas & HiDPI — ResizeObserver, devicePixelRatio and Pointer Events

A blurry WebGL canvas on a retina display. A canvas that misses touch events. A canvas that never resizes when its container changes. These are the three issues that create the most bug reports on interactive simulation sites — and all three have clean, idiomatic solutions.

Problem 1 — Blurry Rendering on Retina Displays

By default a canvas rendered at CSS size 800×600 allocates an 800×600 pixel buffer. On a retina display (devicePixelRatio = 2) that buffer is stretched to 1600×1200 CSS pixels — blurry. The fix: allocate the buffer at physical pixels, then scale back via CSS.

function resizeRendererToDisplaySize(renderer) { const canvas = renderer.domElement; const dpr = Math.min(window.devicePixelRatio, 2); // cap at 2× const width = Math.floor(canvas.clientWidth * dpr); const height = Math.floor(canvas.clientHeight * dpr); const needsResize = canvas.width !== width || canvas.height !== height; if (needsResize) { renderer.setSize(width, height, false); // false = don't set CSS size renderer.setPixelRatio(dpr); } return needsResize; } // In your render loop: function render() { if (resizeRendererToDisplaySize(renderer)) { camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix(); } renderer.render(scene, camera); requestAnimationFrame(render); }

Cap devicePixelRatio at 2. Some Android devices report dpr = 3 or 4; rendering at 4× fills VRAM and tanks frame rate with no perceptible quality gain.

Problem 2 — Canvas Doesn't Resize

window.addEventListener('resize') only fires when the browser window resizes. It misses container resizes from layout changes, sidebar collapses, or CSS grid reflows. Use ResizeObserver instead.

const ro = new ResizeObserver((entries) => { for (const entry of entries) { // entry.contentBoxSize is the CSS pixel size of the container const { inlineSize: w, blockSize: h } = entry.contentBoxSize[0] ?? entry.contentRect; renderer.setSize( Math.floor(w * devicePixelRatio), Math.floor(h * devicePixelRatio), false ); camera.aspect = w / h; camera.updateProjectionMatrix(); } }); ro.observe(canvas.parentElement); // watch the container, not the canvas // Cleanup when sim unmounts: // ro.disconnect();

Problem 3 — Pointer vs Touch Events

The Pointer Events API (W3C standard) unifies mouse, touch and stylus input into a single event stream. Using pointermove instead of mousemove + touchmove halves input handling code and correctly handles multi-finger gestures.

canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); canvas.addEventListener('pointerleave', onPointerUp); function onPointerMove(e) { // e.clientX / e.clientY work for both mouse AND touch // e.pressure: 0 = hover, >0 = contact // e.pointerType: 'mouse' | 'touch' | 'pen' const rect = canvas.getBoundingClientRect(); const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1; const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera({ x: ndcX, y: ndcY }, camera); }

CSS contain:strict for Layout Performance

Adding contain: strict (or content) to the canvas container tells the browser that nothing outside the container is affected by changes inside it. This eliminates full-page layout recalculations on resize and is particularly effective when the rest of the page has complex CSS.

.sim-canvas-wrapper { contain: strict; /* layout + style + paint + size */ width: 100%; height: 100%; overflow: hidden; position: relative; /* establish stacking context */ touch-action: none; /* disable browser pan/zoom on canvas */ }

Don't forget touch-action: none. Without it, the browser intercepts touch events on the canvas for its own scroll/zoom handling, causing 300 ms delays and dropped events on mobile. Setting touch-action: none on the canvas container hands all touch input directly to your Pointer Events handlers.