Tutorial
⏱️ ~70 minutes 🎓 Advanced 🛠️ GLSL · Three.js · WebGL FBOs

GPU Fluid Simulation with WebGL

Real-time incompressible fluid runs entirely on the GPU. Each simulation pass is a full-screen GLSL shader writing to a floating-point texture — no CPU physics loop. This tutorial covers the Navier-Stokes pipeline: advection, external forces, pressure Poisson solve, divergence correction, and dye visualisation using ping-pong render targets in Three.js.

Prerequisites

The Navier-Stokes Pipeline Overview

The incompressible Navier-Stokes momentum equation is:

∂u/∂t = −(u·∇)u − (1/ρ)∇p + ν∇²u + f

where u = velocity field, p = pressure, ν = viscosity, f = external forces. With the incompressibility constraint ∇·u = 0.

Each frame the GPU executes these passes in order:

1. Advection: u_new = advect(u, u, dt) — self-advect the velocity
2. Forces: u += f * dt — add gravity, mouse splat, buoyancy
3. Divergence: div = ∇·u — compute how much fluid is "created/destroyed"
4. Pressure: ∇²p = div — iterative Jacobi solve (20–50 iterations)
5. Projection: u -= ∇p — subtract gradient to enforce ∇·u = 0
6. Dye advect: dye_new = advect(dye, u, dt) — move colour with flow

Ping-Pong Render Targets

A shader cannot read and write the same texture simultaneously. Ping-pong uses two FBOs — one is read while the other is written, then they swap:

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';

function createFBO(width, height) {
  return new THREE.WebGLRenderTarget(width, height, {
    type: THREE.FloatType,           // 32-bit float per channel
    format: THREE.RGBAFormat,
    minFilter: THREE.LinearFilter,
    magFilter: THREE.LinearFilter,
    wrapS: THREE.ClampToEdgeWrapping,
    wrapT: THREE.ClampToEdgeWrapping,
  });
}

class DoubleFBO {
  constructor(w, h) {
    this.read  = createFBO(w, h);
    this.write = createFBO(w, h);
  }
  swap() { [this.read, this.write] = [this.write, this.read]; }
}

const SIM_RES = 256;
const velocity = new DoubleFBO(SIM_RES, SIM_RES);
const pressure = new DoubleFBO(SIM_RES, SIM_RES);
const dye      = new DoubleFBO(SIM_RES, SIM_RES);

FloatType render targets need the OES_texture_float WebGL extension (available in all modern browsers). Three.js enables it automatically when you request THREE.FloatType.

Advection Shader

Semi-Lagrangian advection: "where did this particle come from one timestep ago?" — follow the velocity field backward and sample there:

// advection fragment shader
const advectFrag = /* glsl */`
  uniform sampler2D uVelocity; // velocity field to advect WITH
  uniform sampler2D uSource;   // field to advect (velocity or dye)
  uniform float uDt;
  uniform vec2  uTexelSize;    // 1/SIM_RES for each axis

  varying vec2 vUv;

  void main() {
    // Read velocity at current cell
    vec2 velocity = texture2D(uVelocity, vUv).xy;

    // Trace backward in time: prev_pos = current - velocity * dt
    vec2 prevUv = vUv - velocity * uDt * uTexelSize;

    // Sample the source field at the previous position
    gl_FragColor = texture2D(uSource, prevUv);
  }
`;

To run a shader pass: render a full-screen quad with the shader, writing to the target FBO:

function runPass(material, targetFBO) {
  quad.material = material;
  renderer.setRenderTarget(targetFBO);
  renderer.render(quadScene, quadCamera);
  renderer.setRenderTarget(null);
}

advectMat.uniforms.uVelocity.value = velocity.read.texture;
advectMat.uniforms.uSource.value   = velocity.read.texture;
advectMat.uniforms.uDt.value       = dt;
runPass(advectMat, velocity.write);
velocity.swap();

Divergence and Pressure Poisson

Compute divergence (how much the velocity field expands), then iteratively solve for pressure using Jacobi iterations:

// Divergence shader — reads velocity, writes scalar div into .r channel
const divergenceFrag = /* glsl */`
  uniform sampler2D uVelocity;
  uniform vec2 uTexelSize;
  varying vec2 vUv;
  void main() {
    vec2 ts = uTexelSize;
    float L = texture2D(uVelocity, vUv - vec2(ts.x, 0)).x;
    float R = texture2D(uVelocity, vUv + vec2(ts.x, 0)).x;
    float B = texture2D(uVelocity, vUv - vec2(0, ts.y)).y;
    float T = texture2D(uVelocity, vUv + vec2(0, ts.y)).y;
    float div = 0.5 * (R - L + T - B);
    gl_FragColor = vec4(div, 0, 0, 1);
  }
`;

// Pressure Jacobi iteration shader — run 30–50 times
const pressureFrag = /* glsl */`
  uniform sampler2D uPressure;
  uniform sampler2D uDivergence;
  uniform vec2 uTexelSize;
  varying vec2 vUv;
  void main() {
    vec2 ts = uTexelSize;
    float L = texture2D(uPressure, vUv - vec2(ts.x, 0)).r;
    float R = texture2D(uPressure, vUv + vec2(ts.x, 0)).r;
    float B = texture2D(uPressure, vUv - vec2(0, ts.y)).r;
    float T = texture2D(uPressure, vUv + vec2(0, ts.y)).r;
    float div = texture2D(uDivergence, vUv).r;
    // Jacobi: p_new = (L+R+B+T - div) / 4
    float pNew = (L + R + B + T - div) * 0.25;
    gl_FragColor = vec4(pNew, 0, 0, 1);
  }
`;

// Run 40 Jacobi iterations
for (let i = 0; i < 40; i++) {
  pressureMat.uniforms.uPressure.value = pressure.read.texture;
  runPass(pressureMat, pressure.write);
  pressure.swap();
}

Projection (Subtract Pressure Gradient)

Subtract the pressure gradient from velocity to make the field divergence-free:

const projectFrag = /* glsl */`
  uniform sampler2D uVelocity;
  uniform sampler2D uPressure;
  uniform vec2 uTexelSize;
  varying vec2 vUv;
  void main() {
    vec2 ts = uTexelSize;
    float pL = texture2D(uPressure, vUv - vec2(ts.x, 0)).r;
    float pR = texture2D(uPressure, vUv + vec2(ts.x, 0)).r;
    float pB = texture2D(uPressure, vUv - vec2(0, ts.y)).r;
    float pT = texture2D(uPressure, vUv + vec2(0, ts.y)).r;
    vec2 gradP = vec2(pR - pL, pT - pB) * 0.5;
    vec2 vel = texture2D(uVelocity, vUv).xy;
    gl_FragColor = vec4(vel - gradP, 0, 1);
  }
`;

Adding Forces — Mouse Interaction

Splat forces and dye onto the simulation when the user moves the mouse:

const splatFrag = /* glsl */`
  uniform sampler2D uTarget;
  uniform vec2  uPoint;    // normalised mouse position [0,1]
  uniform vec3  uColor;    // force vector (xy) or dye colour (xyz)
  uniform float uRadius;
  varying vec2 vUv;
  void main() {
    float d = distance(vUv, uPoint);
    float splat = exp(-d * d / uRadius);
    vec4 current = texture2D(uTarget, vUv);
    gl_FragColor = current + vec4(uColor * splat, 0);
  }
`;

// Call on mousemove to inject velocity + dye
function onMouseMove(e) {
  const x = e.clientX / innerWidth;
  const y = 1.0 - e.clientY / innerHeight; // flip y
  const dx = (e.clientX - prevMouseX) / innerWidth  * 15.0; // scale to sim speed
  const dy = (e.clientY - prevMouseY) / innerHeight * 15.0;

  splatMat.uniforms.uPoint.value.set(x, y);
  splatMat.uniforms.uRadius.value = 0.0015;

  // Velocity splat
  splatMat.uniforms.uColor.value.set(dx, -dy, 0);
  splatMat.uniforms.uTarget.value = velocity.read.texture;
  runPass(splatMat, velocity.write); velocity.swap();

  // Dye splat
  splatMat.uniforms.uColor.value.set(0.3, 0.7, 1.0); // cyan dye
  splatMat.uniforms.uTarget.value = dye.read.texture;
  runPass(splatMat, dye.write); dye.swap();
}

Dye Transport and Display

Advect the dye texture with the divergence-free velocity field, then display it:

// Each frame:
// 1. Advect velocity
advectMat.uniforms.uSource.value = velocity.read.texture;
runPass(advectMat, velocity.write); velocity.swap();

// 2. Add forces (if mouse is down)

// 3. Compute divergence
runPass(divergenceMat, divFBO);

// 4. Solve pressure (40 Jacobi iterations)
for (let k = 0; k < 40; k++) { /* ... */ }

// 5. Project
projectMat.uniforms.uVelocity.value = velocity.read.texture;
runPass(projectMat, velocity.write); velocity.swap();

// 6. Advect dye
advectMat.uniforms.uVelocity.value = velocity.read.texture;
advectMat.uniforms.uSource.value   = dye.read.texture;
runPass(advectMat, dye.write); dye.swap();

// 7. Display dye to screen
displayMat.uniforms.uTexture.value = dye.read.texture;
renderer.setRenderTarget(null);
renderer.render(quadScene, quadCamera);

For a full working implementation, see the Fluid simulation on this site — it uses exactly this pipeline at 512×512 resolution with curl-noise forcing for autonomous motion.

Continue Learning

Frequently Asked Questions

What will I learn in this tutorial?

Implement a real-time incompressible fluid simulation entirely on the GPU using ping-pong render targets, Navier-Stokes advection and pressure projection in WebGL / Three.js.

What topics are covered in this tutorial?

This tutorial covers: The Navier-Stokes pipeline overview, Ping-pong render targets, Advection shader, Divergence and pressure Poisson, Projection (subtract pressure gradient), Adding forces (mouse interaction), Dye transport and display.

What tools and technologies does this tutorial use?

This tutorial uses GLSL, Three.js, WebGL FBOs.

How long does this tutorial take?

This tutorial takes approximately 70 minutes to complete.

What prerequisites do I need before starting?

Before starting, you should have: Completed WebGL Shaders Intro; Familiarity with textures and texture2D() sampling; Basic understanding of what a velocity field is.

🛠

Experiment in Playground

Extend this fluid simulation — write and run Three.js code directly in your browser.

Open Playground → View Simulation ↗