Marvyn Bailly

PhD Candidate in Applied Mathematics & Scientific Computation

Understanding Real-Time Fluid Simulation

A Deep Dive into GPU-Accelerated Navier-Stokes Solvers

Fluid dynamics powers everything from weather prediction to visual effects in movies. This article explores how we can simulate realistic fluid motion in real-time, right in your web browser, using the Navier-Stokes equations and GPU acceleration.

What is Fluid Simulation?

Fluid simulation is the computational modeling of how liquids and gases move. From the swirling patterns in your coffee to the formation of hurricanes, fluids follow mathematical laws that we can simulate using computers.

The challenge? Fluids are continuous - they have properties at every point in space. Computers are discrete - they can only work with finite numbers. Our task is to approximate continuous fluid behavior using discrete computations.

Operator Splitting: Divide and Conquer

Solving the full Navier-Stokes equations directly is computationally expensive. Instead, we use operator splitting to break the problem into simpler steps that we solve sequentially.

Simulation Algorithm (Each Frame)

  1. Advection: Transport velocity and dye along the flow
  2. Diffusion: Apply viscosity (optional for fast fluids)
  3. External Forces: Add user input or gravity
  4. Pressure Projection: Enforce incompressibility
  5. Vorticity Confinement: Restore small-scale turbulence

Each step solves a simpler equation that, when combined, approximates the full Navier-Stokes behavior.

Advection: Following the Flow

Advection describes how quantities (velocity, color, temperature) are transported by the fluid flow. The advection equation is:

$$\frac{\partial q}{\partial t} + \mathbf{u} \cdot \nabla q = 0$$

Where \(q\) is any quantity we're transporting (like dye color or velocity itself).

Semi-Lagrangian Advection

We use the Semi-Lagrangian method, which is unconditionally stable. The idea:

  1. For each grid point, trace backwards along the velocity field
  2. Find where this particle came from at the previous timestep
  3. Sample the quantity at that location (with interpolation)

GLSL Advection Shader

// Backtrace particle position
vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;

// Sample quantity at previous position
vec4 result = texture2D(uSource, coord);

// Apply dissipation
float decay = 1.0 + dissipation * dt;
gl_FragColor = result / decay;
Why Backward? Forward tracing can leave gaps in the grid. Backward tracing guarantees we fill every grid point, ensuring stability.

Pressure Projection: Enforcing Physics

After advection and forces, our velocity field likely has divergence (\(\nabla \cdot \mathbf{u} \neq 0\)), violating incompressibility. Pressure projection fixes this.

The Method

We use the Helmholtz-Hodge decomposition, which says any vector field can be split into divergence-free and curl-free components. We want the divergence-free part.

Pressure Projection Steps

  1. Compute Divergence: \(\text{div} = \nabla \cdot \mathbf{u}\)
  2. Solve Poisson Equation: \(\nabla^2 p = \text{div}\)
  3. Subtract Gradient: \(\mathbf{u}_{\text{new}} = \mathbf{u} - \nabla p\)

Jacobi Iteration

The Poisson equation is solved iteratively using the Jacobi method:

$$p_{i,j}^{k+1} = \frac{p_{i+1,j}^k + p_{i-1,j}^k + p_{i,j+1}^k + p_{i,j-1}^k - \text{div}_{i,j}}{4}$$

GLSL Pressure Solver

float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
float divergence = texture2D(uDivergence, vUv).x;

// One Jacobi iteration
float pressure = (L + R + B + T - divergence) * 0.25;
gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0);

We perform 20-40 iterations per frame, progressively reducing divergence. More iterations = more accurate.

GPU Implementation: Massive Parallelism

Modern GPUs can perform thousands of computations simultaneously. We exploit this by representing our simulation grid as textures and computations as fragment shaders.

Why GPU?

Ping-Pong Rendering

Shaders can't read and write to the same texture. We use ping-pong buffering: two textures, alternating between read and write.

DoubleFBO Pattern

// Read from one FBO, write to another
shader.bind();
gl.uniform1i(uniforms.uSource, velocity.read.attach(0));
fboManager.blit(velocity.write);

// Swap for next frame
velocity.swap(); // read ↔ write

Resolution Strategy

We run physics at low resolution (128×128) and visuals at high resolution (1024×1024). This decoupling gives us both performance and quality.

Visual Effects: Making It Beautiful

Raw simulation output looks flat. We add post-processing to enhance aesthetics without affecting physics.

Bloom Effect

Bloom creates a glow around bright areas, mimicking how cameras and human eyes see intense light.

  1. Prefilter: Extract pixels above brightness threshold
  2. Blur: Apply Gaussian blur at multiple resolutions
  3. Composite: Add blurred glow to original image

Sunrays (God Rays)

Radial blur creates light streaks from bright sources, like sunlight through clouds.

// Sample along ray from center
for (int i = 0; i < 16; i++) {
    coord -= dir * density;
    color += texture2D(uTexture, coord).a * illuminationDecay;
    illuminationDecay *= decay;
}

Shading

We compute surface normals from the dye density gradient, then apply simple lighting for depth perception.

Obstacles: Solid Boundary Conditions

Real fluids don't just flow freely - they interact with solid surfaces. Our simulation includes an obstacle (the "M" shape you see) that demonstrates how fluids behave around boundaries.

The Challenge

Solids impose two key physical constraints:

Mathematically, at a solid boundary:

$$\mathbf{u}|_{\text{wall}} = 0$$

Representing Obstacles

We use a binary obstacle mask texture - each pixel stores whether that location is solid (1) or fluid (0). This mask is sampled in every shader to modify behavior near boundaries.

Obstacle Mask Creation

// Create obstacle texture from rectangles
const mask = new Uint8Array(width * height);

for (const obstacle of obstacles) {
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            if (isInsideRectangle(x, y, obstacle)) {
                mask[y * width + x] = 255;  // Mark as obstacle
            }
        }
    }
}

Boundary Conditions in Each Solver Step

1. Advection

When back-tracing particle paths, we check if the traced position lands inside an obstacle. If so, we clamp it to the boundary - particles can't advect through walls.

vec2 tracedPos = vUv - dt * velocity * texelSize;

// Check if traced position hits obstacle
if (texture2D(uObstacles, tracedPos).r > 0.5) {
    tracedPos = vUv;  // Stay at current position
}

2. Divergence Computation

The most subtle part. When computing \(\nabla \cdot \mathbf{u}\), we sample neighboring velocities. If a neighbor is an obstacle, we apply the no-slip reflection:

$$u_{\text{obstacle}} = -u_{\text{current}}$$

This reflection ensures the average velocity at the boundary is zero.

GLSL Divergence with Obstacles

float sampleVelocity(vec2 coords, float currentComponent) {
    // Domain boundaries
    if (coords.x < 0.0 || coords.x > 1.0 || 
        coords.y < 0.0 || coords.y > 1.0) {
        return -currentComponent;  // Reflect at domain edge
    }
    
    // Check for obstacle
    if (texture2D(uObstacles, coords).r > 0.5) {
        return -currentComponent;  // No-slip: reflect
    }
    
    // Normal fluid cell
    return texture2D(uVelocity, coords);
}

3. Pressure Solve

Obstacles use Neumann boundary conditions: \(\frac{\partial p}{\partial n} = 0\). The pressure gradient normal to the wall is zero.

In practice, when sampling pressure neighbors during Jacobi iteration, if a neighbor is an obstacle, we use the current cell's pressure (no gradient).

float samplePressure(vec2 coords) {
    if (texture2D(uObstacles, coords).r > 0.5) {
        // Neumann BC: return current cell pressure
        return texture2D(uPressure, vUv).r;
    }
    return texture2D(uPressure, coords).r;
}

4. Gradient Subtraction

After solving for pressure, we subtract \(\nabla p\) from velocity. In obstacle cells, we skip this and simply enforce zero velocity.

vec2 u = texture2D(uVelocity, vUv).xy;

if (texture2D(uObstacles, vUv).r > 0.5) {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);  // No velocity in obstacles
} else {
    vec2 gradient = vec2(pR - pL, pT - pB) * 0.5;
    gl_FragColor = vec4(u - gradient, 0.0, 1.0);
}
Why This Works: The combination of reflected velocities in divergence, Neumann pressure conditions, and zero velocity enforcement creates a consistent no-slip boundary. The fluid naturally flows around the obstacle, forming vortices and turbulence in its wake.

Visualizing Obstacles

The obstacle itself is rendered in the final display pass by sampling the mask texture and blending a distinct color (bright cyan) where obstacles exist.

Display Shader with Obstacles

vec3 color = texture2D(uDye, vUv).rgb;

#ifdef SHOW_OBSTACLES
    float obstacle = texture2D(uObstacles, vUv).r;
    if (obstacle > 0.5) {
        color = uObstacleColor;  // Override with obstacle color
    }
#endif

gl_FragColor = vec4(color, 1.0);

Challenges & Solutions

Numerical Stability

Problem: Large timesteps cause explosions.

Solution: Semi-Lagrangian advection is unconditionally stable. We clamp timesteps to 16ms max.

Numerical Dissipation

Problem: Interpolation and iteration smooth out details.

Solution: Vorticity confinement adds energy back to small vortices.

Browser Compatibility

Problem: Different browsers support different WebGL features.

Solution: Detect capabilities, fallback from WebGL2 to WebGL1, handle missing extensions gracefully.

Performance Optimization

Device Physics Res Dye Res FPS
Desktop (High) 128×128 1024×1024 60
Mobile (Medium) 128×128 512×512 30-60

Conclusion

Real-time fluid simulation combines classical physics, numerical methods, and modern GPU programming. By breaking the Navier-Stokes equations into manageable pieces and leveraging parallel hardware, we achieve beautiful, interactive simulations in the browser.

The techniques explored here extend beyond fluid simulation - they're fundamental to real-time physics, weather modeling, game engines, and scientific visualization.

Further Reading

Acknowledgments

This implementation is heavily inspired by Pavel Dobryakov's excellent WebGL Fluid Simulation . His work provided invaluable insights into GPU-based fluid dynamics and modern WebGL techniques.

Big thanks to Pavel for creating such an elegant and well-engineered implementation that serves as an outstanding reference for the fluid simulation community.

Further Reading