Wskazówka: WebAssembly dla silników fizyki w przeglądarce — kiedy i jak używać WASM

WASM to nie magia. Ale dla właściwych problemów — ciasnych pętli numerycznych, deterministycznych układów pamięci lub portowania istniejących solverów C/C++/Rust — może dać 3–6× wzrost przepustowości w porównaniu z idiomatycznym JavaScript. Oto praktyczny podręcznik.

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:

Cargo.toml (krata WASM w Rust)
[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
src/lib.rs — solver ograniczeń tkaniny (eksport WASM)
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:

Shell
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.

JavaScript — bufor współdzielony bez kopiowania
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?

✓ Użyj WASM, gdy…
  • 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
✗ Pomiń WASM, gdy…
  • 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:

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.