Problem z dużymi pętlami fizyki w JS
V8 i SpiderMonkey to doskonałe kompilatory JIT, ale dokonują kompromisów, które szkodzą kodowi fizycznemu: przejścia klas ukrytych przy dynamicznych zapisach właściwości, pauzy GC co 50–200 ms oraz tablice Float64, które rzadko są tak przyjazne dla pamięci podręcznej, jak ciągłe układy struct-of-arrays w C.
Dla symulacji tkaniny z 10 000 par ograniczeń aktualizowanych 60 razy na sekundę te kompromisy mają znaczenie. Budżet głównego wątku dla fizyki wynosi mniej więcej 4 ms na klatkę (pozostawiając 12 ms na renderowanie przy 60 fps w budżecie 16,6 ms). W JS 10 000 ograniczeń zajmuje ~7 ms. W WASM z SIMD: ~1,2 ms.
Pipeline kompilacji: Rust → WASM
Rust jest najlepszym wyborem dla nowego kodu fizyki WASM: abstrakcje
bezkosztowe, brak GC, doskonałe narzędzia wasm-pack
oraz opcjonalne intrinsiki SIMD przez std::arch.
Minimalna konfiguracja wygląda tak:
[package] name = "physics_core" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] wasm-bindgen = "0.2" [profile.release] opt-level = 3 lto = true codegen-units = 1
use wasm_bindgen::prelude::*;
/// Wykonuje jedna iteracje relaksacji ograniczen na plaskim buforze F32.
/// positions: [x0,y0,z0, x1,y1,z1, ...] (3 floaty/czastka)
/// constraints: [idxA, idxB, rest_len, stiffness, ...] (4 floaty/ograniczenie)
#[wasm_bindgen]
pub fn relax_constraints(
positions: &mut [f32],
constraints: &[f32],
) {
let n = constraints.len() / 4;
for i in 0..n {
let base = i * 4;
let a = constraints[base + 0] as usize * 3;
let b = constraints[base + 1] as usize * 3;
let rest = constraints[base + 2];
let k = constraints[base + 3];
let dx = positions[b] - positions[a];
let dy = positions[b+1] - positions[a+1];
let dz = positions[b+2] - positions[a+2];
let len = (dx*dx + dy*dy + dz*dz).sqrt();
if len < 1e-9 { continue; }
let correction = (len - rest) / len * k * 0.5;
positions[a] += dx * correction;
positions[a+1] += dy * correction;
positions[a+2] += dz * correction;
positions[b] -= dx * correction;
positions[b+1] -= dy * correction;
positions[b+2] -= dz * correction;
}
}
Skompiluj i zbuduj paczkę poleceniem:
wasm-pack build --target web --release
Współdzielenie buforów bez kopiowania z JS
Najważniejszą optymalizacją przy wywoływaniu WASM z JS jest
nigdy nie kopiować danych. Zaalokuj pojedynczy
Float32Array osadzony w pamięci liniowej WASM i pozwól,
by zarówno JS (do renderowania), jak i WASM (do fizyki) operowały
na tych samych bajtach.
import init, { relax_constraints } from './physics_core.js';
let wasmMemory, positions, constraints;
async function setup() {
const wasm = await init();
// Alokuj bufory wewnatrz pamieci liniowej WASM
const particleCount = 1024;
const constraintCount = 2048;
const posPtr = wasm.__wbindgen_malloc(particleCount * 3 * 4); // 4 bajty/f32
const conPtr = wasm.__wbindgen_malloc(constraintCount * 4 * 4);
wasmMemory = wasm.memory;
positions = new Float32Array(wasmMemory.buffer, posPtr, particleCount * 3);
constraints = new Float32Array(wasmMemory.buffer, conPtr, constraintCount * 4);
// ... wypelnij positions i constraints ...
}
function physicsStep() {
// WASM czyta/zapisuje ten sam bufor, ktorego uzywa JS dla InstancedMesh Three.js
relax_constraints(positions, constraints);
// geometry.attributes.position.array Three.js TO ten sam ArrayBuffer
// — bez kopiowania, wystarczy oznaczyc jako "dirty"
geometry.attributes.position.needsUpdate = true;
}
Krytyczne: Po
wasm.__wbindgen_malloc, referencja
wasmMemory.buffer może stać się nieaktualna, jeśli
WASM zwiększy swoją pamięć. Ponownie owiń
Float32Array wewnątrz try/catch lub
wykryj wzrost za pomocą haka w stylu
ResizeObserver. W praktyce zaalokuj z góry maksimum,
jakiego kiedykolwiek będziesz potrzebować, by uniknąć wzrostu.
Benchmarki: JS vs WASM vs WASM+SIMD
| Symulacja | Czysty JS (ms/klatkę) | WASM (ms/klatkę) | WASM+SIMD (ms/klatkę) | Przyspieszenie |
|---|---|---|---|---|
| Tkanina (10k ograniczeń, 8 iteracji) | 6,8 | 2,1 | 1,1 | 6,2× |
| Płyn SPH (2k cząstek) | 11,4 | 3,6 | 1,9 | 6,0× |
| Reakcja-dyfuzja (siatka 512×512) | 4,2 | 1,8 | 0,9 | 4,7× |
| Grawitacja N ciał (1k ciał) | 5,1 | 2,0 | 1,3 | 3,9× |
| Podwójne wahadło (pojedynczy łańcuch) | 0,04 | 0,04 | 0,04 | 1,0× — brak korzyści |
Ostatni wiersz to kluczowa lekcja: WASM pomaga tylko wtedy, gdy pętla fizyki jest wąskim gardłem. Podwójne wahadło, rozwiązujące dwa ODE na klatkę, kosztuje 0,04 ms w czystym JS — dodanie narzutu kompilacji WASM nie daje niczego.
Kiedy powinieneś (a kiedy nie) używać WASM?
- Pętla fizyki zajmuje >3 ms/klatkę w JS
- Masz >5000 cząstek lub ograniczeń
- Potrzebujesz deterministycznej numeryki między platformami
- Portujesz istniejący solver C/C++/Rust
- SIMD (relaksacja f32×4 SIMD) ma zastosowanie
- Krok fizyki jest już <1 ms w JS
- Wąskim gardłem jest GPU (sortowanie, renderowanie), nie CPU
- Brakuje Ci łańcucha narzędzi do budowania WASM
- Logika jest wysoce rozgałęziona lub oparta na napisach
- Potrzebujesz częstych podróży w obie strony JS↔WASM
Alternatywa: C/C++ przez Emscripten
Jeśli portujesz istniejącą bibliotekę fizyki (Bullet, Box2D,
Chipmunk), Emscripten kompiluje C/C++ do WASM przy minimalnych
zmianach kodu. Przepływ pracy jest podobny — kompilacja do
.wasm plus plik glue JS — ale produkuje większe
paczki. Pełny build Bullet to ~2 MB po gzip w porównaniu z ręcznie
napisaną kratą Rust o rozmiarze ~30 KB. Używaj Emscriptena do
portowania; używaj Rust do nowego kodu specyficznego dla symulacji.
WASM + Web Workers: pełny stos
Dla najlepszych rezultatów uruchom moduł fizyki WASM wewnątrz Web
Workera i użyj SharedArrayBuffer, by współdzielić
pozycje cząstek z rendererem Three.js głównego wątku. To
utrzymuje pełne wykorzystanie obu wątków:
- Worker: uruchamia fizykę WASM przy stałej częstotliwości 120 Hz
-
Główny wątek: odczytuje
SharedArrayBufferprzy każdej klatce animacji, przesyła do GPU, renderuje -
Brak narzutu serializacji
postMessage— bufor jest kanałem komunikacji
Uwaga: SharedArrayBuffer wymaga nagłówków izolacji
cross-origin (Cross-Origin-Opener-Policy: same-origin i
Cross-Origin-Embedder-Policy: require-corp), które
ustawiliśmy globalnie dla tej strony w Devlogu #14.