Урок · Середній рівень · ~45 хв
OffscreenCanvas · Web Workers · Three.js · Comlink

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().