Why SPH?
After Boids, I wanted something that felt more physical. Fluid. Water you could stir. SPH (Smoothed Particle Hydrodynamics) is the standard particle-based method for simulating fluids — it's used in movies, games and scientific computing alike.
The method represents the fluid as a cloud of particles. Each particle carries mass, position, velocity and pressure. At every timestep, you sum the contributions of all nearby particles to compute forces, then integrate those forces to move the particles.
Simple in theory. Fiendishly expensive in practice.
The Math (Brief Version)
The core of SPH is the kernel-weighted interpolation. For any continuous field A, the value at position r is approximated as:
Where W is the smoothing kernel, h is the smoothing radius, mⱼ is particle mass and ρⱼ is density.
The pressure force between particles looks like:
The kernel I used is the Poly6 kernel for density and the Spiky kernel for pressure gradients — the Spiky kernel's non-zero gradient at the centre prevents particle clustering.
The Naïve Implementation (and Why It's Slow)
My first implementation was dead simple: for each particle, loop over
every other particle, check if the distance is within the smoothing
radius h, accumulate contributions.
With 1000 particles that's 1,000,000 comparisons per frame. At 60 FPS, that's 60 million comparisons per second. In JavaScript. The sim ran at ~3 FPS.
The Spatial Hash — The Key Optimisation
The solution is spatial hashing: divide 3D space into a grid of cells
sized h × h × h. Each particle is assigned to a cell
based on its position. When computing neighbours, you only check the
27 cells surrounding the particle's cell.
// Hash a 3D grid position to an integer bucket
function hashCell(ix, iy, iz) {
return (ix * 73856093 ^ iy * 19349663 ^ iz * 83492791) % TABLE_SIZE;
}
// Given world position, find the cell
function cellIndex(x, y, z, h) {
return hashCell(
Math.floor(x / h),
Math.floor(y / h),
Math.floor(z / h)
);
}
With the spatial hash, the average particle only checks ~30 neighbours instead of 999. The simulation jumped from 3 FPS to 58 FPS overnight. That's the value of the right data structure.
Surface Tension and Viscosity
Pure pressure forces produce jelly-like blobs. Two more terms make it look like real water:
-
Viscosity — damps relative velocities between
particles, making the fluid resist shearing. Added as a Laplacian
term:
F_visc = μ · Σⱼ mⱼ · (vⱼ − vᵢ) / ρⱼ · ∇²W - Surface tension — pulls particles at surfaces inward, producing smooth droplets. Computed via the surface normal and curvature.
Getting the viscosity coefficient right took most of an afternoon of tuning. Too low and the water splashes everywhere like marbles. Too high and it moves like honey.
Lessons Learned
- Profile first. The bottleneck was always the neighbour search, not the force computation.
-
Float32 is your friend. Using
Float32Arrayinstead of object arrays cut memory bandwidth in half. - The kernel matters. Using the wrong kernel gradient (I briefly tried the Poly6 for pressure) caused particle stacking and explosions.
- Timestep stability. SPH has a maximum stable timestep. Exceeding it causes the sim to explode. I added a CFL condition check.
The fluid simulation is live at /fluid/. Click to splash particles around — it's satisfying.