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.