Fluid Simulation in the Browser — Real-Time Computation on a 100x60 Grid
Go deeper on this topic
Creative Coding
Mathematical simulations, nature visualization, and generative art in the browser
Article 2 of 12 in this series.
Next reads
Drawing Fractal Mathematics in the Browser — From Mandelbrot Set to Barnsley Fern
Rendering fractals in the browser using complex number iteration, escape time algorithm, IFS (iterated function systems), and recursive drawing.
Generating Natural Terrain with Perlin Noise — Why Randomness Can Be Beautiful
Step-by-step implementation of natural terrain generation with Perlin noise, from the mathematics of gradient noise to octave composition and 3D mesh rendering on Canvas.
Visualizing Wave Equations in the Browser — Interference, Diffraction, and Resonance
Implementing 1D and 2D wave equations with finite difference methods and visualizing interference, diffraction, and resonance patterns on Canvas. Rendering techniques for WaveInterference, WaveEquation, and ChladniPlate.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Three Steps of Fluid Simulation
Directly solving the Navier-Stokes equations is too computationally expensive. Following Jos Stam's method, the problem is decomposed into three steps:
- Diffusion: The velocity and density fields spread to their surroundings
- Advection: The fluid is carried along its own velocity field
- Pressure Correction (Projection): The velocity field is corrected to maintain incompressibility
Grid Structure
Scalar values (density) and vector values (velocity) are stored on a 100x60 grid:
const N = 100
const M = 60
const size = (N + 2) * (M + 2) // Including boundary cells
const density = new Float32Array(size)
const velocityX = new Float32Array(size)
const velocityY = new Float32Array(size)Float32Array is used because numerical operations are faster than with regular arrays, and memory usage is halved (64-bit to 32-bit).
Diffusion Step
Diffusion is approximated using Gauss-Seidel iteration:
function diffuse(
b: number,
x: Float32Array,
x0: Float32Array,
diff: number,
dt: number,
) {
const a = dt * diff * N * M
for (let k = 0; k < 20; k++) {
for (let j = 1; j <= M; j++) {
for (let i = 1; i <= N; i++) {
const idx = i + (N + 2) * j
x[idx] =
(x0[idx] + a * (x[idx - 1] + x[idx + 1] +
x[idx - (N + 2)] + x[idx + (N + 2)])) /
(1 + 4 * a)
}
}
setBoundary(b, x)
}
}20 iterations is a trade-off between accuracy and speed. In FluidSimulation, reducing to 4 iterations produces virtually no visual difference.
Advection Step
Using the semi-Lagrangian method, values are interpolated by backtracing through the velocity field from each cell:
function advect(
b: number,
d: Float32Array,
d0: Float32Array,
u: Float32Array,
v: Float32Array,
dt: number,
) {
for (let j = 1; j <= M; j++) {
for (let i = 1; i <= N; i++) {
const idx = i + (N + 2) * j
// Backtrace
let x = i - dt * N * u[idx]
let y = j - dt * M * v[idx]
// Clamp
x = Math.max(0.5, Math.min(N + 0.5, x))
y = Math.max(0.5, Math.min(M + 0.5, y))
// Bilinear interpolation
const i0 = Math.floor(x)
const j0 = Math.floor(y)
const s1 = x - i0
const t1 = y - j0
const s0 = 1 - s1
const t0 = 1 - t1
d[idx] =
s0 * (t0 * d0[i0 + (N + 2) * j0] + t1 * d0[i0 + (N + 2) * (j0 + 1)]) +
s1 * (t0 * d0[i0 + 1 + (N + 2) * j0] + t1 * d0[i0 + 1 + (N + 2) * (j0 + 1)])
}
}
setBoundary(b, d)
}Backtracing is counterintuitive, but it ensures numerical stability. Forward tracing would require handling overlap when determining "which cell it reaches."
Pressure Correction (Projection)
Helmholtz decomposition creates a divergence-free velocity field:
function project(
u: Float32Array,
v: Float32Array,
p: Float32Array,
div: Float32Array,
) {
for (let j = 1; j <= M; j++) {
for (let i = 1; i <= N; i++) {
const idx = i + (N + 2) * j
div[idx] = -0.5 * (
(u[idx + 1] - u[idx - 1]) / N +
(v[idx + (N + 2)] - v[idx - (N + 2)]) / M
)
p[idx] = 0
}
}
setBoundary(0, div)
setBoundary(0, p)
// Solve Poisson equation with Gauss-Seidel
for (let k = 0; k < 20; k++) {
for (let j = 1; j <= M; j++) {
for (let i = 1; i <= N; i++) {
const idx = i + (N + 2) * j
p[idx] = (div[idx] + p[idx - 1] + p[idx + 1] +
p[idx - (N + 2)] + p[idx + (N + 2)]) / 4
}
}
setBoundary(0, p)
}
// Subtract gradient from velocity field
for (let j = 1; j <= M; j++) {
for (let i = 1; i <= N; i++) {
const idx = i + (N + 2) * j
u[idx] -= 0.5 * N * (p[idx + 1] - p[idx - 1])
v[idx] -= 0.5 * M * (p[idx + (N + 2)] - p[idx - (N + 2)])
}
}
setBoundary(1, u)
setBoundary(2, v)
}Canvas API Rendering
Density values are mapped to colors and rendered. ImageData is manipulated directly for pixel-level painting:
function renderFluid(
ctx: CanvasRenderingContext2D,
density: Float32Array,
width: number,
height: number,
) {
const imageData = ctx.createImageData(width, height)
const cellW = width / N
const cellH = height / M
for (let j = 1; j <= M; j++) {
for (let i = 1; i <= N; i++) {
const d = Math.min(255, density[i + (N + 2) * j] * 255)
const px = Math.floor((i - 1) * cellW)
const py = Math.floor((j - 1) * cellH)
// Paint pixels within the cell (simplified)
const idx = (py * width + px) * 4
imageData.data[idx] = d * 0.3 // R
imageData.data[idx + 1] = d * 0.6 // G
imageData.data[idx + 2] = d // B
imageData.data[idx + 3] = 255 // A
}
}
ctx.putImageData(imageData, 0, 0)
}Difference from FluidPipes
FluidPipes specializes in fluid "transport," with pipe network flow rate calculation as its main concern. It doesn't use Navier-Stokes; instead, it computes flow rates from pressure differences across each pipe — a much simpler model. They look similar, but the computational models are entirely different.
Summary: Tools for Browser Fluid Simulation
For understanding the mathematical foundations of fluid dynamics, the books on the tool shelf are excellent references. Reading Jos Stam's original paper "Stable Fluids" (1999) alongside them deepens understanding further.
FAQ
- Q. Is it possible to run a real-time fluid simulation in the browser?
- Yes. By applying a simplified 3-step Navier-Stokes process (diffusion, advection, pressure correction) to a 100x60 grid and using Float32Array with ImageData for fast rendering, it runs at 60fps.
- Q. How many iterations are needed for the diffusion step in fluid simulation?
- 20 iterations with Gauss-Seidel is standard, but for real-time rendering, reducing to 4 iterations produces virtually no visual difference.
- Q. What is the difference between FluidSimulation and FluidPipes?
- FluidSimulation is a continuous fluid simulation based on the Navier-Stokes equations. FluidPipes is a discrete model that calculates flow from pressure differences across a pipe network — the computational approaches are entirely different.