The Problem with Large JS Physics Loops
V8 and SpiderMonkey are excellent JIT compilers, but they make trade-offs that hurt physics code: hidden-class transitions from dynamic property writes, GC pauses every 50–200 ms, and Float64 arrays that are rarely as cache-friendly as contiguous C struct-of-arrays layouts.
For a cloth simulation with 10,000 constraint-pairs updating 60 times per second, those trade-offs matter. The main-thread budget for physics is roughly 4 ms per frame (leaving 12 ms for rendering at 60 fps on a 16.6 ms budget). In JS, 10,000 constraints takes ~7 ms. In WASM with SIMD: ~1.2 ms.
The Compilation Pipeline: Rust → WASM
Rust is the best choice for new WASM physics code: zero-cost
abstractions, no GC, excellent wasm-pack tooling, and
optional SIMD intrinsics via std::arch. A minimal setup
looks like:
[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::*;
/// Runs one iteration of constraint relaxation over a flat F32 buffer.
/// positions: [x0,y0,z0, x1,y1,z1, ...] (3 floats/particle)
/// constraints: [idxA, idxB, rest_len, stiffness, ...] (4 floats/constraint)
#[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;
}
}
Compile and bundle with:
wasm-pack build --target web --release
Zero-Copy Buffer Sharing with JS
The most important optimisation when calling WASM from JS is
never copying data. Allocate a single
Float32Array backed by the WASM linear memory, and let
both JS (for rendering) and WASM (for physics) operate on the same
bytes.
import init, { relax_constraints } from './physics_core.js';
let wasmMemory, positions, constraints;
async function setup() {
const wasm = await init();
// Allocate buffers inside WASM linear memory
const particleCount = 1024;
const constraintCount = 2048;
const posPtr = wasm.__wbindgen_malloc(particleCount * 3 * 4); // 4 bytes/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);
// ... populate positions and constraints ...
}
function physicsStep() {
// WASM reads/writes the same buffer JS uses for Three.js InstancedMesh
relax_constraints(positions, constraints);
// Three.js geometry.attributes.position.array IS the same ArrayBuffer
// — no copy needed, just flag it dirty
geometry.attributes.position.needsUpdate = true;
}
Critical: After
wasm.__wbindgen_malloc, the
wasmMemory.buffer reference can become stale if WASM
grows its memory. Re-wrap the Float32Array inside a
try/catch or detect growth with a
ResizeObserver-style hook. In practice, pre-allocate
the maximum you'll ever need to avoid any growth.
Benchmarks: JS vs WASM vs WASM+SIMD
| Simulation | Pure JS (ms/frame) | WASM (ms/frame) | WASM+SIMD (ms/frame) | Speedup |
|---|---|---|---|---|
| Cloth (10k constraints, 8 iters) | 6.8 | 2.1 | 1.1 | 6.2× |
| SPH fluid (2k particles) | 11.4 | 3.6 | 1.9 | 6.0× |
| Reaction-diffusion (512×512 grid) | 4.2 | 1.8 | 0.9 | 4.7× |
| N-body gravity (1k bodies) | 5.1 | 2.0 | 1.3 | 3.9× |
| Double pendulum (single chain) | 0.04 | 0.04 | 0.04 | 1.0× — no benefit |
The last row is the key lesson: WASM only helps when the physics loop is a bottleneck. A double pendulum solving two ODEs per frame costs 0.04 ms in pure JS — adding WASM compilation overhead gives you nothing.
When Should (and Should Not) You Use WASM?
- Physics loop takes >3 ms/frame in JS
- You have >5,000 particles or constraints
- You need deterministic cross-platform numerics
- You're porting an existing C/C++/Rust solver
- SIMD (f32×4 SIMD relaxation) is applicable
- Physics step is already <1 ms in JS
- Bottleneck is GPU (sorting, rendering), not CPU
- You lack a WASM build toolchain
- The logic is highly branchy or string-heavy
- You need frequent JS↔WASM round-trips
Alternative: C/C++ via Emscripten
If you're porting an existing physics library (Bullet, Box2D,
Chipmunk), Emscripten compiles C/C++ to WASM with minimal code
changes. The workflow is similar — compile to .wasm + a
JS glue file — but produces larger bundles. A full Bullet build is ~2
MB gzipped vs a hand-crafted Rust crate at ~30 KB. Use Emscripten for
porting; use Rust for new simulation-specific code.
WASM + Web Workers: The Full Stack
For the best results, run the WASM physics module inside a Web Worker,
and use a SharedArrayBuffer to share particle positions
with the main thread's Three.js renderer. This keeps both threads
fully utilised:
- Worker: runs WASM physics at a fixed 120 Hz
-
Main thread: reads
SharedArrayBufferevery animation frame, uploads to GPU, renders -
No
postMessageserialisation overhead — the buffer is the communication channel
Note: SharedArrayBuffer requires cross-origin isolation
headers (Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp), which we set
globally for this site in Devlog #14.