An interactive, real-time fluid simulation that runs entirely in the browser. Drag to stir luminous ink through a solvable velocity field; every part of the solver and the look is exposed as a dial.
index.html. There is no build step.Documentation is also available as a styled standalone page:
docs.html. Release history lives inCHANGELOG.md.
open index.html # macOS — or just double-click the file
# or serve it (optional):
python3 -m http.server 8000 # → http://localhost:8000
Works on phones and tablets too: the deck becomes a bottom sheet behind the "rheo" chip, touch targets grow, each finger is its own brush, double-tap exits zen mode, and Share uses the native share sheet.
Requirements: any current Chrome, Firefox, Safari or Edge with WebGL.
WebGL2 is preferred; WebGL1 with half-float texture extensions works as a
fallback. Fonts (Instrument Serif, IBM Plex Mono) are self-hosted in
fonts/ — no Google CDN, no third-party requests at all. Served over
HTTPS the app is an installable PWA that runs fully offline.
| Input | Effect |
|---|---|
| Drag | Stir the fluid and inject ink along the stroke |
| Shift-drag | Apply force only — push the fluid without adding ink |
| Right-drag | Spin a vortex at the pointer (shift reverses the direction) |
| 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) |
| V | Toggle the vortex brush (touch-friendly alternative to right-drag) |
| 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 small status chip (top right) shows FPS, simulation grid size, ink texture size, and a pause indicator.
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.
Seven one-click moods. Applying a preset highlights its chip; touching any dial afterwards clears the highlight ("custom" state). 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 |
| 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 small eddies the grid would otherwise 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 |
| 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 — makes dye rise like smoke |
The force pass is skipped entirely when all three are zero.
| 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 |
| Dynamics | 0–1 (0.35) | Stroke speed scales the splat radius — fast strokes paint wide, like a loaded brush. On a stylus, pen pressure scales it too |
| Color mode | rainbow (default) | Rainbow cycle · Random per stroke · Palette · Fixed color |
| Cycle speed | 0–100 (10) | Hue-advance rate for rainbow mode |
| Palette | Aurora (default) | Curated swatch set used by Palette mode (see below) |
| Fixed color | #2ea8ff |
The single color used by Fixed mode |
| Symmetry | off (default) | Mirror (2-way) reflects every splat across the vertical axis; Kaleido (4-way) across both axes |
Palettes — each stroke 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 |
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.
| 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 that gives ink a silky, embossed depth |
| Background | Canvas clear color (#050403) |
Up to ~37 000 glowing tracer particles ride the velocity field, making the currents themselves visible as drifting embers. Positions live in a float ping-pong texture updated by a fragment pass (lifespan, respawn, wall avoidance — particles never enter painted walls) and render as additive soft points colored by the local ink. Count (9k/16k/37k), size and brightness dials; hidden on WebGL1.
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. Sunrays (on, with a Ray weight dial) adds volumetric light shafts: a brightness-derived occlusion mask is radially blurred from the screen centre and modulates the ink and glow, giving the fluid sunlight-through-water depth.
Burst (R) · Clear (C) · Step (S) · Save PNG (rheo.png) ·
Record ⏺ (WebM video via MediaRecorder, rheo.webm) ·
Session report (standalone HTML with session stats, settings and gallery) ·
Reset settings.
Today's flow derives a complete mood — palette, swirl, fades, symmetry,
forces, glow, plus an opening splat choreography — deterministically from
today's date, so everyone sees the same flow on the same day. Random
flow rolls a named seed (e.g. ember-whorl-23); Share copies a
?seed= link that reproduces the flow exactly on any machine.
The wall brush (tool selector or O) paints solid obstacles straight into the flow domain; the fluid parts around them, ink cannot enter, and a faint rim light makes them read as warm stone. Erase selectively or clear all walls; walls survive window resizes.
Press G (or ⏺ Record loop in the deck), draw for up to 20 seconds, press G again — and your passage replays forever as a ghost, an autonomous performer that re-paints your strokes with a fresh palette color on every pass. Up to three ghosts loop simultaneously, each with play/pause and delete; they persist across reloads, never touch your progress counters, and pair beautifully with zen mode for a self-playing performance.
Unusually energetic moments are captured automatically (scored by a decaying activity measure, at most one shot per 20 s) into a nine-slot gallery of thumbnails, persisted locally. Clicking a shot restores the full settings that produced it. Snapshot now pins the current moment manually.
A small notebook widget presents one experiment at a time — an 18-step chain of escalating goals (stir, push without ink, bursts, presets, whip the flow past 60 tex/s, kaleido drawing, walls, field views, filling a third of the tank with ink, gravity pours, snapshots, seeded flows, max swirl, zen, free research) with a live progress bar measured against real simulation state: on WebGL2 the app reads peak flow speed and ink coverage back from the GPU; WebGL1 falls back to activity proxies. Completing all 18 takes a relaxed 30–40 minutes, doubles as a deep tour of every feature, and progress persists across sessions. Two presets are gated on the chain (Singularity at 8, Glasswing at 16). Dismissable; re-enable under Surprises & sound.
Eight presets ship locked, shown as 🔒 ??? with a hint on hover (or
tap). They unlock by playing, with a toast when discovered — tap the toast to
apply the new preset instantly — the first lands within the first minute: Undertow (15 strokes) · Kaleidos
(20 strokes in 4-way symmetry) · Riptide (build with the wall brush) ·
Maelstrom (60 strokes) · Tidepool (3 minutes of total stirring) ·
Eclipse (Swirl 60 and brush force ≥ 15 000) · Singularity
(8 experiments) · Glasswing (16 experiments). The stats chip shows a
✦ n/8 discovery counter; progress persists across sessions.
The first ambient surprise fires within the first minute or two — a comet streak, a ring bloom, or a gravity surge — then every 2–4 minutes of play. And the canvas never sits dead: when you stop stirring and the ink fades, idle drift breathes gentle blooms into the fluid (off for reduced-motion users; both have checkboxes). Rotating one-line tips (max 3 per session) surface features you haven't tried, and returning on a new day greets you with your streak and a fresh flow of the day.
A WebAudio layer (on by default; browsers start it at the first interaction — one checkbox turns it off): a drone follows the fluid's activity — calm fluid hums low, vigorous stirring opens the filter — and every ink stroke adds a soft pentatonic pluck, stereo-panned to where it lands. No samples — oscillators only.
Z hides every UI element and lets the lab drift: a slow breathing wind plus occasional gentle blooms. Press Z again to come back.
A live measurement panel (WebGL2 only) reads the velocity and vorticity fields back from the GPU twice a second and reports mean/peak speed, mean kinetic energy and RMS vorticity — a virtual rheometer for the simulated fluid.
First launch runs a four-step quiet tour (stir → shift-drag → presets → shortcuts) driven by what you actually do; it never appears again.
A guided calm session: pick a pattern (Box 4·4·4·4, Relax 4·7·8, Coherent 5·5) and a duration (2/5/10 min). The fluid choreographs each phase — a converging ring and gentle lift on the inhale, a diverging ring and settle on the exhale — under a quiet serif overlay with the remaining time and a breath counter. End anytime with Esc.
Compose the live URL into a zero-click display for stream overlays, lobby screens, or kiosks:
| Parameter | Effect |
|---|---|
?preset=neon |
Apply any preset (including hidden ones) by name |
?ui=0 |
Kiosk mode — nothing on screen but fluid |
?zen=1 |
Zen drift (self-playing) |
?seed=silk-veil-42 |
Deterministic flow |
?sound=0 / 1 |
Audio override |
?fps=30 |
Frame cap (saves GPU/battery on displays) |
?breathe=1 |
Start a breathing session |
?bench=1 |
Run the benchmark |
URL-driven sessions are ephemeral — they never overwrite a visitor's saved settings.
Opt-in microphone listening: a 512-bin FFT watches the bass; detected beats fire palette-colored bursts whose force scales with intensity (Sensitivity dial). Audio stays on the device — nothing is recorded or sent anywhere. Explicit start/stop, never persisted.
A standardized 20-second run — four 5-second phases sweeping sim/ink resolution under a deterministic Lissajous stir with all effects on — produces a resolution-weighted score with a per-phase fps breakdown, copies a share line to the clipboard, and remembers your best score.
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:
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.
| 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 |
On WebGL1 the app falls back to RGBA half-float (or full-float) textures,
probes every format for actual renderability before committing, and compiles
the advection shader with a manual-bilinear #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.
webglcontextlost halts the loop and offers a reload.pointerup
(focus stolen mid-drag) is detected via buttons === 0; the final flick
impulse of a stroke is preserved for one extra frame before the pointer is
reaped.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
The repo is deploy-ready for any static host:
main
deploys. GitHub Pages works too if the repo is public.rheo.thorstenmeyerai.com) at the
host. HTTPS is required for the clipboard, native share, and the
service worker.index.html points
at https://rheo.thorstenmeyerai.com/og.jpg; adjust if you choose a
different domain (crawlers need an absolute URL).VERSION in sw.js whenever shell files
change, or installed clients keep the old version one visit longer.CI — .github/workflows/ci.yml runs scripts/smoke.mjs on every
push: headless Chromium boots the app, stirs the fluid, and fails on any
console/page error, missing deck section, or dead render loop.
Privacy posture — no cookies, no third-party requests, all state in
localStorage: no consent banner required. Footer links to Impressum and
privacy policy. If you want usage numbers later, Plausible or GoatCounter
are banner-free options — add their one script tag to index.html.
Built-in resilience — if sustained FPS drops below 30 for ~3 s the
app lowers the ink resolution one notch (once per session, with a toast);
prefers-reduced-motion users get no opening burst and surprises off by
default.
The technique is Jos Stam's Stable Fluids 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.
# RHEO · fluid lab
An interactive, real-time fluid simulation that runs entirely in the browser.
Drag to stir luminous ink through a solvable velocity field; every part of the
solver and the look is exposed as a dial.
- **One file, zero dependencies.** Everything — markup, styles, shaders,
solver, UI — lives in [`index.html`](index.html). There is no build step.
- **GPU solver.** Jos Stam's *Stable Fluids* (1999) implemented from scratch
in WebGL fragment shaders, following the structure popularized by
GPU Gems ch. 38.
- **~40 controls** in a collapsible "deck": presets, solver parameters, body
forces, brush behaviour, color systems, field visualizers, glow, and
capture tools.
> Documentation is also available as a styled standalone page:
> [`docs.html`](docs.html). Release history lives in
> [`CHANGELOG.md`](CHANGELOG.md).
---
## Quick start
```sh
open index.html # macOS — or just double-click the file
# or serve it (optional):
python3 -m http.server 8000 # → http://localhost:8000
```
Works on phones and tablets too: the deck becomes a bottom sheet behind the
"rheo" chip, touch targets grow, each finger is its own brush, double-tap
exits zen mode, and Share uses the native share sheet.
Requirements: any current Chrome, Firefox, Safari or Edge with WebGL.
WebGL2 is preferred; WebGL1 with half-float texture extensions works as a
fallback. Fonts (Instrument Serif, IBM Plex Mono) are **self-hosted** in
`fonts/` — no Google CDN, no third-party requests at all. Served over
HTTPS the app is an installable **PWA** that runs fully offline.
## Interaction
| Input | Effect |
|---|---|
| **Drag** | Stir the fluid and inject ink along the stroke |
| **Shift-drag** | Apply force only — push the fluid without adding ink |
| **Right-drag** | Spin a vortex at the pointer (shift reverses the direction) |
| **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) |
| **V** | Toggle the vortex brush (touch-friendly alternative to right-drag) |
| **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 small status chip (top right) shows FPS, simulation grid size, ink texture
size, and a pause indicator.
## The 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. Applying a preset highlights its chip; touching any
dial afterwards clears the highlight ("custom" state). 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 small eddies the grid would otherwise 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 — makes dye rise 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 |
| Dynamics | 0–1 (0.35) | Stroke speed scales the splat radius — fast strokes paint wide, like a loaded brush. On a stylus, pen pressure scales it too |
| Color mode | rainbow (default) | `Rainbow cycle` · `Random per stroke` · `Palette` · `Fixed color` |
| Cycle speed | 0–100 (10) | Hue-advance rate for rainbow mode |
| Palette | Aurora (default) | Curated swatch set used by Palette mode (see below) |
| Fixed color | `#2ea8ff` | The single color used by Fixed mode |
| Symmetry | off (default) | `Mirror (2-way)` reflects every splat across the vertical axis; `Kaleido (4-way)` across both axes |
**Palettes** — each stroke 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 that gives ink a silky, embossed depth |
| Background | Canvas clear color (`#050403`) |
### Stardust (WebGL2)
Up to ~37 000 glowing tracer particles ride the velocity field, making
the currents themselves visible as drifting embers. Positions live in a
float ping-pong texture updated by a fragment pass (lifespan, respawn,
wall avoidance — particles never enter painted walls) and render as
additive soft points colored by the local ink. Count (9k/16k/37k), size
and brightness dials; hidden on WebGL1.
### 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. **Sunrays** (on, with a **Ray
weight** dial) adds volumetric light shafts: a brightness-derived
occlusion mask is radially blurred from the screen centre and modulates
the ink and glow, giving the fluid sunlight-through-water depth.
### Actions
**Burst (R)** · **Clear (C)** · **Step (S)** · **Save PNG** (`rheo.png`) ·
**Record ⏺** (WebM video via MediaRecorder, `rheo.webm`) ·
**Session report** (standalone HTML with session stats, settings and gallery) ·
**Reset settings**.
---
## Play & discovery
### Flows of the day & share links
**Today's flow** derives a complete mood — palette, swirl, fades, symmetry,
forces, glow, plus an opening splat choreography — deterministically from
today's date, so everyone sees the same flow on the same day. **Random
flow** rolls a named seed (e.g. `ember-whorl-23`); **Share** copies a
`?seed=` link that reproduces the flow exactly on any machine.
### Walls (paintable obstacles)
The **wall brush** (tool selector or **O**) paints solid obstacles straight
into the flow domain; the fluid parts around them, ink cannot enter, and a
faint rim light makes them read as warm stone. Erase selectively or clear
all walls; walls survive window resizes.
### Ghosts (a loop pedal for strokes)
Press **G** (or ⏺ Record loop in the deck), draw for up to 20 seconds,
press **G** again — and your passage replays forever as a *ghost*, an
autonomous performer that re-paints your strokes with a fresh palette
color on every pass. Up to three ghosts loop simultaneously, each with
play/pause and delete; they persist across reloads, never touch your
progress counters, and pair beautifully with zen mode for a
self-playing performance.
### Gallery
Unusually energetic moments are captured automatically (scored by a decaying
activity measure, at most one shot per 20 s) into a nine-slot gallery of
thumbnails, persisted locally. Clicking a shot restores the full settings
that produced it. **Snapshot now** pins the current moment manually.
### The Lab Notebook (experiments)
A small notebook widget presents one **experiment** at a time — an 18-step
chain of escalating goals (stir, push without ink, bursts, presets, whip
the flow past 60 tex/s, kaleido drawing, walls, field views, filling a
third of the tank with ink, gravity pours, snapshots, seeded flows, max
swirl, zen, free research) with a live progress bar measured against real
simulation state: on WebGL2 the app reads peak flow speed and ink coverage
back from the GPU; WebGL1 falls back to activity proxies. Completing all
18 takes a relaxed 30–40 minutes, doubles as a deep tour of every feature,
and progress persists across sessions. Two presets are gated on the chain
(Singularity at 8, Glasswing at 16). Dismissable; re-enable under
Surprises & sound.
### Unlockables
Eight presets ship locked, shown as `🔒 ???` with a hint on hover (or
tap). They unlock by playing, with a toast when discovered — **tap the toast to
apply the new preset instantly** — the first lands within the first minute: **Undertow** (15 strokes) · **Kaleidos**
(20 strokes in 4-way symmetry) · **Riptide** (build with the wall brush) ·
**Maelstrom** (60 strokes) · **Tidepool** (3 minutes of total stirring) ·
**Eclipse** (Swirl 60 *and* brush force ≥ 15 000) · **Singularity**
(8 experiments) · **Glasswing** (16 experiments). The stats chip shows a
✦ n/8 discovery counter; progress persists across sessions.
### Surprises & idle drift
The first ambient surprise fires within the first minute or two — a
**comet** streak, a **ring bloom**, or a **gravity surge** — then every
2–4 minutes of play. And the canvas never sits dead: when you stop
stirring and the ink fades, **idle drift** breathes gentle blooms into
the fluid (off for reduced-motion users; both have checkboxes). Rotating
one-line tips (max 3 per session) surface features you haven't tried,
and returning on a new day greets you with your streak and a fresh flow
of the day.
### Sound
A WebAudio layer (on by default; browsers start it at the first
interaction — one checkbox turns it off): a drone follows the fluid's
activity — calm fluid hums low, vigorous stirring opens the filter — and
every ink stroke adds a soft pentatonic pluck, stereo-panned to where it
lands. No samples — oscillators only.
### Zen mode
**Z** hides every UI element and lets the lab drift: a slow breathing wind
plus occasional gentle blooms. Press **Z** again to come back.
### Rheometer
A live measurement panel (WebGL2 only) reads the velocity and vorticity
fields back from the GPU twice a second and reports mean/peak speed, mean
kinetic energy and RMS vorticity — a virtual rheometer for the simulated
fluid.
### Onboarding
First launch runs a four-step quiet tour (stir → shift-drag → presets →
shortcuts) driven by what you actually do; it never appears again.
---
## Useful modes
### Breathe
A guided calm session: pick a pattern (Box 4·4·4·4, Relax 4·7·8,
Coherent 5·5) and a duration (2/5/10 min). The fluid choreographs each
phase — a converging ring and gentle lift on the inhale, a diverging
ring and settle on the exhale — under a quiet serif overlay with the
remaining time and a breath counter. End anytime with Esc.
### Ambient display & OBS (URL API)
Compose the live URL into a zero-click display for stream overlays,
lobby screens, or kiosks:
| Parameter | Effect |
|---|---|
| `?preset=neon` | Apply any preset (including hidden ones) by name |
| `?ui=0` | Kiosk mode — nothing on screen but fluid |
| `?zen=1` | Zen drift (self-playing) |
| `?seed=silk-veil-42` | Deterministic flow |
| `?sound=0` / `1` | Audio override |
| `?fps=30` | Frame cap (saves GPU/battery on displays) |
| `?breathe=1` | Start a breathing session |
| `?bench=1` | Run the benchmark |
URL-driven sessions are **ephemeral** — they never overwrite a
visitor's saved settings.
### Music mode
Opt-in microphone listening: a 512-bin FFT watches the bass; detected
beats fire palette-colored bursts whose force scales with intensity
(Sensitivity dial). Audio stays on the device — nothing is recorded or
sent anywhere. Explicit start/stop, never persisted.
### Benchmark
A standardized 20-second run — four 5-second phases sweeping sim/ink
resolution under a deterministic Lissajous stir with all effects on —
produces a resolution-weighted score with a per-phase fps breakdown,
copies a share line to the clipboard, and remembers your best score.
## How 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:
1. **Splat** — pointer strokes (and bursts) add Gaussian blobs of momentum
into the velocity texture and color into the dye texture.
2. **Body forces** *(skipped when zero)* — gravity, wind, and buoyancy
(ink density × upward force) are added to velocity.
3. **Curl** — vorticity ω = ∂v/∂x − ∂u/∂y is measured into its own texture.
4. **Vorticity confinement** — a force perpendicular to the vorticity
gradient, scaled by *Swirl*, is added back. This counteracts the numerical
smearing of the semi-Lagrangian step and keeps small eddies alive.
5. **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.
6. **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.
7. **Projection** — the pressure gradient is subtracted from velocity,
making it (approximately) divergence-free, i.e. incompressible.
8. **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 |
On WebGL1 the app falls back to `RGBA` half-float (or full-float) textures,
probes every format for actual renderability before committing, and compiles
the advection shader with a manual-bilinear `#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
- **Resize settling** — live window drags resize the canvas every frame;
framebuffers are only reallocated 250 ms after the size stops changing.
- **Context loss** — `webglcontextlost` halts the loop and offers a reload.
- **Boot errors** — shader-compile failures on exotic GPUs surface in an
error overlay rather than failing silently.
- **Pointer edge cases** — strokes use pointer capture; a missed `pointerup`
(focus stolen mid-drag) is detected via `buttons === 0`; the final flick
impulse of a stroke is preserved for one extra frame before the pointer is
reaped.
- **PNG capture** runs in the same frame as the render because the drawing
buffer is not preserved.
- **Keyboard safety** — shortcuts never shadow OS combos (Cmd/Ctrl/Alt are
ignored) and never fire while a control has focus.
## Architecture
```
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
```
## Performance notes
- 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.
## Production & deployment
The repo is deploy-ready for any static host:
1. **Host** — connect the repo to Cloudflare Pages, Netlify, or Vercel
(no build command, output directory = repo root). Every push to `main`
deploys. GitHub Pages works too if the repo is public.
2. **Domain** — point a CNAME (e.g. `rheo.thorstenmeyerai.com`) at the
host. HTTPS is required for the clipboard, native share, and the
service worker.
3. **og:image URL** — the Open Graph/Twitter image in `index.html` points
at `https://rheo.thorstenmeyerai.com/og.jpg`; adjust if you choose a
different domain (crawlers need an absolute URL).
4. **Cache busting** — bump `VERSION` in `sw.js` whenever shell files
change, or installed clients keep the old version one visit longer.
**CI** — `.github/workflows/ci.yml` runs `scripts/smoke.mjs` on every
push: headless Chromium boots the app, stirs the fluid, and fails on any
console/page error, missing deck section, or dead render loop.
**Privacy posture** — no cookies, no third-party requests, all state in
localStorage: no consent banner required. Footer links to Impressum and
privacy policy. If you want usage numbers later, Plausible or GoatCounter
are banner-free options — add their one script tag to `index.html`.
**Built-in resilience** — if sustained FPS drops below 30 for ~3 s the
app lowers the ink resolution one notch (once per session, with a toast);
`prefers-reduced-motion` users get no opening burst and surprises off by
default.
## Provenance
The technique is Jos Stam's *Stable Fluids* 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.