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.