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.

Frequently Asked Questions

What will I learn in this tutorial?

Compile a particle physics simulation written in Rust to WebAssembly, call it from JavaScript, and render 100k particles at 60fps in the browser.

What topics are covered in this tutorial?

This tutorial covers: Set up the Rust/wasm-pack project, Write the particle simulation in Rust, Expose typed-array buffers to JS, Load and call from JavaScript, Zero-copy rendering with Three.js.

What tools and technologies does this tutorial use?

This tutorial uses Rust, WebAssembly, wasm-bindgen, Three.js.

How long does this tutorial take?

This tutorial takes approximately 70 minutes to complete.

What prerequisites do I need before starting?

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