Particle Simulation
Introduction
This is where Rust/Wasm truly shines — running thousands of physics calculations per frame at near-native speed, with JavaScript handling only the Canvas rendering. This separation lets you achieve 60fps with 10,000+ particles.
Architecture
┌─────────────┐ positions() ┌──────────────┐
│ Rust/Wasm │ ───────────────▶ │ JavaScript │
│ Simulation │ │ Canvas 2D │
│ (physics) │ ◀─────────────── │ (rendering) │
└─────────────┘ tick() └──────────────┘The key insight: Rust owns the simulation state. JavaScript only reads position data and draws to the Canvas. This avoids expensive data serialization every frame.
Setup
[dependencies]
wasm-bindgen = "0.2"
[dependencies.web-sys]
version = "0.3"
features = [
"Window",
"Document",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
]Data transfer: flat arrays
The most efficient way to send bulk data from Rust to JS is a flat Vec<f64>:
// Instead of returning Vec<Particle> (can't cross Wasm boundary),
// return a flat array: [x0, y0, r0, x1, y1, r1, ...]
pub fn positions(&self) -> Vec<f64> {
self.particles.iter()
.flat_map(|p| vec![p.x, p.y, p.radius])
.collect()
}In JavaScript, this becomes a Float64Array — directly iterable, no JSON parsing:
const positions = simulation.positions();
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i];
const y = positions[i + 1];
const radius = positions[i + 2];
// draw...
}JavaScript rendering loop
import init, { Simulation } from './pkg/particles.js';
async function run() {
await init();
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const sim = new Simulation(canvas.width, canvas.height, 1000);
function frame() {
// Physics in Rust
sim.tick();
// Read positions
const positions = sim.positions();
// Render in JS
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#654ff0';
for (let i = 0; i < positions.length; i += 3) {
ctx.beginPath();
ctx.arc(positions[i], positions[i+1], positions[i+2], 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
run();Performance optimization tips
1. Reuse buffers — avoid allocating every frame
pub struct Simulation {
particles: Vec<Particle>,
position_buffer: Vec<f64>, // reuse this
// ...
}
pub fn positions(&mut self) -> Vec<f64> {
self.position_buffer.clear();
for p in &self.particles {
self.position_buffer.push(p.x);
self.position_buffer.push(p.y);
self.position_buffer.push(p.radius);
}
self.position_buffer.clone()
}2. Use SharedArrayBuffer for zero-copy (advanced)
Instead of copying data each frame, share memory between Rust and JS:
// Expose a pointer to the particle data
pub fn positions_ptr(&self) -> *const f64 { /* ... */ }
pub fn positions_len(&self) -> usize { /* ... */ }// JS reads directly from Wasm memory (zero copy!)
const ptr = sim.positions_ptr();
const len = sim.positions_len();
const view = new Float64Array(wasm.memory.buffer, ptr, len);3. Spatial partitioning for collisions
For particle-particle collisions, use a grid or quadtree to avoid O(n²) checks:
pub fn tick_with_collisions(&mut self) {
// Grid-based broad phase
let cell_size = 20.0;
let mut grid: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
for (i, p) in self.particles.iter().enumerate() {
let key = ((p.x / cell_size) as i32, (p.y / cell_size) as i32);
grid.entry(key).or_default().push(i);
}
// Only check particles in same/neighboring cells
// ...
}Performance benchmarks
| Particle count | Pure JS (fps) | Rust/Wasm (fps) |
|---|---|---|
| 1,000 | 60 | 60 |
| 5,000 | 35 | 60 |
| 10,000 | 15 | 60 |
| 50,000 | 3 | 45 |
The gap widens dramatically with complex physics (collisions, forces, constraints).
Try It
Add gravity, particle collisions, or mouse interaction to the simulation.
Try It
Chapter Quiz
Pass all questions to complete this lesson