Responsywny Canvas i HiDPI — ResizeObserver, devicePixelRatio i zdarzenia Pointer

Rozmyty canvas WebGL na ekranie retina. Canvas, który nie łapie zdarzeń dotykowych. Canvas, który nigdy nie zmienia rozmiaru, gdy zmienia się jego kontener. To trzy problemy generujące najwięcej zgłoszeń błędów na stronach z interaktywnymi symulacjami — i wszystkie trzy mają czyste, idiomatyczne rozwiązania.

Problem 1 — Rozmyte renderowanie na ekranach retina

Domyślnie canvas renderowany w rozmiarze CSS 800×600 alokuje bufor 800×600 pikseli. Na ekranie retina (devicePixelRatio = 2) ten bufor jest rozciągany do 1600×1200 pikseli CSS — czyli rozmywa się. Rozwiązanie: alokować bufor w pikselach fizycznych, a następnie skalować z powrotem przez CSS.

function resizeRendererToDisplaySize(renderer) { const canvas = renderer.domElement; const dpr = Math.min(window.devicePixelRatio, 2); // ogranicz do 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 = nie ustawiaj rozmiaru CSS renderer.setPixelRatio(dpr); } return needsResize; } // W pętli renderowania: function render() { if (resizeRendererToDisplaySize(renderer)) { camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix(); } renderer.render(scene, camera); requestAnimationFrame(render); }

Ogranicz devicePixelRatio do 2. Niektóre urządzenia z Androidem zgłaszają dpr = 3 lub 4; renderowanie w 4× zapycha VRAM i drastycznie obniża liczbę klatek na sekundę bez odczuwalnego wzrostu jakości.

Problem 2 — Canvas nie zmienia rozmiaru

window.addEventListener('resize') uruchamia się tylko wtedy, gdy zmienia się rozmiar okna przeglądarki. Nie łapie zmian rozmiaru kontenera wynikających ze zmian układu, zwijania panelu bocznego czy przebudowy siatki CSS grid. Zamiast tego użyj ResizeObserver.

const ro = new ResizeObserver((entries) => { for (const entry of entries) { // entry.contentBoxSize to rozmiar kontenera w pikselach CSS 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); // obserwuj kontener, nie sam canvas // Sprzątanie przy odmontowaniu symulacji: // ro.disconnect();

Problem 3 — Zdarzenia Pointer kontra Touch

API Pointer Events (standard W3C) łączy wejście z myszy, dotyku i rysika w jeden strumień zdarzeń. Użycie pointermove zamiast mousemove + touchmove zmniejsza o połowę kod obsługi wejścia i poprawnie obsługuje gesty wielopalcowe.

canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); canvas.addEventListener('pointerleave', onPointerUp); function onPointerMove(e) { // e.clientX / e.clientY działają zarówno dla myszy, JAK I dotyku // e.pressure: 0 = najechanie, >0 = kontakt // 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 dla wydajności układu

Dodanie contain: strict (lub content) do kontenera canvasu informuje przeglądarkę, że nic poza kontenerem nie zależy od zmian wewnątrz niego. Eliminuje to pełne przeliczanie układu strony przy zmianie rozmiaru i jest szczególnie skuteczne, gdy reszta strony ma złożone CSS.

.sim-canvas-wrapper { contain: strict; /* layout + style + paint + size */ width: 100%; height: 100%; overflow: hidden; position: relative; /* ustanawia kontekst warstwowania */ touch-action: none; /* wyłącza przesuwanie/powiększanie przeglądarki na canvasie */ }

Nie zapomnij o touch-action: none. Bez tego przeglądarka przechwytuje zdarzenia dotykowe na canvasie na potrzeby własnej obsługi przewijania/powiększania, co powoduje opóźnienia 300 ms i gubione zdarzenia na urządzeniach mobilnych. Ustawienie touch-action: none na kontenerze canvasu przekazuje całe wejście dotykowe bezpośrednio do Twoich handlerów Pointer Events.