Урок · Середній рівень · ~45 хв
Offscreen Canvas + Web Workers
OffscreenCanvas переносить увесь рендеринг WebGL до
окремого Web Worker, тому довгі кадри симуляції ніколи не блокують потік
інтерфейсу. Головний потік обробляє DOM-події та передає їх воркеру через
postMessage. Воркер виконує цикл рендерингу й напряму подає
зображення на GPU без зайвих звернень до головного потоку.
1Передавання canvas воркеру
// main.js — виконується в головному потоці const canvas =
document.getElementById('sim-canvas'); if
(!canvas.transferControlToOffscreen) { console.warn('OffscreenCanvas
не підтримується — повертаємось до головного потоку'); // запасний варіант: запускаємо
Three.js безпосередньо тут } else { const offscreen =
canvas.transferControlToOffscreen(); const worker = new
Worker('./render-worker.js', { type: 'module' }); worker.postMessage({
type: 'init', canvas: offscreen, width: canvas.clientWidth, height:
canvas.clientHeight, pixelRatio: devicePixelRatio, }, [offscreen]); //
← передаємо володіння, а не копію }
Передавайте володіння через другий аргумент
postMessage. Після цього головний потік більше не може
використовувати контекст рисування елемента canvas — воркер володіє
ним виключно.
2Налаштування Three.js усередині воркера
// render-worker.js — виконується в потоці воркера import * as THREE from
'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; let
renderer, scene, camera; self.addEventListener('message', e => { const
{ type } = e.data; if (type === 'init') { const { canvas, width,
height, pixelRatio } = e.data; renderer = new THREE.WebGLRenderer({
canvas, antialias: true }); renderer.setPixelRatio(pixelRatio);
renderer.setSize(width, height, false); // false = не задавати style
scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(60,
width / height, 0.1, 1000); camera.position.z = 5; scene.add(new
THREE.AmbientLight(0xffffff, 0.6)); const mesh = new THREE.Mesh( new
THREE.TorusKnotGeometry(1, 0.3, 128, 16), new
THREE.MeshStandardMaterial({ color: 0x22c55e }) ); scene.add(mesh); //
Цикл рендерингу на боці воркера let last = 0; function loop(t) {
mesh.rotation.y = t * 0.001; renderer.render(scene, camera);
self.requestAnimationFrame(loop); // працює у воркерах! }
self.requestAnimationFrame(loop); } });
3Проксування DOM-подій до воркера
// main.js — пересилаємо події вказівника та клавіатури const worker = /* ...
*/; function forwardEvent(type, e) { worker.postMessage({ type, x:
e.clientX, y: e.clientY, dx: e.movementX, dy: e.movementY, button:
e.button, key: e.key, }); } canvas.addEventListener('pointermove', e
=> forwardEvent('pointermove', e));
canvas.addEventListener('pointerdown', e =>
forwardEvent('pointerdown', e)); canvas.addEventListener('pointerup',
e => forwardEvent('pointerup', e)); canvas.addEventListener('wheel', e
=> { worker.postMessage({ type: 'wheel', deltaY: e.deltaY });
e.preventDefault(); }, { passive: false });
// render-worker.js — обробляємо переслані події let isDragging = false,
lastX = 0, lastY = 0; self.addEventListener('message', e => { switch
(e.data.type) { case 'pointermove': if (!isDragging) break;
camera.position.x += e.data.dx * 0.005; camera.position.y -= e.data.dy
* 0.005; break; case 'pointerdown': isDragging = true; break; case
'pointerup': isDragging = false; break; case 'wheel':
camera.position.z += e.data.deltaY * 0.01; break; } });
4Обробка зміни розміру між потоками
// main.js — стежимо за розміром canvas і сповіщаємо воркер const
resizeObserver = new ResizeObserver(entries => { const {
contentBoxSize } = entries[0]; const w = contentBoxSize[0].inlineSize;
const h = contentBoxSize[0].blockSize; worker.postMessage({ type:
'resize', width: w, height: h }); }); resizeObserver.observe(canvas);
// render-worker.js — застосовуємо зміну розміру до рендерера та камери if
(e.data.type === 'resize') { const { width, height } = e.data;
renderer.setSize(width, height, false); camera.aspect = width /
height; camera.updateProjectionMatrix(); }
Ніколи не викликайте сетери
canvas.width чи
canvas.height з воркера — використовуйте
renderer.setSize, який задає розміри внутрішнього буфера
рисування, не торкаючись DOM.
5Використання Comlink для чистіших API
// Comlink загортає воркери в Proxy, тож ви можете викликати методи напряму
import * as Comlink from
'https://cdn.jsdelivr.net/npm/comlink@4/dist/esm/comlink.mjs'; //
render-worker.js const api = { init(canvas, w, h, dpr) { /* ... */ },
setColor(hex) { mesh.material.color.setHex(hex); }, exportFrame() {
return renderer.domElement.toDataURL(); }, // лише для OffscreenCanvas };
Comlink.expose(api); // main.js const worker = new
Worker('./render-worker.js', { type: 'module' }); const remoteRenderer
= Comlink.wrap(worker); const offscreen =
canvas.transferControlToOffscreen(); await
remoteRenderer.init(Comlink.transfer(offscreen, [offscreen]),
innerWidth, innerHeight, devicePixelRatio); // Тепер викликаємо методи так, ніби вони
локальні:
document.getElementById('color-picker').addEventListener('input', e =>
{ remoteRenderer.setColor(parseInt(e.target.value.slice(1), 16)); });
Comlink бере на себе шаблонний код
postMessage /
addEventListener, надаючи чистий асинхронний інтерфейс
RPC. Передавані об'єкти (як-от OffscreenCanvas) усе одно
потребують явного загортання у Comlink.transfer().