Lattice-Boltzmann Method in About 200 Lines of JavaScript
You don't need a Navier-Stokes solver, a Poisson pressure equation, or a mesh generator to simulate real fluid flow. The Lattice-Boltzmann Method gets you flow around a cylinder — with a proper Kármán vortex street — from a handful of local, embarrassingly parallel array operations.
1. The D2Q9 Lattice
"D2Q9" means 2 spatial dimensions, 9 discrete velocities. A particle
population can either sit still, or move to one of its 8 neighbours
(4 axis-aligned + 4 diagonal) each timestep. Every direction has a
weight w[i] used in the equilibrium formula:
// D2Q9 velocity set: index 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, for bounce-back
2. Data Layout
Nine flat Float64Arrays (one per direction) hold the whole
grid's distributions. A boolean mask marks obstacle cells — here, a
circle placed a third of the way into the domain:
const NX = 200, NY = 80;
const N = NX * NY;
const f = Array.from({length: 9}, () => new Float64Array(N));
const feq = Array.from({length: 9}, () => new Float64Array(N));
const rho = new Float64Array(N).fill(1);
const ux = new Float64Array(N);
const uy = new Float64Array(N);
const obstacle = new Uint8Array(N);
const cx = NX / 5, cy = NY / 2, R = NY / 9;
for (let y = 0; y < NY; y++)
for (let x = 0; x < NX; x++)
if ((x - cx)**2 + (y - cy)**2 < R*R)
obstacle[y * NX + x] = 1;
3. Macroscopic Variables
Density and velocity fall directly out of the distributions as the zeroth and first velocity moments — no extra equation to solve:
function computeMacroscopic() {
for (let n = 0; n < N; n++) {
let r = 0, vx = 0, vy = 0;
for (let i = 0; i < 9; i++) {
const fi = f[i][n];
r += fi; vx += fi * ex[i]; vy += fi * ey[i];
}
rho[n] = r;
ux[n] = vx / r;
uy[n] = vy / r;
}
}
4. Equilibrium Distribution
The BGK equilibrium is a second-order truncated expansion of the Maxwell-Boltzmann distribution in local velocity u:
function computeEquilibrium() {
for (let n = 0; n < N; n++) {
const r = rho[n], vx = ux[n], vy = uy[n];
const usq = vx * vx + vy * vy;
for (let i = 0; i < 9; i++) {
const eu = ex[i] * vx + ey[i] * vy;
feq[i][n] = w[i] * r * (1 + 3 * eu + 4.5 * eu * eu - 1.5 * usq);
}
}
}
5. Collision (BGK Relaxation)
Every distribution relaxes toward its equilibrium at rate
1/τ. This single subtraction is where viscosity lives —
larger τ means slower relaxation and a more viscous fluid:
const tau = 0.6; // τ > 0.5 required for stability; ν = (τ-0.5)/3
function collide() {
for (let i = 0; i < 9; i++)
for (let n = 0; n < N; n++)
if (!obstacle[n])
f[i][n] -= (f[i][n] - feq[i][n]) / tau;
}
6. Streaming
Streaming shifts every distribution one lattice step along its own direction — a pure array-copy with wraparound skipped at the domain edges (handled separately by boundary conditions):
function stream() {
for (let i = 0; i < 9; i++) {
const src = f[i], dst = new Float64Array(N);
for (let y = 0; y < NY; y++)
for (let x = 0; x < NX; x++) {
const xs = x - ex[i], ys = y - ey[i]; // pull from upstream neighbour
if (xs < 0 || xs >= NX || ys < 0 || ys >= NY) continue;
dst[y * NX + x] = src[ys * NX + xs];
}
f[i] = dst;
}
}
7. Boundary Conditions
Three different rules close the domain: a fixed-velocity inlet (simplified Zou-He), a zero-gradient outlet, and bounce-back for solid walls and the cylinder — by far the simplest no-slip condition in LBM, just reverse the incoming populations in place:
function applyBoundaries() {
// Bounce-back: obstacle nodes reflect every incoming direction
for (let n = 0; n < N; n++) {
if (!obstacle[n]) continue;
const tmp = new Float64Array(9);
for (let i = 0; i < 9; i++) tmp[i] = f[i][n];
for (let i = 0; i < 9; i++) f[i][n] = tmp[opp[i]];
}
// Left edge (inlet): force a uniform horizontal velocity u0
const u0 = 0.08; // keep well below c_s/√3 ≈ 0.577 for stability
for (let y = 0; y < NY; y++) {
const n = y * NX;
ux[n] = u0; uy[n] = 0;
let s = 0;
for (let i = 0; i < 9; i++) if (ex[i] <= 0) s += f[i][n];
rho[n] = s / (1 - u0);
for (let i = 0; i < 9; i++) {
const eu = ex[i] * u0;
f[i][n] = w[i] * rho[n] * (1 + 3 * eu + 4.5 * eu * eu - 1.5 * u0 * u0);
}
}
// Right edge (outlet): zero-gradient — copy the column just upstream
for (let y = 0; y < NY; y++) {
const n = y * NX + (NX - 1), nPrev = n - 1;
for (let i = 0; i < 9; i++) f[i][n] = f[i][nPrev];
}
// Top / bottom: bounce-back (solid channel walls)
for (let x = 0; x < NX; x++) {
for (const y of [0, NY - 1]) {
const n = y * NX + x;
const tmp = new Float64Array(9);
for (let i = 0; i < 9; i++) tmp[i] = f[i][n];
for (let i = 0; i < 9; i++) f[i][n] = tmp[opp[i]];
}
}
}
8. Rendering Vorticity
Raw velocity is hard to read visually; vorticity (curl of velocity) makes the alternating Kármán vortices pop as red/blue bands. A simple central-difference curl and a diverging red-white-blue colour map do the job:
function renderVorticity(ctx, img) {
const data = img.data;
for (let y = 1; y < NY - 1; y++)
for (let x = 1; x < NX - 1; x++) {
const n = y * NX + x;
const dvdx = uy[n + 1] - uy[n - 1];
const dudy = ux[n + NX] - ux[n - NX];
const curl = (dvdx - dudy) * 40; // scale for visibility
const p = (y * NX + x) * 4;
if (obstacle[n]) { data[p] = data[p+1] = data[p+2] = 40; }
else if (curl > 0) { data[p] = clamp(curl * 255); data[p+1] = 20; data[p+2] = 20; }
else { data[p] = 20; data[p+1] = 20; data[p+2] = clamp(-curl * 255); }
data[p+3] = 255;
}
ctx.putImageData(img, 0, 0);
}
function clamp(v) { return Math.max(0, Math.min(255, v)); }
9. Wiring It Together — the Main Loop
Each animation frame runs the four steps in order — macroscopic, collide, stream, boundaries — then renders. That's the entire LBM algorithm, under 200 lines including setup and rendering:
function step() {
computeMacroscopic();
computeEquilibrium();
collide();
stream();
applyBoundaries();
}
const canvas = document.getElementById('lbm');
canvas.width = NX; canvas.height = NY;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(NX, NY);
// Initialize at rest with a small rightward velocity everywhere
computeEquilibriumAt(0.05);
function loop() {
for (let s = 0; s < 4; s++) step(); // a few LBM steps per rendered frame
renderVorticity(ctx, img);
requestAnimationFrame(loop);
}
loop();
Frequently Asked Questions
What will I learn in this tutorial?
Build a working D2Q9 Lattice-Boltzmann CFD solver from scratch in about 200 lines of JavaScript: streaming, BGK collision, bounce-back walls, and a vorticity-colored canvas render of flow around a cylinder.
What topics are covered in this tutorial?
This tutorial covers: The D2Q9 Lattice, Data Layout, Macroscopic Variables, Equilibrium Distribution, Collision, Streaming, Boundary Conditions, Rendering Vorticity.
How long does this tutorial take?
This tutorial takes approximately 30 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate – Advanced-level tutorial — no special preparation beyond basic JavaScript is assumed.