Tip: WebAssembly for Browser Physics Engines — When and How to Use WASM

WASM is not magic. But for the right problems — tight numerical loops, deterministic memory layouts, or porting existing C/C++/Rust solvers — it can give you 3–6× throughput gains over idiomatic JavaScript. Here's the practical playbook.

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:

Cargo.toml (Rust WASM crate)
[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 — Cloth constraint solver (WASM export)
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:

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

JavaScript — Zero-copy shared buffer
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?

✓ Use WASM when…
  • 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
✗ Skip WASM when…
  • 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:

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.