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.
- Completed WebGL Shaders Intro
-
Familiarity with textures and
texture2D()sampling - Basic understanding of what a velocity field is
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:
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.
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.