Tutorial · WebAssembly · C++ · Performance
📅 July 2026 ⏱ ≈ 35 min 🎯 Advanced

WebAssembly + C++ for a Physics Engine in the Browser

JavaScript physics engines hit a wall around a few thousand bodies: garbage collection pauses, boxed numbers, and JIT deoptimizations all fight against tight numerical loops. Compiling a C++ physics core to WebAssembly gives near-native throughput and predictable frame times — this tutorial builds one from scratch with Emscripten and wires it into a Three.js scene.

1. Why WebAssembly for physics

A physics step is a tight numerical loop executed 60+ times a second: integrate, broad-phase, narrow-phase, solve. JavaScript engines JIT-compile hot loops well, but three things still get in the way at scale:

WebAssembly is a stack-based binary instruction format compiled ahead-of-time to near-native machine code, with a flat linear memory buffer you control explicitly — no GC, no hidden classes, predictable cache behaviour. It won't beat hand-tuned SIMD JavaScript trivially, but it removes the worst-case tail latency that makes physics feel janky.

When it's not worth it: for a few hundred bodies or a simple particle demo, plain JavaScript (or the from-scratch JS engine) is simpler to debug and ships faster. Reach for WASM once profiling shows the physics step itself — not rendering — as the bottleneck.

2. Toolchain setup with Emscripten

Emscripten compiles C/C++ to WebAssembly plus a JS "glue" file that loads and instantiates the module.

# One-time setup
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

# Compile physics.cpp to a WASM module + JS loader
emcc physics.cpp -O3 \
  -lembind \
  -s MODULARIZE=1 \
  -s EXPORT_ES6=1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s ENVIRONMENT=web \
  -o physics.js
Flag Effect
-O3 Aggressive optimization — essential for a physics hot loop
-lembind Links the embind runtime for exposing C++ classes to JS
-s MODULARIZE=1 -s EXPORT_ES6=1 Emits an ES module factory function instead of a global
-s ALLOW_MEMORY_GROWTH=1 Lets the WASM heap grow past its initial size at runtime

3. A minimal C++ physics core

Store bodies as a struct-of-arrays (SoA), not an array of structs — this keeps position/velocity data contiguous and cache-friendly, and maps directly onto flat typed arrays on the JS side.

// physics.cpp
#include <vector>
#include <cmath>

struct World {
  std::vector<float> px, py, pz;   // positions
  std::vector<float> vx, vy, vz;   // velocities
  std::vector<float> invMass;
  float gravity = -9.81f;

  int addBody(float x, float y, float z, float mass) {
    px.push_back(x); py.push_back(y); pz.push_back(z);
    vx.push_back(0); vy.push_back(0); vz.push_back(0);
    invMass.push_back(mass > 0 ? 1.0f / mass : 0.0f);
    return (int)px.size() - 1;
  }

  void step(float dt) {
    size_t n = px.size();
    for (size_t i = 0; i < n; ++i) {
      if (invMass[i] == 0.0f) continue; // static body
      vy[i] += gravity * dt;
      px[i] += vx[i] * dt;
      py[i] += vy[i] * dt;
      pz[i] += vz[i] * dt;
      // Floor collision at y=0
      if (py[i] < 0.0f) { py[i] = 0.0f; vy[i] *= -0.5f; }
    }
  }

  // Pointer to the start of the position buffer — used for zero-copy transfer
  uintptr_t positionsPtr() { return (uintptr_t)px.data(); }
  int count() const { return (int)px.size(); }
};
SoA vs AoS: an array-of-structs (vector<Body>) is easier to write but scatters position/velocity/mass across memory per body. SoA keeps every x coordinate contiguous — better for both CPU cache lines and for exposing flat typed-array views to JavaScript.

4. Binding with embind

embind generates the JS ↔ C++ glue automatically — no manual pointer arithmetic needed for simple method calls.

// physics.cpp — append at the bottom
#include <emscripten/bind.h>
using namespace emscripten;

EMSCRIPTEN_BINDINGS(physics_module) {
  class_<World>("World")
    .constructor<>()
    .function("addBody", &World::addBody)
    .function("step", &World::step)
    .function("positionsPtr", &World::positionsPtr)
    .function("count", &World::count);
}
// main.js — load and use the module
import createPhysicsModule from './physics.js';

const Module = await createPhysicsModule();
const world = new Module.World();

for (let i = 0; i < 5000; i++) {
  world.addBody(Math.random() * 10, 5 + Math.random() * 10, Math.random() * 10, 1.0);
}

function animate() {
  requestAnimationFrame(animate);
  world.step(1 / 60);
  renderer.render(scene, camera);
}

5. Zero-copy data transfer via linear memory

Calling an embind-wrapped getter per body per frame (e.g. one JS call to read each body's x) reintroduces call-boundary overhead 5,000+ times a frame. Instead, read the raw WASM linear memory directly as a typed array — zero copies, zero per-body calls:

// A Float32Array view directly over the WASM heap — no copy!
function getPositionsView(Module, world, count) {
  const ptr = world.positionsPtr();
  return new Float32Array(Module.HEAPF32.buffer, ptr, count);
}

const positions = getPositionsView(Module, world, 5000);
// positions[i] now reads body i's x coordinate directly from C++ memory
// re-slice after world.step() only if ALLOW_MEMORY_GROWTH reallocated the heap
Watch for detached buffers: when the WASM heap grows (ALLOW_MEMORY_GROWTH=1), the underlying ArrayBuffer is replaced and any previously created Float32Array view becomes stale/detached. Re-create the view after any operation that might trigger growth, or pre-reserve enough capacity in C++ (vector::reserve) to avoid growth entirely during steady-state simulation.

6. Driving a Three.js scene from WASM state

With a stable Float32Array view over the WASM heap, feed it straight into an InstancedMesh without any per-body JS object allocation:

const geometry = new THREE.SphereGeometry(0.2, 8, 8);
const material = new THREE.MeshStandardMaterial({ color: 0x60a5fa });
const mesh = new THREE.InstancedMesh(geometry, material, 5000);
scene.add(mesh);

const dummy = new THREE.Object3D();

function syncMeshFromWasm() {
  const n = world.count();
  const ptr = world.positionsPtr();
  const px = new Float32Array(Module.HEAPF32.buffer, ptr, n);
  // (in this layout py/pz live in separate vectors — expose their pointers too)
  for (let i = 0; i < n; i++) {
    dummy.position.set(px[i], py[i], pz[i]);
    dummy.updateMatrix();
    mesh.setMatrixAt(i, dummy.matrix);
  }
  mesh.instanceMatrix.needsUpdate = true;
}

function animate() {
  requestAnimationFrame(animate);
  world.step(1 / 60);
  syncMeshFromWasm();
  renderer.render(scene, camera);
}

7. Threads: pthreads and SharedArrayBuffer

Even at native speed, a large simulation can still exceed one frame's budget on a single thread. Emscripten can compile std::thread/pthreads to real Web Workers backed by a SharedArrayBuffer, letting the physics step run off the main thread:

emcc physics.cpp -O3 -lembind \
  -pthread -s PTHREAD_POOL_SIZE=4 \
  -s SHARED_MEMORY=1 \
  -s MODULARIZE=1 -s EXPORT_ES6=1 \
  -o physics.js
Cross-origin isolation required: SharedArrayBuffer only works when the page is served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers — missing these silently disables threading and Emscripten falls back to a single-threaded build at runtime.

The typical architecture: the physics World::step() runs inside a worker on a shared linear memory buffer; the main thread reads the same buffer's Float32Array view every render frame — no message-passing serialization needed for the hot path, only a small "step complete" notification.

8. Benchmark: WASM vs plain JavaScript

Rough numbers from stepping N free-falling spheres with floor collision only (no broad/narrow phase between bodies), measured on a mid-range laptop:

Bodies Plain JS (avg step) WASM (avg step) Speedup
500 0.15 ms 0.06 ms ≈2.5×
5,000 1.9 ms 0.5 ms ≈3.8×
50,000 21 ms 4.2 ms ≈5×

The gap widens with body count for two reasons: WASM's flat SoA memory keeps the loop cache-friendly at scale, while JS's per-frame allocations trigger more frequent (and larger) GC pauses as the heap grows. The real win isn't just average step time — it's the reduction in worst-case frame spikes that cause visible stutter.

Next steps: add a real broad phase (see the from-scratch physics engine tutorial for AABB sweep-and-prune) and expose it through the same embind pattern, or replace the hand-rolled core with a WASM build of Rapier, which ships an official Rust→WASM physics engine using this exact zero-copy transfer strategy.

Frequently Asked Questions

What will I learn in this tutorial?

Compile a C++ rigid-body physics engine to WebAssembly with Emscripten: memory layout, embind bindings, SharedArrayBuffer transfer to JS/Three.js, and performance vs plain JavaScript.

What topics are covered in this tutorial?

This tutorial covers: Why WebAssembly for physics, Toolchain setup with Emscripten, A minimal C++ physics core, Binding with embind, Zero-copy data transfer via linear memory, Driving a Three.js scene from WASM state, Threads: pthreads and SharedArrayBuffer, Benchmark: WASM vs plain JavaScript.

How long does this tutorial take?

This tutorial takes approximately 35 minutes to complete.

What prerequisites do I need before starting?

This is a Advanced-level tutorial — no special preparation beyond basic JavaScript is assumed.