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.

Documentation is also available as a styled standalone page: docs.html. Release history lives in CHANGELOG.md.


Quick start

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

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

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.

# 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.