Physics · Fluid Dynamics · WebGL
📅 Березень 2026 ⏱ ≈ 13 хв читання 🎯 Advanced

Navier-Stokes Equations in WebGL — real-time fluid simulation

Jos Stam's 1999 paper "Stable Fluids" showed how to solve the incompressible Navier-Stokes equations in a way that never blows up, regardless of timestep. The key insight is operator splitting: treat advection, diffusion, external forces and pressure projection as four separate sub-steps each timestep. On modern hardware the entire solver runs in WebGL fragment shaders at 60 FPS.

1. The incompressible Navier-Stokes equations

The incompressible Navier-Stokes equations describe the motion of a constant-density Newtonian fluid. In vector form they are two coupled PDEs:

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

u — velocity field (2D or 3D vector field)
p — pressure field (scalar)
ν — kinematic viscosity (ν = μ/ρ)
ρ — density (constant for incompressible flow)
f — external body forces (gravity, mouse drag, etc.)

The continuity equation ∇ · u = 0 is the incompressibility constraint: fluid cannot compress, so divergence of the velocity field must be zero everywhere. This constraint drives the pressure solver.

The momentum equation has four terms on the right:

2. Operator splitting — the Stable Fluids approach

Stam's method advances the solution by one timestep Δt using four sequential sub-steps, each applied to a field grid stored as a floating-point texture:

1 Add forces — accumulate external impulses (mouse velocity splat, gravity) directly into the velocity grid
2 Diffuse — solve u = u + ν Δt ∇²u implicitly via Jacobi iteration; unconditionally stable even at large ν or Δt
3 Advect — trace each grid point backward along the velocity field and sample from the previous field (semi-Lagrangian)
4 Project — compute pressure field that makes u divergence-free; subtract its gradient from u
Why is it "stable"? Explicit finite-difference advection requires the CFL condition (Δt · |u| / Δx < 1). Semi-Lagrangian advection (step 3) backtracks along streamlines and uses bilinear interpolation, which is unconditionally stable — at the cost of some numerical diffusion.

3. Semi-Lagrangian advection

The idea: instead of asking "where does the fluid at grid cell x go?", ask "where did the fluid at x come from?" Trace the velocity field backwards by Δt and sample the previous field there.

u_new(x) = u_old( x − Δt · u_old(x) )

x_src = x − Δt · u(x) ← "where it came from"
bilinear interpolation at x_src in u_old

In GLSL (one channel of a floating-point texture = velocity component):

// Advect shader — gl_FragCoord.xy maps to UV in [0,1]²
uniform sampler2D uVelocity;   // current velocity field (vec2 encoded as RG)
uniform sampler2D uSource;     // field to advect (velocity or dye)
uniform vec2      uTexelSize;  // 1/resolution
uniform float      uDt;

void main() {
  vec2 uv   = gl_FragCoord.xy * uTexelSize;
  vec2 vel  = texture(uVelocity, uv).rg;
  vec2 prev = uv - uDt * vel * uTexelSize; // backtrace in texture space
  gl_FragColor = texture(uSource, prev);  // bilinear sample (FREE in GPU)
}
Numerical dissipation: bilinear interpolation is a low-pass filter. Each advection step subtly smears the velocity field. For sharper turbulence, use MacCormack advection (forward + backward advect + correction pass) at the cost of two extra texture reads.

4. Diffusion — Jacobi iteration

Solving (I − ν Δt ∇²) u_new = u_old implicitly for u_new gives a sparse linear system. On a regular grid, the Laplacian discretises as:

∇²u ≈ (u_{i+1} + u_{i−1} + u_{j+1} + u_{j−1} − 4 u_{ij}) / Δx²

The implicit system u_{ij} = α·(sum of 4 neighbours) + β·u_old
where α = Δx² / (ν Δt), β = 1 / (4 + α)

Jacobi iteration: iterate uNew = β · (α·u_old + L(uPrev))
converges in ~20–40 iterations for typical ν and Δt
// Jacobi shader (one iteration; ping-pong between two FBOs)
uniform sampler2D uX;        // current iterate x_k
uniform sampler2D uB;        // right-hand side b  (u_old)
uniform float      uAlpha;    // Δx² / (ν·Δt)
uniform float      uBeta;     // 1 / (4 + α)
uniform vec2      uTexelSize;

void main() {
  vec2 uv = gl_FragCoord.xy * uTexelSize;
  vec4 xL = texture(uX, uv - vec2(uTexelSize.x, 0));
  vec4 xR = texture(uX, uv + vec2(uTexelSize.x, 0));
  vec4 xB = texture(uX, uv - vec2(0, uTexelSize.y));
  vec4 xT = texture(uX, uv + vec2(0, uTexelSize.y));
  vec4 b  = texture(uB, uv);
  gl_FragColor = (xL + xR + xB + xT + uAlpha * b) * uBeta;
}

For low-viscosity fluids you can skip explicit diffusion entirely (ν = 0) — the numerical diffusion from advection alone provides sufficient smoothing and saves 20–40 Jacobi passes per frame.

5. Pressure projection — Helmholtz decomposition

After advection, the velocity field u̅ is generally divergent (∇ · u̅ ≠ 0) — incompatible with real fluid behaviour. The Helmholtz decomposition guarantees any vector field can be split into a divergence-free part and a gradient:

u̅ = u + ∇p / ρ
∇ · u = 0 ⟹ ∇²p = ρ ∇ · u̅ (Poisson equation for pressure)

Step 1: compute divergence D = ∇ · u̅
Step 2: solve Poisson ∇²p = D via Jacobi (20–50 iterations)
Step 3: subtract gradient u = u̅ − ∇p / ρ
// Divergence shader
void main() {
  vec2 uv = gl_FragCoord.xy * uTexelSize;
  float vL = texture(uVelocity, uv - vec2(uTexelSize.x,0)).x;
  float vR = texture(uVelocity, uv + vec2(uTexelSize.x,0)).x;
  float vB = texture(uVelocity, uv - vec2(0,uTexelSize.y)).y;
  float vT = texture(uVelocity, uv + vec2(0,uTexelSize.y)).y;
  gl_FragColor.r = 0.5 * (vR - vL + vT - vB);  // central differences
}

// Gradient subtraction shader
void main() {
  vec2 uv = gl_FragCoord.xy * uTexelSize;
  float pL = texture(uPressure, uv - vec2(uTexelSize.x,0)).r;
  float pR = texture(uPressure, uv + vec2(uTexelSize.x,0)).r;
  float pB = texture(uPressure, uv - vec2(0,uTexelSize.y)).r;
  float pT = texture(uPressure, uv + vec2(0,uTexelSize.y)).r;
  vec2  vel = texture(uVelocity, uv).xy;
  vel -= 0.5 * vec2(pR - pL, pT - pB);
  gl_FragColor = vec4(vel, 0, 1);
}

The pressure Poisson solve uses the same Jacobi shader as diffusion — parameter α = −Δx², β = 1/4. 20–50 iterations are typically enough for smooth flow; a Gauss-Seidel or multigrid solver would converge faster.

6. WebGL implementation — ping-pong textures

A WebGL fluid solver maintains several floating-point render targets (FBO pairs for ping-pong). A minimal setup requires:

// Minimal WebGL2 FBO helper
function createDoubleFBO(gl, w, h, format) {
  const make = () => {
    const tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texImage2D(gl.TEXTURE_2D, 0, format.internal,
                   w, h, 0, format.format, gl.FLOAT, null);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    const fbo = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
    return { tex, fbo };
  };
  let read = make(), write = make();
  return {
    get read()  { return read;  },
    get write() { return write; },
    swap() { [read, write] = [write, read]; }
  };
}

// Per-frame update loop
function update(dt) {
  addForces(velocity, mouseForce);          // step 1
  for (let i = 0; i < 20; i++)
    jacobi(velocity, velocity, alpha, beta); // step 2: diffuse
  advect(velocity, velocity, dt);           // step 3: advect vel
  computeDivergence(velocity, divergence);  // step 4a
  pressure.read.clearToZero();               // step 4b: reset p
  for (let i = 0; i < 40; i++)
    jacobi(pressure, divergence, -dx*dx, 0.25); // 4c: pressure solve
  subtractGradient(velocity, pressure);     // step 4d: project
  advect(dye, velocity, dt);                // advect dye
  renderDye(dye);
}
WebGL2 requirement: floating-point texture rendering requires either EXT_color_buffer_float (WebGL 1) or WebGL 2 with the appropriate internal format (gl.RG32F or gl.R32F). Check gl.getExtension('EXT_color_buffer_float') and gracefully fall back to half-float textures (gl.RGBA16F) for mobile support.

7. Dye advection and visualisation

The velocity field itself is invisible. Two popular visualisation techniques:

// Curl shader — vorticity confinement colour pass
void main() {
  vec2 uv = gl_FragCoord.xy * uTexelSize;
  float vL = texture(uVelocity, uv - vec2(uTexelSize.x, 0)).y;
  float vR = texture(uVelocity, uv + vec2(uTexelSize.x, 0)).y;
  float uB = texture(uVelocity, uv - vec2(0, uTexelSize.y)).x;
  float uT = texture(uVelocity, uv + vec2(0, uTexelSize.y)).x;
  float curl = 0.5 * ((vR - vL) - (uT - uB));  // ∂v/∂x − ∂u/∂y
  gl_FragColor = vec4(curl, 0.0, 0.0, 1.0);
}

// Vorticity confinement force — re-energises vortices to counter dissipation
void main() {
  vec2  uv  = gl_FragCoord.xy * uTexelSize;
  float wL  = abs(texture(uCurl, uv - vec2(uTexelSize.x, 0)).r);
  float wR  = abs(texture(uCurl, uv + vec2(uTexelSize.x, 0)).r);
  float wB  = abs(texture(uCurl, uv - vec2(0, uTexelSize.y)).r);
  float wT  = abs(texture(uCurl, uv + vec2(0, uTexelSize.y)).r);
  float w   = texture(uCurl, uv).r;
  vec2  eta = normalize(vec2(wT - wB, wR - wL) + 1e-5);
  vec2  force = eta * w * uConfinement * uTexelSize;
  vec2  vel = texture(uVelocity, uv).xy + force * uDt;
  gl_FragColor = vec4(vel, 0, 1);
}

8. Extensions and further reading

💧 SPH Fluid Simulation

The particle-based approach to fluid dynamics — no pressure Poisson solve required.

Open SPH Fluid →