GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Canvas Fluid Simulation: 9 Grid Effects

A canvas fluid simulation doesn't solve the Navier-Stokes equations — it advects, diffuses, and decays density and velocity grids per frame til the screen

Auto-plays · click a tile to jump to its section · all nine in one zip

Why these nine, in this order

This article is neither the per-pixel refraction of the canvas scene effects set nor the one-particle-at-a-time scattering of the canvas particle engine. Its axis is Eulerian grid math in the Stable Fluids lineage: one Float32Array grid stores velocity (u, v) and density (d), and each frame moves the whole grid, smooths it, and shrinks it. Nothing is stacked particle by particle, and no wave equation bends a surface — a handful of numbers per cell, updated in a loop, is the entire machine.

Board 01 isolates the backbone of that loop, backward advection, and board 02 peels off the one step that blends each cell with its neighbors. Board 03 puts back the rotation the grid itself shaves away, and boards 04 and 05 lay click injection and buoyancy on top of the 01 backbone. Board 06 flips the direction of the experiment: seed one initial condition and watch it fade. Board 07 widens the channels to three, board 08 lets a human hand add the force, and board 09 stops simulating and asks how the numbers should be scaled up to the screen. Read in order, they show that fluid simulation code splits into a few independent parts: advection, diffusion, vorticity, boundaries.

01Stable fluid advection

What you're seeing

Self-advection of the velocity field and advection of the dye layer run together on one screen: the backtrace — sampling where the current cell came from last frame along its own velocity — is all the stability there is, and it holds however large the timestep grows. Use it for slowly churning fluid behind a hero.

Stable fluid advection
Open in a new tab

01 · Stable fluid advection — Measured area 11.342% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Float32ArrayMath.hypotrequestAnimationFrame

Where to look in the code

The render measured a cumulative area of 11.342%, an average intensity of 155.3, and movement in 23/23 frames.

function advect(b, dt) {
  var src = b === 'u' ? u0 : (b === 'v' ? v0 : d0);
  var dst = b === 'u' ? u : (b === 'v' ? v : d);
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      var x = i - dt * N * u0[IX(i, j)];
      var y = j - dt * N * v0[IX(i, j)];
      if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
      var i0 = x | 0; var i1 = i0 + 1;
      if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
      var j0 = y | 0; var j1 = j0 + 1;
      var s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
      dst[IX(i, j)] = s0 * (t0 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
                      s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
    }
  }
}

02Diffusion bleed

What you're seeing

One diffusion step that blends each cell slightly with its four neighbors is the smudging of ink in water: a high diffusion coefficient spreads it like a decal; near zero keeps sharp boundaries. Use it for watercolour section backgrounds.

Diffusion bleed
Open in a new tab

02 · Diffusion bleed — Measured area 45.362% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Float32ArrayputImageData

Where to look in the code

The render measured a cumulative area of 45.362%, an average intensity of 31.5, and movement in 23/23 frames.

function diffuse(dt) {
  var a = dt * DIFF * N * N;
  for (var k = 0; k < ITER; k++) {
    for (var j = 1; j <= N; j++) {
      for (var i = 1; i <= N; i++) {
        d[IX(i, j)] = (d0[IX(i, j)] + a * (d[IX(i - 1, j)] + d[IX(i + 1, j)] + d[IX(i, j - 1)] + d[IX(i, j + 1)])) / (1 + 4 * a);
      }
    }
    bounds();
  }
}

03Curl swirl

What you're seeing

Vorticity confinement computes the curl of the velocity field by central differences and feeds force back along the gradient of its magnitude, reviving dying smoke: the small eddies a coarse grid's numerical viscosity shaved off come back in one computed step. Use it for living smoke on a large banner.

Curl swirl
Open in a new tab

03 · Curl swirl — Measured area 7.641% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Float32ArrayMath.hypot

Where to look in the code

The render measured a cumulative area of 7.641%, an average intensity of 115.1, and movement in 23/23 frames.

function calcCurl() {
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      curl[IX(i, j)] = 0.5 * ((v0[IX(i + 1, j)] - v0[IX(i - 1, j)]) - (u0[IX(i, j + 1)] - u0[IX(i, j - 1)]));
    }
  }
}

function confine(dt) {
  for (var j = 2; j < N; j++) {
    for (var i = 2; i < N; i++) {
      var idx = IX(i, j);
      var gx = (Math.abs(curl[IX(i + 1, j)]) - Math.abs(curl[IX(i - 1, j)])) * 0.5;
      var gy = (Math.abs(curl[IX(i, j + 1)]) - Math.abs(curl[IX(i, j - 1)])) * 0.5;
      var len = Math.sqrt(gx * gx + gy * gy) + EPS;
      gx /= len; gy /= len;
      var c = curl[idx];
      u0[idx] += gx * c * VORT * dt;
      v0[idx] += gy * c * VORT * dt;
    }
  }
}

04Ink bloom

What you're seeing

Seed a blob of dye density where the user clicks, keep diffusion low, and advection grows the ink with a wet, ragged edge like paper — a multiplicative falloff makes the denser core spread slower, so the bloom reads in stages. It shares the grid and the advect call of board 01, fitting a click-reactive card background.

Ink bloom
Open in a new tab

04 · Ink bloom — Measured area 13.76% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.exppointerdown

Where to look in the code

The render measured a cumulative area of 13.76%, an average intensity of 46.8, and movement in 23/23 frames.

for (var k = 0; k < SIZE; k++) {
  var decay = 1 - Math.min(0.7, d[k] * 0.32);
  u[k] *= decay; v[k] *= decay;
}
u0.set(u); v0.set(v); d0.set(d);
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);
diffuse('d', 0.0009, dt); // diffusion near zero — advection draws the edge
bounds();
for (var m = 0; m < SIZE; m++) { d[m] *= 0.9985; u[m] *= 0.985; v[m] *= 0.985; }

05Rising smoke

What you're seeing

A buoyancy force proportional to density, added to the velocity field every frame, lifts a smoke column from below while deterministic Gaussian noise nudges it sideways: three fixed spots at the bottom take turns igniting, so several columns climb at once. Use it for smoke in a dark-mode footer.

Rising smoke
Open in a new tab

05 · Rising smoke — Measured area 47.947% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameMath.sin

Where to look in the code

The render measured a cumulative area of 47.947%, an average intensity of 57.1, and movement in 23/23 frames.

// Buoyancy — denser cells get pushed up harder. No temperature field: density stands in for it.
for (var k = 0; k < SIZE; k++) v0[k] -= Math.min(1.6, d0[k]) * BUOY * dt;
// Sideways sway — deterministic Gaussian noise shakes u a little each step.
var h = Math.sin(seedT * 1.7) * 3.1;
for (var j = 1; j <= N; j++) {
  for (var i = 1; i <= N; i++) {
    var idx2 = IX(i, j);
    var sw = gnoise(i * 0.31 + h, j * 0.27 - h * 0.6) + gnoise(i * 0.13 - h * 0.4, j * 0.19 + h * 0.8) * 0.5;
    u0[idx2] += sw * WIND * dt * Math.min(1, d0[idx2] * 0.9);
  }
}
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);

06Vortex decay

What you're seeing

One Rankine vortex — solid rotation at the core, inverse falloff outside — is seeded at init and then left alone with weak diffusion and a per-frame multiplicative decay, so the rotation smears itself out over time. A single decay constant decides how long it survives, which makes it a good fit for a slow whirl on a loading screen.

Vortex decay
Open in a new tab

06 · Vortex decay — Measured area 61.031% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.powFloat32Array

Where to look in the code

The render measured a cumulative area of 61.031%, an average intensity of 26.8, and movement in 20/23 frames.

function seedVortex(cx, cy, dir) {
  u.fill(0); v.fill(0); d.fill(0);
  u0.fill(0); v0.fill(0); d0.fill(0);
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      var dx = i - cx, dy = j - cy;
      var r = Math.sqrt(dx * dx + dy * dy) || 0.0001;
      var vTheta = r <= R_CORE ? OMEGA * r : OMEGA * (R_CORE * R_CORE) / r;
      var idx = IX(i, j);
      u[idx] = dir * (-dy / r) * vTheta;
      v[idx] = dir * (dx / r) * vTheta;
      var ring = Math.exp(-Math.pow((r - R_CORE * 1.15) / (R_CORE * 0.85), 2));
      d[idx] = ring * 1.4;
    }
  }
}

07Dye mixing

What you're seeing

Advecting and diffusing three dye channels independently makes two colors blend like liquids, with smooth intermediates appearing on their own — the mix falls out of the field math rather than a pre-blended palette. Use it for a hero that blends two brand colours.

Dye mixing
Open in a new tab

07 · Dye mixing — Measured area 43.042% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

putImageDataFloat32Array

Where to look in the code

The render measured a cumulative area of 43.042%, an average intensity of 21.6, and movement in 23/23 frames.

function step(dt) {
  velSource();
  u0.set(u); v0.set(v);
  advect(u, u0, dt); u0.set(u);
  advect(v, v0, dt); v0.set(v);
  velBounds();

  r0.set(r); g0.set(g); b0.set(b);
  inject(seedT);
  diffuse(r, r0, 0.09); diffuse(g, g0, 0.09); diffuse(b, b0, 0.09);
  r0.set(r); g0.set(g); b0.set(b);
  advect(r, r0, dt); advect(g, g0, dt); advect(b, b0, dt);
  dyeBounds(r); dyeBounds(g); dyeBounds(b);
  for (var k = 0; k < SIZE; k++) { r[k] *= 0.996; g[k] *= 0.996; b[k] *= 0.996; }

08Pointer stirring

What you're seeing

Splatting the pointer's frame-to-frame delta into the velocity field with an exponential radius makes the cursor genuinely stir the fluid, leaving brushed trails of paint; faster motion deposits more force. Use it for a gallery intro people play with.

Pointer stirring
Open in a new tab

08 · Pointer stirring — Measured area 51.135% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermoverequestAnimationFrame

Where to look in the code

The render measured a cumulative area of 51.135%, an average intensity of 62.1, and movement in 23/23 frames.

function splat(gx, gy, fx, fy, amt) {
  var rad = 7;
  for (var j = -rad; j <= rad; j++) {
    for (var i = -rad; i <= rad; i++) {
      var q = (i * i + j * j) / (rad * rad);
      if (q > 1) continue;
      var gi = gx + i, gj = gy + j;
      if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
      var f = Math.exp(-q * 1.8) * amt;
      var idx = IX(gi, gj);
      u[idx] += fx * f; v[idx] += fy * f;
      d[idx] = Math.min(2.4, d[idx] + f * 0.62);
    }
  }
}

09Density field rendering

What you're seeing

The simulation holds nothing but a 32x32 array of numbers, and how that array is scaled up to screen resolution decides whether the same data reads as blocky pixels or a smooth gradient: nearest-neighbor and bilinear interpolation sit side by side for comparison. Use it as a comparison demo upscaling low-res computation to a high-res display.

Density field rendering
Open in a new tab

09 · Density field rendering — Measured area 54.627% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createImageDataimageSmoothingEnabled

Where to look in the code

The render measured a cumulative area of 54.627%, an average intensity of 40.0, and movement in 23/23 frames.

function render() {
  paintSource();
  var w = cvA.width, h = cvA.height;
  ctxA.imageSmoothingEnabled = false;
  ctxA.clearRect(0, 0, w, h);
  ctxA.drawImage(src, 0, 0, w, h);
  ctxB.imageSmoothingEnabled = true;
  ctxB.imageSmoothingQuality = 'high';
  ctxB.clearRect(0, 0, w, h);
  ctxB.drawImage(src, 0, 0, w, h);
}

What does fluid simulation code actually compute on the grid?

Three steps, repeated

All nine boards run the same three steps every frame — add force (buoyancy, clicks, vorticity confinement), advect (sample values backwards along the velocity), then diffuse and decay (blend with neighbors, shrink multiplicatively). Change the order or drop one step and a different board of the nine comes out; that is the core of this set.

Why a grid at all

Unlike stacking positions of individual particles, a grid keeps its cell count fixed, so the computation never grows however long values accumulate. The same 32x32 size produces nine different scenes — that is exactly why.

How this differs from a Python fluid simulation

Move the same advection, diffusion, and decay procedures into Python fluid simulation code and the result is identical — the grid arrays and the loops are the essence, not the language. It is written in JS here only because it has to run in a browser; anyone porting these examples to another environment copies the two functions advect and diffuse and nothing else. For a fluid simulation online rather than offline, a search for what runs in the browser today returns mostly WebGL shaders — this set stays on the 2D context deliberately.

Where it breaks — the trap

Pressure projection is missing

None of the nine solves the Poisson equation that enforces incompressibility, the pressure projection step that the original Stable Fluids algorithm runs after advection. Its place is taken by a decay coefficient, which is why board 08, when stirred hard and fast, can pile density explosively into one cell for a moment.

Too approximate to call a solver

Skipping projection means these boards are visual approximations, not a fluid solver — anyone needing physically accurate flow should reach for SPH or a grid-based solver such as the position-based integration of the Verlet soft-body physics set instead. The source in the 2zk3kwsz archive marks that omission in comments, so read it before dropping a board into a real project.

Three variants and accessibility

Raising the diffusion coefficient

Lift board 02's diffusion from 0.08 to 0.30 and the ink spreads wide like a wash, its boundaries going soft. Keeping the relaxation iterations untouched and changing only the coefficient is enough.

Raising vorticity confinement

Lift board 03's VORT from 12 to 30 and ripple-scale eddies multiply along the smoke's rim. Push it further and the grid starts to jitter, so around 30 is the safe line.

Changing the colour

Swap $subject-blue for $subject-mint and the cold ink becomes a fresh mint dye. The computation stays as is; only the palette variable moves.

The still frame accessibility leaves behind

Boards that loop automatically, like 01, 03, 06, 07, and 09, pre-run 40 steps when prefers-reduced-motion is detected and draw a single still frame of a flow that has already settled. Pointer-driven boards like 02, 04, 05, and 08 keep one initial pose and update a frame only when a person actually clicks or moves. Neither case leaves the screen blank.

FAQ

Is a fluid simulation different from a physics engine?

Yes. A physics engine usually computes collisions and constraints between rigid bodies or particles, while this fluid simulation advects and diffuses density and velocity fields on a grid — Eulerian math. The objects being computed are different things.

Can I drop these demos into a real project?

As visual effects, yes, but they skip pressure projection, so they fit backgrounds and interactive decoration rather than work needing physically accurate flow such as engineering CFD — exactly the use they are tuned for.

Does a bigger grid (N) look more realistic?

It gets finer, but the cost grows with N squared and 60fps becomes hard to hold. These nine sit at 32x32 — as much detail as a canvas animation needs.


References: MDN getImageData · MDN putImageData · MDN Float32Array · MDN requestAnimationFrame · MDN pointermove · MDN prefers-reduced-motion · Wikipedia: Fluid simulation

Enter the archive password

The password is inside this article. You will find it as you read.