Step 1 · Equations
Incompressible flow on a grid
The pair of ideas incompressible grid solvers use: a divergence constraint (no piling up of fluid) and a momentum balance. The rest of the tutorial is how that pair becomes cells, cell edges, pressure, and projection in code — the equations come first.
Incompressible model
Incompressible smoke on a grid uses two linked ideas. Incompressibility: fluid does not accumulate in a cell. Momentum: how velocity changes.
One constraint: \(\nabla\cdot\mathbf u=0\). The panel below is the full meaning — keyboard focus on the expression still matches the readout.
Hover or focus \(\mathbf a\) or each right-hand term. The panel explains what that piece of the balance says; body-force and viscous terms are marked ✕ — external forces and viscous diffusion are omitted for this tutorial’s scope.
Hover a term for a short definition.
Compressible reference (not built here)
The full momentum equation adds the material derivative and the full stress tensor (shear and bulk viscosity), plus body forces. This tutorial does not implement that system. The equation below is context only.
Step 2 · One cell
Incompressibility in one cell
Now that Step 1 has fixed the meaning of \(\nabla\cdot\mathbf u=0\) in continuous form, Step 2 makes it concrete in one place: a cell with flux across its edges — the discrete “no accumulation” rule before tiling the whole domain.
To represent fluid moving through space on a grid, split the domain into cells and measure motion across each cell’s edges (in 2D, each cell is a polygon bounded by line segments — not “faces” of a 3D volume). That is the discrete picture behind \(\nabla\cdot\mathbf u=0\) from Step 1: net flux out of a region must be zero, so mass does not accumulate inside (for constant density).
The single-cell figure below makes that concrete: 1 at the center is fixed mass; the four numbers on the edges are fluxes (drag to adjust). They ease toward balance so the box stays mass-conserving — same idea as \(\nabla\cdot\mathbf u=0\), one cell at a time.
The FluidGrid snippet on the right is only allocation: resolution,
CellSize, and two arrays for velocities on edges — one for \(u\), one for \(v\). How those
arrays are sized and used on a full grid is spelled out in Step 3.
That is the discrete picture of \(\nabla \cdot \mathbf{u} = 0\).
Each value is signed flux out of the cell: positive = outflow, negative = inflow. Arrows point along that sign.
Step 3 · MAC layout
Staggered MAC grid: where \(u\), \(v\), and \(p\) live
Now that fluid motion through a single cell — flux in and out of its edges — is in place, Step 3 tiles the domain: many cells stitched together, with an explicit rule for where each velocity component and pressure sample lives.
In 2D, partition the plane into rectangular cells that meet along edges — line segments shared by two cells. Index the grid by vertical edges (between columns) and horizontal edges (between rows): store \(u\) on vertical edges and \(v\) on horizontal edges (each component normal to the edge where it lives). In each cell, divergence is built from how \(u\) varies across the cell in \(x\) and how \(v\) varies in \(y\) — the discrete \(\partial u/\partial x + \partial v/\partial y\) pattern. Pressure \(p\) sits at cell centers, where \(\nabla\cdot\mathbf u=0\) is enforced. The classic name for this layout is MAC (marker‑and‑cell); the interactive grid below uses it.
Per-cell divergence
With edge velocities filled, each cell has a discrete divergence from the four edge values around it (the code at right shows one way to combine them). Positive divergence means net outflow — more leaving than entering; negative means net inflow; near zero the cell is locally balanced (what a projection step enforces everywhere).
public float CalculateVelocityDivergenceAtCell(int cellX, int cellY) { float velocityTop = VelocitiesY[cellX, cellY + 1]; float velocityLeft = VelocitiesX[cellX, cellY]; float velocityRight = VelocitiesX[cellX + 1, cellY]; float velocityBottom = VelocitiesY[cellX, cellY]; float gradientX = (velocityRight - velocityLeft) / CellSize; // ≈ ∂u/∂x float gradientY = (velocityTop - velocityBottom) / CellSize; // ≈ ∂v/∂y float divergence = gradientX + gradientY; return divergence; }
Debug draw: cell colors
The same divergence feeds a debug pass: each cell quad is filled from the base cell color toward a positive- or negative-divergence color, then optional text prints the value at the center. Production smoke passes skip this; it is for inspecting the field.
for (int x = 0; x < fluidGrid.CellCountX; x++) for (int y = 0; y < fluidGrid.CellCountY; y++) { Color col = cellCol; float div = 0f; if (displayDivergence) { div = fluidGrid.CalculateVelocityDivergenceAtCell(x, y); float t = Mathf.Abs(div) / divergenceDisplayRange; col = Color.Lerp(cellCol, div < 0 ? negativeDivergenceCol : positiveDivergenceCol, t); } Draw.Quad(CellCentre(x, y), cellDisplaySize, col); if (displayDivergence) Draw.Text(font, $"{div:0.00}", fontSize, CellCentre(x, y), Anchor.TextCentre, Color.white); }
Many cells. Drag horizontally on a red dot (horizontal \(u\)) or vertically on a green dot (vertical \(v\)) to change that edge velocity; divergence and debug tint update live. Use (R) Randomize or the R key for new values. Only grid size and colors are below; stroke weight and arrow scaling are fixed in code to match the reference draw style.
Drag on an edge dot: red \(u\) — drag left/right; green \(v\) — drag up/down. Arrows show direction and magnitude. With Debug draw, cell fill and numbers reflect divergence. R or (R) Randomize for new field.
Step 4 · Stencil
Pressure stencil and one-step edge prediction
Step 3 fixed where \(u\), \(v\), and \(p\) live on the grid, and how each cell’s divergence \(\nabla\cdot\mathbf u\) is computed from edge velocities — a different object from \(u\) or \(v\) alone. This step connects that setup to pressure: a small stencil of neighbors, and a one-step prediction — meaning “after one timestep \(\Delta t\), where would each edge velocity go if the local acceleration from the pressure field were applied?” (That provisional value is written \(v\) below.)
Dragging arrows in Steps 2–3 showed mass balance on edges; that is a teaching picture, not a literal implementation of the momentum equation. A discrete model instead fixes attention on a central cell \(C\) with neighbors L, R, T, B, each with its own cell-centered pressure \(p_C, p_L, p_R, p_T, p_B\) and edge velocities linking \(C\) to those neighbors. Pressure differences drive acceleration; a tiny forward step predicts how each edge velocity updates.
Five-cell stencil
One-step prediction
One-step here means one timestep \(\Delta t\) — not a full path over many steps. The solver already stores a current normal velocity on each edge; call it \(u\) with a subscript for which edge (right, left, top, bottom). The prediction asks: if that edge felt a single acceleration \(a\) over this short interval (from the pressure pattern around \(C\) and its neighbors), where would the velocity land? This page writes that candidate next value as \(v\) with the same subscript — e.g. \(v_R\) — so \(u\) marks “before projection” and \(v\) marks “after this one \(\Delta t\) nudge.” The linear model is:
\(v_R\) \(=\) \(u_R\) \(+\) \(a_R\) \(\,\Delta t\)
Step 5 · Pressure solve
Building the pressure solver
Now that Step 4 named the stencil and wrote \(v = u + a\,\Delta t\) on each edge, this step is about how you build the pressure part of the solver: find a cell-centered \(p\) field (via a Poisson-type update — iteration / relaxation in code) so that, after correction, the four edges stop creating or draining mass — the Poisson side of incompressibility. What you are building is the routine that computes that \(p\) field each timestep; the velocities you keep are the corrected ones, not the raw post–advection field.
Each cell has four edge velocities. As in Steps 1–2, nothing should accumulate inside the cell — flow in should match flow out. That balance is what \(\nabla\cdot\mathbf u=0\) is shorthand for.
Solver here means the fluid code that advances the simulation each timestep — the same
staggered VelocitiesX / VelocitiesY / pressure layout as Step 3, not the Step 2
single-cell widget or the Step 3 canvas used only to explain layout. After advection and
forces, that integrated field is still generally not divergence-free:
summing the four edges of a cell usually shows a small net “too much out” or “too much in.” That is the
runtime state the pressure step corrects; it is not claiming the Step 3 diagram is
“incorrect” — that diagram is for teaching layout and divergence, not the post-physics imbalance.
The pressure projection corrects that. A pressure \(p\) is computed on the cells; edge velocities are then adjusted so the surplus or deficit at each cell goes away. In practice that means solving a Poisson-type equation (iteration / relaxation in code), not ad hoc tuning — the same rule as Steps 1–2, applied on the grid.
From prediction to pressure-corrected edges
Color key (same hue = same symbol throughout): \(v\) predicted edge velocity, \(u\) current, \(p\) pressures, \(k\) bundle \(\Delta t/(\rho w)\), \(\rho\), \(w\), \(\Delta t\).
Step 4 wrote a one-step prediction \(v_R = u_R + a_R\,\Delta t\) on the right edge. In a projection step, the acceleration from the pressure gradient across that edge is modeled as \(a_R \approx -(p_R - p_C)/(\rho w)\) (same \(\rho\), cell width \(w\), and neighbor pressures as in the stencil). Substituting gives the pressure-corrected edge velocity:
For this algebraic pass, bundle \(\Delta t/(\rho w)\) into a single scalar \(k = \dfrac{\Delta t}{\rho w}\) (mint \(k\) in the equations above). Then the bottom line is the same physics with less ink:
The same pattern holds on the other three edges (signs chosen so each term pulls fluid down the pressure hill toward \(C\)):
The goal is edge velocities \(v_R,v_L,v_T,v_B\) that are divergence-free in the discrete sense. With cell width \(w\), impose
Substitute the four relations into \(\dfrac{v_R - v_L}{w}\) and \(\dfrac{v_T - v_B}{w}\). Each bracket expands like \(u_R - k(p_R - p_C) - \bigl(u_L - k(p_C - p_L)\bigr)\); the two \(p_C\) pieces from the \(k\)-terms combine into \(-2k\,p_C\) from the left/right edge contributions (and similarly \(-2k\,p_C\) from top/bottom). Adding the horizontal and vertical parts and setting the sum to zero gives:
The last line is solved for \(p_C\). Writing \(k = \Delta t/(\rho w)\) and simplifying yields the cell-centered pressure in terms of neighbor pressures and the predicted edge velocities:
Reading left to right, \(p_C\) is the average of the four neighbor pressures, minus a term that depends on how much the edge velocities fail to cancel. That term is \(\rho\,w\) times \((u_R - u_L + u_T - u_B)\), divided by \(4\,\Delta t\) — cell width \(w\), density \(\rho\), and timestep \(\Delta t\) scale how strong that correction is. When the opposing edges balance (\(u_R \approx u_L\) and \(u_T \approx u_B\)), the second piece vanishes and \(p_C\) is just the neighbor average.
Step 6 · Pressure sweep
Solving pressure on the whole grid
Step 5 gives an explicit update for one cell’s pressure in terms of its neighbors; on the full grid, those neighbor pressures are unknowns as well. This step covers iteration — sweeping the domain until the coupled \(p\)-field settles.
Why iteration is required
Step 5 wrote an explicit formula for one cell’s pressure from its four neighbor pressures and the edge velocities. That is useful algebra — but on the full grid those neighbor pressures are not given numbers from elsewhere: they are unknowns, and every interior cell has the same kind of equation tying it to its neighbors. No single cell can be solved in isolation; the whole \(p\)-field is coupled and must be resolved together.
There is no single “plug in once” answer for that coupled system, so solvers use iteration: repeated passes over the grid, each pass updating every cell from the latest neighbor values until the field stops changing much. Gauss–Seidel is one pattern: visit cells in some order and, at each step, write a new \(p\) using the most recent stored values for neighbors (including cells already updated earlier in the same pass). That reuse is why visit order shows up in real code.
The demo below uses the same cell grid picture as Step 3 (centers only; no arrows).
New random field assigns a random pressure to every cell. Each sweep
step replaces the highlighted cell with a 5-point stencil matching
\(p=0\) outside the grid (ghost neighbors count as zero), so it is always
one quarter of the four cardinal neighbors — the same idea as
GetPressure returning 0 out of bounds in a typical pressure solve.
Solid neighbor (example: left wall)
When the cell on one side is solid, that direction is not a fluid neighbor: \(p_L\) and \(u_L\) drop out of the balance and the average runs only over the remaining directions. The denominator becomes the number of fluid neighbors (here 3 instead of 4). The velocity correction uses the same count in the \(\Delta t\) scale.
Same idea in code
One Jacobi-style cell update: each cardinal neighbor gets a flow flag (fluid = 1,
solid = 0). fluidEdgeCount is the open-side count—the same denominator as in the
diagram. pressureSum and the edge velocities are multiplied by those flags so solid sides
contribute nothing; the return divides by fluidEdgeCount so the average matches a
reduced stencil when a wall is present.
public float PressureSolveCell(int x, int y) { int flowTop = IsSolid(x, y + 1) ? 0 : 1; int flowLeft = IsSolid(x - 1, y) ? 0 : 1; int flowRight = IsSolid(x + 1, y) ? 0 : 1; int flowBottom = IsSolid(x, y - 1) ? 0 : 1; int fluidEdgeCount = flowTop + flowLeft + flowRight + flowBottom; if (IsSolid(x, y) || fluidEdgeCount == 0) return 0f; float pressureTop = GetPressure(x, y + 1); float pressureLeft = GetPressure(x - 1, y); float pressureRight = GetPressure(x + 1, y); float pressureBottom = GetPressure(x, y - 1); float pressureSum = flowTop * pressureTop + flowLeft * pressureLeft + flowRight * pressureRight + flowBottom * pressureBottom; float velocityTop = VelocitiesY[x, y + 1]; float velocityLeft = VelocitiesX[x, y]; float velocityRight = VelocitiesX[x + 1, y]; float velocityBottom = VelocitiesY[x, y]; float deltaVelocitySum = velocityRight - velocityLeft + velocityTop - velocityBottom; return (pressureSum - Density * CellSize * deltaVelocitySum / TimeStep) / fluidEdgeCount; }
Step 7 · Pressure → velocity
Projecting with pressure
Earlier steps showed how a cell pressure \(p\) can be built or relaxed when the velocity field is not yet incompressible. That work answers “what pressure fits the current mismatch?” This step answers a different question: given a pressure field on the cells, how do you change the edge velocities so the flow actually responds to that pressure? That response is what people often mean by the projection half of a pressure projection step — the velocity update that comes after the pressure has been chosen.
What this step does
Picture two things at once: a scalar \(p\) stored at each fluid cell (from your Poisson or relaxation pass), and a normal velocity component stored on each interior edge between cells (the staggered MAC layout from Step 4). This step does not re-solve for \(p\). It only reads those cell pressures and adjusts the edge velocities so that, together, they push the discrete flow toward less net inflow/outflow per cell — i.e. toward divergence-free behavior.
So: \(p\) is the input here; the outputs you are changing are the edge velocities. Pressure can still vary in space — that is normal. What should shrink (over a full pressure–projection cycle) is not “\(p\) everywhere” but the imbalance of velocity through each cell’s boundary. This paragraph is only the velocity side of that cycle.
The update rule
The concrete rule is local. Take one interior edge — a line segment between two fluid cells. Each cell has a pressure; the edge has a stored velocity across it. If pressures on the two sides differ, that difference tells you how much to add or subtract from the edge velocity: roughly speaking, you move the flow across that edge in the direction high-to-low pressure, scaled so units match your time step, grid spacing, and density. In symbols, with \(K = \rho\,\Delta x / \Delta t\), the correction subtracts \(K\) times the pressure jump across that edge (\(\Delta p\)) from the stored normal component on that edge — one subtraction per edge in the interior loops.
On a staggered grid, “which edge array am I in?” matches Step 4: VelocitiesX
holds vertical lines (left–right flow) and uses the left and right cell pressures;
VelocitiesY holds horizontal lines (bottom–top flow) and uses the bottom and top cell
pressures. GetPressure(x,y) is the usual cell-centered lookup for the scalar \(p\). The snippet
below follows that layout.
public void UpdateVelocities() { float K = (Density * CellSize) / TimeStep; // ——— Horizontal ——— for (int y = 0; y < VelocitiesX.GetLength(1); y++) for (int x = 0; x < VelocitiesX.GetLength(0); x++) { if (IsSolid(x, y) || IsSolid(x - 1, y)) { VelocitiesX[x, y] = 0; continue; } float pressureRight = GetPressure(x, y); float pressureLeft = GetPressure(x - 1, y); VelocitiesX[x, y] -= K * (pressureRight - pressureLeft); } // ——— Vertical ——— for (int y = 0; y < VelocitiesY.GetLength(1); y++) for (int x = 0; x < VelocitiesY.GetLength(0); x++) { if (IsSolid(x, y) || IsSolid(x, y - 1)) { VelocitiesY[x, y] = 0; continue; } float pressureTop = GetPressure(x, y); float pressureBottom = GetPressure(x, y - 1); VelocitiesY[x, y] -= K * (pressureTop - pressureBottom); } }
Interactive sandbox
Each frame, the demo computes divergence from the staggered edge velocities (that scalar
is not drawn), runs a small Jacobi Poisson-style step so each cell gets a
pressure \(p\) consistent with that divergence field, then applies a scaled
UpdateVelocities-style correction on interior edges only; domain
perimeter edges stay at zero normal velocity. Boundary cells still use a
reduced neighbor count in the pressure update (in-domain neighbors only), like a
fluidEdgeCount stencil — not a ring of solid cells.
Red / blue tint and the printed numbers are \(p\) after that solve — not a
divergence heatmap. The main thing to notice is cascading: a velocity change on one edge
shifts divergence in the neighborhood, but the pressure solve is globally coupled, so
every cell’s \(p\) can respond — the whole interior “feels” a local edit.
Why “New random velocities” repaints the cells: a random field is almost never divergence-free, so divergence is non-zero in many cells from the first draw. The solver then produces a non-zero \(p\) (hence color) almost everywhere. If the velocities were perfectly incompressible, \(p\) from this pass would stay near neutral — but random is the opposite of that. Divergence is what feeds the solve and should shrink over a full predictor–corrector loop; here it is not visualized. Arrows show velocities after this frame’s correction. Use \(K = (\text{Density} \cdot \text{CellSize}) / \text{TimeStep}\) — if \(K\) is inverted, the step blows up.
Hold and drag an interior edge — the Poisson + correction loop runs every frame while dragging and stops on release. New random velocities or R reshuffles in one frame. Cells show pressure \(p\) (from the solve), not divergence. Random flow is usually not incompressible, so colors change everywhere — that global \(p\) field is the coupled response. Arrows are velocities after this frame’s correction.
Step 8 · Bilinear velocity
Velocity inside a cell
Edge arrays hold staggered normal velocities \(u\) and \(v\). A full vector \(\mathbf u=(u,v)\) at a point
inside the domain is not stored — it is reconstructed by interpolating
from the surrounding samples. Use a different bilinear stencil for \(u\) (on vertical
faces) than for \(v\) (on horizontal faces): the same world position is sampled, but the four neighbors for
\(u\) are not the same four as for \(v\). Passing identical worldPos into a single generic
bilinear on both VelocitiesX and VelocitiesY without that staggered layout is a
common bug.
Visualization
Large white arrows are the stored MAC edge values. Small blue arrows are the interpolated field on a 4×4 subgrid per cell, placed in the cell interior (margins so samples stay away from the outer faces where ghosts / walls matter). Two panels sit side by side: the left panel is interpolation only — drag interior edges (same as Step 7) and the blue field updates from bilinear reconstruction alone. The right panel adds the pressure solver: click and hold anywhere to use a brush (edges are not draggable there) that stirs many edges and runs a short Poisson + projection each frame (yellow = samples inside the brush disk).
It can look like a fluid, but it is not behaving like one in the full sense: there is no advection of material and no particles (or dye, smoke, or tracers) carried through the domain — only a velocity field sampled on a grid, plus optional pressure projection. A real fluid step would also move those quantities along \(\mathbf u\); this demo stops at reconstruction and local pressure correction.
Interpolation only
Pressure solver + brush
Both panels start at zero. Reset both clears both fields. Left: drag interior white arrows. Right: hold to brush only (no edge drag) — pressure projection runs each frame while holding.