Tutorial · Advanced · ~70 min
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.