Tutorial · Advanced · ~70 min
Rust · WebAssembly · wasm-bindgen · Three.js

Rust + WebAssembly for Simulations

Rust compiles to WebAssembly at near-native speed with zero garbage collection pauses — ideal for physics simulations that must maintain 60 fps under heavy CPU load. wasm-bindgen makes interop with JavaScript trivial: expose Rust functions and share typed-array memory directly without copies.

1Set up the Rust / wasm-pack project

# 1. Install Rust (if not present) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # 2. Add the WASM target rustup target add wasm32-unknown-unknown # 3. Install wasm-pack cargo install wasm-pack # 4. Create a new library crate cargo new --lib particle-sim cd particle-sim
# Cargo.toml — add these dependencies and crate type [package] name = "particle-sim" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] wasm-bindgen = "0.2" [profile.release] opt-level = 3 lto = true

2Write the particle simulation in Rust

// src/lib.rs use wasm_bindgen::prelude::*; const N: usize = 100_000; pub struct Sim { px: Vec<f32>, py: Vec<f32>, // positions vx: Vec<f32>, vy: Vec<f32>, // velocities } #[wasm_bindgen] pub struct Particles { sim: Sim, } #[wasm_bindgen] impl Particles { #[wasm_bindgen(constructor)] pub fn new() -> Particles { let mut rng_state: u32 = 12345; let mut rng = |s: &mut u32| -> f32 { *s ^= *s << 13; *s ^= *s >> 17; *s ^= *s << 5; (*s as f32) / (u32::MAX as f32) }; Particles { sim: Sim { px: (0..N).map(|_| rng(&mut rng_state) * 2.0 - 1.0).collect(), py: (0..N).map(|_| rng(&mut rng_state) * 2.0 - 1.0).collect(), vx: (0..N).map(|_| (rng(&mut rng_state) - 0.5) * 0.01).collect(), vy: (0..N).map(|_| (rng(&mut rng_state) - 0.5) * 0.01).collect(), } } } pub fn step(&mut self, dt: f32) { let sim = &mut self.sim; for i in 0..N { // Gravity + boundary reflection sim.vy[i] -= 9.8 * dt * 0.001; sim.px[i] += sim.vx[i]; sim.py[i] += sim.vy[i]; if sim.px[i] > 1.0 { sim.px[i] = 1.0; sim.vx[i] *= -0.8; } if sim.px[i] < -1.0 { sim.px[i] = -1.0; sim.vx[i] *= -0.8; } if sim.py[i] < -1.0 { sim.py[i] = -1.0; sim.vy[i] *= -0.6; } } }

3Expose typed-array buffers to JS

// Continued in src/lib.rs — inside impl Particles: /// Returns a pointer to the position X buffer. /// The JS side can create a Float32Array view without copying. pub fn px_ptr(&self) -> *const f32 { self.sim.px.as_ptr() } pub fn py_ptr(&self) -> *const f32 { self.sim.py.as_ptr() } pub fn len(&self) -> usize { N } }
# Build the WASM package wasm-pack build --target web --release # Output: pkg/particle_sim.js (JS bindings) + pkg/particle_sim_bg.wasm

4Load and call from JavaScript

import init, { Particles } from './pkg/particle_sim.js'; const wasm = await init('./pkg/particle_sim_bg.wasm'); const sim = new Particles(); const N = sim.len(); // Get zero-copy views into WASM linear memory // These point directly into the WASM heap — no copy! const memory = wasm.memory; function getPxView() { return new Float32Array(memory.buffer, sim.px_ptr(), N); } function getPyView() { return new Float32Array(memory.buffer, sim.py_ptr(), N); } // Advance the simulation at 60 fps function update() { sim.step(1 / 60); }
Zero-copy access: memory.buffer is the WASM linear memory ArrayBuffer. Creating a typed array view from a pointer avoids any serialisation — the JS side reads the bytes the Rust side wrote directly.

5Zero-copy rendering with Three.js

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; // Build the geometry from current buffer view const geo = new THREE.BufferGeometry(); // interleave x,y into a 3-component position array const pos3 = new Float32Array(N * 3); let posAttr = new THREE.BufferAttribute(pos3, 3); posAttr.setUsage(THREE.DynamicDrawUsage); geo.setAttribute('position', posAttr); const mat = new THREE.PointsMaterial({ size: 0.002, color: 0x22c55e }); const points = new THREE.Points(geo, mat); scene.add(points); renderer.setAnimationLoop(() => { sim.step(1 / 60); // After step(), refresh the typed-array view (WASM memory may have grown) const px = new Float32Array(memory.buffer, sim.px_ptr(), N); const py = new Float32Array(memory.buffer, sim.py_ptr(), N); // Copy into interleaved position buffer for (let i = 0; i < N; i++) { pos3[i * 3] = px[i]; pos3[i * 3 + 1] = py[i]; pos3[i * 3 + 2] = 0; } posAttr.needsUpdate = true; renderer.render(scene, camera); });
The copy from (px, py) to interleaved pos3 is unavoidable without changing the Rust data layout to SoA→AoS. For maximum performance restructure the Rust struct to store xyz interleaved directly.