Web Workery dla ciężkiej fizyki — przenieś obliczenia do wątku w tle

Pętla renderowania i pętla fizyki w przeglądarce walczą o ten sam wątek główny. Przenieś krok fizyki do Workera, przekazuj dane przez przekazywalne (transferable) ArrayBuffer — a oba wątki będą działać z pełną prędkością, nie blokując się nawzajem.

Dlaczego wątek główny się blokuje

JavaScript w wątku głównym jest jednowątkowy. Gdy krok fizyki zajmuje 12 ms, a pętla renderowania potrzebuje kolejnych 8 ms, na klatkę wychodzi 20 ms — to ledwie 50 FPS przy wyświetlaczu 60 Hz. A przy złożonej symulacji z 2000 ciałami sztywnymi to jeszcze optymistyczne założenie.

Rozwiązaniem jest rozdzielenie obowiązków: wątek renderujący wywołuje requestAnimationFrame i rysuje; wątek fizyki uruchamia Cannon-es i zapisuje pozycje z powrotem. Komunikują się asynchronicznie.

Architektura: potok dwuwątkowy

Wątek główny (render)
←── pozycje Float32Array ───
Physics Worker
Three.js scene.render()
──── wiadomość 'step' ──────────→
Cannon-es world.step(dt)

Co klatkę wątek główny wysyła do workera wiadomość step. Worker przesuwa symulację o krok, pakuje pozycje i kwaterniony ciał do wspólnego Float32Array i przekazuje go z powrotem. Wątek renderujący odczytuje tablicę i aktualizuje macierze siatek Three.js.

Konfiguracja Workera

// 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 = []; // Wątek główny wysyła 'init' z konfiguracją ciał, potem 'step' co klatkę 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() }, [/* obiekt przekazywalny — patrz niżej */]); break; } }); function packPositions() { // 7 liczb na ciało: 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; }

Przekazywalne ArrayBuffer (zero kopiowania)

Domyślnie postMessage kopiuje dane — dla dużych buforów jest to kosztowne. Oznaczenie leżącego u podstaw ArrayBuffer jako transferable (przekazywalnego) każe przeglądarce przenieść własność na drugi wątek w czasie stałym. Oryginalny bufor staje się pusty (odłączony) po przekazaniu:

// W workerze — przekazujemy bufor zamiast go kopiować const buf = packPositions(); self.postMessage({ type: 'positions', buffer: buf }, [buf.buffer]); // buf.buffer jest teraz odłączony w tym wątku — nie używaj go ponownie // W main.js — odbierz i zastosuj 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; }); });

Alternatywa: SharedArrayBuffer + Atomics

Jeśli wolisz odpytywanie zamiast przesyłania wiadomości (niższe opóźnienie przy aktualizacjach o wysokiej częstotliwości), przydziel wspólny bufor, który oba wątki mogą jednocześnie odczytywać i zapisywać. Wymaga nagłówków Cross-Origin-Isolation:

// main.js — tworzymy SAB i przekazujemy go do workera const sab = new SharedArrayBuffer(N * 7 * 4); // Float32, 7 na ciało const view = new Float32Array(sab); worker.postMessage({ type: 'init', sab }); // pętla renderowania — po prostu czytamy view[] bezpośrednio, bez postMessage meshes.forEach((mesh, i) => { mesh.position.set(view[i*7], view[i*7+1], view[i*7+2]); });
Bufor przekazywalny
Nagłówki CORS: niewymagane
Synchronizacja: oparta na wiadomościach
Narzut: jedna wiadomość na klatkę
Dobre dla: większości symulacji
SharedArrayBuffer
Nagłówki CORS: wymagane COOP + COEP
Synchronizacja: Atomics.wait / odpytywanie
Narzut: bliski zeru
Dobre dla: bardzo dużej liczby ciał

Kiedy NIE używać Workerów

Pętla renderowania musi pozostać w wątku głównym. requestAnimationFrame, rysowanie na canvasie i renderer.render() Three.js są niedostępne w Workerach. Odciążenie fizyki jest bezpieczne; odciążenie renderowania wymaga OffscreenCanvas (ograniczone wsparcie przeglądarek i trudniejsze użycie z Three.js — na razie niezalecane).

Inne sytuacje, w których workery nie pomagają:

Wsparcie przeglądarek: Web Workery są dostępne we wszystkich nowoczesnych przeglądarkach. SharedArrayBuffer wymaga nagłówków odpowiedzi Cross-Origin-Opener-Policy: same-origin i Cross-Origin-Embedder-Policy: require-corp na twoim serwerze — inaczej funkcja jest wyłączona ze względów bezpieczeństwa od czasu podatności Spectre.

Powiązane wpisy

Devlog #15 opisuje uzupełniające techniki wydajnościowe, które działają razem z Workerami — adaptacyjne presety jakości, leniwe ładowanie Three.js oraz throttling zależny od poziomu baterii.