SPH Fluid — From Wikipedia to 60 FPS

Smoothed Particle Hydrodynamics looked terrifying on paper — neighbour searches, kernel functions, pressure gradients. Here's how I went from zero understanding to a silky smooth 3D fluid simulation running fully in the browser.

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:

A(r) = Σⱼ mⱼ · (Aⱼ / ρⱼ) · W(r − rⱼ, h)

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:

F_pressure = −m · Σⱼ mⱼ · ((Pᵢ + Pⱼ) / 2ρⱼ) · ∇W(rᵢ − rⱼ, h)

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:

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

  1. Profile first. The bottleneck was always the neighbour search, not the force computation.
  2. Float32 is your friend. Using Float32Array instead of object arrays cut memory bandwidth in half.
  3. The kernel matters. Using the wrong kernel gradient (I briefly tried the Poly6 for pressure) caused particle stacking and explosions.
  4. 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.