Physics · CFD · Kinetic Theory
📅 March 2026 ⏱ ≈ 11 min read 🎯 Intermediate–Advanced · Last updated: 22 June 2026

Lattice Boltzmann Method — fluid dynamics from kinetic theory

Most CFD (computational fluid dynamics) codes solve the Navier-Stokes equations — the governing equations of fluid motion — directly. The Lattice Boltzmann Method (LBM) takes a different route: it simulates populations of fictitious particles streaming on a regular lattice, and recovers macroscopic fluid behaviour as an emergent consequence of collision rules. The result is a naturally parallelisable algorithm that runs in real time in the browser.

TL;DR: Instead of solving fluid-flow equations directly, the Lattice Boltzmann Method tracks particle populations hopping between lattice cells and colliding locally (the BGK rule). Summing those populations at each node recovers density and velocity, reproducing Navier-Stokes flow while staying easy to parallelise and simulate live in a browser.

1. Kinetic theory — the Boltzmann equation

Classical fluid dynamics (Navier-Stokes) works with macroscopic fields: density ρ, velocity u and pressure p. Kinetic theory takes a mesoscopic view: the fluid is described by a distribution function f(x, ξ, t) — the density of particles at position x with microscopic velocity ξ at time t.

The continuous Boltzmann transport equation governs the evolution of f:

∂f/∂t + ξ · ∇f = Ω(f)

Left side: streaming (free propagation of particles)
Right side: Ω — collision operator (restores f toward equilibrium)

The key insight of LBM is to discretise both space and velocity. Space becomes a regular lattice; the continuous velocity space collapses to a small finite set of allowed velocities ei. The collision term is approximated by the simple BGK model. Despite these drastic simplifications, the Chapman-Enskog expansion shows that the resulting equations reproduce the incompressible Navier-Stokes equations to second order in the Mach number.

2. D2Q9 lattice — velocity set and weights

The name "D2Q9" encodes the dimensionality and velocity count: 2 dimensions, 9 velocity directions. Every node on the lattice propagates density along 9 directions (including rest).

f61/36
f21/9
f51/36
f31/9
·f04/9
f11/9
f71/36
f41/9
f81/36
Velocity vectors e_i (in lattice units):
e_0 = (0,0) rest
e_1 = (1,0) e_2 = (0,1) e_3 = (-1,0) e_4 = (0,-1) axis-aligned
e_5 = (1,1) e_6 = (-1,1) e_7 = (-1,-1) e_8 = (1,-1) diagonal

Weights w_i:
w_0 = 4/9 rest
w_1..4 = 1/9 axis-aligned
w_5..8 = 1/36 diagonal

Speed of sound: c_s² = 1/3 (in lattice units)

The weights w_i are chosen so that the lattice correctly recovers the first and second moments (mass and momentum) of the equilibrium distribution. The constraint Σ w_i = 1 ensures mass conservation.

3. BGK collision operator

The Bhatnagar-Gross-Krook (BGK) approximation replaces the true collision integral with a relaxation toward local equilibrium. Each distribution function fi relaxes at rate 1/τ:

Collision step:
f_i*(x, t) = f_i(x, t) − (1/τ) · [f_i(x, t) − f_i^eq(x, t)]

Equilibrium distribution:
f_i^eq = w_i · ρ · [1 + (e_i · u)/c_s² + (e_i · u)²/(2 c_s⁴) − u²/(2 c_s²)]

where c_s² = 1/3, so:
f_i^eq = w_i · ρ · [1 + 3(e_i · u) + 9(e_i · u)²/2 − 3u²/2]

Kinematic viscosity: ν = c_s² (τ − 1/2) = (τ − 0.5) / 3

The relaxation time τ controls viscosity: τ = 1 gives ν = 1/6 (moderately viscous). Stability requires τ > 0.5. As τ → 0.5, viscosity → 0 and the simulation becomes unstable for high-velocity flows.

Stability constraint: τ > 0.5 is necessary but not sufficient. The Mach number Ma = |u| / cs must also be small (Ma < 0.3) to keep the low-Mach-number assumption valid. For turbulent flows at higher Re, use τ closer to 1.

4. Streaming step

After collision, each post-collision distribution fi* propagates to the neighbouring lattice node in direction ei:

Streaming step:
f_i(x + e_i · Δt, t + Δt) = f_i*(x, t)

In one time step Δt = 1 (lattice units), each f_i shifts exactly one cell in its direction.
Pull scheme (common in code):
f_i(x, t+1) = f_i*(x − e_i, t) ← gather from upstream node

The full LBM update cycle per time step is therefore:

  1. Compute macroscopic ρ and u from the current f
  2. Compute equilibrium distributions feq
  3. Apply BGK collision: f* = f − (1/τ)(f − feq)
  4. Stream: shift each fi* one step in direction ei
  5. Apply boundary conditions

5. Macroscopic recovery — ρ, u from f

The macroscopic fluid quantities are moments of the distribution function. Mass density ρ is the zeroth moment; momentum ρu is the first:

ρ(x, t) = Σ_i f_i(x, t) ← sum over all 9 directions
ρu_x(x, t) = Σ_i f_i(x, t) · e_ix
ρu_y(x, t) = Σ_i f_i(x, t) · e_iy

Pressure (ideal gas in LBM units): p = ρ c_s² = ρ/3

This local computation — requiring only the 9 fi at a single node — is what makes LBM highly parallelisable. Each node is independent during the collision step; only the streaming step requires neighbouring data.

6. Boundary conditions

No-slip walls — Bounce-Back

The simplest no-slip boundary: any distribution arriving at a solid wall node is reflected back in the opposite direction — as if the particle bounced off the wall.

f_ī(x_wall, t+1) = f_i*(x_wall, t)

ī = opposite direction: opposite(0)=0, opposite(1)=3, opposite(3)=1 ...

Moving walls — Zou-He

For inflow/outflow with known velocity u or known pressure ρ, the Zou-He scheme extrapolates the unknown distributions analytically. For a left inlet with velocity u = (u_x, 0):

ρ_in = (f_0 + f_2 + f_4 + 2(f_3 + f_6 + f_7)) / (1 − u_x)
f_1 = f_3 + (2/3) ρ_in u_x
f_5 = f_7 − (1/2)(f_2 − f_4) + (1/6) ρ_in u_x
f_8 = f_6 + (1/2)(f_2 − f_4) + (1/6) ρ_in u_x

7. Full JavaScript D2Q9 implementation

// ── D2Q9 constants ────────────────────────────────────────────────
const NX = 200, NY = 100;  // grid size
const TAU = 0.6;            // relaxation time  (ν = (τ−0.5)/3 ≈ 0.033)
const OMEGA = 1 / TAU;      // relaxation rate
const N = NX * NY;

// Velocity vectors: 0=rest, 1–4 axis, 5–8 diagonal
const EX = [0,  1,  0, -1,  0,  1, -1, -1,  1];
const EY = [0,  0,  1,  0, -1,  1,  1, -1, -1];
const W  = [4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36];
const OPP = [0, 3, 4, 1, 2, 7, 8, 5, 6]; // opposite direction index

// Distribution functions: f[i * N + x + y*NX]
const f    = new Float32Array(9 * N).fill(0);
const fTmp = new Float32Array(9 * N);
const solid = new Uint8Array(N); // 1 = obstacle

function idx(x, y) { return x + y * NX; }
function fi(i, x, y) { return i * N + idx(x, y); }

// ── Initialise: uniform flow u_x = u0 ─────────────────────────────
function init(u0 = 0.1) {
  for (let y = 0; y < NY; y++) {
    for (let x = 0; x < NX; x++) {
      for (let i = 0; i < 9; i++) {
        f[fi(i, x, y)] = feq(i, 1.0, u0, 0.0);
      }
    }
  }
  // Add a circular obstacle near the left
  const cx = NX / 4, cy = NY / 2, r = NY / 8;
  for (let y = 0; y < NY; y++) {
    for (let x = 0; x < NX; x++) {
      if ((x-cx)**2 + (y-cy)**2 < r*r) solid[idx(x,y)] = 1;
    }
  }
}

// ── Equilibrium distribution ──────────────────────────────────────
function feq(i, rho, ux, uy) {
  const eu  = EX[i] * ux + EY[i] * uy;
  const u2  = ux * ux + uy * uy;
  return W[i] * rho * (1 + 3*eu + 4.5*eu*eu - 1.5*u2);
}

// ── Single LBM time step ──────────────────────────────────────────
function step(u0 = 0.1) {
  // 1. Collision + stream (pull scheme)
  for (let y = 0; y < NY; y++) {
    for (let x = 0; x < NX; x++) {
      if (solid[idx(x, y)]) continue;

      // Compute macroscopic quantities
      let rho = 0, ux = 0, uy = 0;
      for (let i = 0; i < 9; i++) {
        const fi_val = f[fi(i, x, y)];
        rho += fi_val;
        ux  += fi_val * EX[i];
        uy  += fi_val * EY[i];
      }
      ux /= rho; uy /= rho;

      // BGK collision, pull stream
      for (let i = 0; i < 9; i++) {
        // Source node for streaming
        const sx = x - EX[i], sy = y - EY[i];
        if (sx < 0 || sx >= NX || sy < 0 || sy >= NY) {
          fTmp[fi(i, x, y)] = feq(i, 1.0, u0, 0.0); // inflow
          continue;
        }
        if (solid[idx(sx, sy)]) {
          // Bounce-back from obstacle
          fTmp[fi(i, x, y)] = f[fi(OPP[i], x, y)];
          continue;
        }
        const f_src = f[fi(i, sx, sy)];
        fTmp[fi(i, x, y)] = f_src - OMEGA * (f_src - feq(i, rho, ux, uy));
      }
    }
  }
  fTmp.copyTo ? fTmp.copyTo(f) : f.set(fTmp);
}

// ── Render: colour by velocity magnitude ─────────────────────────
function render(ctx, canvas) {
  const img = ctx.createImageData(NX, NY);
  const d   = img.data;
  for (let y = 0; y < NY; y++) {
    for (let x = 0; x < NX; x++) {
      const p = idx(x, y);
      if (solid[p]) { d[p*4]   = 200; d[p*4+1] = 200; d[p*4+2] = 200; d[p*4+3] = 255; continue; }
      let ux = 0, uy = 0, rho = 0;
      for (let i = 0; i < 9; i++) {
        const v = f[fi(i, x, y)]; rho += v; ux += v*EX[i]; uy += v*EY[i];
      }
      ux /= rho; uy /= rho;
      const spd = Math.hypot(ux, uy) / 0.25; // normalise to [0,1]
      const t   = Math.min(spd, 1);
      d[p*4]   = (t * 56)  | 0;  // r
      d[p*4+1] = (t * 189) | 0;  // g (→ cyan)
      d[p*4+2] = (t * 248) | 0;  // b
      d[p*4+3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
}
Performance tip: replace the inner loops with SIMD-friendly typed arrays and offload the step function to a Worker. At 200 × 100 nodes, a single-threaded JS implementation comfortably runs at 60 FPS. At 512 × 256, a WebGL2 compute pass (using floating-point textures for ping-pong) is 30–100× faster.

8. Applications and extensions

💧 SPH Fluid Simulation

Experience real-time particle fluid dynamics — a particle-based complement to the Eulerian LBM grid approach.

Open Fluid Sim →