rheo · fluid lab
An interactive, real-time fluid simulation that runs entirely in the browser. Drag to stir luminous ink through a solved velocity field; every part of the physics and the look is exposed as a dial.
One file, zero dependencies — markup, styles, shaders, solver and
UI all live in index.html; there is no build step.
The physics is Jos Stam's Stable Fluids (1999), implemented from
scratch in WebGL fragment shaders following the structure popularized by
GPU Gems ch. 38, and steered by a collapsible control deck
with roughly forty dials.
01Quick start
open index.html # macOS — or just double-click the file
python3 -m http.server 8000 # optional: serve it → http://localhost:8000
Requirements: any current Chrome, Firefox, Safari or Edge with WebGL. WebGL2 is preferred; WebGL1 with half-float extensions works as a fallback. Fonts load from Google Fonts; offline, the app falls back to system fonts and is otherwise fully functional.
02Interaction
| Input | Effect |
|---|---|
| drag | Stir the fluid and inject ink along the stroke |
| shift + drag | Apply force only — push the fluid without adding ink |
| G | Record a ghost loop (press again to stop) |
| right-drag | Spin a vortex at the pointer (shift reverses); V toggles a vortex brush for touch |
| multi-touch | Each finger is an independent brush |
| space | Pause / resume the simulation |
| S | Advance exactly one step while paused |
| R | Random burst of splats |
| C | Clear ink, velocity and pressure |
| O | Toggle the wall brush (paint solid obstacles) |
| Z | Zen mode — UI vanishes, the fluid drifts on its own |
| H | Hide / show the control deck |
| esc | Drop focus from a control back to the canvas |
A status chip (top right) shows FPS, simulation grid size, ink texture size and a pause LED.
03The control deck
All settings persist to localStorage (key
rheo-fluid-lab-v1) with a 200 ms debounce; the pause
state is intentionally never persisted so the app never boots frozen.
Reset settings clears storage and restores defaults.
Presets
Seven one-click moods. Touching any dial afterwards clears the active chip. Presets restore everything except resolutions and pause.
| Preset | Character |
|---|---|
| Default | Balanced rainbow ink on black |
| Sumi ink | Slow indigo ink on warm paper — calligraphic, no glow |
| Incense | Pale smoke rising through buoyancy on near-black |
| Neon | Fast-cycling hues, heavy glow, low threshold |
| Aurora | Palette mode (teal/violet), gentle wind + buoyancy drift |
| Fever dream | Maximum swirl, near-zero velocity fade, saturated cycling |
| Molasses | Viscous amber drips under gravity — high velocity fade, almost no swirl |
Simulation
| Dial | Range (default) | What it does |
|---|---|---|
| Sim resolution | 32–512 (128) | Velocity-grid size on the short axis; higher = finer vortices, more GPU work per pass |
| Ink resolution | 256–2048 (1024) | Dye texture size — purely visual sharpness, independent of the physics grid |
| Time warp | 0.1–3 (1.0) | Multiplies the integration timestep |
| Velocity fade | 0–4 (0.2) | Dissipation applied to velocity during advection — low = momentum persists, high = syrup |
| Ink fade | 0–4 (1.0) | Dissipation of the dye — how quickly trails dissolve |
| Swirl | 0–60 (30) | Vorticity-confinement strength; re-energizes the small eddies the grid would smear away |
| Pressure fade | 0–1 (0.8) | How much of last frame's pressure seeds the next solve (warm start) |
| Solver iterations | 1–80 (20) | Jacobi relaxation passes — more = stricter incompressibility, linear GPU cost |
| Paused | toggle | Freeze the solver; rendering and input still run |
Forces
| Dial | Range (default) | What it does |
|---|---|---|
| Gravity | −300–300 (0) | Constant downward (negative = upward) body force |
| Wind | −300–300 (0) | Constant horizontal body force |
| Buoyancy | 0–150 (0) | Upward force proportional to local ink density — dye rises like smoke |
The force pass is skipped entirely when all three are zero.
Brush
| Dial | Range (default) | What it does |
|---|---|---|
| Radius | 0.02–1 (0.25) | Gaussian splat radius (aspect-corrected) |
| Force | 1000–20000 (6000) | How strongly a stroke's motion is injected into the velocity field |
| Color mode | rainbow | Rainbow cycle · Random per stroke · Palette · Fixed color |
| Cycle speed | 0–100 (10) | Hue-advance rate for rainbow mode |
| Palette | Aurora | Curated swatch set used by Palette mode |
| Fixed color | #2ea8ff | The single color used by Fixed mode |
| Symmetry | off | Mirror (2-way) reflects every splat across the vertical axis; Kaleido (4-way) across both |
Each stroke in Palette mode picks a random swatch from the active set:
| Palette | Swatches |
|---|---|
| Aurora | #15e0a1 #27c4f5 #7b5cff #aef5dd |
| Ember | #ff4d00 #ff9a1f #ffd24d #b3150a |
| Glacier | #9fd8ff #3f8efc #dff6ff #1b4fbf |
| Orchid | #ff5fa2 #ff9966 #ffd1dc #c33aff |
| Verdant | #2bd95f #a8f54d #0f8f5e #e7ffb3 |
Auto bursts — periodic random splat showers: Enabled (off), Interval 0.1–5 s (1.0), Burst size 1–25 (6). Bursts respect the symmetry setting and use varied hues unless the color mode is Palette or Fixed.
Display
| Control | What it shows |
|---|---|
| Field: Ink | The dye, with optional pseudo-3D shading and glow |
| Field: Velocity | Direction → hue, speed → brightness |
| Field: Pressure | Diverging map — warm = positive, cool = negative |
| Field: Vorticity | Green = counter-clockwise, violet = clockwise rotation |
| Field gain | 0.1–10 (1.0) — brightness multiplier for the three diagnostic views |
| Shading | Normal-from-density-gradient diffuse term: silky, embossed depth |
| Background | Canvas clear color (#050403) |
Glow & rays — a bloom pipeline applied to the ink view only: Glow (on),
Intensity 0.1–3 (0.8), Threshold 0–1 (0.6) with a Soft knee 0–1 (0.7)
quadratic roll-off below it; plus Sunrays — volumetric light shafts
radially blurred from an ink-occlusion mask, modulating ink and glow.
Actions — Burst R · Clear C · Step
S · Save PNG (downloads the frame as rheo.png) ·
Reset settings.
04Play & discovery
| Feature | What it does |
|---|---|
| Flow of the day | A complete mood — palette, swirl, fades, symmetry, forces, glow, opening choreography — derived deterministically from today's date. Random flow rolls a named seed (e.g. ember-whorl-23); Share copies a ?seed= link that reproduces the flow exactly anywhere. |
| Walls | The wall brush (O) paints solid obstacles into the domain; fluid parts around them, ink cannot enter, and a faint rim light makes them read as warm stone. Erase selectively or clear all; walls survive resizes. |
| Ghosts | A loop pedal for strokes: G records up to 20 s of drawing at splat granularity (position, force, dynamics radius, inkless flag); the loop replays forever with a fresh palette color each pass. Up to 3 ghosts with play/pause/delete, persisted across reloads, never counted as your strokes. |
| Gallery | Unusually energetic moments are captured automatically (activity-scored, max one per 20 s) into nine persisted thumbnails; clicking one restores the settings that produced it. Snapshot now pins manually. |
| Stardust | Up to ~37k glowing tracer particles advected by the velocity field (positions in a float ping-pong texture, fragment-pass update with lifespan/respawn/wall avoidance, additive point rendering colored by local ink). Count, size, brightness dials; WebGL2 only. Stylus pressure also scales the brush. |
| Lab Notebook | An 18-experiment chain presented one goal at a time in a small widget with a live progress bar, measured against real simulation state (GPU readback of peak flow speed and ink coverage on WebGL2; activity proxies on WebGL1). A relaxed 30–40 minute arc that tours every feature; progress persists; two presets are gated on it (Singularity at 8, Glasswing at 16). |
| Unlockables | Eight presets ship locked (🔒 ???, hint on hover/tap) and unlock by playing — the first within a minute: Undertow 15 strokes · Kaleidos 20 strokes in 4-way symmetry · Riptide build with the wall brush · Maelstrom 60 strokes · Tidepool 3 min total stirring · Eclipse Swirl 60 + force ≥ 15000 · Singularity 8 experiments · Glasswing 16 experiments. The stats chip counts discoveries (✦ n/8). |
| Surprises & idle drift | The first ambient event (comet, ring bloom, gravity surge) fires within the first minute or two, then every 2–4 minutes. Idle drift keeps the canvas breathing with gentle blooms when you stop stirring — the screen never sits dead black. Rotating tips (max 3/session) surface untried features; returning on a new day greets you with your streak. |
| Sound | WebAudio layer (on by default, starts at the first interaction; one checkbox disables): an activity-following drone plus a soft pentatonic pluck per stroke, stereo-panned to where it lands. |
| Zen mode | Z hides all UI and lets the lab drift with a slow breathing wind and occasional blooms. |
| Rheometer | WebGL2-only live measurements read back from the GPU at 2 Hz: mean/peak speed, mean kinetic energy, RMS vorticity. |
| Recording | Record ⏺ captures WebM video of the canvas via MediaRecorder; Session report downloads a standalone HTML report with stats, settings and gallery shots. |
| Onboarding | A four-step quiet first-run tour driven by what you actually do; never shown again. |
Useful modes
| Mode | What it does |
|---|---|
| Breathe | Guided calm sessions (Box / 4·7·8 / Coherent, 2–10 min): the fluid converges on the inhale and releases on the exhale under a quiet phase overlay with timer and breath counter. esc ends. URL: ?breathe=1. |
| Ambient / OBS | URL API for zero-click displays: ?preset= (any preset), ?ui=0 (kiosk), ?zen=1, ?sound=0|1, ?fps=30, composable with ?seed=. URL sessions never overwrite saved settings. |
| Music | Opt-in microphone FFT: bass beats fire palette bursts scaled by intensity, with a sensitivity dial. Audio never leaves the device; explicit start/stop. |
| Benchmark | Standardized 20 s sweep (4 phases, deterministic stir, all effects on) → resolution-weighted score, per-phase fps, clipboard share line, best score remembered. URL: ?bench=1. |
05How the solver works
The fluid is modelled by the incompressible Navier–Stokes equations on a 2D grid. Velocity and dye live in floating-point textures; every stage of the solve is one fragment-shader pass drawing a fullscreen quad into a framebuffer ("ping-pong" pairs swap read/write each pass). Per frame, in order:
- Splat — pointer strokes (and bursts) add Gaussian blobs of momentum into the velocity texture and color into the dye texture.
- Body forces (skipped when zero) — gravity, wind, and buoyancy (ink density × upward force) are added to velocity.
- Curl — vorticity ω = ∂v/∂x − ∂u/∂y is measured into its own texture.
- Vorticity confinement — a force perpendicular to the vorticity gradient, scaled by Swirl, is added back, keeping small eddies alive against numerical smearing.
- Divergence of the velocity field is computed, with no-slip walls: boundary samples reflect the centre velocity so fluid cannot flow out of the domain.
- Pressure solve — last frame's pressure (scaled by Pressure fade as a warm start) is relaxed with N Jacobi iterations of the Poisson equation ∇²p = ∇·u.
- Projection — the pressure gradient is subtracted from velocity, making it (approximately) divergence-free, i.e. incompressible.
- Advection — velocity transports itself, then the dye, by tracing each texel backwards along the flow (semi-Lagrangian: unconditionally stable, hence "Stable Fluids"). Dissipation here implements the two fade dials as
value / (1 + fade·dt).
Rendering then composes the result: an optional glow pass (bright-pass prefilter with soft knee → 8-level downsample blur → additive upsample → intensity), the shading term, an ordered dither to defeat banding on dark gradients, and premultiplied-alpha blending over the background color.
Textures & formats
| Buffer | Format | Resolution | Filtering |
|---|---|---|---|
| Velocity (ping-pong) | RG16F | sim grid | linear |
| Dye (ping-pong) | RGBA16F | ink grid | linear |
| Pressure (ping-pong) | R16F | sim grid | nearest |
| Divergence, Curl | R16F | sim grid | nearest |
| Glow + 8 mip levels | RGBA16F | 256 base | linear |
#define when linear filtering of float textures isn't
supported. If no float render target exists at all, a clear error screen
is shown instead of a silent black page.
Robustness details
| Concern | Handling |
|---|---|
| Live window resize | Framebuffers reallocate only 250 ms after the size stops changing |
| GPU context loss | Loop halts; an overlay offers reload |
| Boot failures | Shader-compile errors surface in an error overlay, never a silent black page |
| Pointer edge cases | Pointer capture; missed pointerup detected via buttons === 0; the final flick impulse survives one extra frame |
| PNG capture | Runs in the same frame as the render — the drawing buffer is not preserved |
| Keyboard safety | Shortcuts ignore Cmd/Ctrl/Alt combos and never fire while a control has focus |
06Architecture
index.html
├── <style> deck, chips, custom sliders, stats, hint, error overlay
├── <body> canvas + deck skeleton (sections are built in JS)
└── <script>
├── Config + persistence DEFAULTS, localStorage load/save
├── GL bootstrap WebGL2→1 fallback, format probing
├── Shaders 14 fragment programs + shared quad VS
├── Render targets FBO/ping-pong helpers, resize logic
├── Solver step forces→curl→vorticity→div→jacobi→project→advect
├── Rendering display modes, glow pipeline
├── Splats / colors symmetry, bursts, palettes, HSV
├── Input pointer + keyboard
├── Control deck declarative slider/select/chip builders
├── Presets 7 mood presets
└── Main loop dt clamping, auto bursts, stats, capture
07Performance
The dominant costs are the Jacobi iterations (N passes over the sim grid) and dye advection (one pass over the ink grid). Halving Sim resolution roughly quarters solver cost; Ink resolution only affects visual passes. Verified at 77 fps in software rendering (SwiftShader, 1440×900 headless Chromium); a real GPU runs the defaults far beyond display refresh. The timestep is clamped to 1/60 s · Time warp and frame gaps to 50 ms, so backgrounded tabs don't explode the integration on return.
08Provenance
The technique is Jos Stam's Stable Fluids (SIGGRAPH 1999) as adapted for GPUs in GPU Gems ch. 38; the interaction pattern (splat-driven dye + velocity) follows the well-known WebGL fluid-simulation lineage (e.g. Pavel Dobryakov's MIT implementation). This codebase is an independent, from-scratch implementation with its own UI ("the deck"), preset system, curated palettes, symmetry brush and field visualizers.