Fluid simulation is a desktop luxury. It shouldn't be.
CFD lives in overnight batch jobs and licensed workstations. But the qualitative behavior that makes flow legible — recirculation, shedding, the way a wake forgets a disturbance — fits comfortably in a browser tab if you spend the milliseconds where they matter.
The brief I set myself: a real Navier–Stokes solver, one HTML file, no dependencies, 60 fps on a mid-range phone — honest enough that an engineer wouldn't wince, and beautiful enough to be the front door of a portfolio.
Incompressible flow, split into solvable pieces.
The solver integrates the incompressible Navier–Stokes equations:
Following Stam's stable fluids scheme, each frame splits the operator into steps that are individually unconditionally stable — the simulation cannot blow up, only blur:
- Advection — semi-Lagrangian: each cell traces its velocity backward through time and samples where it came from. Stability for free, at the cost of numerical diffusion.
- Vorticity confinement — the diffusion tax is paid back by measuring curl and re-injecting force toward vortex centers, keeping eddies crisp at scales the grid would otherwise smear away.
- Pressure projection — a Jacobi relaxation (24 sweeps per frame) solves the Poisson equation for pressure, whose gradient is subtracted to make the field divergence-free. This is the step that makes it incompressible — and the reason a disturbance heals.
- Boundary conditions — a soft inlet on the left holds the free stream, walls close top and bottom, and a no-slip cylinder sits at x = 0.382·L: the golden section of the working section, because the composition system governs the physics too.
Nothing above is a trick: the Kármán street you see is not animated. It is an instability of the solved velocity field — put a bluff body in a steady stream and symmetry breaks on its own, at a shedding frequency the solver chooses, not me.
Where the milliseconds actually go.
The state lives in ping-ponged floating-point textures — velocity, pressure, divergence, curl, and dye — and every solver step is one fragment-shader pass. Velocity resolves on a coarse grid; the dye (the visible gold) advects through it at five times the resolution, which is why the streaklines stay silky while the physics stays cheap.
| Velocity grid | 200 × H (128 on phones) |
| Dye resolution | 1024 (448 on phones) |
| Pressure sweeps | 24 Jacobi / frame, governor-shed to 12 |
| Frame budget | 16.6 ms, self-measured |
| Texture format | RGBA16F, verified renderable at boot |
| Warm start | 160 pre-steps behind the reveal |
What broke, and what fixed it: a family of Android GPUs renders half-float textures but refuses to filter them — the advection shader carries a manual-bilinear fallback selected at boot. The pressure field is warm-started at 0.8× the previous frame instead of cleared, halving the sweeps needed for the same divergence. And the wake is fully developed before the page reveals: 160 solver steps run behind the loading state so the first thing you see is already interesting.
On touch devices, a gesture lock resolves the classic conflict: a horizontal drag stirs the flow and is captured; a vertical drag scrolls and is not. The page never fights your thumb.
Constraints are a style.
Everything distinctive about this piece came from refusing an easier version of it. No library meant understanding every pass. One file meant every byte arguing for its place. A phone budget meant building the quality governor — which then made the desktop version unshakeable. The golden-section cylinder placement started as a composition rule and ended as the thing people ask about.
The same architecture — split operators, ping-pong state, honest fallbacks — now underpins the beam simulation on the front page and the geodesic renderer in the observatory. One discipline, three instruments.