Проблема 1 — Розмитий рендеринг на retina-дисплеях
За замовчуванням canvas, відрендерений у CSS-розмірі 800×600, виділяє буфер 800×600 пікселів. На retina-дисплеї (devicePixelRatio = 2) цей буфер розтягується до 1600×1200 CSS-пікселів — розмито. Рішення: виділяти буфер у фізичних пікселях, а потім масштабувати назад через CSS.
function resizeRendererToDisplaySize(renderer) { const canvas =
renderer.domElement; const dpr = Math.min(window.devicePixelRatio,
2); // обмежуємо до 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 =
не встановлювати CSS-розмір renderer.setPixelRatio(dpr); } return
needsResize; } // У вашому циклі рендерингу: function render() { if
(resizeRendererToDisplaySize(renderer)) { camera.aspect =
canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix(); } renderer.render(scene, camera);
requestAnimationFrame(render); }
Обмежуйте devicePixelRatio значенням 2. Деякі
Android-пристрої повідомляють dpr = 3 або 4; рендеринг у 4× заповнює
VRAM і суттєво знижує частоту кадрів без відчутного приросту якості.
Проблема 2 — Canvas не змінює розмір
window.addEventListener('resize') спрацьовує лише
коли змінюється розмір вікна браузера. Він пропускає зміни розміру
контейнера через зміни макета, згортання бічної панелі або
перебудову CSS grid. Замість цього використовуйте
ResizeObserver.
const ro = new ResizeObserver((entries) => { for (const entry of
entries) { // entry.contentBoxSize — це розмір контейнера в
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); // спостерігаємо за
контейнером, а не за canvas // Очищення при демонтажі симуляції:
// ro.disconnect();
Проблема 3 — Pointer проти Touch подій
Pointer Events API (стандарт W3C) об'єднує введення з миші,
дотику та стилуса в єдиний потік подій. Використання
pointermove замість mousemove +
touchmove вдвічі скорочує код обробки введення і
коректно обробляє багатопальцеві жести.
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointerleave', onPointerUp); function
onPointerMove(e) { // e.clientX / e.clientY працюють і для миші, і
для дотику // e.pressure: 0 = наведення, >0 = контакт //
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 для продуктивності макета
Додавання contain: strict (або content)
до контейнера canvas повідомляє браузеру, що нічого поза межами
контейнера не залежить від змін усередині нього. Це усуває повний
перерахунок макета сторінки при зміні розміру і особливо ефективне,
коли решта сторінки має складний CSS.
.sim-canvas-wrapper { contain: strict; /* layout + style + paint +
size */ width: 100%; height: 100%; overflow: hidden; position:
relative; /* встановлюємо контекст нашарування */ touch-action:
none; /* вимикаємо панорамування/масштабування браузера на canvas
*/ }
Не забувайте про touch-action: none.
Без нього браузер перехоплює дотикові події на canvas для власної
обробки прокрутки/масштабування, що спричиняє затримки в 300 мс
і втрачені події на мобільних пристроях. Встановлення
touch-action: none
на контейнері canvas передає все дотикове введення безпосередньо
вашим обробникам Pointer Events.