Smoothed Particle Hydrodynamics (SPH) represents fluid as a collection
of particles that interact through kernel-weighted interpolation. No
grid, no finite differences — just particles, forces, and three magic
kernel functions. This tutorial builds a complete 2D SPH solver under
200 lines of JavaScript, rendering to a Canvas 2D context.
1SPH kernels — Poly6, Spiky, Viscosity
SPH interpolates any quantity A at a point r as a
weighted sum over neighbours within radius h:
// Smoothing radius const H = 30; // px — all neighbours must be
within this distance const H2 = H * H; // Poly6 kernel — for density
estimation (smooth, even at r=0) function Wpoly6(r2) { if (r2 >= H2)
return 0; const x = H2 - r2; return 4 / (Math.PI * H2 * H2 * H2 * H2)
* x * x * x; // exact prefactor: 4/(pi * h^8), but we bake into
REST_DENSITY choice } // Spiky kernel gradient — for PRESSURE forces
(non-zero at r=0, resists clumping) function gradWspiky(rx, ry, r) {
if (r <= 0 || r >= H) return { x: 0, y: 0 }; const coeff = -30 /
(Math.PI * H2 * H2 * H2) * (H - r) * (H - r) / r; return { x: coeff *
rx, y: coeff * ry }; } // Viscosity kernel Laplacian — for VISCOSITY
forces (positive, diffuses velocity) function lapWviscosity(r) { if (r
>= H) return 0; return 40 / (Math.PI * H2 * H2 * H2) * (H - r); }
Why three different kernels? The Poly6 kernel is smooth at the origin
— good for density. The Spiky kernel has a non-zero gradient even at
very small distances, so pressure forces always push overlapping
particles apart. The viscosity Laplacian is positive everywhere,
ensuring viscosity always damps relative velocity.
2Density estimation
const REST_DENSITY = 300; // target density (kg/m²-ish, in particle
units) const MASS = 1; // particle mass function
computeDensities(particles) { for (const pi of particles) { let
density = 0; for (const pj of particles) { const dx = pi.x - pj.x, dy
= pi.y - pj.y; const r2 = dx*dx + dy*dy; density += MASS * Wpoly6(r2);
} pi.density = Math.max(density, REST_DENSITY * 0.01); // avoid zero }
}
3Pressure force
Pressure is derived from density via an equation of state.
Weakly compressible SPH uses a stiffness constant:
The naive O(N²) neighbour loop becomes impractical beyond ~500
particles. Spatial hashing creates a grid where each cell holds the
indices of particles it contains. Neighbour search then only visits
the 9 nearby cells:
class SpatialHash { constructor(cellSize) { this.cs = cellSize;
this.table = new Map(); } key(x, y) { return
`${Math.floor(x/this.cs)},${Math.floor(y/this.cs)}`; } clear() {
this.table.clear(); } insert(p) { const k = this.key(p.x, p.y); if
(!this.table.has(k)) this.table.set(k, []); this.table.get(k).push(p);
} neighbours(p, radius) { const result = []; const cells =
Math.ceil(radius / this.cs); const cx = Math.floor(p.x / this.cs), cy
= Math.floor(p.y / this.cs); for (let dx = -cells; dx <= cells;
dx++) for (let dy = -cells; dy <= cells; dy++) { const bucket =
this.table.get(`${cx+dx},${cy+dy}`); if (bucket)
result.push(...bucket); } return result; } } // Use in computeForces:
replace inner loop with // const neighbours =
spatialHash.neighbours(pi, H); // for (const pj of neighbours) { ... }
// Time complexity: O(N * k) where k = avg neighbours (~20–50)
With spatial hashing, 2,000 particles simulate at 60 FPS in a
single-threaded browser. For 10,000+ particles, move the solver to a
Web Worker or port to WebGL compute shaders using transform feedback
or a texture ping-pong approach.