Веб-воркери для важкої фізики — виносимо обчислення у фоновий потік

Цикл рендеру і цикл фізики в браузері змагаються за один головний потік. Перенесіть крок фізики у Worker, передавайте дані через переданні (transferable) ArrayBuffer — і обидва потоки працюватимуть на повній швидкості, не заважаючи один одному.

Чому головний потік блокується

JavaScript у головному потоці однопотоковий. Якщо крок фізики займає 12 мс, а циклу рендеру потрібно ще 8 мс, на кадр іде 20 мс — це заледве 50 FPS на дисплеї з частотою 60 Гц. А на складній симуляції з 2000 твердих тіл це ще оптимістична оцінка.

Рішення — розділити обов'язки: потік рендеру викликає requestAnimationFrame і малює кадр; потік фізики виконує Cannon-es і записує позиції назад. Вони обмінюються даними асинхронно.

Архітектура: двопотоковий конвеєр

Головний потік (рендер)
←── позиції Float32Array ───
Physics Worker
Three.js scene.render()
──── повідомлення 'step' ──────────→
Cannon-es world.step(dt)

Кожен кадр головний потік надсилає воркеру повідомлення step. Воркер просуває симуляцію вперед, пакує позиції та кватерніони тіл у спільний Float32Array і передає його назад. Потік рендеру читає масив і оновлює матриці мешів Three.js.

Налаштування воркера

// physics.worker.js import * as CANNON from 'https://cdn.jsdelivr.net/npm/cannon-es@0.20.0/dist/cannon-es.js'; const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -9.81, 0) }); const bodies = []; // Головний потік надсилає 'init' з конфігурацією тіл, потім 'step' щокадру self.addEventListener('message', ({ data }) => { switch (data.type) { case 'init': initBodies(data.bodies); break; case 'step': world.fixedStep(data.dt); self.postMessage({ type: 'positions', buffer: packPositions() }, [/* переданий об'єкт — див. нижче */]); break; } }); function packPositions() { // 7 чисел на тіло: x, y, z, qx, qy, qz, qw const buf = new Float32Array(bodies.length * 7); bodies.forEach((body, i) => { const o = i * 7; buf[o] = body.position.x; buf[o+1] = body.position.y; buf[o+2] = body.position.z; buf[o+3] = body.quaternion.x; buf[o+4] = body.quaternion.y; buf[o+5] = body.quaternion.z; buf[o+6] = body.quaternion.w; }); return buf; }

Переданні ArrayBuffer (без копіювання)

За замовчуванням postMessage копіює дані — це дорого для великих буферів. Позначення нижньолежачого ArrayBuffer як transferable (переданого) каже браузеру перенести право власності на інший потік за константний час. Початковий буфер стає порожнім (від'єднаним) після передачі:

// У воркері — передаємо буфер замість копіювання const buf = packPositions(); self.postMessage({ type: 'positions', buffer: buf }, [buf.buffer]); // buf.buffer тепер від'єднаний у цьому потоці — не використовуйте його повторно // У main.js — отримуємо і застосовуємо worker.addEventListener('message', ({ data }) => { if (data.type !== 'positions') return; const buf = data.buffer; meshes.forEach((mesh, i) => { const o = i * 7; mesh.position.set(buf[o], buf[o+1], buf[o+2]); mesh.quaternion.set(buf[o+3], buf[o+4], buf[o+5], buf[o+6]); mesh.matrixWorldNeedsUpdate = true; }); });

Альтернатива: SharedArrayBuffer + Atomics

Якщо вам більше подобається опитування замість обміну повідомленнями (нижча затримка для оновлень з високою частотою), виділіть спільний буфер, який обидва потоки можуть одночасно читати й записувати. Потребує заголовків Cross-Origin-Isolation:

// main.js — створюємо SAB і передаємо його воркеру const sab = new SharedArrayBuffer(N * 7 * 4); // Float32, 7 на тіло const view = new Float32Array(sab); worker.postMessage({ type: 'init', sab }); // цикл рендеру — просто читаємо view[] напряму, без postMessage meshes.forEach((mesh, i) => { mesh.position.set(view[i*7], view[i*7+1], view[i*7+2]); });
Переданий буфер
Заголовки CORS: не потрібні
Синхронізація: через повідомлення
Накладні витрати: одне повідомлення на кадр
Підходить для: більшості симуляцій
SharedArrayBuffer
Заголовки CORS: потрібні COOP + COEP
Синхронізація: Atomics.wait / опитування
Накладні витрати: майже нульові
Підходить для: дуже великої кількості тіл

Коли НЕ варто використовувати воркери

Цикл рендеру має залишатися в головному потоці. requestAnimationFrame, малювання на canvas і renderer.render() у Three.js недоступні у воркерах. Виносити фізику безпечно; винесення рендеру вимагає OffscreenCanvas (обмежена підтримка браузерами і складніше використання з Three.js — наразі не рекомендуємо).

Інші випадки, коли воркери не допомагають:

Підтримка браузерами: Web Workers доступні в усіх сучасних браузерах. SharedArrayBuffer потребує заголовків відповіді Cross-Origin-Opener-Policy: same-origin і Cross-Origin-Embedder-Policy: require-corp на вашому сервері — інакше функція вимкнена з міркувань безпеки після вразливості Spectre.

Пов'язані публікації

Devlog #15 розповідає про суміжні прийоми оптимізації продуктивності, що працюють у парі з воркерами — адаптивні пресети якості, ліниве завантаження Three.js та контроль навантаження залежно від заряду батареї.