0.0%
Paper
Preset
Brush 28 px
Flow 0.40
Taper
Fluid
v1.2
v1.3
v1.10
v1.11
v1.12
timeline
Readout coverage —
Brush size, loads, brush-only presets
Paint load 1.00×
Water load 1.00×
Texture  
Intensity 0.70
Brush preset
SVG transition container, drawing in, deluge out
Container × px
Load SVG
In duration 2000 ms
In easing
In path pause 150 ms
Out (Deluge)
Deluge velocity 40.0
Deluge pressure 20.0
Deluge radius 2000 px
Deluge lift 0.98
Deluge rotation 0 (off)
Deluge jitter 0.00
Deluge rays 1 ray
Advection ×1
Playground paper, animations, text, photos, splashes
Paper wetness
Animation
Background
Visualization
Text
Paint photo
Image stamp 8 px
Settings toggles, resolution, fade, performance
Performance
Quality  
Other perf-relevant knobs live in their own homes:
  • Resolution (below) — biggest single perf lever. Higher px/cell = faster.
  • Advection mode (Splash section) — semi-Lagrangian is fastest; standard/clamp cheaper but show cardinal-cross artifact at high velocity.
  • Edge darkening, Fade painting (toggles below) — each adds a per-frame pass when on.
Evaporation 10×
Resolution 2.0 px/cell
Max velocity 1.14
Gravity direction  
Pull 10
Edge fade 0 px
Background  

Watercolor

A browser-based interactive watercolor simulation. Paints with three real pigments through a physically-motivated wet-paper model — capillary diffusion, edge darkening, Kubelka-Munk color mixing, granulation, masking fluid. Composable layers, a complete programmatic API, optional GPU acceleration. Single HTML file, no dependencies, runs offline.

Getting started

Move your pointer over the canvas above to see the brush cursor. Click and drag to paint. The dab spreads through the wet substrate, bleeds at its edges, and mixes with adjacent color via Kubelka-Munk reflectance — the same equations published in the seminal SIGGRAPH 1997 paper by Curtis, Anderson, Seims, Fleischer, and Salesin.

The control panel at the bottom contains everything: brush and pigment selection, background washes, autonomous animations, generative visualizations, text and SVG painters, paper-wetness presets, fade-painting, masking fluid, and the WebGL render toggle. Hover any control for an inline tooltip explaining what it does.

Design philosophy

Every feature composes through one path: paint. When you click the canvas, when the Tornado animation paints its funnel, when the Kaleidoscope visualization stamps its rotational pattern, when text or SVG geometry traces — all of them call the same function and inherit the same physics. That's why every brush color works with every painter: Rainbow text, Mask-traced SVG, Water-brush dot-burst.

The simulation is honest about being a model. Pigment is three pigments. Paper is one cream substrate with subtle warm noise. Time is sim-step ticks at the requested resolution. No filters, no shortcuts, no post-processing. What you see is the wet-paper equations playing out.

Brushes & painting

Pigments

Three pigments from Figure 5 of the Curtis et al. paper, chosen as a complementary triad for color-mixing demos:

Quinacridone Rose
Magenta-red. High staining (ω=5.5) and granulation (γ=0.81) — sticks where it lands, fills in paper texture.
Hansa Yellow
Warm yellow. Low staining (ω=1.0) and granulation (γ=0.08) — flows freely, blends smoothly.
Cerulean Blue
Cool blue. The paper's authentic Cerulean is a famous lifting pigment; we raise its staining and granulation here for visual consistency with Quinacridone Rose.

Pigments mix via Kubelka-Munk reflectance, not RGB averaging. Mixing yellow and blue gives a real watercolor green — not the muddy gray you'd get from adding their colors directly. The K and S values per channel (absorption and scattering) determine how light propagates through the layer.

Transparent vs opaque (v0.37+). The default three pigments above are transparent watercolor — light passes through them, bounces off the paper underneath, passes back through with selective absorption. That's why they're nearly invisible on dark paper: with no light reflected from below, there's nothing for them to filter. Toggling Gouache mode (in the toggles row, or via wc.gouacheMode(true)) swaps in a parallel PIGMENTS_OPAQUE set with high-scattering K/S values tuned to render vibrantly on near-black paper. Same three colors, same brush behavior, much higher saturation + opacity. Affects both new strokes and the rendering of already-deposited pigment — toggle while a painting is in progress to preview the difference. See the v0.37, v0.38, and v0.39 changelog entries for tuning history.

Water, lift, rainbow, paper

Four special brushes alongside the pigments:

Water
Deposits water and pressure without pigment. Lifts a fraction of deposited pigment back into suspension at each touched cell, letting you re-mobilize dry paint for blending, edge-softening, or washout effects.
Lift
Removes pigment from each touched cell. Drag-compounding: a single dab at brush center keeps 78% of the pigment; ten dabs keep 8%. Leaves water and pressure alone so the surroundings don't bleed.
Rainbow
Same dynamics as a regular pigment brush, but the stamp color cycles through rose → yellow → blue over time. Long strokes traverse the spectrum.
Paper (v0.32+)
Paints the current paperColor() — visually equivalent to an opaque paper-color brush that erases the sketch back to the canvas color. Sentinel pigment index -5; available via pigment('paper'), or as a mode in obliterate({ mode: 'paper' }). Unlike lift (which moves deposit back into suspension), paper zeros deposited + suspended pigment outright in its footprint and adds wetness for a soft wash edge. Dynamically follows whatever paperColor() returns — no separate "white" pigment to retune if you change paper color.

Masking fluid

The amber-yellow swatch at the end of the brush row. Paints a translucent reservation layer over the paper:

  • Paint mask first — masked cells reject all subsequent paint, water, and lift
  • Paint over mask — pigment frozen beneath stays unchanged through every sim step
  • Click "Remove mask" — the rubbery layer comes off in one pass, revealing whatever was frozen underneath (paper, or pigment that was there when you masked)

The visible amber tint scales with mask density — a single dab is faint, dense coverage is fully amber. The tint sits on top of any pigment underneath, so you see through the mask to your painting while it's working. This is how real masking fluid behaves on real paper.

Composition layers

Three independent autonomous painters sit in the controls row, each driven by its own dropdown. They paint through the same simulation your brush uses, so wet-on-wet mixing, edge darkening, and granulation all apply.

Backgrounds

One-shot washes painted across the canvas in ~0.75 seconds. Five options:

Day
Pale cerulean sky wash, lighter toward the horizon.
Dawn
Cerulean top, rose mid, yellow horizon — gentle pre-sunrise gradient.
Sunset
Warm rose-and-yellow horizon over cool cerulean zenith.
Night
Deep blue-purple, denser than the others — rose tints the upper sky.
Tornado
The sickly green-yellow tornado sky from American Plains storms. Pair with the Tornado animation for the full picture.

Animations

Continuously-running weather and ambient scenes. Each calls paint on its own schedule with appropriate brushes:

AI painter
Weighted stroke strategies that paint indefinitely with all three pigments.
Rainy
Falling drops of cerulean and water; rose flecks for variety.
Sunny
Slow horizontal warm sweeps across the canvas.
Windy
Fast horizontal streaks with periodic gust-direction flips.
Thunderstorm
Heavy rain plus periodic yellow lightning bolts.
Partly cloudy
Three large slow clouds drifting across the sky.
Snowing
Cerulean wash + water-brush flakes lifting it for white snow trails.
Snowing (additive)
Tiny cerulean dabs with rose flecks — the original snow preset, kept for the additive look.
Tornado
14 cursors orbit a meandering vertical axis with layered-sine motion. 5-second pause between passes. Pair with the Tornado background for the green sky.

Visualizations

Generative loops that run indefinitely, showcasing what the simulation can do:

Kaleidoscope
Six cursors at 60° rotational symmetry around canvas center. Phases cycle through arc-trace (organic curves), dot-burst (sharp hexagonal dots), and rose-petals (r = R·cos(k·θ) flower curves).
Lissajous
A master point traces sin(a·t)·Rx, sin(b·t)·Ry where (a, b) morph slowly via low-frequency carrier sines. Dense overlapping curves that never repeat.
Flow field
22 particles follow a vector field derived from the paperH noise + a slow time offset. Wind-blown / topographic line patterns.
Pulse
Concentric rings expand from random epicenters. Strength tapers as each ring grows, giving fading wave-fronts that interfere where they overlap.

Text painting

Type any phrase in the Text control's input, press Enter or click Paint. The text renders at 96px Georgia bold to an offscreen canvas, then each ink pixel becomes a paint call. Uses the currently-selected brush — try Rainbow for shifting hue, or Mask to reserve a text-shaped area for a later wash.

SVG tracing

Click Load SVG to pick a vector file. The parser walks every path, line, polyline, polygon, rect, circle, and ellipse element, converts each to a path-d string, and uses SVGPathElement.getPointAtLength to sample arc-length-parametrized points along it. Points are queued and 10 are painted per frame, so the SVG draws itself with a watercolor brush over a couple of seconds. Uses the currently-selected pigment.

Canvas controls

Paper wetness

Five named presets borrowed from real watercolor practice, tied to evaporation rate:

Flooded
Pooled water and dramatic blooms. ~9.5 second half-life. Least performant.
Wet-on-wet (default)
Soft Curtis-style bleed. ~3.2 second half-life. Balanced for interactive use.
Damp
Controlled blending, firmer edges. ~1.2 second half-life.
Dry-brush
Paint sits where placed, granular texture shows. ~0.5 second half-life.
Bone-dry
Marks stay crisp like pen. ~0.2 second half-life. Most performant.

Faster evaporation reclaims active cells sooner, which reclaims simulation cost — useful as a performance lever for heavy animations and visualizations, at the cost of less wet-on-wet bleed.

Toggles

Edge darkening
Concentrates pigment at the boundaries of wet regions — the way real watercolor pools at edges when water dries. On by default.
Pause drying
Stops evaporation entirely. Pigment stays suspended indefinitely.
Auto-dry background
Settles the canvas instantly when a Background wash completes. Saves substantial CPU when running visualizations on top of a finished wash.
Fade painting
Slowly fades deposited pigment back toward paper. Half-life adjustable via the slider. As of v0.34, fade hard-cuts to zero below a small threshold so a faded canvas eventually returns fully to paper (was infinite-asymptote before).
Transparent
Makes the body background transparent so the canvas layers over the page background. Useful for embedding.
WebGL render
Routes the per-pixel render through a WebGL2 fragment shader instead of the CPU. Falls back silently if WebGL2 isn't available.
Perf
Show the performance overlay (FPS, p50/p95/p99 frame times, per-phase ms, active cells, heap delta, long animation frames). Also enables instrumentation; zero overhead when off. Can be opened via ?perf=1 URL param.
Mobile
Enables palm rejection — three filters drop accidental contact while painting with a finger or stylus. Auto-enabled on touch-primary devices; toggle to override either way.
Continuous flow
When holding the pointer in place, pigment + water keep flowing each frame — like a marker pressed to paper. Off = traditional one-stamp-per-position behavior. On by default.
Gouache (v0.37+)
Swaps the three pigments to high-scattering opaque versions tuned for vibrant rendering on dark paper. Real watercolor pigments are transparent and effectively invisible on black; gouache pigments scatter light back from their own particles regardless of background. Pair with a dark paper color for a dark-mode aesthetic. Affects existing as well as new strokes — the K/S values are read fresh every frame.

Actions

Re-wet
Restores wetness across the whole canvas and lifts 30% of deposited pigment back into suspension — dried strokes become mobile again.
Big splash
Dramatic dispersal across the canvas. Large splash zones (~280px radius), strong velocity, lifts 85% of deposited pigment. Like a bucket of water hitting a fresh wash.
Fine spritz
Many small concentrated bursts (8–15 epicenters). Small splash zones (~80px radius), gentle velocity, lifts 50% of deposited pigment. Like flicking water from a brush onto the painting.
Obliterate (v0.30+)
Destroys the current drawing via a wet wash centered on the canvas. Click alone → 'water' mode (rinse, pigment lifts and diffuses out). Shift-click → 'pigment' mode (opaque wash in the active color). Alt-click → 'paper' mode (clears back to canvas color). All complete in 500ms; returns a Promise from the API.
Dry
Instantly evaporates all water. Suspended pigment locks in place as deposited.
Remove mask
Clears all masking fluid in one pass.
Export PNG
Saves the current canvas. Composites paper color underneath unless Transparent is on.
Reset
Clears all pigment, returns the canvas to fresh wet paper.

Performance overlay

Hidden by default. Enable two ways:

// URL parameter
?perf=1

// Console
Watercolor.perf(true);

The overlay shows FPS, p50/p95/p99 frame times, per-phase ms breakdown (sim, render, anim+viz+svg, wash+fade), active cell count, heap delta (Chromium-only), and the Long Animation Frames counter. Refreshes at 10 Hz; zero overhead when off.

Programmatic API

Everything in the UI is also reachable through window.Watercolor — the page's live engine instance (the factory namespace itself is window.Washes). Drive the canvas from the browser console, an external script, or an automation file. Designed to be small, composable, and discoverable. The engine speaks API 2.0: normalized 0..1 coordinates everywhere, every setter chains.

Quickstart

Open DevTools (F12 or ⌘⌥I), paste these into the console one at a time, and watch the canvas:

// Set a sunset wash
Watercolor.backgroundAnimation('sunset');

// One rainbow dab at canvas center — normalized coords,
// radius as a fraction of the smaller side
Watercolor.paint(0.5, 0.5, 0.04, 'rainbow', 0.8);

// Six brushes painting in a hexagonal orbit
let t = 0;
setInterval(() => {
  t += 0.03;
  for (let i = 0; i < 6; i++) {
    const angle = t + i * Math.PI / 3;
    const r = 0.2 + Math.sin(t * 2) * 0.08;
    Watercolor.paint(0.5 + Math.cos(angle) * r,
                     0.5 + Math.sin(angle) * r,
                     0.015, 'blue', 0.5);
  }
}, 33);

Painting from code

Four verbs put pigment on paper. All take normalized coordinates — 0..1 across the canvas, radius as a fraction of the smaller display side — and all return the instance for chaining.

paint(nx, ny, nradius?, pigment?, strength?)
One dab. Omitted arguments fall back to live brush state (brushSize(), pigment(), pressure()).
stroke(nx, ny, opts?)
Feed it a stream of positions (e.g. from pointer events) and it interpolates stamps between them with the live flow/taper model — the same path your mouse uses. Call penUp() to end the stroke.
line(nx0, ny0, nx1, ny1, opts?)
A complete straight stroke from A to B in one call.
splash(epicenters?, style?)
Water dispersal — see Actions.

A "programmable brush" is just a plain object your code moves around — no special handle type needed:

// A self-propelled brush in ~6 lines
const b = { x: 0.5, y: 0.5, hue: 'blue', size: 0.02 };
setInterval(() => {
  b.x += (Math.random() - 0.5) * 0.03;
  b.y += (Math.random() - 0.5) * 0.03;
  Watercolor.paint(b.x, b.y, b.size, b.hue, 0.5);
}, 50);

Code that genuinely thinks in grid cells (pixel-art pieces, cell automata) has a first-class home on the wc.grid namespace — see coordinates.

Modes

The three composition layers — animations, visualizations, backgrounds — each use one unified accessor: pass a name to set, omit it to read. Setters accept {replace: bool}; default replace: true mirrors the dropdown behavior (switching presets clears the old one's state).

Watercolor.animation(name, opts?) // set
Watercolor.animation() → string       // read

Watercolor.visualization(name, opts?)
Watercolor.visualization() → string

Watercolor.backgroundAnimation(name)
Watercolor.backgroundAnimation() → string | null
Watercolor.backgroundAnimationRunning() → bool

Valid names:

Animations
'off', 'ai', 'rainy', 'sunny', 'windy', 'thunderstorm', 'partlyCloudy', 'snowing', 'snowingAdditive', 'tornado'
Visualizations
'off', 'kaleidoscope', 'lissajous', 'flowField', 'pulse'
Backgrounds
'day', 'dawn', 'sunset', 'night', 'tornado'

Text & SVG

Watercolor.paintText(text, opts?) → number of dabs

opts: { x, y, fontSize, sampleStep, strength, pigment }

Renders text centered on the given grid coordinates (defaults to canvas center). pigment accepts a color name string ('rose', 'blue', 'rainbow', …); defaults to the UI's currently-selected brush.

Watercolor.paintImage(source, opts?) → Promise<number of stamps>

opts: { brushSize, strength, pigment, alphaThreshold,
        density, flipX, flipY, maxStamps }

New in v0.49. Loads a raster photo and paints it as a watercolor reproduction. source accepts a File or Blob (from a file input), an HTMLImageElement (already loaded or loading), or a URL string (including data: URLs). Returns a Promise that resolves to the number of stamps painted once decoding + stamping finishes.

Each sampled pixel decomposes into a rose+yellow+blue mix via the same subtractive color theory as traceSVG's approximateColor: rose = 1 - g, yellow = 1 - b, blue = 1 - r. Pure white pixels deposit nothing (paper shows through); pure black mixes all three at max for a deep muddy watercolor black. Sample spacing scales with brush radius — large brush gives an impressionistic feel, small brush recovers more detail. There's a hard stamp cap (default 20000, configurable via maxStamps) so pathological combinations of huge images and tiny brushes can't freeze the UI.

Loading is async (the browser has to decode the image); stamping is synchronous once decoded. The whole image materializes as a wash in one frame, like the fillInstant behavior in traceSVG. Pixel reading respects CORS — cross-origin images without proper headers will produce a tainted canvas, the function returns 0 and warns to console.

Watercolor.traceSVG(source, opts?) → points queued

Watercolor.cancelSVGTrace()

opts: {
  pigment, brushSize, strength, flipY, flipX,
  instant, durationMs, easing, perStrokePauseMs,
  triggerColors,    // v0.43+ — exact chroma-key matching (default true)
  approximateColor, // v0.43+ — decompose any color into pigment mix (default false)
  fillShapes,       // v0.45+ — stamp interior of filled shapes (default true)
  fillInstant       // v0.46+ — stamp fills synchronously at load (default true)
}

source accepts SVG markup (a string starting with <), a URL string (fetched then parsed), or a File object (e.g. from a file picker).

All opts are optional and fall through to live brush state when omitted:

pigment
Name ('rose', 'yellow', 'blue', 'cerulean', 'rainbow', 'water', 'lift', 'mask') or numeric index. The fallback brush — used when an SVG element's fill or stroke doesn't resolve to a recognized paint, or for truly unstyled elements. v0.48+ routes this fallback to the slot the author specified (fill= fills with it, stroke= outlines with it). Default: the active pigment from pigment().
brushSize
Brush diameter in display pixels. Default: brushSize().
strength
Stamp strength, 0..1. Default: pressure().
flipY
Mirror Y around the SVG's vertical center. Default false as of v0.42 — standard SVGs (Illustrator, Inkscape, Figma, web exports) use Y-down, same as Canvas and CSS, so no flip is needed. Pass true for genuinely Y-up SVG sources (rare; some CAD exports or plot-style diagrams).
flipX (v0.42+)
Mirror X around the SVG's horizontal center. Default false. Use when an SVG appears horizontally mirrored — usually a content issue (e.g. an unconsumed transform="scale(-1,1)" on a parent group) but exposing the flag here means you can fix it without editing the SVG.
instant
When true, paints every point synchronously before traceSVG returns. No animation. Useful for thumbnails on page load.
durationMs
Spreads the trace over roughly this many milliseconds. The library tracks elapsed time per frame and uses easing to shape the rate.
easing
Speed curve. See the easing reference below. Default 'linear' from the API; the demo panel defaults to 'penStroke'.
perStrokePauseMs
Pause in milliseconds between SVG path elements. With multi-path SVGs, each path completes, then a brief pause, then the next starts — like a pen being lifted and repositioned. Default 0 (continuous).
triggerColors (v0.43+, was colorMap in v0.41–v0.42)
When true (default), recognized fill/stroke colors on each SVG element route that element to the matching pigment via strict chroma-key matching (see table below). Set false to disable trigger-color matching entirely. The old colorMap name still works as an alias. Unrecognized colors and elements that fall through both color resolvers route through the v0.48+ fallback rules below.
approximateColor (v0.43+)
When true, parseable colors that aren't exact triggers get decomposed into a mix of rose+yellow+blue and painted as such — e.g. #FF8000 orange becomes ~35% rose + 100% yellow, #808080 grey becomes equal parts of all three. Default false. Can be combined with triggerColors: triggers win for exact matches, approximation handles everything else. Colors that don't parse at all (HSL, gradients, unknown named colors) route through the v0.48+ fallback rules — they still respect their author-specified fill= vs stroke= role and use the active brush as the color. See "Approximated color mixing" below for the algorithm.
fillShapes (v0.45+)
When true (default), elements with a recognized fill paint get their interior stamped — a regular grid of sample points inside the path, each painted with the fill paint. Elements with a stroke also get their outline traced as before; filled+stroked shapes get both. When false, the v0.44 behavior is preserved: filled-only shapes have their outline traced in the fill color (which is invisible for shapes without an explicit stroke). See "Filled shape stamping" below.
fillInstant (v0.46+)
When true (default), fill stamps paint synchronously at load time instead of animating through the trace queue. The filled region appears as a wash the moment traceSVG() is called, and any outlines (if present) animate separately over durationMs. When false, fills animate point-by-point through the bounding-box grid alongside outlines (the v0.45 behavior, which reads as a sweep across the shape). Has no effect on stroke-only SVGs. See "Filled shape stamping" below for why this matters.

Per-element color mapping (v0.41+)

By default, the SVG loader inspects each geometry element's stroke first, then fill, walking up through parent <g> groups to honor inheritance. When the value matches one of the recognized chroma-key colors, that element traces with the corresponding pigment. Elements with no recognized color fall through to the call's opts.pigment (or the active brush if unset). This lets a single multicolor SVG drive multiple pigments in one trace call — no per-element wiring needed on the caller side.

ColorHexNamedPigment
pure red #FF0000 / #F00red rose
pure yellow #FFFF00 / #FF0yellow yellow
pure blue #0000FF / #00Fblue blue
pure green #00FF00 / #0F0lime mask
pure magenta #FF00FF / #F0Fmagenta / fuchsiarainbow

Matching is strict#FE0000, #FF0001, "red-ish" hexes that aren't exactly the pure primary all fall back to opts.pigment. The named colors above are the only CSS keywords mapped. green notably does not map to mask — the CSS keyword green resolves to #008000 (dark green), not #00FF00. Use lime for the named-color route, or #00FF00 directly.

Resolution model (v0.45+): stroke and fill resolve independently. The loader walks up the DOM tree separately for each property, collecting the first non-none value it finds at each level. Each property contributes its own pass to the canvas — a fill paint stamps the interior, a stroke paint traces the outline, and an element with both gets both.

For each property the walk goes:

  1. Element's own stroke= / fill= attribute (or inline style)
  2. If absent, walk up through parent <g> groups, taking the first attribute found
  3. Stop at the <svg> root
  4. Resolve the found value to a paint via triggerColors (strict) then approximateColor (CMY mix)
  5. If still unresolved but the attribute was present somewhere, fall back to opts.pigment (or currentPigment) in that slot — see v0.48 fallback rules below

An explicit fill="none" or stroke="none" stops the walk and suppresses that paint entirely. No fallback is applied for explicit none values — the author asked for nothing.

Worked example: a single SVG drives three pigments in one call:

// orchard-sketch.svg
<svg viewBox="0 0 100 100">
  <path d="M10 80 Q50 20 90 80"  stroke="#FF0000" fill="none" />
  <circle cx="50" cy="50" r="20" stroke="#FFFF00" fill="none" />
  <line x1="0" y1="100" x2="100" y2="100" stroke="#0000FF" />
</svg>

// JS — no per-element wiring needed
wc.traceSVG(svgText, { durationMs: 1500, easing: 'penStroke' });
// curve traces in rose, circle in yellow, baseline in blue

Backwards compatibility: the chroma-key matching is strict, so existing SVGs that use black strokes, neutral fills, or arbitrary colors don't trip the trigger path — they fall through to opts.pigment via the v0.48+ fallback rules (filled elements get filled with it, stroked elements outlined). For a paranoid escape hatch (e.g. you have an SVG that happens to use these pure primaries for unrelated reasons), pass triggerColors: false.

Approximated color mixing (v0.43+)

Pass approximateColor: true to decompose any parseable color into a mix of rose+yellow+blue and paint it that way. Useful for SVGs authored with a full palette (logos, illustrations, hand-drawn sketches with shading) where you want the watercolor output to look approximately like the original colors instead of forcing everything to one pigment.

The algorithm is subtractive color theory: each pigment absorbs its complementary wavelength, so the weight needed for a given target color is one minus the channel that pigment absorbs:

rose weight   = 1 - g    // rose ≈ magenta, absorbs green
yellow weight = 1 - b    // yellow,         absorbs blue
blue weight   = 1 - r    // blue ≈ cyan,    absorbs red
Input colorRGBRoseYellowBlueResult
orange #FF80000.501.000.00orange (rose+yellow)
green #0080000.501.001.00muted dark green
purple #8000801.000.500.50purple (rose-dominant mix)
pink #FFC0CB0.250.200.00pale rose-tinted
brown #A52A2A0.840.840.35warm brown (heavy rose+yellow)
black #0000001.001.001.00dark muddy (all three at max)
grey #8080800.500.500.50brownish grey
white #FFFFFF0.000.000.00no paint (paper shows)

How it deposits. Each approximated point fires up to three paint calls — one per non-zero weight — at strength scaled by that weight. The simulation's Kubelka-Munk compositor then renders the combined deposited pigment as the approximated color naturally. No special "blend" logic; the physics handles it.

Wetness consequence. A mix-paint stamp can fire 2–3 paint calls at the same point, which is 2–3× the wetness of a single-pigment stamp. Mid-saturation colors and darks bleed more. This is actually accurate to real watercolor — mixing two pigments physically does deposit more wet paint than using one. If it's too much for a given use case, scale brush waterLoad down or compose the mix in a drier state via paperWetness('damp').

Color parsing. The approximation path accepts:

  • Hex 3-digit (#F80) and 6-digit (#FF8000)
  • rgb(r, g, b) and rgba(r, g, b, a) (alpha ignored), absolute or percentage
  • Common CSS named colors: orange, purple, pink, brown, gray/grey, black, white, silver, gold, coral, salmon, khaki, turquoise, crimson, navy, teal, olive, maroon, indigo, violet, skyblue, lightblue, lightgreen, darkgreen, darkblue, darkred, green, cyan/aqua, plus the trigger names (red, yellow, blue, lime, magenta/fuchsia).

Colors that don't parse (unknown names, malformed hex, HSL, named gradients) fall through to opts.pigment like they did pre-v0.43.

Triggers + approximation together. Both flags can be on simultaneously. Resolution order per element:

  1. If triggerColors and stroke matches a trigger primary → use that pigment
  2. If approximateColor and stroke parses to a color → decompose into mix
  3. If triggerColors and fill matches a trigger primary → use that pigment
  4. If approximateColor and fill parses to a color → decompose into mix
  5. Walk up to parent <g> and repeat
  6. Final fallback: opts.pigment or currentPigment

Common pairing. Setting both triggerColors: true and approximateColor: true lets a designer use chroma-key colors for "I want this exact pigment" (e.g. #FF00FF for a rainbow accent) and any other colors for naturalistic mixing. Best of both worlds.

Filled shape stamping (v0.45+)

Before v0.45 the SVG loader only traced outlines — it sampled points along each element's perimeter and painted them in order. Stroked SVGs (line drawings, contours, calligraphic strokes) worked beautifully. Filled SVGs (logos, icons, regions with solid colors and no stroke) were effectively invisible: the loader traced the outline using the fill color, but a one-pixel-thick outline doesn't read as "a filled red square."

v0.45 changes that. fillShapes: true (default) adds a fill pass alongside the outline pass. For each element with a recognized fill paint, the loader:

  1. Computes the element's bounding box via p.getBBox()
  2. Samples a regular grid of points across the bounding box, spaced at 0.85 × brushRadius in grid units (tight, slightly overlapping coverage)
  3. For each sample, calls p.isPointInFill({x, y}) — the browser's exact fill-test, including holes via the fill-rule
  4. Keeps the inside points and adds them to the trace list with the fill paint

The fill samples come before outline samples in the array, so when both are present the outline appears on top of the fill — the natural watercolor "wash first, ink the edge after" order. Filled+stroked elements get both. Pure-fill elements just get stamped. Pure-stroke elements just get outlined (unchanged from v0.44).

Stroke and fill resolve independently. Before v0.45 the resolver collapsed both to a single paint: it walked looking for any recognized stroke OR fill and used the first hit. v0.45 splits them. An element with stroke="blue" fill="yellow" resolves to {stroke: blue, fill: yellow} — the interior stamps in yellow and the outline traces in blue. fill="none" explicitly blocks fill inheritance up the tree; stroke="none" blocks stroke inheritance.

Fallback routing for unrecognized colors (v0.48+)

When a fill or stroke value doesn't resolve to a recognized paint — an unrecognized hex with approximateColor: false, a CSS color name not in the parser's table, both flags off entirely — the loader has to choose what to do with the active brush. The rule (v0.48+) is to respect which attribute the author actually wrote, not just whether it parsed:

  • Author wrote fill="..." and it didn't resolve → the shape gets filled with the active brush. The author asked for a fill; we don't know the color, so we use yours.
  • Author wrote stroke="..." and it didn't resolve → outline with the active brush. Same reasoning.
  • Author wrote both → both happen, each falling back as needed (recognized values keep their resolved paints; unrecognized values use the active brush).
  • Author wrote neither → outline with the active brush. The "trace this SVG in my active brush" contract from the earliest versions.
  • Author wrote fill="none" or stroke="none" → that paint is suppressed entirely. No fallback. The author explicitly said no.

Before v0.48, the fallback brush always went to the OUTLINE slot regardless of which attribute the author wrote — so a graphic with fill="#2a2a2a" (off-black, common in icon sets) got outlined in the active brush instead of filled, leaving a hollow shape on the canvas. v0.48 routes the fallback to the slot the author asked for, which makes most icon-style SVGs work the way the eye expects them to.

Adjacent change: the SVG cascade now matches how browsers actually render SVG. A child's explicit attribute (recognized or not) takes precedence over a parent's. Before v0.48 an unrecognized child stroke= would keep walking up looking for a recognized parent value; now it stops at the child. <g stroke="red"><rect stroke="weird"/></g> now outlines the rect in the active brush (rect's stroke won, the value didn't resolve, fallback applies), not in rose (which v0.47 would have inherited from g).

Sample density & cap. Spacing of 0.85 × brushRadius means roughly 1.4 stamps per brush diameter. Dense enough for solid coverage with natural watercolor variation, sparse enough to avoid oversampling. There's a 10000-sample hard cap per element to protect against pathological SVGs (huge viewBoxes + tiny brush). Hitting the cap silently skips the fill for that element and continues with the outline.

Wetness consequence. A large filled shape fires hundreds to thousands of paint calls in its bounding box. Combined with approximateColor, each can become 2–3 calls per stamp (one per pigment weight). The simulation handles this fine, but if you're stamping a wide solid wash of a dark color, expect a lot of wet pigment migration during the trace. paperWetness('dry') before the trace gives crisper edges; paperWetness('wet') beforehand encourages dramatic bleeding.

Off-switch. fillShapes: false falls back to the v0.44 behavior: if an element has a fill but no stroke, the outline gets traced in the fill color (which is invisible for typical filled SVGs — use this flag only if you specifically want the old behavior). Filled+ stroked elements still trace just the outline in this mode.

Instant fills (v0.46+). By default, fill stamps paint synchronously the moment traceSVG() is called — the entire wash appears in one frame rather than animating across the duration. This is what the brain expects from a watercolor wash: you push a wet brush onto paper and the whole footprint deposits at once. The trace animation is reserved for outlines, which actually benefit from sequential reveal (a pen-stroke really does follow a path).

Without instant fills, v0.45 painted the bounding-box grid in row-major order, which the eye read as "the brush is sweeping across the shape" — still better than tracing the perimeter, but not what a watercolorist sees. With instant fills, you get the wash-then-ink rhythm: filled regions land immediately, outlines animate over them. Pass fillInstant: false to restore the v0.45 row-by-row sweep behavior.

Performance note. Instant fills can fire hundreds to thousands of paint calls in a single frame for a large SVG. The simulation handles this fine on desktop hardware; on mobile or for very dense SVGs you might see a brief stutter on the first frame. If that's a concern, fillInstant: false spreads the work over durationMs at the cost of the sweep look.

Easing curves for drawing animation

The trace animation needs a timing function — how quickly to march through the point list as wall-clock time progresses. Linear paints at a constant rate; eased curves slow at certain moments to feel more natural. Choose based on what feeling you want:

'linear'
Uniform rate throughout. Predictable; reads as mechanical or technical. Useful for procedural / CAD-style traces where the rhythm itself doesn't matter.
'easeIn'
Slow start, accelerating finish. Rare in drawing — feels hesitant at the start in a way that real strokes usually aren't. Mostly here for completeness.
'easeOut'
Fast start, decelerating finish. Mirrors a confident initial stroke that eases into the final position. Reads as casual but assured.
'easeInOut'
Slow at both ends, fast in the middle. Reads as deliberate — like the artist takes their time positioning at the start and finishing at the end. Good for "careful drawing" feel.
'penStroke'
Quick acceleration (~15% of duration), steady middle (~70%), brief deceleration at end (~15%). Closest to how a hand actually moves a pen through a continuous stroke. Pairs especially well with perStrokePauseMs > 0 for a believable multi-stroke drawing.

For SVGs with many paths (logos, illustrations, line drawings), 'penStroke' with perStrokePauseMs: 100..300 mimics the actual rhythm of a human sketching: smooth-ish strokes punctuated by pen-lift moments. For single-path traces (signatures, single curves), 'easeOut' or 'penStroke' alone is fine.

Brush dynamics

Three knobs that apply equally to mouse, pen, finger, and SVG tracing. Previously hardcoded in the pointer handlers.

Watercolor.pressure() → current value
Watercolor.pressure(v) // 0..1, default 0.7

Watercolor.flow() → current value
Watercolor.flow(v) // 0.05..2.0, default 0.4

Watercolor.usePointerPressure(v) // default false

Watercolor.continuousFlow(v) // default true
pressure
Base stamp strength for each dab. Lower values produce lighter strokes; higher saturate faster. Captured at pointer-down and held for the rest of the stroke so feel is consistent across a single drag.
flow
Stamp spacing along a drag, as a fraction of brush radius. Smaller = more stamps per stroke = smoother continuous look (and more sim work). Larger = fewer stamps = dotted/dabbed look. Clamped to 0.05..2.0 to prevent pathological values.
usePointerPressure
When true, multiplies stamp strength by PointerEvent.pressure from the stylus or pressure-sensitive trackpad. Default off because mouse browsers report a neutral 0.5 that would create unwanted dimming. Only consult e.pressure when this is on.
continuousFlow
When on, holding the pointer in place keeps pumping pigment + water at a reduced rate (CONTINUOUS_FLOW_STRENGTH = 0.12). Mimics how a real brush bleeds when held still on wet paper. Off = classic one-stamp-per-position behavior.

Toggles

All toggles follow the same pattern: pass a boolean to set, omit to read.

Watercolor.edgeDarkening();        // current state
Watercolor.edgeDarkening(false);   // disable

Watercolor.drying(false);          // pause evaporation (v2 polarity: drying ON is default)
Watercolor.autoDryBackground(true);
Watercolor.fadePainting(true);
Watercolor.transparent(true);
Watercolor.webgl(true);            // enable WebGL render path
Watercolor.webglDebugTint(true);   // visible green tint for verification

Watercolor.mobile(true);           // palm rejection on (auto on touch devices)
Watercolor.cursorPreview(false);   // disable host's brush-shape cursor (v0.34+)
Watercolor.gouacheMode(true);      // swap to opaque pigments (v0.37+)
Watercolor.gouacheMode('auto');    // LERP pigments with paper darkness (v0.51+)
Watercolor.gouacheLerpAmount();    // → 0..1, current LERP value (v0.51+)
Watercolor.evaporation(8);         // numeric evaporation multiplier (v0.52+)
Watercolor.evaporation('damp');    // ...or a preset name
Watercolor.splash('deluge');       // overwhelming single-epicenter splash (v0.52+)
Watercolor.getPreset();            // → full settings snapshot (v0.52+)
Watercolor.applyPreset(obj);       // restore from a snapshot (v0.52+)
Watercolor.perf(true);             // enable perf instrumentation + overlay
Watercolor.evaporation(value)

Accepts a paper-wetness preset name ('flooded', 'wetOnWet', 'damp', 'dryBrush', 'boneDry') or a numeric multiplier for advanced control. Called with no arg, returns the current effective multiplier.

Note: the numeric path was documented from v0.30 but only actually wired up in v0.52 (the docs led the implementation by 22 versions). Code that fell back to paperWetness(presetName) still works. Pre-v0.52 callers passing a number were silently no-op'd.

Watercolor.paperWetnessPresets();
// → ['flooded', 'wetOnWet', 'damp', 'dryBrush', 'boneDry']

Brush load

Per-dab multipliers for the pigment and water brushes. Both default to 1.0 and accept floats; the UI sliders clamp them to sensible ranges (paint 0.1–3, water 0.2–3), but the API doesn't enforce a hard cap — extreme values just saturate the per-cell maxima.

Watercolor.paintLoad(mult?)
Watercolor.waterLoad(mult?)

// Examples
Watercolor.paintLoad(2);     // dense, saturated pigment
Watercolor.paintLoad(0.3);   // pale, sparse pigment
Watercolor.waterLoad(2.5);   // very wet water brush — big pools
Watercolor.waterLoad(0.5);   // damp water brush — gentle lifting

paintLoad affects pigment brushes (rose, yellow, blue) and rainbow — it controls how much pigment each dab deposits.

waterLoad affects every brush that carries water: the dedicated water brush (where it scales wet, pressure, and lift), and the pigment and rainbow brushes (where it scales the wet + pressure contribution they implicitly add along with their pigment). The two sliders combine on pigment brushes to model a physical "loading the brush" picture:

paintLoad 2×, waterLoad 1×
Saturated brush, normal water — dense pigment with normal bleeding.
paintLoad 2×, waterLoad 2×
Saturated wet brush — dense pigment with dramatic bleeding.
paintLoad 0.3×, waterLoad 2×
Sparse wet brush — pale paint with lots of bleed (dry-brush-into-wash effect).
paintLoad 2×, waterLoad 0.3×
Thick dry brush — saturated paint that barely spreads.

Actions

Chainable unless noted — exportImage returns a data URL, obliterate a Promise.

Watercolor.rewet();
Watercolor.splash();                    // default preset, auto epicenters
Watercolor.splash('bigSplash');         // big preset
Watercolor.splash('fineSpritz');        // fine preset
Watercolor.dry();
Watercolor.removeMask();
Watercolor.exportImage();               // → data URL (was exportPNG in v1)
Watercolor.reset();

Watercolor.sketchMode();                // v0.30+ — felt-pen bundle
Watercolor.obliterate();                // v0.30+ — wet wash (returns Promise)
Watercolor.obliterate({ mode: 'pigment', durationMs: 800 });
Watercolor.obliterate({ mode: 'paper', x: 60, y: 60, durationMs: 500 });

sketchMode (v0.30+)

sketchMode() is a one-shot settings bundle, not a mode flag. It applies a series of settings in sequence: a 7px fine-tip brush (its brushSize option stays in display pixels), paintLoad(1.6), waterLoad(0.25), pressure(0.95), paperWetness('boneDry'), continuousFlow(false), edgeDarkening(false), fadePainting(false). After it runs there's no "sketch mode" to turn off — just a brush configured for fine-line work. Tuned for SVG tracing and thumbnail-style drawing where you want crisp, controlled strokes instead of wet diffusion. Pass { brushSize, paintLoad, ... } to override specific settings while keeping the rest of the bundle.

obliterate (v0.30+)

Destroys the current drawing via an animated wash. Returns a Promise that resolves when the animation finishes. Three modes:

// Water rinse (default) — pigment lifts and diffuses out
await Watercolor.obliterate();

// Opaque wash in the active pigment color
await Watercolor.obliterate({ mode: 'pigment', durationMs: 800 });

// Clear to paper color
await Watercolor.obliterate({
  mode: 'paper', durationMs: 500,
  x: gridX, y: gridY,        // splash center (defaults to canvas center)
});

Modes correspond to the panel button's modifier keys: plain click → 'water', shift → 'pigment', alt → 'paper'. {x, y} are grid coordinates — use grid.fromDisplay(clientX, clientY) to convert pointer events and pass its gx/gy. The Multi-Watercolor quickstart's click handler documents the common pattern.

Splash with explicit coordinates

Pass an array of {x, y} points — normalized 0..1 across the canvas, like every other v2 position — to choreograph the splash exactly. Each point can include optional radius (fraction of the smaller side), velocity, pressure, and lift overrides that apply just to that epicenter.

// Two splashes at specific locations
Watercolor.splash([
  { x: 0.2, y: 0.45 },
  { x: 0.7, y: 0.75 }
]);

// With a preset
Watercolor.splash([{ x: 0.5, y: 0.5 }], 'fineSpritz');

// Per-point overrides — one big, one subtle
Watercolor.splash([
  { x: 0.2, y: 0.45, radius: 0.25, velocity: 1.2 },
  { x: 0.7, y: 0.75, radius: 0.05, velocity: 0.3 }
]);

// Object form (alternative)
Watercolor.splash({
  coords: [{ x: 0.5, y: 0.5 }],
  preset: 'bigSplash'
});

// Discover preset names
Watercolor.splashPresets();
// → ['default', 'bigSplash', 'fineSpritz', 'deluge']

// Chain after a wash to disperse it
Watercolor.backgroundAnimation('sunset').splash('fineSpritz');

Per-point overrides win over the preset; preset values fill in anything the point doesn't specify.

Performance

Watercolor.perf(v?)

Toggle or read the performance overlay state. The overlay shows p50/p95/p99 frame times, per-phase ms breakdown, active cell count, heap delta (Chromium-only), and Long Animation Frames.

Watercolor.webglAvailable() → bool

Tests for WebGL2 support. Lazy-inits on first call. Use this before calling Watercolor.webgl(true) to know whether the toggle will succeed.

Presets (v0.52+)

Watercolor.getPreset() → preset object
Watercolor.applyPreset(obj) → instance

Capture and restore every user-controllable setting. The returned preset object is a plain JSON-serializable snapshot (~350 bytes) covering 18 fields:

{
  version: 1,
  // Brush
  brushSize, pigment, pressure, flow, paintLoad, waterLoad,
  // Paper / sim
  paperColor: { r, g, b },
  evaporation,        // numeric multiplier
  gouacheMode,        // false | true | 'auto'
  edgeDarkening, pauseDrying, transparent, continuousFlow,
  // Fade
  fadePainting, fadeHalfLifeMs,
  // Animation / visualization
  animationMode, visualizationMode
}

Excludes painted artwork (the d[]/g[] arrays are content, not settings) and system-dependent toggles (WebGL, mobile mode, perf overlay). Apply order is enforced: paper color first (so gouacheMode('auto') recomputes its LERP from the new paper darkness), then brush, then toggles, then fade, then animation/visualization last. Missing fields are skipped (forward-compat with old presets); unknown fields are ignored (forward-compat with future-version presets).

Applying a preset fires 'presetapplied' on the host element with detail: { preset }. Useful for hosts that mirror lib state into DOM controls (sliders, swatches, toggles) — listen and re-sync.

// Save / load via localStorage
localStorage.setItem('myPreset', JSON.stringify(wc.getPreset()));

const saved = JSON.parse(localStorage.getItem('myPreset'));
wc.applyPreset(saved);

// Apply across instances (Washes is the factory namespace)
const wc2 = Washes.create(otherEl);
wc2.applyPreset(wc.getPreset());

// React to applied presets
hostEl.addEventListener('presetapplied', (e) => {
  console.log('applied preset:', e.detail.preset);
});

Coordinates

Normalized coordinates are THE space of API 2.00..1 across the canvas in both axes, radii as fractions of the smaller display side. They're resolution-independent: the same call paints the same composition at every quality setting. Two other spaces exist at the edges: grid coordinates (0..GW × 0..GH simulation cells) for code that thinks in cells, and display pixels, which is what pointer events come in as. The grid namespace holds the cell-space verbs and all the converters:

Watercolor.grid.width;   // → 1152   (live values — change on rescale,
Watercolor.grid.height;  // → 648     so don't cache them)

// Cell-space verb family (v1's grid-space calls, exactly)
Watercolor.grid.paint(gx, gy, gridRadius, pigment?, strength?);
Watercolor.grid.stir(gx, gy, vx, vy, gridRadius?);
Watercolor.grid.rewet(gx, gy, gridRadius?);
Watercolor.grid.dry(gx, gy, gridRadius?);
Watercolor.grid.sample(gx, gy);

// Converters
Watercolor.grid.fromDisplay(displayX, displayY);  // → { gx, gy }
Watercolor.grid.toDisplay(gx, gy);                // → { x, y } display px
Watercolor.grid.toNorm(gx, gy);                   // → { nx, ny }
Watercolor.grid.fromNorm(nx, ny);                 // → { gx, gy }

State snapshot

Watercolor.state() → object

Snapshot of top-level simulation state. Useful for debugging. Does not include the giant Float32Arrays.

{
  // governor + sim status (v1.4–v1.5)
  autoPerf: true,
  perfLevel: 'full',
  isIdle: false,
  totalWetness: 1841.2,
  totalSuspended: 96.4,
  // geometry + config
  gridWidth: 1152,
  gridHeight: 648,
  displayWidth: 1920,
  displayHeight: 1080,
  brushSize: 30,             // NOTE: internal px diameter — the
                             // brushSize() accessor speaks fractions
  pigment: 0,                // current pigment index
  canvasScale: 0.6,
  animationMode: 'rainy',
  visualizationMode: 'kaleidoscope',
  backgroundRunning: false,
  edgeDarkening: true,
  fadePainting: false,
  pauseDrying: false,
  gouacheMode: false | true | 'auto'  // v0.37+, 'auto' added in v0.51
}

state() is settings and status — for the painting itself, saveState() returns a full snapshot of the pigment/water field that loadState() restores bit-exactly (v1.22+). Pair with getPreset() to round-trip a whole session.

Agents & automation

This page is for coding agents and automation tools — Cursor, Claude Code, ChatGPT, Antigravity, Windsurf, Continue, Cline, Aider, custom scripts, evaluation harnesses — that want to drive the canvas programmatically. Humans are welcome too. The information here is dense by design.

Quick spec

What this is. A real-time pigment-and-water simulation, written in vanilla JavaScript with no dependencies. The library implements the Curtis et al. (1997) watercolor model with Kubelka–Munk compositing, shallow-water flow on a staggered grid (Foster & Metaxas 1996), and edge darkening. Runs CPU-side by default with an optional WebGL2 render path for compositing.

How to mount. The library exposes a factory:

const wc = Washes.create(targetEl, options?);
// (window.Watercolor is a legacy alias of the same namespace; on THIS
// page the playground reassigns window.Watercolor to its live instance)

Every method on the returned wc handle is stateful, and nearly all are synchronous. The async exceptions return Promises: obliterate(), paintImage(), exportImage({ asBlob: true }), and once(event). The simulation runs on a single requestAnimationFrame loop owned by the instance. The host's only job is to call methods when state changes are wanted.

Coordinate system. All primary position arguments (paint, stroke, line, splash epicenters, mask rects) are normalized: 0..1 across the canvas, radii as fractions of the smaller display side. Grid-cell space lives on the wc.grid namespace (grid.paint, grid.stir, …; paintText and obliterate also take grid coords). Read wc.grid.width / wc.grid.height for current cell dimensions; convert display px via wc.grid.fromDisplay(clientX, clientY){gx, gy}.

Idle behavior. The sim auto-idles when no pigment is moving (no recent paint events, no animation running, no visualization). When idle, the RAF loop is essentially free — it ticks at 60Hz but skips simStep and render. Painting via paint automatically wakes the sim.

Type signatures

TypeScript-style signatures for every public method. Getter-setter pattern: omit the argument to read; pass to write. All setters return the API handle (chainable) unless otherwise noted.

// === FACTORY ===
Washes.create(targetEl: HTMLElement, options?: CreateOptions): Instance
Washes.version   // engine semver string
Washes.compat1(instance)   // rebuild the complete v1 surface (until 3.0)

interface CreateOptions {
  canvasScale?: number       // 0.1..1.0, default auto from target width
  scale?: number             // sim resolution divisor
  size?: { width, height }   // fixed canvas size instead of host-fill
  mobile?: boolean           // override matchMedia('(pointer: coarse)')
  continuousFlow?: boolean   // default true
  pointer?: boolean          // default true; false disables pointer events
  cursorPreview?: boolean    // default: ON on desktop, OFF on touch devices
  gouacheMode?: boolean | 'auto' | number
  paperColor?: { r, g, b }   // 0..1 RGB
  pauseDrying?: boolean
  qualityHint?: string       // initial quality preset
  transparent?: boolean
  pigments?: PigmentSpec[]   // custom palette (K/S/density/staining/granulation)
  seed?: number              // v1.23 — deterministic splash/rainbow randomness
}

// === BRUSH STATE ===
brushSize(): number | brushSize(fraction: number): Instance
    // RADIUS as fraction of the smaller display side; default 0.03
pigment(): number | pigment(name: string | idx: number): Instance
pressure(): number | pressure(v: number): Instance        // 0..1, default 0.7
flow(): number | flow(v: number): Instance                // 0.05..2.0, default 0.4
paintLoad(): number | paintLoad(v: number): Instance      // 0.1+, default 1.0
waterLoad(): number | waterLoad(v: number): Instance      // 0.2+, default 1.0
brushMode(): string | brushMode(name: string): Instance   // 'wash' | 'crayon' | …
continuousFlow(): boolean | continuousFlow(v: boolean): Instance
usePointerPressure(): boolean | usePointerPressure(v: boolean): Instance

// === PAINTING (normalized 0..1; nradius = fraction of smaller side) ===
paint(nx, ny, nradius?, pigment?, strength?): Instance
stroke(nx, ny, opts?): Instance      // pointer-stream stroke; penUp() ends it
line(nx0, ny0, nx1, ny1, opts?): Instance
penUp(): Instance
stir(nx, ny, vx, vy, nradius?): Instance
rewet(): Instance | rewet(nx, ny, nradius?): Instance
dry(): Instance | dry(nx, ny, nradius?): Instance
lift(nx, ny, nradius?, fraction?): Instance
blot(nx, ny, nradius?, strength?): Instance
sample(nx, ny): SampleResult
mask(nx, ny, nw, nh, radii?): Instance
unmask(nx, ny, nw, nh, radii?): Instance
splash(epicenters?, style?, opts?): Instance
paintText(text: string, opts?: TextOptions): number   // grid coords; returns dabs
paintImage(source: File | Blob | HTMLImageElement | string,
           opts?: PaintImageOptions): Promise<number>  // resolves to stamps painted
traceSVG(svgText: string, opts?: SVGTraceOptions): number  // returns points queued
cancelSVGTrace(): Instance

// === GRID NAMESPACE (cell space) ===
grid.width, grid.height          // live cell dims — don't cache
grid.paint(gx, gy, gridRadius, pigment?, strength?): Instance
grid.stir / grid.rewet / grid.dry / grid.sample   // v1 grid-space impls, exactly
grid.fromDisplay(x, y) → {gx, gy}
grid.toDisplay(gx, gy) → {x, y}
grid.toNorm(gx, gy) → {nx, ny} | grid.fromNorm(nx, ny) → {gx, gy}

interface TextOptions {
  x?: number; y?: number; fontSize?: number; sampleStep?: number;
  strength?: number; pigment?: string | number;
}
interface PaintImageOptions {
  brushSize?: number;                   // drives sample spacing
  strength?: number;                    // base strength per stamp, 0..1
  pigment?: string | number;            // fallback for skipped pixels
  alphaThreshold?: number;              // 0..1 — pixels below skip. default 0.05
  density?: number;                     // spacing multiplier — <1 denser, >1 sparser. default 1
  flipX?: boolean;                      // mirror horizontally. default false
  flipY?: boolean;                      // mirror vertically. default false
  maxStamps?: number;                   // hard cap, default 20000
}
interface SVGTraceOptions {
  pigment?: string | number; brushSize?: number;
  strength?: number;
  flipY?: boolean;                      // default false (v0.42+); true mirrors Y
  flipX?: boolean;                      // default false (v0.42+); true mirrors X
  instant?: boolean;                    // paint synchronously, no animation
  durationMs?: number;                  // spread trace over this many ms
  easing?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'penStroke';
  perStrokePauseMs?: number;            // pause between SVG path elements
  triggerColors?: boolean;              // default true (v0.43+); exact chroma-key match
  approximateColor?: boolean;           // default false (v0.43+); CMY mix decomposition
  fillShapes?: boolean;                 // default true (v0.45+); stamp interior of filled elements
  fillInstant?: boolean;                // default true (v0.46+); paint fills synchronously at load
  colorMap?: boolean;                   // (deprecated alias for triggerColors)
}

// === SCENES (unified accessors: name to set, no args to read) ===
animation(name: AnimationName, opts?: { replace?: boolean }): Instance
animation(): string
visualization(name: VisualizationName, opts?: { replace?: boolean }): Instance
visualization(): string
backgroundAnimation(name: BackgroundName): Instance
backgroundAnimation(): string | null
backgroundAnimationRunning(): boolean

// === RUN POLICY ===
run(policy: 'auto' | 'until-dry' | 'always'): Instance | run(): string
drying(v: boolean): Instance | drying(): boolean   // false pauses evaporation

type AnimationName = 'off' | 'ai' | 'rainy' | 'sunny' | 'windy'
                   | 'thunderstorm' | 'partlyCloudy' | 'snowing'
                   | 'snowingAdditive' | 'tornado';
type VisualizationName = 'off' | 'kaleidoscope' | 'lissajous'
                       | 'flowField' | 'pulse';
type BackgroundName = 'day' | 'dawn' | 'sunset' | 'night' | 'tornado';

// === TOGGLES (omit arg to read; setters return Instance) ===
edgeDarkening(v?: boolean)
autoDryBackground(v?: boolean)
fadePainting(v?: boolean)
transparent(v?: boolean)
webgl(v?: boolean)
webglAvailable(): boolean
webglDebugTint(v?: boolean)
mobile(v?: boolean)
mobileDetected(): boolean
cursorPreview(v?: boolean)
gouacheMode(v?: boolean | 'auto' | number)
gouacheLerpAmount(): number
perf(v?: boolean)

// === CONTINUOUS PARAMS ===
fadeHalfLife(ms?: number)               // 500..30000
scale(v?: number)                       // 1..6 — PRESERVES painting (v2);
                                        //   { wipe: true } opts out
remeasure(opts?: { wipe?: boolean })    // re-fit to host, preserving
quality(preset?: string)                // 'auto' | 'full' | 'half' | …
paperWetness(name?: string)
paperColor(r?, g?, b?)                  // 0..1 RGB, or a CSS string
evaporation(v?: number | string)

// === ACTIONS ===
rewet(): Instance        // no-arg = full-canvas soak
dry(): Instance          // no-arg = instant full dry; fires 'driedinstantly'
reset(): Instance        // clear all paint + paper state
removeMask(): Instance
splashPresets(): string[]
paperWetnessPresets(): string[]
pigments(): number[]     // available pigment indices

// === HIGH-LEVEL PRESETS (v0.30) ===
sketchMode(opts?: SketchOptions): Instance   // felt-pen settings bundle
obliterate(opts?: ObliterateOptions): Promise<void>

interface SketchOptions {
  brushSize?: number;     // default 7
  paintLoad?: number;     // default 1.6
  waterLoad?: number;     // default 0.25
  pressure?: number;      // default 0.95
  paperWetness?: string;  // default 'boneDry'
}
interface ObliterateOptions {
  mode?: 'water' | 'pigment' | 'paper';     // default 'water'
  pigment?: string | number;       // for 'pigment' mode; default current
  durationMs?: number;             // default 500
  x?: number; y?: number;          // grid coords; default canvas center
  radiusFraction?: number;         // 0..1, default 0.7
  intensity?: 'gentle' | 'normal' | 'extreme';  // default 'normal'
}

// === RENDER + EXPORT ===
exportImage(opts?: { transparent?, mimeType?, quality? }): string  // data URL
exportImage({ asBlob: true, ... }): Promise<Blob>
buildPigmentSwatches(rootEl: HTMLElement): Instance

// === STATE + EVENTS ===
state(): StateSnapshot                   // settings + status (small)
saveState(): Snapshot                    // full painting — v1.22
loadState(snapshot): Instance            // bit-exact restore
getPreset(): Preset | applyPreset(p): Instance
on(name, cb): Unsubscribe                // typed events; also mirrored as
once(name): Promise<detail>              //   lowercase DOM CustomEvents
onFrame(cb): Unsubscribe
displayRect(): { width, height, ... }
coverage(threshold?): number             // fraction of canvas painted
destroy(): void   // cancels RAF, removes listeners, drops references

Pigment name resolution. All methods accepting a pigment accept one of: 'rose' | 0, 'yellow' | 1, 'blue' | 'cerulean' | 2, 'water' | -1, 'lift' | -2, 'rainbow' | -3, 'mask' | -4, 'paper' | -5. Unknown names throw Error: Unknown pigment: <name>.

Recipes

Common tasks as standalone snippets. All assume wc = Washes.create(targetEl) has been called and the instance is in its default state.

Single dab at canvas center

wc.paint(0.5, 0.5, 0.02, 'blue', 0.7);
// Normalized: (0.5, 0.5) is center at every resolution; radius
// 0.02 = 2% of the smaller display side.

Continuous stroke from A to B

// One call — line() interpolates stamps with the live flow model,
// the same path pointer strokes take.
wc.line(0.1, 0.15, 0.45, 0.6, { pigment: 'rose' });

// For streamed input (a robot hand, a replay, generated points),
// feed positions to stroke() and end with penUp():
for (const p of points) wc.stroke(p.x, p.y);
wc.penUp();

Loop-driven painting (timed sequence)

// Paint a sequence with realistic timing: paint, let it bleed for
// 2 seconds, paint again, let it dry. Note that you do NOT need to
// step the sim manually — the lib's RAF runs continuously. You
// just decide when to inject paint events.
async function sequence() {
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  wc.pigment('rose').brushSize(0.03);        // setters chain in v2
  wc.paint(0.28, 0.45, 0.02, 'rose', 0.7);
  await sleep(2000);                          // bleed for 2s
  wc.paint(0.36, 0.52, 0.018, 'blue', 0.7); // wet-on-wet mixing
  await sleep(3000);                          // bleed more
  wc.dry();                                   // instant dry
  const png = wc.exportImage();
  return png;
}

Multi-instance setup

// Each create() returns an independent simulation. No globals
// are shared between instances; safe to mount many on one page.
const instA = Washes.create(document.getElementById('canvas-a'));
const instB = Washes.create(document.getElementById('canvas-b'),
  { canvasScale: 0.5 });   // half-resolution for the smaller embed
instA.backgroundAnimation('sunset');
instB.animation('rainy');

SVG trace with custom brush

const svgText = await fetch('/some-vector.svg').then(r => r.text());
wc.traceSVG(svgText, {
  pigment: 'cerulean',
  brushSize: 30,
  strength: 0.6,
  // flipY: false is the default — standard SVGs (Illustrator, Figma,
  // Inkscape, web exports) are Y-down already, so no flip is needed.
  // Pass flipY: true for genuinely Y-up sources (some CAD/plot exports).
});

Filled-icon SVG painted in the active brush

// v0.48+ — Drop a filled icon (no triggers, no approximation needed)
// onto the canvas as a fill in the user's currently-active pigment.
// Works because v0.48 routes the fallback brush to whichever slot
// the SVG author wrote — fill="#222" gets filled with active brush,
// stroke="#222" gets outlined with active brush.
wc.pigment('rose');
wc.traceSVG(iconSvgText, {
  brushSize: 24,
  triggerColors: false,   // ignore #FF0000 etc.
  approximateColor: false, // no CMY decomposition
  // Result: every fill="..." gets stamped in rose, every stroke="..."
  // gets outlined in rose. The author's "fill vs stroke" choice is
  // preserved — you just get to choose the color.
});

Mixed SVG — naturalistic mixing for any color, brush for unknowns

// Best-of-all-worlds setup for hand-illustrated SVGs.
wc.traceSVG(illustrationSvg, {
  brushSize: 18,
  triggerColors: true,     // chroma-key colors map to specific pigments
  approximateColor: true,  // arbitrary RGB decomposes into rose+yellow+blue mix
  // Result: #FF00FF rainbow accent → rainbow brush; #FF8000 orange →
  // rose+yellow mix; truly unparseable colors (HSL, named outside the
  // table) → fall back to active brush, preserving fill/stroke role.
});

Set scene + animation, then capture frames

// Useful for procedurally generating training data / examples.
wc.backgroundAnimation('dawn');
wc.animation('rainy');
const frames = [];
const intervalId = setInterval(() => {
  frames.push(wc.exportImage());
  if (frames.length >= 60) {
    clearInterval(intervalId);
    wc.animation('off');
  }
}, 1000 / 30);   // 30 fps capture

Listen for sim state changes

// v1.24 unified events: one typed map, subscribed via on()/once().
// Names: 'idle', 'active', 'dry', 'rescale', 'perflevel',
// 'palettechange', 'gouachechange', 'cursorpreviewchange',
// 'presetapplied', 'driedinstantly'.
const off = wc.on('rescale', (detail) => {
  console.log('grid is now', detail.gridWidth, 'x', detail.gridHeight);
});
off();                                  // unsubscribe

await wc.once('idle');                  // promise form — fires once

// Every event is also mirrored as a lowercase DOM CustomEvent on
// targetEl (detail on e.detail) for hosts that prefer listeners:
targetEl.addEventListener('rescale', (e) => { /* e.detail.scale … */ });
// Plus 'pigmentchange', fired by lib-built swatches when clicked
// (only when using buildPigmentSwatches).

Project thumbnails (sketch + obliterate)

Use SVG-traced sketches as thumbnails, then "destroy" the sketch on click as a transition into the project page. Combines sketchMode() (felt-pen settings bundle), traceSVG() (drawing engine), and obliterate() (transition animation).

// Each thumbnail mounts its own Washes instance.
async function mountThumbnail(targetEl, svgUrl, projectUrl) {
  const wc = Washes.create(targetEl, {
    canvasScale: 0.5,    // small canvas, scale brush proportionally
    pointer: false,      // no painting from clicks — we handle clicks ourselves
  });
  wc.sketchMode();       // felt-pen settings bundle
  const svgText = await fetch(svgUrl).then(r => r.text());
  // instant: true — paint the entire trace synchronously so the
  // thumbnail appears finished on page load (no animation visible).
  // For a deliberate drawing animation, swap to { durationMs: 1500 }.
  wc.traceSVG(svgText, { pigment: 'rose', instant: true });

  // Click handler — obliterate the sketch, then navigate.
  targetEl.addEventListener('click', async (e) => {
    e.preventDefault();
    // Pass clientX/Y DIRECTLY — grid.fromDisplay does its own
    // getBoundingClientRect internally. Pre-subtracting rect.left/top
    // double-subtracts. The result keys are gx/gy (honest cell-space
    // names, renamed from v1 toGrid's ambiguous x/y).
    const { gx, gy } = wc.grid.fromDisplay(e.clientX, e.clientY);
    await wc.obliterate({
      mode: 'water',     // or 'pigment' for a color wash
      durationMs: 500,
      x: gx, y: gy,      // obliterate speaks grid coords
    });
    window.location = projectUrl;
  });
  return wc;
}

// Wire up 4 project tiles. Each is independent.
const tiles = [
  { el: '#tile-a', svg: '/sketches/proj-a.svg', url: '/projects/a' },
  { el: '#tile-b', svg: '/sketches/proj-b.svg', url: '/projects/b' },
  { el: '#tile-c', svg: '/sketches/proj-c.svg', url: '/projects/c' },
  { el: '#tile-d', svg: '/sketches/proj-d.svg', url: '/projects/d' },
];
tiles.forEach(t => mountThumbnail(document.querySelector(t.el), t.svg, t.url));

The hero canvas in the same page can run its own animation via a fifth instance — e.g. heroWc.animation('rainy') — with no coordination needed. Each instance owns its own RAF loop and state arrays.

For pigment obliteration instead of water rinse, pass mode: 'pigment' and optionally pigment: 'blue' — opaque wash of the chosen color covers the sketch.

V2 raster note: The current obliterate works with any pigment configuration on the canvas, so a future "trace from raster" path that deposits pigment per-pixel sampling will participate cleanly. The transition animation has nothing SVG-specific.

Dark-mode portfolio (gouache on near-black paper)

Combine gouacheMode with a dark paperColor() for the "vibrant pigment on dark background" aesthetic (v0.37+):

const wc = Washes.create(targetEl, {
  canvasScale: 0.6,
  pointer: true,
  gouacheMode: true,             // opaque pigments from boot
});

// Near-black with a hint of warmth. Pure black looks flat — a
// little color gives the gouache something to register against,
// like real charcoal-grey paper. 0..1 RGB (or a CSS string).
wc.paperColor(0.08, 0.07, 0.09);

// Standard brush state; the K/S values do the heavy lifting.
wc.brushSize(0.08);
wc.pigment('rose');
wc.paint(/* … */);

Toggle gouache mode at runtime to preview the difference mid-painting — the K/S values are read per-frame, so already-deposited pigment recomposites under the new physics:

// Paint a wash in transparent mode
wc.pigment('rose');
wc.paint(0.3, 0.45, 0.05, 'rose', 0.8);

// Flip — same wash now renders opaque
wc.gouacheMode(true);

// Flip back — and back to transparent
wc.gouacheMode(false);

// v0.51 — Auto mode: pigments LERP with paper darkness.
// Light paper → mostly transparent, dark paper → mostly opaque,
// continuous in between.
wc.gouacheMode('auto');
wc.paperColor(0.1, 0.1, 0.12);     // dark paper — LERP snaps to ~95% opaque
wc.gouacheLerpAmount();            // → 0.929 (current mix)

// Direct numeric override — bypass paper-driven LERP,
// pin to any value in [0, 1]. Treated as 'auto' for state semantics.
wc.gouacheMode(0.3);                // 70% transparent, 30% opaque
wc.gouacheLerpAmount();            // → 0.3

Listen for the 'gouachechange' CustomEvent on targetEl if your UI shows mode-dependent state (e.g. swatches need to refresh because their visible colors come from the active K/S set):

targetEl.addEventListener('gouachechange', (e) => {
  console.log('gouache:', e.detail.enabled);
  rebuildSwatchUI();
});

Gotchas & invariants

Surprising-but-true facts. These are things that will save you from chasing phantom bugs.

Coordinates are normalized 0..1, NOT pixels.
paint(0.5, 0.5, 0.03, 'blue', 0.7) paints at canvas center with a radius of 3% of the smaller display side — at every resolution. Values > 1 are almost always a v1 habit (grid or px coords) leaking through: the cell-space calls live on wc.grid (grid.paint(gx, gy, gridRadius, …)), and pointer clientX/Y convert via wc.grid.fromDisplay().
All radii are RADII, as fractions of the smaller side.
brushSize() too — v2 unified it with every other radius (v1's px diameter is on compat1 only). Default 0.03. To hit an exact px diameter, bridge through displayRect(): wc.brushSize(px / 2 / Math.min(r.width, r.height)).
Pigments mix subtractively, not RGB-additively.
Red + green = brown-ish (not yellow), red + blue = purple, etc. Driven by Kubelka–Munk K/S triplets from Figure 5 of Curtis et al. 1997. 'water' doesn't deposit pigment; it deposits wetness and dilutes whatever is there. 'lift' draws pigment back into suspension.
scale() rebuilds the sim — but PRESERVES the painting (v2).
Changing resolution reallocates all state arrays and resamples the painting into them (v1 wiped; pass { wipe: true } for that). The instance fires 'rescale' (renamed from v1's 'rescaled') when done. Don't call scale() in a tight loop — debounce by ~80ms. One carve-out: the automatic rebuild on host resize still wipes for now.
mask is sticky.
Mask deposits frozen-pigment-protection cells. Painting with 'mask' over an area makes that area immutable to subsequent painting + diffusion until you call removeMask(). reset() also clears the mask along with everything else.
WebGL toggle does not change physics.
wc.webgl(true) moves the per-pixel Kubelka–Munk compositing onto the GPU. The bleeding, advection, evaporation, edge darkening — the actual simulation — still runs on the CPU. So toggling WebGL changes frame rate, not the resulting painting.
fadePainting() doesn't fade unpainted pixels.
Only cells with deposited pigment fade. The paper texture itself doesn't decay. If a canvas is fully covered, fade will gradually return everything to paper-tone; if it's partially covered, only the painted regions thin out.
destroy() is required for clean unmount.
Without it, the RAF loop keeps running and the canvas element keeps capturing pointer events. In test harnesses or hot-reload scenarios, always wc.destroy() before unmounting targetEl.
SVG trace flipY default is false (v0.42+).
Standard SVG sources (Illustrator, Figma, Inkscape, web exports) and Canvas / CSS all use Y-down, so no flip is needed and the trace appears right-side-up by default. Pass { flipY: true } for genuinely Y-up sources (rare — some CAD exports, plot-style diagrams in Cartesian convention). Behavior change: v0.28..v0.41 defaulted to true, which made standard SVGs render upside-down. If you were passing flipY: false explicitly to compensate, you can now omit it. If you weren't passing anything and your SVGs looked correct, you were using one of the rare Y-up sources and should now pass flipY: true. v0.42 also added flipX for the analogous horizontal mirror, defaulting to false.
pressure() is captured at pointer-down.
Changing pressure() mid-stroke does NOT affect the in-progress drag. The strength for the rest of the stroke is locked at pointer-down time. This is intentional — per-event pressure variability looks chaotic to most users.

Driving from a loop

Patterns for autonomous canvas driving. The library is designed to be controlled imperatively: you don't queue events, you just call methods when you want changes.

// Pattern 1: Step-by-step deterministic sequence
async function paint() {
  for (const step of plan) {
    if (step.type === 'paint') {
      wc.paint(step.nx, step.ny, step.nr, step.pigment, step.strength);
    } else if (step.type === 'wait') {
      await new Promise(r => setTimeout(r, step.ms));
    } else if (step.type === 'dry') {
      wc.dry();
    }
  }
}

// Pattern 2: Continuous control loop with state polling
function controller(targetNX, targetNY) {
  let active = true;
  function tick() {
    if (!active) return;
    const s = wc.state();
    // Decide next action based on current state — e.g. keep painting
    // until the sim settles.
    if (!s.isIdle) {
      wc.paint(targetNX, targetNY, 0.015, 'rose', 0.5);
    }
    requestAnimationFrame(tick);
  }
  tick();
  return () => { active = false; };
}

// Pattern 3: Test harness — discrete steps with deterministic state
async function runEval(scenario) {
  const host = document.createElement('div');
  document.body.appendChild(host);
  const wc = Washes.create(host, {
    pointer: false,               // no UI input
    seed: scenario.seed ?? 42,    // v1.23 — reproducible randomness
  });
  for (const action of scenario.actions) action(wc);
  await new Promise(r => setTimeout(r, scenario.settleMs ?? 1500));
  const png = wc.exportImage();
  wc.destroy();
  document.body.removeChild(host);
  return png;
}

Determinism. The sim core is fully deterministic — no Math.random inside the physics, and create({ seed }) (v1.23) pins the splash/rainbow randomness, so an identical call sequence stepped the same way reproduces a painting bit-exactly (that's how the engine's 249 golden checks work). What is not deterministic in the browser is timing: RAF cadence decides how many sim steps land between your calls, so wall-clock-driven runs still vary. For exact round-trips use saveState()/loadState() (bit-exact, v1.22); for RAF-driven evals compare structurally (coverage(), sample(), bounding boxes) rather than byte-for-byte.

Pointer-free instances. Pass { pointer: false } in create options to disable the built-in pointer event handlers. Useful for test harnesses where mouse-driven painting would interfere with programmatic painting via paint.

llms.txt & AGENTS.md

Two emerging conventions for making projects readable by coding agents. Neither is required to use this library, but both work well alongside it if you're embedding the library in a larger codebase.

llms.txt — A short Markdown file at your site root that summarizes the project for an LLM fetching it. Recommended location: /llms.txt. A reasonable template for projects using this library:

# My Watercolor App

A <one-sentence project description>.

## What it uses
- Washes 2.x engine — a physical watercolor simulation
  implementing Curtis et al. 1997. State is held inside a factory
  instance returned by `Washes.create(targetEl, options)`.
  Coordinates are normalized 0..1; radii are fractions of the
  smaller display side.

## API surface this app uses
- wc.backgroundAnimation(name)
- wc.paint(nx, ny, nr, pigment, strength)
- wc.exportImage()

## Where to look
- src/main.js — instance creation + UI wiring
- src/scenes/ — declarative scene definitions
- public/index.html — markup the library mounts into

See the Washes engine's own `/docs#agents` page for full
API surface and gotchas.

AGENTS.md — A README-style file aimed at coding agents. Whereas README.md targets humans skim-reading on GitHub, AGENTS.md targets an LLM looking for actionable knowledge. Keep it terse, link to specific files, list constraints explicitly.

# AGENTS.md

## Project shape
- Single-page web app, no framework, vanilla JS.
- Entry: `src/main.js` (creates one Watercolor instance).
- No build step; deploy by uploading `index.html` + `src/`.

## Conventions
- Async: prefer `async/await`. No callbacks.
- Naming: camelCase functions, PascalCase classes, ALL_CAPS constants.
- File length cap: ~500 lines per file; split otherwise.

## When making changes
- DO NOT add npm dependencies without asking. This repo is
  zero-dependency by policy.
- DO NOT modify `vendor/washes.standalone.js` — it's an external
  library. To customize behavior, configure via options or wrap
  the instance.
- Tests live in `test/` and run via `node --test`.

## Common tasks
- Add a new scene: create `src/scenes/yourName.js` exporting a
  default function `(wc) => void`, then register in `scenes.js`.
- Change pigment palette: pass `pigments: [...]` to
  `Washes.create()` — consult `docs#agents` for the
  K/S/density/staining/granulation tuple meanings first.

For evaluations and benchmarks. If you're building eval harnesses that drive this library, the test pattern in "Driving from a loop" above (Pattern 3) is the recommended shape: pointer-free instance, sequence of programmatic paint calls, settle period, capture frame, destroy.

Multi-Watercolor quickstart

A friendly walkthrough for putting four watercolor canvases on a single page — one full-viewport hero you can paint on, plus three thumbnail cards that animate themselves on load and wash away when clicked. This is what the multi-watercolor-v0.4.html demo does; here is the path that got us there, simplified into something you can build in an afternoon.

What you'll need

Two things: washes.standalone.js (the engine — one file, no dependencies, exposes a global Washes.create() factory) and an HTML file. No bundler, no build step, no npm. Open it in a browser and it works.

The big idea

The library is built around a factory function. You give it a host element and an options object; you get back an "instance" handle with all the painting methods. State (pigment arrays, paint loads, animation modes, the lot) is closed over per instance — they don't share anything except the global Washes namespace.

That means you can call Washes.create() as many times as you want on the same page, and each canvas gets its own world. That's the trick that makes a "multi-watercolor" page possible at all.

const hero   = Washes.create(document.getElementById('hero'));
const thumb1 = Washes.create(document.getElementById('thumb-1'));
const thumb2 = Washes.create(document.getElementById('thumb-2'));
const thumb3 = Washes.create(document.getElementById('thumb-3'));
// Four independent painting systems, ~150 lines of total code between them.

Each instance owns its own RAF loop, its own simulation grid, its own brush state. Tell hero to use a big rose brush and thumb1 to do sketch mode in blue, and they don't fight.

The bones

You need a host div for each canvas. The lib attaches a <canvas> element inside whatever you give it.

<!-- Full-viewport background -->
<div class="hero" id="hero"></div>

<!-- Three thumbnail cards over the hero -->
<div class="thumbnails">
  <article class="thumb" data-project="orchard">
    <div class="thumb-canvas" id="thumb-1"></div>
    <div class="thumb-title">Orchard</div>
  </article>
  <article class="thumb" data-project="harbor">
    <div class="thumb-canvas" id="thumb-2"></div>
    <div class="thumb-title">Harbor</div>
  </article>
  <article class="thumb" data-project="meadow">
    <div class="thumb-canvas" id="thumb-3"></div>
    <div class="thumb-title">Meadow</div>
  </article>
</div>

The hero is the background — position: fixed; inset: 0. The thumbnails sit on top in their own container with a higher z-index. That stacking order is doing real work for you: when the user clicks a thumbnail, the DOM event system routes the click to the card (because it's on top). When they click empty hero area, the event goes to the hero canvas (because nothing else is in the way). No event-coordination code needed.

Layout that flips for mobile

The original spec was 3 rows × 1 column on desktop, 1 row × 3 columns on mobile. That's a one-line flex-direction flip.

.thumbnails {
  position: fixed;
  z-index: 5;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;       /* vertical column on desktop */
  gap: 18px;
  width: min(440px, calc(100vw - 48px));
  pointer-events: none;         /* default; children re-enable */
}

.thumb {
  width: 100%;
  aspect-ratio: 1;              /* keep cards square */
  pointer-events: auto;         /* re-enable clicks on the cards */
  /* …visual styling… */
}

@media (max-width: 760px) {
  .thumbnails {
    flex-direction: row;        /* horizontal row on mobile */
    top: auto; left: 0; right: 0;
    bottom: 16px;
    transform: none;
    width: auto;
    padding: 0 12px;
  }
  .thumb {
    flex: 1;                    /* split row evenly */
  }
}

A few details worth pointing out, because they're easy to skip and painful to debug:

pointer-events: none on the container, pointer-events: auto on the cards
Without this, the container would catch clicks in the gaps between cards, intercept them, and the hero canvas wouldn't receive paint events when the user dragged through that area. The "none on the parent, auto on the children" pattern is the right hammer for "this element is for layout only, let interactions fall through."
aspect-ratio: 1
Keeps the cards square at every container size. The SVG sketches are designed for square viewBoxes, so this matters for the artwork to fit.
flex: 1 on mobile cards
Lets them split the row evenly. If the viewport is 375px wide with 12px padding and 10px gaps, each card gets (375 - 24 - 20) / 3 = ~110px. They stay square because of the aspect-ratio rule.

Don't forget touch-action: none

If you skip this, painting on a touch device will silently scroll the page instead of leaving paint. Add it to html, body:

html, body {
  touch-action: none;
  overflow: hidden;
  overscroll-behavior: none;
  -webkit-user-select: none;
  user-select: none;
}

This tells the browser "give every touch gesture to JS, don't consume them yourself for scrolling/zoom." On a drawing surface, that's what you want.

Boot the hero

The hero is full-viewport, paintable, and runs a randomized scene on load.

const hero = Washes.create(document.getElementById('hero'), {
  canvasScale: 0.6,             // smaller simulation grid → smoother on modest hardware
  pointer: true,                // enable mouse/touch/pen → paint
  cursorPreview: false,         // use the CSS crosshair instead of a DOM cursor
});

// Brush state tuned for casual finger/mouse painting on a large
// surface — setters chain in v2. brushSize is a radius fraction of
// the smaller display side: 0.055 ≈ a 60px brush on a 1080p viewport.
hero.brushSize(0.055).paintLoad(0.85).waterLoad(1.1).pigment('rose');

A few of these are worth a second of context:

canvasScale: 0.6
The simulation grid is 60% of the host's pixel resolution. CSS scales the rendered output back up. You lose a little crispness; you gain a lot of CPU headroom. On a 1920×1080 viewport, that's ~700k cells instead of ~2M. Pick lower if the hero is animation-heavy.
pointer: true
This is the one option that makes the hero drawable. The lib attaches its own pointermove/pointerdown/pointerup listeners to the canvas. Set to false and the canvas becomes decorative (which is what you want for the thumbnails).
cursorPreview: false
The lib has a flag for "should there be a brush-shaped cursor following the pointer?" but the actual DOM cursor element is the host's responsibility (in the main demo app it's wired up; here we just use a CSS crosshair to keep the multi-watercolor file simple).

Random scene on load

Each visit picks one animation and one background. Curate the pools to keep the first impression friendly — leave the dramatic stuff (thunderstorm, tornado) for explicit opt-in elsewhere.

const ANIMATIONS  = ['rainy', 'sunny', 'windy', 'partlyCloudy',
                     'snowing', 'snowingAdditive', 'ai'];
const BACKGROUNDS = ['day', 'dawn', 'sunset', 'night'];
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];

hero.backgroundAnimation(pick(BACKGROUNDS));   // base wash first
hero.animation(pick(ANIMATIONS));              // animation on top

Order matters here. backgroundAnimation is a one-shot paint of a time-of-day wash. animation registers a continuous per-frame step function. If you set the animation first, it paints briefly, then gets covered by the background. Background first → animation second → both stay visible.

That gives 28 unique combinations (7 × 4). The page feels different every time someone lands.

The thumbnails

Same factory, different config. Each one runs sketchMode() (which is a settings bundle — small brush, dry paper, no bleed, no edge darkening), then traces an SVG.

const SVGS = {
  orchard: '<svg viewBox="0 0 100 100">...</svg>',
  harbor:  '<svg viewBox="0 0 100 100">...</svg>',
  meadow:  '<svg viewBox="0 0 100 100">...</svg>',
};

const thumbConfigs = [
  { hostId: 'thumb-1', project: 'orchard', pigment: 'blue'   },
  { hostId: 'thumb-2', project: 'harbor',  pigment: 'rose'   },
  { hostId: 'thumb-3', project: 'meadow',  pigment: 'yellow' },
];

const thumbs = thumbConfigs.map((cfg, i) => {
  const wc = Washes.create(document.getElementById(cfg.hostId), {
    canvasScale: 0.7,
    pointer: false,          // clicks go to the card, not the canvas
    cursorPreview: false,
  });
  wc.sketchMode();           // felt-pen bundle

  // Stagger so all three don't draw simultaneously — looks more alive
  setTimeout(() => {
    wc.traceSVG(SVGS[cfg.project], {
      pigment: cfg.pigment,
      durationMs: 1500,        // 1.5s animation
      easing: 'penStroke',     // mimics how a hand draws
      perStrokePauseMs: 200,   // brief pause between SVG paths
    });
  }, 250 + i * 350);

  return { ...cfg, wc };
});

A few things worth chewing on:

canvasScale: 0.7 on the thumbnails
They're small (~140px on desktop, ~110px on mobile) so we can afford a slightly higher resolution than the hero without trouble.
pointer: false
Keeps the lib from listening for pointer events on the thumbnail canvas — we want clicks to route to the parent <article> card so we can run our own obliterate logic.
sketchMode()
A one-shot settings bundle, not a mode flag. It applies a series of settings: a 7px fine-tip brush, paintLoad(1.6), waterLoad(0.25), pressure(0.95), paperWetness('boneDry'), continuousFlow(false), edgeDarkening(false), fadePainting(false). After it runs there's no "sketch mode" to turn off — just a brush configured for fine-line work.
easing: 'penStroke'
The easing curve that mimics hand-drawn motion: a quick 15% ramp-up to get up to speed, a steady 70% middle, then a 15% deceleration to land. Combined with perStrokePauseMs: 200, you get a believable drawing rhythm — smooth strokes punctuated by pen-lift moments between SVG path elements. This is the single best feel-improvement you can make over linear timing.
The setTimeout stagger
250 + i * 350 means the three thumbnails start drawing 250ms / 600ms / 950ms after load. Simultaneous animations on a page read as "all running automatically." Staggered ones read as "drawing themselves, one after another." Small thing, big difference.

The click handler (where I goofed, so you don't have to)

When you click a thumbnail, you want the wash to start where you clicked, not at the center. The library has grid.fromDisplay() to convert client coordinates into the simulation's internal grid coordinates.

thumbs.forEach((thumb) => {
  const card = document.querySelector(`[data-project="${thumb.project}"]`);
  let busy = false;

  card.addEventListener('click', async (e) => {
    if (busy) return;
    busy = true;

    // ⚠️ Pass clientX/clientY DIRECTLY. Don't pre-subtract rect.left.
    // The lib does its own getBoundingClientRect() inside fromDisplay.
    const { gx, gy } = thumb.wc.grid.fromDisplay(e.clientX, e.clientY);

    await thumb.wc.obliterate({
      mode: 'water',           // water rinse — pigment lifts and diffuses
      durationMs: 500,
      x: gx, y: gy,            // splash centered where you clicked
    });

    console.log('Project clicked:', thumb.project);
    // In a real portfolio, navigate here: router.push(...)

    // For this demo, redraw the sketch so the page stays interactive
    setTimeout(() => {
      thumb.wc.reset();
      thumb.wc.sketchMode();
      thumb.wc.traceSVG(SVGS[thumb.project], {
        pigment: thumb.pigment, durationMs: 1200,
        easing: 'penStroke', perStrokePauseMs: 150,
      });
      busy = false;
    }, 900);
  });
});

The gotcha I want you to skip. Earlier versions of this demo had:

// 🚨 Wrong (v1 era)
const rect = card.getBoundingClientRect();
const grid = thumb.wc.toGrid(e.clientX - rect.left, e.clientY - rect.top);
await thumb.wc.obliterate({ x: grid.gx, y: grid.gy });

Two bugs in two lines. (1) The converter already does getBoundingClientRect() on its own canvas — pre-subtracting double-subtracts. (2) v1's toGrid returned {x, y}, not {gx, gy}. The destructured gx/gy were undefined, which obliterate silently defaulted to canvas center.

Result: every click obliterated at center. It looked like it worked, because there was always some animation. But the wash never followed the click point.

Moral: read the return shape, and trust the helper to do what it says. Bug (2) is also why v2 renamed the helper: grid.fromDisplay(clientX, clientY) returns honest {gx, gy} keys, so the wrong destructure can't fail silently anymore.

The busy flag prevents double-clicks while an obliterate is in flight. Without it, rapidly clicking would queue overlapping obliterates and the redraw setTimeout would fire while a previous one was still running, getting into weird states.

Keyboard shortcuts (the only "UI")

The demo deliberately has no buttons. Brush selection, reset, perf, help — all keyboard.

document.addEventListener('keydown', (e) => {
  // Skip if user is typing in a form field somewhere
  if (e.target?.tagName === 'INPUT' || e.target?.tagName === 'TEXTAREA') return;

  switch (e.key) {
    case '1': hero.pigment('rose');   break;
    case '2': hero.pigment('yellow'); break;
    case '3': hero.pigment('blue');   break;
    case '4': hero.pigment('water');  break;
    case 'r': case 'R': hero.reset(); break;
    case 'p': case 'P': togglePerf(); break;
    case 'h': case 'H': toggleHelp(); break;
    case 'Escape': closeOverlaysIfOpen();
  }
});

The reason to skip buttons isn't dogmatic minimalism — it's that for a portfolio background, every visible control fights with the artwork for attention. Keyboard shortcuts are discoverable through a small H-toggled help overlay, and you can document them in a hint that fades after first paint. The result is a page that looks like nothing but a painted background and three cards, but rewards keyboard exploration.

Perf overlay

The library exposes perf(true) to enable instrumentation and perfMetrics() to read the numbers. Build a tiny DOM panel and poll at 4 Hz:

const perfEl = document.getElementById('perf');
let perfTimer = null;

function updatePerf() {
  const m = hero.perfMetrics();
  setText('perf-fps',    m.fps?.toFixed(1) ?? '—');
  setText('perf-p50',    m.framep50.toFixed(2) + 'ms');
  setText('perf-active', m.activePct.toFixed(1) + '%');
  // …etc, see v0.4 source for the full list
}

function togglePerf() {
  const open = perfEl.classList.toggle('open');
  hero.perf(open);                 // enable/disable instrumentation
  if (open) {
    updatePerf();
    perfTimer = setInterval(updatePerf, 250);
  } else {
    clearInterval(perfTimer);
    perfTimer = null;
  }
}

One subtle thing: the lib gates performance.now() calls behind perfEnabled to avoid paying for instrumentation when it's off. That means toggling perf actually changes what you're measuring slightly — a kind of perf Heisenberg. For a portfolio page this doesn't matter; for serious profiling, leave it on for the whole session.

Going further

Ideas that fit naturally:

  • Project navigation on click: replace the demo's redraw with an actual route push. router.push('/projects/' + thumb.project) after the obliterate completes.
  • Raster-trace thumbnails: instead of SVG paths, use paintImage() — or sample pixels yourself and paint each one. The obliterate animation works unchanged — it doesn't care how the pigment got onto the canvas.
  • Persistent painting: saveState() the hero to localStorage on beforeunload, loadState() on boot — the round-trip is bit-exact (v1.22+). The user's doodles survive across visits.
  • Click-and-hold for sustained pour: increase the brush's flow() value while a pointer is held down, return to default on release. Felt-tip → fountain pen → faucet.
  • Color from URL: parse ?pigment=blue from location.search and apply on load. Lets you link to specific moods.

The library is the same in all these cases. You're just composing its primitives — paint, traceSVG, obliterate, animation, backgroundAnimation, the brush-state setters — in different patterns.

A short list of things to remember

  1. One instance per host element. Each Washes.create() call is independent. State doesn't bleed.
  2. pointer: true for drawable surfaces, false for displays. This is the single most important option.
  3. Z-order routes clicks naturally. Cards above hero in stacking order = clicks on cards go to cards, clicks elsewhere go to hero. No coordination code needed.
  4. grid.fromDisplay(clientX, clientY) returns {gx, gy}. Pass raw client coordinates. Read gx and gy. Anything else is a bug.
  5. touch-action: none on the html/body or finger-painting will scroll the page instead of leaving paint.
  6. Background before animation. Setting an animation first then a background covers the animation with the wash.

Have fun. The library is built for this kind of composition — you're not fighting it, you're using it the way it wants to be used. Make something weird.

Concepts

The physics

Each cell on the grid carries seven values: wet (water amount), g (suspended pigment, 3 channels), d (deposited pigment, 3 channels), u and v (velocity components), pressure (drives flow), paperH (paper height noise for granulation), and mask (masking fluid coverage).

Every sim step runs these phases in order:

  1. applyEdgeDarkening — gradient of wet against neighbors creates pressure that pulls pigment outward (the dark ring at wash edges).
  2. updateVelocity — pressure gradient drives u and v. Wet cells flow; dry cells are walls.
  3. movePigment — advection by the velocity field, then isotropic diffusion. Suspended pigment moves; deposited pigment doesn't.
  4. transferPigment — adsorption (suspended → deposited at rate proportional to 1 - h·granulation) and desorption (deposited → suspended at rate staining). The granulation term makes paper-texture variation cause variable deposition.
  5. diffuseWet — capillary spread of water through paper fibers via Laplacian diffusion.
  6. evaporate — water decays exponentially. Cells that fall below the dry threshold settle their suspended pigment to deposited.

The render phase then computes per-pixel color via Kubelka-Munk: weighted K and S from the three pigments, run through the sinh/cosh-based reflectance formula, composited over the paper substrate.

Transparent vs opaque pigments (v0.37+). The K-M model handles both kinds with the same compositor; what differs is the K/S values. Transparent pigments (the default Curtis et al. set: rose, yellow, blue) have low S, so light passes through the pigment layer to the paper, bounces off, passes back through with selective K-absorption, and emerges as color. That's why they're effectively invisible on dark paper — no light comes back from below. Opaque pigments (the gouache set: PIGMENTS_OPAQUE, available via gouacheMode(true)) have high S across all channels, so the pigment particles themselves reflect light back, regardless of background. Same K-M math; different physical regime. Toggling switches the active pigment array reference; the compositor is unchanged. See v0.37 for the implementation details.

Active rect

A bounding box of cells with active dynamics (wet > threshold). Every sim phase iterates only inside this rect. On a fresh canvas with one dab, the rect might be 200×200 cells — out of 1152×648 = 746,000 total. The simulation isn't doing nothing on the unpainted area; it's doing literally zero work there.

The rect grows when paint lands outside it (via expandActiveRect inside paintAt). It shrinks periodically when all cells along an edge have dried out. The render is also rect-localized — only cells inside the rect get re-drawn each frame.

Mask rect

Same idea as the active rect, but tracking the bounding box of masked cells. Without this optimization, every sim phase paid a per-cell mask check across the entire active rect, even when the mask covered <1% of cells. With it, the mask check is restricted to the small region where the mask actually lives.

A small mask (625 cells in a 462,681-cell active rect) now adds −1.1% overhead to movePigment — within measurement noise. Previously the same scenario would have added 80–100% overhead.

WebGL render path

An optional fragment-shader implementation of the render phase, toggleable via the WebGL render button. The shader mirrors the CPU code's Kubelka-Munk math bit-for-bit (verified across 792 sweep points; max difference 3.3e-16).

Simulation phases still run on the CPU when this toggle is the only thing enabled; state arrays are uploaded as textures each render frame. A full GPU simulation path also exists (developed against the same bit-exactness harness, reachable via gpuSim()) but stays opt-in until real-browser render validation lands.

The toggle silently no-ops if WebGL2 isn't available. The tooltip updates to reflect the unavailable state.

Origin

Washes exists because of a chain of conversations and shared links among friends. The path that led here is worth recording, both out of gratitude and because it's itself an example of how technical work tends to happen — through casual recommendation, shared enthusiasm, and the slow accretion of other people's interesting work into one's own.

The nerd-snipe

The project began as a nerd-snipe by Dan Knutson, via Cyrene Zhang, and Kanad Gupta. The combination of inputs:

  • Dan had implemented the watercolor paper texture — the fibrous, slightly absorbent substrate that real watercolor washes interact with — by hand. Seeing someone build the paper rather than the paint made the question "what would the paint look like running over this?" feel concrete and answerable.
  • Kanad shared his favorite coding color palette: Flexoki by Steph Ango, an inky color scheme designed around the feeling of pigment on paper. Flexoki is built on the Oklab color space and explicitly emulates the way watercolor pigments grow vibrant as they're diluted — the same physical intuition that drives the Kubelka-Munk model underneath Washes.
  • The Flexoki page on stephango.com links to a SIGGRAPH paper presentation that turned out to be the conceptual catalyst: Practical Pigment Mixing for Digital Painting by Šárka Sochorová and Ondřej Jamriška (SIGGRAPH Asia 2021) — the Mixbox paper. Watching the fast-forward presentation made vivid how badly most digital painting tools mix color, and that there was a well-understood physical model for fixing it.

Mixbox and Washes are siblings in the same intellectual lineage. Both use Kubelka-Munk pigment compositing; both treat colors as physical materials rather than additive light. Mixbox publishes a practical RGB-in-RGB-out approximation that any painting tool can drop in. Washes implements the full physical sim from Curtis et al. 1997, including the shallow-water fluid layer underneath. They're answering the same underlying question — what if digital paint behaved like real paint? — from different angles and 24 years apart.

Conversations about color and math

Once the project was underway, extended conversations with Evan Gedrich Pintado, Iris Fernandes Valdez, Lissa Hyacinth, and Jonathan King shaped the project's intellectual scaffolding — high-level discussion of the math and physics (Kubelka-Munk, the shallow-water equations, advection) and of other color tools doing adjacent work in the same broad space.

Two of those tools deserve specific mention, both by David Aerne (meodai), because they're working on closely-related problems:

  • RYBitten — translates digital colors through Johannes Itten's RYB (red/yellow/blue) subtractive color wheel from the Bauhaus tradition. The explicit goal is "making digital color behave more like paint" — the same underlying impulse that motivates K-M, Mixbox, and Washes, approached through art-theoretic rather than physics-based mixing.
  • OKPalette — a palette extractor that picks colors from images using OKLCh perceptual distance and harmony-aware selection. Different problem (extracting palettes vs. mixing pigments) but the same recognition that perceptual color spaces and physical mixing models both matter for color work that goes beyond the default RGB representation.

Both tools come from the same observation Mixbox makes and Washes embodies: that digital color work has something to learn from how color actually behaves in the physical world (as pigment, as ink, as light falling on paper), and that the default representations in most software are working against that intuition. The Washes project sits inside this broader conversation, and the conversations made the project feel like part of a movement rather than an isolated reimplementation.

Context

For the record, much of this happened during a batch at the Recurse Center — the kind of environment where someone will recommend you a 28-year-old SIGGRAPH paper at lunch and treat that as an unremarkable thing to do. References and prior-art sections that follow are academic in tone, but the project's actual origin is more social than academic. Curtis et al. published the paper in 1997. Sochorová & Jamriška republished an approximation in 2021. Aerne built RYBitten and OKPalette. Steph Ango wrote about pigments on paper. Dan built the paper. Kanad shared the palette. Cyrene made the introduction. Evan, Iris, Lissa, and Jonathan kept the math and color discussions going. The math came from the academy; the motivation came from people.

References

Sources for the algorithms, physical models, and numerical methods used in Washes. The Dig Deeper section below walks through how each one shaped the lib's evolution.

  1. Curtis, C. J., Anderson, S. E., Seims, J. E., Fleischer, K. W., & Salesin, D. H. (1997). Computer-generated watercolor. In Proceedings of the 24th annual conference on Computer graphics and interactive techniques (SIGGRAPH '97), pp. 421–430. ACM Press/Addison-Wesley. doi: 10.1145/258734.258896. Paper PDF available at grail.cs.washington.edu/projects/watercolor. The foundation paper Washes is built on. Three-layer water/pigment/paper model, capillary diffusion, edge darkening, and Kubelka-Munk compositing. Figure 5 lists the K and S coefficients per pigment that ship in this lib.
  2. Sochorová, Š. & Jamriška, O. (2021). Practical Pigment Mixing for Digital Painting. ACM Transactions on Graphics (Proceedings of SIGGRAPH Asia 2021), 40(6), 234. Project page: scrtwpns.com/mixbox · DCGI: dcgi.fel.cvut.cz · Fast-forward video: youtube.com/watch?v=6JVjHqas60Q The Mixbox paper. A practical RGB-in/RGB-out approximation of Kubelka-Munk pigment mixing, designed to drop into any digital painting tool without changing its representation. Sibling work to Washes: same physical lineage (K-M), different goals (drop-in usability vs. full fluid sim). The fast-forward presentation video for this paper is the conceptual catalyst that started the Washes project — see the Origin section.
  3. Kubelka, P. & Munk, F. (1931). Ein Beitrag zur Optik der Farbanstriche. Zeitschrift für Technische Physik, 12, 593–601. English translation by S. Westin available at graphics.cornell.edu/~westin/pubs/kubelka.pdf. Original German title roughly: "A contribution to the optics of paint layers". The two-flux radiative transfer model that gives watercolor its luminous, glaze-able look. Each pigment is characterized by an absorption coefficient K and a scattering coefficient S; the reflectance of a layer of thickness x over a background of reflectance Rbg is a closed-form function of K, S, x, and Rbg.
  4. Foster, N. & Metaxas, D. (1996). Realistic animation of liquids. Graphical Models and Image Processing, 58(5), 471–483. doi: 10.1006/gmip.1996.0039. Originally presented at Graphics Interface '96. The first Navier-Stokes-based fluid solver targeted at computer graphics — staggered grid (MAC), explicit time integration, finite-difference Navier-Stokes. Curtis et al. cite this as the basis for their shallow-water step, which is the basis for the velocity field in Washes.
  5. Stam, J. (1999). Stable Fluids. In Proceedings of the 26th Annual Conference on Computer Graphics and Interactive Techniques (SIGGRAPH '99), pp. 121–128. ACM Press/Addison-Wesley. Paper available at pages.cs.wisc.edu/~chaol/data/cs777/stam-stable_fluids.pdf. Introduced semi-Lagrangian advection to the graphics community. The "stable" in the title refers to the scheme being unconditionally stable at any timestep — no CFL bound to satisfy. This is the advection scheme that finally fixed the cross artifact in Washes v0.67, after 11 versions chasing the wrong root cause.
  6. Bridson, R. (2015). Fluid Simulation for Computer Graphics (2nd ed.). AK Peters/CRC Press. ISBN: 978-1482232837. Publisher page: routledge.com Textbook reference for everything in this lib's fluid pipeline. The Advection Algorithms chapter (semi-Lagrangian, donor-cell, MacCormack) and the chapter on Making Fluids Incompressible are particularly relevant to the v0.67-0.72 work.
  7. Courant, R., Friedrichs, K., & Lewy, H. (1928). Über die partiellen Differenzengleichungen der mathematischen Physik. Mathematische Annalen, 100(1), 32–74. doi: 10.1007/BF01448839. English translation: "On the Partial Difference Equations of Mathematical Physics", IBM Journal of Research and Development, 11(2), 215–234 (1967). The English translation is available at web.stanford.edu/class/cme324/classics/courant-friedrichs-lewy.pdf. The CFL condition: an explicit time-marching scheme is unstable when the numerical domain of dependence is smaller than the physical one. For donor-cell advection on a grid with spacing 1, the condition is |u|·Δt + |v|·Δt ≤ 1 per cell. Violating it gives the cross artifact's negative-pigment streaks in Washes pre-v0.69.

Similar projects

These were surveyed after Washes reached a shippable state. Washes is built directly on Curtis et al. 1997 and was developed without reference to any of the projects below, but each does thoughtful work in adjacent parts of the same space. Included here because if you arrived at this lib because you're interested in watercolor in code, you should know about them too.

How to Hack a Painting — Tyler Hobbs

tylerxhobbs.com/essays/2020/how-to-hack-a-painting · Strange Loop 2017 talk with 2020 transcript · companion essay: A Generative Approach to Simulating Watercolor Paints

A widely-influential generative-art approach using recursive polygon deformation: start with a polygon, perturb each edge midpoint with a Gaussian random offset, recurse around seven times for soft watercolor-like boundaries, then stack 30 to 100 nearly-transparent layers of these deformed polygons over each other to produce the gradual-fade-out appearance. This is the algorithm behind the majority of the "watercolor blob" libraries you'll find on GitHub (watercolorizer, watercolors-generative, and many smaller gists all descend from this work).

A different level of abstraction from Washes: Hobbs is modeling the resulting visual appearance with geometry; Washes is modeling the underlying physics with shallow-water flow and Kubelka-Munk optics. The visual outputs look related; the implementations have very little in common. If you want generative-art watercolor shapes that compose well with other vector graphics, Hobbs's approach is probably the right one to reach for. If you want a sim that responds to interactive input and produces gestural, fluid-feeling marks, that's where Washes lives.

p5.brush

github.com/acamposuribe/p5.brush · MIT licensed · ~660 stars · by Alejandro Campos Uribe · live site: p5-brush.cargo.site

A p5.js library that adds natural drawing tools to p5.js sketches: pencils, charcoal, markers, watercolor fills, hatch patterns, and vector fields that bend strokes organically. The watercolor fill system produces naturally-fluid color blending using stacked alpha and flow-field perturbation, in a style oriented toward high-resolution generative art and print output.

If you want to drop watercolor-feeling brushes into a p5.js generative-art sketch with a one-line API, this is the right tool. Washes is a different shape of library — full interactive simulation with persistent pigment fields, lift, deluge, evaporation, and pen-stroke SVG tracing — but the visual goals overlap. Both are MIT-licensable; both target the same kind of artist; the internal mechanics are entirely different.

THREE.Watercolor

github.com/mattatz/THREE.Watercolor · by mattatz · based on Bousseau et al., "Interactive watercolor rendering with temporal coherence and abstraction" (NPAR 2006)

A post-processing effect pass for three.js that applies a watercolor look to any rendered 3D scene. Uses a paper texture and screen-space techniques to add the characteristic pigment-bleed, edge-darkening, and turbulent flow appearance to an existing render. Fundamentally different from Washes' grid-based fluid simulation: THREE.Watercolor is a stylization pass, not a paint medium.

Useful if you have an existing three.js scene and want a watercolor-stylized output without changing your rendering pipeline. The technique it implements (Bousseau's NPAR 2006 work) is itself a notable line of watercolor research, parallel to Curtis et al. and worth knowing about. Curtis et al. simulate paint physics; Bousseau et al. stylize rendered images to look like paint.

Where Washes fits

Washes sits in a different region of the space than the three above: a direct, interactive, browser-native implementation of Curtis et al.'s 1997 three-layer fluid model, with deluge and SVG-trace animations suitable for UI transitions and other gestural effects. The closest comparable academic implementation is the Texas A&M GPU thesis on real-time watercolor (~2014), but that's a C++ and GLSL implementation, not an interactive library.

As far as a reasonable prior-art survey could establish, the intersection of "physically-based shallow-water watercolor sim" with "interactive JavaScript library running in a browser tab" was empty before this project. The other projects in this section are all doing interesting work, none of them is trying to do the same thing, and that's why all of them are worth knowing about.

Dig deeper

The math and physics that shaped Washes, with pointers to the source material. Citations are numbered to the References section above. Most of this content emerged from chasing one specific artifact — the cross-shape that appeared in deluge splashes — across thirteen versions before finding its actual root cause.

What's actually being simulated

Following Curtis et al. [1], Washes maintains three layers per pigment channel on a 2D grid (in this lib, three channels):

  • The shallow-water layer — velocity field (u, v), pressure p, and "wet" mask. Pigment dissolved in this layer moves with the water flow.
  • The pigment-deposition layer — tracks gk (granular/suspended pigment, advects) vs dk (deposited pigment, stays put) for each pigment k.
  • The paper layer — capillary diffusion field; controls how moisture spreads through paper fibers.

The render pipeline composites these via the Kubelka-Munk reflectance model [2]: each cell becomes a layer of given thickness over a paper-colored background, and the layer's reflectance R is computed from its K and S coefficients. Pigments listed in Figure 5 of Curtis et al. [1] (used here unmodified) have direction-dependent K and S that produce glaze-like layered transparency.

The advection equation and why it had a cross

For pigment density g moving in a velocity field (u, v), conservation of pigment mass gives the advection equation:

∂g/∂t + u · ∂g/∂x + v · ∂g/∂y = 0

The first-order donor-cell (upwind) discretization that Washes started with computes the per-cell flux as flux = |u|·g·Δt for the upwind axis, with similar for v. The scheme is mass-conserving and easy to implement, but it has two well-known issues:

  1. CFL bound. The scheme is conditionally stable: |u|·Δt + |v|·Δt ≤ 1 per cell. Above that bound, the cell donates more pigment than it holds, goes negative, and Kubelka-Munk renders negative values as brighter-than-paper. The condition is Courant, Friedrichs, & Lewy 1928 [6].
  2. Cardinal anisotropy. Even at CFL ≤ 1, cells with pure-cardinal velocity (V, 0) donate to one neighbor while diagonal cells (V/√2, V/√2) split across two. The L1 norm (|u| + |v|) used in the CFL bound differs between these by √2, and the discretization itself has subtle direction-dependent transport rates. See Bridson [5], Advection Algorithms chapter.

Stam's semi-Lagrangian advection [4] sidesteps both problems by changing the integration direction. Instead of moving pigment forward from each cell along its velocity vector (donor-cell), it traces backward from each cell along the velocity vector to find where the pigment "came from" one step ago, then samples that source position via bilinear interpolation:

For each cell (x, y):
  src_x = x - u(x,y) · Δt
  src_y = y - v(x,y) · Δt
  g_new(x, y) = bilinear_sample(g, src_x, src_y)

The bilinear interpolation function has no preferred axis — it weights neighbors by continuous proximity rather than per-axis flux. So the scheme is isotropic by construction. And because src_x and src_y can be anywhere in the grid, there's no CFL bound: the scheme is unconditionally stable. The trade-off is slight numerical diffusion from the bilinear smoothing, which for watercolor reads as natural soft blending.

Mass conservation vs density conservation

Plain semi-Lagrangian preserves density (value per cell), but for a divergent flow this isn't what you want. Consider a small fluid parcel in a radial outflow: its area expands over time, so the pigment originally covering 1 cell now covers (say) 1.1 cells. The total mass is constant, but the density has dropped to 1/1.1.

Plain semi-Lagrangian doesn't see this: each cell samples a single backward source and keeps that source's density. For a deluge, that produced the wrong visual — the splash appeared to redistribute pigment uniformly instead of clearing the center and accumulating at the wave front. Washes v0.68 fixed it with a divergence correction:

div(v) = ∂u/∂x + ∂v/∂y    ← computed via central differences
area_ratio = exp(-div · Δt)   ← Jacobian of the backward map
g_new = bilinear_sample · area_ratio

In a divergent region (∇·v > 0, fluid spreading out), area_ratio < 1 so density drops; in a convergent region (∇·v < 0, fluid piling up), area_ratio > 1 so density grows. This restores the "lift and reveal" feel of a real watercolor deluge. The exp() form rather than (1 - div·Δt) matters for stability when divergence is large (e.g. near the splash center, ∇·v ~ 2V/r becomes large for small r). See Bridson [5] for the mass-conservative formulation.

The square vs circular velocity clamp (a √2 bug hiding in plain sight)

For 13 versions, I was chasing the cross artifact in the pigment advection code. The artifact persisted in every mode (donor-cell, flux-clamped, substep-CFL-safe, semi- Lagrangian, mass-conserving semi-Lagrangian). The clue I finally noticed in v0.69 was that the velocity field itself was anisotropic one step after the splash:

After 1 simStep at default deluge (v0.68):
  Cardinal cells (E,W,N,S): velocity magnitude = 1.14
  Diagonal cells (NE,NW,SE,SW): velocity magnitude = 1.62

  Ratio: 1.62 / 1.14 = 1.42  ≈  √2

The cause was the velocity clamp in updateVelocity:

if (nu >  VEL_CLAMP) nu =  VEL_CLAMP;
if (nu < -VEL_CLAMP) nu = -VEL_CLAMP;
if (nv >  VEL_CLAMP) nv =  VEL_CLAMP;
if (nv < -VEL_CLAMP) nv = -VEL_CLAMP;

Each axis was clamped independently to ±VEL_CLAMP. The allowed region in (u, v) space is a SQUARE [-CLAMP, CLAMP]² rather than a circle. The four corners of that square (the diagonal directions) reach magnitude √2·CLAMP, while the four edge midpoints (the cardinal directions) reach magnitude CLAMP. Diagonal directions got √2× the speed of cardinal directions at all times. The pigment was advecting through an already-anisotropic velocity field; every pigment-advection scheme I tried inherited that anisotropy and produced a cross.

The fix is magnitude clamping: bound the L2 norm rather than each axis independently.

const mag = Math.sqrt(nu*nu + nv*nv);
if (mag > VEL_CLAMP) {
  const s = VEL_CLAMP / mag;
  nu *= s;  nv *= s;
}

Direction is preserved (uniform scale factor on both components); magnitude is bounded isotropically (the allowed region is now a circle of radius CLAMP); all eight compass directions get the same maximum speed.

Singular cells at the splash epicenter

The last residual artifact (v0.70-0.72) was a dark pinpoint at the click site. Tracing it isolated the cause to the radial-velocity injection guard:

if (d2 > r2 || d2 < 0.5) continue;
const dist = Math.sqrt(d2);
const inv = 1 / dist;
const radialX = dx * inv;
const radialY = dy * inv;

The d² < 0.5 threshold was meant to avoid 1/0 at the exact epicenter (where dx=dy=0). But 1/0 only happens at exactly d² = 0, possible only when the epicenter falls at integer-valued coordinates and a grid cell sits exactly there. Cells with small but nonzero d² (e.g. 0.25) have perfectly well-defined unit-radial vectors. The 0.5 threshold was throwing away 1–2 cells' worth of valid injection around every splash, leaving them in a singular state (zero velocity, zero pressure, symmetric neighborhood so the Navier-Stokes update couldn't perturb them off zero).

Several frames into the simulation, this caused something unexpected. The remaining pressure injection decays as p ← 0.94·p per step, leaving a pressure dip (zero where neighbors are at the injection value). Pressure gradients drive flow from high to low pressure, so once the initial outward velocity damped, the dip became the dominant gradient and started pulling neighbors' velocity INTO the singular cell:

After 10 simSteps (v0.71, before threshold fix):
  Singular cell (324,271):  u=+0.57, v=+0.10
  West neighbor (323,271):  u=+1.12  ← reversed, pointing INTO singular
  East neighbor (325,271):  u=-1.12  ← reversed, pointing INTO singular
  North neighbor (324,270): v=+1.14  ← reversed, pointing INTO singular

  Pigment accumulated at singular cell: 5× the surrounding density.

The fix is at the source. Tighten the guard to d² < 1e-10: a safe margin against genuine 1/0 (far below floating-point precision risk) but no longer over-aggressive. All cells with valid radial directions now receive proper injection, so there's no singularity to begin with, and no pressure dip can form.

Open boundaries: paint falling off the edge of the page

v0.81–v0.84 added three edge-boundary modes (closed, open, gravity) and a configurable velocity bias with an outward-radial option. This required a small but interesting hybrid of advection schemes, because the natural way to think about outflow boundaries doesn't fit the structure of semi-Lagrangian advection.

The conceptual asymmetry

Semi-Lagrangian advection works backward: for each destination cell, trace backward along the velocity vector to find where the pigment came from one step ago, then sample that source position with bilinear interpolation. The scheme is naturally stable (no CFL bound) and naturally isotropic (bilinear interp has no axis preference). Both properties were what made the v0.67 switch to semi-Lag fix the cross artifact.

But outflow boundaries are inherently forward: they describe where pigment goes when it leaves the canvas. Semi-Lagrangian doesn't model that. A cell at the boundary back-traces to an interior cell upstream and keeps that pigment. There's no mechanism by which pigment "leaves" the grid through this scheme, because the loop iterates over destination cells, not source cells.

The first thing I tried was to modify the back-trace clamp: when the back-trace landed outside an open edge, sample "background" (zero pigment) instead of clamping to the interior. This is the textbook open-boundary condition for semi-Lagrangian and it works for some patterns — it handles cells whose back-trace goes off-canvas, which represents the case "in the previous frame, this cell's contents came from off-canvas, so we have no idea what was there, so assume nothing." But the case I actually needed was the opposite: cells whose forward trajectory goes off-canvas. The back-trace modification handles a real but secondary case.

The hybrid: semi-Lagrangian interior, donor-cell drainage at edges

The fix was to add an explicit forward-flux drainage pass on the cells adjacent to each open edge, after the main semi-Lagrangian advection. For each such cell, compute the outgoing normal flux:

flux_out = max(0, v_normal) · Δt
pigment[i] *= (1 - flux_out)

where vnormal is the component of velocity pointing across the boundary (positive y for the bottom edge, positive x for the right, and so on). Capped at flux_out ≤ 1 so cells can't go negative. The same fraction is applied to g[], d[], wet[], and pressure[] uniformly, since whatever leaves the canvas takes everything with it.

This is essentially a one-cell-deep donor-cell advection step at the boundary, layered on top of the semi-Lagrangian interior. It's a hybrid scheme, not published anywhere I know of, but it follows naturally from Bridson's treatment [5] of boundary conditions in the Advection Algorithms chapter. The interior keeps the benefits of semi-Lagrangian (isotropy, stability); the boundary gets the explicit forward-flux model that outflow requires.

The drainage propagates upstream gradually

Only cells directly adjacent to an open edge drain in any given frame. Cells further inside drain over many frames via the semi-Lagrangian back-trace pulling from increasingly empty downstream cells. Concretely: a cell at (x, GH-3) one row inside the drainage row back-traces to some position upstream when it has upward velocity (no drainage), but when it has downward velocity, the back-trace position is upstream-and-down of itself, which eventually corresponds to a cell at (x', GH-2). That cell has just been drained, so the back-traced sample picks up the reduced value and the cell at (x, GH-3) becomes slightly emptier.

The net effect is drainage that propagates one cell per frame upstream, mediated by the velocity field. With a radial gravity bias, this happens from all four edges simultaneously, producing the centrally-emptying effect that gave radial gravity its visual appeal. Without bias, drainage only occurs where the original splash velocity pushed pigment toward an edge.

Why radial gravity drains 4× faster than directional

Empirically, radial gravity at Pull=0.10 drains a centered splash by 9.9% of total mass in 100 frames, vs 2.6% for directional-down gravity at the same Pull. The ratio is not coincidental.

With directional gravity (say, "down"), only the bottom edge is open. Pigment must traverse from wherever it is to the bottom row before it can drain. The upper-half of the canvas never directly contributes to drainage; it has to first move down through the entire grid.

With radial gravity, all four edges are open AND every cell has bias pointing toward the nearest edge from its position. A cell near the top-left has bias pointing up and left; a cell at the bottom-right has bias pointing down and right. Every cell contributes drainage flux to its nearest edge. The total outflow surface area is 4× larger, and the average distance from any cell to its drainage edge is roughly half that of the directional case (each cell's nearest edge vs the bottom edge specifically).

The 4× ratio is the geometric consequence of having four open edges instead of one, plus the per-cell radial bias aiming every cell at its nearest edge.

Velocity clamp and CFL revisited

The v0.84 work exposed VEL_CLAMP — the per-cell velocity magnitude cap — as a user-facing slider. This is worth a note because it reopens the CFL discussion from the cross-artifact arc.

First-order donor-cell advection is conditionally stable: |u|·Δt + |v|·Δt ≤ 1. With Δt = DT · 0.7 ≈ 0.0117 and a 2D donor-cell stencil, the practical bound is VEL_CLAMP ≤ 1.7 on a unit grid. The original default min(1.5, 2/SCALE) was chosen to sit comfortably below this bound at all supported resolutions.

Semi-Lagrangian has no CFL bound because the backward-trace can land anywhere on the grid via bilinear sample. So values of VEL_CLAMP from 2 up to ~7.5 are numerically safe in semi-Lagrangian mode (above ~7.5 the back-trace can skip over so many cells that bilinear interp visibly smears, which is a stylistic limit rather than a stability one). The new slider exposes this range, and the docs warn that other advection modes will produce the cross artifact above ~1.7.

The interaction with the gravity bias is also worth flagging. The bias is added to (nu, nv) before the magnitude clamp. So at high Pull values, the bias can push velocity past VEL_CLAMP and the clamp scales both components back down. Above Pull = 100 (where the bias magnitude equals VEL_CLAMP itself), increasing Pull doesn't make pigment move faster — it just makes the bias arrive faster and dominate other forces like splash velocity and pressure gradients. To actually move pigment faster, raise VEL_CLAMP via the new Max velocity slider.

Debugging fluid simulations — lessons

A few principles I should have applied earlier in this project, in case you (or any future maintainer, including another AI) hit similar issues:

  1. When an artifact has a strong axis-aligned shape, the artifact's appearance is real evidence — but it doesn't identify which subsystem produced it. The cross looked like a donor-cell advection signature, so I lived in the pigment-advection code for 13 versions. The actual source was the velocity clamp, one function upstream. Symptoms can be inherited downstream from an unrelated subsystem.
  2. Dump numerical state, don't just stare at renders. The cross diagnosis took 13 versions visually but would have taken 5 minutes if I'd printed {cardinal_velocity, diagonal_velocity} after one step. Rendered output is the convolution of every layer of the sim; numerical dumps isolate the layers.
  3. Fix the source, don't patch the symptom. The pinpoint had three rounds of patches (v0.70 single-cell smooth, v0.71 multi-cell smooth, v0.72 fix the threshold). Each patch was closer to "don't break it in the first place" and further from "patch the damage afterward." Lesson: when a fix targets the consequences of a guard or threshold, check whether the guard or threshold itself is what's wrong.
  4. Test the boundary cases, not just the convenient ones. v0.70 worked for the integer-coordinate "Deluge Now" button but failed for random clicks with offset-fractional coordinates. The bug was in a specific subset of input that I hadn't included in my test cases.
  5. Conserved quantities are diagnostic. Mass conservation, momentum conservation, and isotropy can be checked numerically and used as invariants. Any drift from those invariants over many frames is a signal pointing at a specific subsystem.

Further reading

  • For more on the K-M model and its limitations on dyed/highly-absorbing papers, see the Wikipedia K-M article (a good overview) and Westin's translation of the original 1931 paper [2].
  • For more on semi-Lagrangian and the broader fluid-solver landscape, the canonical modern textbook is Bridson [5]. Stam's "Stable Fluids" paper [4] is short, readable, and remains the simplest introduction to the technique. Dan Morris has an even-more-introductory annotated walkthrough at dmorris.net.
  • For more on Curtis et al.'s shallow-water approach in particular, the SIGGRAPH '97 paper [1] is the source. Yann Semet's slide presentation gives a good high-level overview.
  • For more on the CFL condition and its modern descendents (von Neumann analysis, Lax equivalence), The Courant-Friedrichs-Lewy (CFL) Condition: 80 Years After Its Discovery (Kubrusly & de Moura, eds., Birkhäuser 2012) collects current applications and historical perspective.

For maintainers

Tools and patterns from the v0.56-v0.72 development arc — useful if you ever need to debug a regression, port the lib somewhere new, or extend it. Most of this content emerged from chasing two specific artifacts (the cross and the pinpoint) across 17 versions. A companion file, washes-test-harness.js, ships alongside this page and runs all the diagnostic patterns described below.

Evidence trail: where each bug was first spotted

The cross and pinpoint artifacts were diagnosed iteratively across many user-reported observations. Below is the timeline of what was reported, what was tested in response, and what the result drove.

  1. v0.61-0.62 — screen recording of the cross during a deluge. User showed a deluge wavefront leaving four arms in cardinal directions, mostly visible above CFL bound (velocity = 40). Initial hypothesis: donor-cell advection anisotropy at high CFL. Drove v0.65 (substep advection).
  2. v0.66-0.68 — cross persists in screenshots across all four advection modes. Tried donor-cell, flux-clamp, substep CFL-safe, semi-Lagrangian. Cross visible in every mode. This was the key signal: the artifact is not in the pigment advection code. Drove anisotropy testing in v0.69.
  3. v0.69 — numerical confirmation of √2 anisotropy in the velocity field itself. Running _debug_anisotropy 5 steps after a splash: cardinal velocity magnitude 1.14, diagonal magnitude 1.62. Ratio 1.42 ≈ √2. The L1-vs-L2 norm tell. Root cause: per-axis velocity clamp making the allowed region a square. Fixed in v0.69 with magnitude clamp. Cross was gone.
  4. v0.69-0.70 — screenshot of a single dark pixel at the center of cleared paper. New artifact: a pinpoint at the splash epicenter when triggered by the "Deluge Now" button (integer-valued ec). Diagnosed: the d² < 0.5 injection guard left the epicenter cell singular (u=v=0, p=0). Patched in v0.70 with post-process spatial smoothing of one cell.
  5. v0.71 — user report: "every other click" still pinpoints. v0.70 only handled the integer-ec case. For offset-fractional ec coordinates (e.g. (324.4, 270.7)), two cells fell inside the d²<0.5 region, not one. Generalized the smoothing to scan all 4 candidates in the 2×2 grid around the ec.
  6. v0.72 — user report: "every three clicks" still pinpoints. v0.71 reduced frequency but didn't eliminate. Running _debug_cell over 30 frames revealed something unexpected: by frame 10, neighbors' velocities had reversed direction and were pointing INTO the singular cell. The smoothing of pressure had created a pressure dip; once the initial outward injection decayed, the dip became the dominant gradient and pulled flow inward. The smoothing fix was creating worse pinpoints than the original singularity. Real root cause: the d²<0.5 guard itself. Tightened to d²<1e-10; pinpoints gone everywhere.

Headless test harness

The lib targets browsers, but its core simulation is pure JS arrays and math. To inspect numerical state without a rendering loop, stub the DOM/Canvas APIs and load the lib in Node. This is what every diagnostic in the v0.56-0.72 arc used.

The stub doesn't need to do anything useful visually — it just needs to satisfy the lib's bootstrap code that touches document.createElement, window.innerWidth, canvas.getContext, and similar. Once the stubs are in place, new Function(libSource)() runs the lib and exposes window.Washes. A full working stub is in washes-test-harness.js; the essential parts look like this:

// Minimal mock DOM. Stub everything the lib touches.
function makeEl() {
  return {
    style: {}, dataset: {}, attributes: {}, children: [],
    setAttribute(n, v) { this.attributes[n] = v; },
    appendChild(c) { this.children.push(c); return c; },
    addEventListener() {},
    getBoundingClientRect() {
      return { width: 1080, height: 900, left: 0, top: 0 };
    },
    getContext() { return { /* canvas2d API stubs */ }; },
  };
}
global.document = {
  createElement: makeEl, createElementNS: makeEl,
  getElementById: () => null, body: makeEl(),
  documentElement: { style: { setProperty() {} } },
};
global.window = { innerWidth: 1080, innerHeight: 900,
                  devicePixelRatio: 1, addEventListener() {} };
// Modern Node: navigator and performance are read-only getters,
// so use defineProperty instead of direct assignment.
Object.defineProperty(global, 'navigator', {
  value: { maxTouchPoints: 0 }, configurable: true, writable: true });
Object.defineProperty(global, 'performance', {
  value: { now: () => Date.now() }, configurable: true, writable: true });

// Load the lib.
const src = require('fs').readFileSync('./watercolor-lib.js', 'utf8');
new Function(src)();
const wc = window.Washes.create(makeEl());

Instrumenting the lib (without modifying it)

The lib's public API doesn't expose internal arrays. To inspect them, inject debug methods via string replacement on the source before eval. Find a known insertion point in the public-API object literal and add methods with the same closure scope as state():

let src = fs.readFileSync('./watercolor-lib.js', 'utf8');
src = src.replace('  state() {', `
  _debug_cell(x, y) {
    const i = y * GW + x;
    return { g: g[0][i] + g[1][i] + g[2][i],
             d: d[0][i] + d[1][i] + d[2][i],
             u: u[i], v: v[i], wet: wet[i], pressure: pressure[i] };
  },
  _debug_simStep(n) { for (let i = 0; i < n; i++) simStep(); },
  _debug_paintGrid(amt) {
    for (let i = 0; i < N; i++) d[0][i] = amt;
    setActiveRectFull();
  },
  state() {`);
new Function(src)();

This adds _debug_* methods that have access to the lib's private state (g, d, u, v, wet, pressure, GW, GH, N) without committing any changes to the lib itself. Each test injects only what it needs.

Five diagnostic patterns

Each pattern below is implemented in washes-test-harness.js and is named for the bug it caught. Run with:

node washes-test-harness.js ./watercolor-lib.js anisotropy
node washes-test-harness.js ./watercolor-lib.js trace
node washes-test-harness.js ./watercolor-lib.js hotspots
node washes-test-harness.js ./watercolor-lib.js cfl
node washes-test-harness.js ./watercolor-lib.js mass-balance
node washes-test-harness.js ./watercolor-lib.js all

Pattern 1: anisotropy detection (caught the v0.69 cross)

Compare pigment density at distance d from the splash center in cardinal vs diagonal directions. For an isotropic flow, the ratio should be 1.0. Ratios near √2 or 1/√2 are diagnostic of per-axis vs L2-magnitude confusion. Output:

=== Anisotropy (cardinal/diagonal ratio at d=100) ===
  velocity=40 (default deluge)     card=0.268  diag=0.269  ratio=0.997
  velocity=10 (CFL safe)           card=0.423  diag=0.424  ratio=0.999
  velocity=5  (well below CFL)     card=0.447  diag=0.447  ratio=1.001

Post-v0.69 the ratio is ~1.0 across all velocity scales. Pre-v0.69 at velocity=40 the ratio was ~0.71 (cardinal less than diagonal by √2).

Pattern 2: per-cell trace (caught the v0.72 pinpoint)

For one cell of interest, dump (g, d, u, v, wet, p) immediately after the splash and then at frames 1, 10, 30. Look for sign reversals, magnitude inversions, or accumulation. The v0.72 pinpoint was diagnosed via this pattern when neighbors' velocities reversed direction by frame 10. Output (post-v0.72, correct behavior):

After 10 simSteps:
    (324,271):  g=0.010 d=0.020 u= -0.407 v=  0.320  ← target
    (325,271):  g=0.010 d=0.019 u=  0.501 v=  0.298  ← east, still + (outward)
    (323,271):  g=0.017 d=0.026 u= -0.917 v=  0.209  ← west, still − (outward)
    (324,270):  g=0.011 d=0.018 u= -0.364 v= -0.527  ← north, still − (outward)

Pre-v0.72 the same cells showed reversed signs at frame 10, and the target cell had accumulated 5× the surrounding density by frame 30.

Pattern 3: hotspot scan (regression check)

Brute-force scan: for every cell, compare its total pigment to the mean of its 4 cardinal neighbors. Cells with self > 1.5× neighbor mean (and a meaningful absolute excess) are reported as hotspots. Run with many fractional epicenter coordinates to catch position-dependent regressions. Post-v0.72 output shows only the four canvas corners (expected mass-conservation accumulation at boundaries), with no near-epicenter pinpoints across any tested fractional position.

Pattern 4: CFL bound check (sanity check before changing scales)

For first-order donor-cell advection on a unit grid, the stability condition is (|u| + |v|)·Δt ≤ 1 per cell. Computing the max over the active region tells you whether the velocity scale + timestep combination will stay stable. Useful when changing VEL_CLAMP, DT, or splash velocity defaults. Semi-Lagrangian has no CFL bound, so this check is informational rather than required when running in semilag mode.

Pattern 5: mass balance (verifies boundary-mode invariants, v0.84+)

Total mass tracking distinguishes the three edge modes numerically. Sum g[] + d[] over the entire grid before and after a sim run, and the change should match the expected behavior for the active mode:

  • Closed mode: mass loss equals evaporation only. With evaporation disabled, mass should be conserved to within floating-point precision (~1e-6 relative drift). Any larger drift means the mass-conservation invariant of semi-Lagrangian with divergence correction is broken.
  • Open mode: mass loss equals boundary drainage. The integral of outward normal flux over all four edges across all frames. With no pigment near any edge, mass loss is zero; with a splash near an edge, mass loss scales with the velocity-weighted boundary integral.
  • Gravity mode, directional: mass loss scales with Pull (which sets the average outward velocity at the open edge) and with simulation time.
  • Gravity mode, radial: mass loss is roughly 4× the directional case at equal Pull (one open edge vs four, plus per-cell radial bias). Smoke-tested in v0.83 verification.

This is the right pattern to use when adding new boundary behaviors or changing the drainage flux formula. Closed mode acts as a control (mass should still conserve); open and gravity modes verify that the new drainage is doing something measurable. The harness now includes this pattern (run with node washes-test-harness.js /path/to/lib.js mass-balance) and validates all three modes plus a regression check that closed mode hasn't been changed by the boundary work.

Best practices for fluid-sim debugging

Distilled from the v0.56-v0.72 arc, in roughly the order I should have applied each.

  1. Symptoms can be inherited from upstream subsystems. The cross looked exactly like donor-cell axis-aligned error, so I lived in the pigment-advection code for 13 versions. The actual source was the velocity clamp one function upstream. Lesson: when an artifact has a strong visual signature, that signature identifies a class of bug, not the specific subsystem producing it.
  2. Dump numerical state, don't just look at renders. The √2 cross diagnosis took 13 versions visually but would have taken 5 minutes if I'd printed {cardinal_speed, diagonal_speed} after one step. Rendered output is the convolution of every layer of the sim; numerical dumps isolate the layers.
  3. The √2 (or 1/√2) ratio is a diagnostic tell. Any 1.4× ratio between similar measurements suggests L1 vs L2 confusion. Per-axis clamps, per-axis flux limits, and Manhattan-distance bounds all produce √2-anisotropic fields when the physical thing wants L2.
  4. Fix the source, not the symptom. The pinpoint had three rounds of patches: v0.70 (smooth one cell), v0.71 (smooth multi-cell skip region), v0.72 (fix the threshold so no cell is skipped). Each was closer to "don't break it in the first place." When a fix is patching the consequences of a guard or threshold, check whether the guard itself is wrong.
  5. Test boundary cases of input parameters. v0.70 worked for the integer-coordinate "Deluge Now" button but failed for random clicks. v0.71 worked for most fractional coordinates but failed for offset half-integers. v0.72 needed deliberate testing across integer, half-integer, and offset-fractional epicenters. Test the boundaries of input categories, not just the convenient default.
  6. Use conservation laws as diagnostics. Mass, momentum, divergence, isotropy are all checkable numerically without rendering. Any drift from those invariants over many frames is a signal pointing at a specific subsystem. This is what the hotspot scan tests: "is the simulation diverging from uniform-mass behavior?"
  7. Tighten one variable at a time. When something feels off, isolate it: same paint, same splash position, same timestep, vary one parameter, observe. The anisotropy test holds everything but the splash velocity constant; the trace test holds everything but the time axis constant. Multivariate "this whole thing is broken" reports almost always resolve to one specific variable's bug.

Source materials and uploads from the original session

The bugs above were diagnosed across many user-uploaded screenshots and screen recordings. The most useful ones (referenced here as a paper trail rather than embedded):

  • Screen recording of the cross during a deluge animation — 4-arm wavefront radiating from the splash center. The persistence of this shape across 4 advection modes was the key signal that the issue wasn't in the advection code.
  • Screenshot of a single dark pixel after a deluge cleared the rest of the canvas — the pinpoint artifact. Visible against an otherwise clean paper, locating the bug at the splash epicenter rather than in the bulk transport.
  • Multiple screenshots of click-deluge sessions — documenting the "every other click" and "every three clicks" failure patterns that drove v0.71 and v0.72 respectively.

The verification scripts that confirmed each fix are archived in the test harness file. If you find a similar artifact, the patterns above will likely localize it quickly — or at least point at which subsystem isn't the cause.

Changelog

Reverse-chronological release history. Each version is a standalone .html file kept in /mnt/user-data/outputs/ so you can always go back to a previous version's exact behavior.

v1.0.23 — stage above the fold

The painting stage was a full 100vh — an immersive surface, but every visitor had to guess there was documentation below and scroll blind to find it, and the sim was paying for a canvas taller than most people paint on. This release trims the stage to 72vh. Nothing about the brush, the panel, or the API changes; the canvas is simply a little shorter.

Changed

  • perf Stage trimmed to 72vh — ~28% fewer grid cells. The lib sizes its simulation grid from the canvas host, and sim cost scales with canvas area, so a shorter stage is cheaper on every frame — a free win on modest and battery-bound hardware, on top of the resolution governor that was already shifting quality under load. Embedded iframes still fill their full height.
  • feature Documentation peeks above the fold. With the stage at 72vh, the top of the docs (the section rule and the first heading) is visible at rest, so the page reads as “paint and read” rather than hiding half of itself below the scroll line.
  • fix Painting chrome now clears over the docs. The body.docs-visible rules that hide the panel, toggles, perf overlay, and cursor over the documentation were defined but never wired. A scroll listener now flips the class once the stage is mostly scrolled past (half its height), so reading is uninterrupted by the fixed panel — while the at-rest peek keeps the controls in place for painting.

v1.0.22 — the docs speak v2

The teaching sections catch up with the engine. v1.0.21 made the app native v2; every console snippet in the docs still taught the v1 surface — on this page, where window.Watercolor is the live v2 instance, most of them would throw if pasted. This release is docs-only: no wiring, no engine, no behavior changes.

Changed

  • docs Every teaching snippet audited against the 2.1.0 surface. Normalized coordinates throughout (paint/stroke/line/splash), unified mode accessors (animation()/visualization()/backgroundAnimation()), drying(false) for pauseDrying(true), exportImage() for exportPNG(), grid.fromDisplay() for toGrid(), Washes.create() as the factory spelling. The agents page's type signatures are regenerated from the real d.ts, and its determinism guidance inverts: the core is deterministic, create({ seed }) and saveState()/loadState() make replays bit-exact.
  • fix Documented-but-nonexistent API removed. The cursor()/cursors() handle API and fadeFullRender() appeared in no version's runtime surface (not even the v1 143-member snapshot); the section teaching them is replaced by the real painting verbs. Also corrected: paperWetnessPresets() returns names (not a map), splashPresets() includes 'deluge', paperColor() takes 0..1 RGB, and state() gained its v1.4/v1.5 governor keys.

Changelog entries below this line remain v1-phrased on purpose — they describe the page as it was.

v1.0.21 — native v2

The playground now speaks the engine's v2 API directly — the compat1() pin (v1.0.19–v1.0.20) is retired, and the console migration warnings are gone.

Changed

  • infra All wiring migrated to v2 names and units. paint/stroke/stir/sample take normalized coordinates; the fluid tools, deluge clicks and probe all convert through one locate(); run('until-dry'), drying(), animation()/visualization()/backgroundAnimation(), exportImage(). The Size slider, brush presets and stroke taper keep their px-diameter UI through one brushPx()/setBrushPx() bridge pair (saved brush presets stay portable).
  • behavior Scale slider now preserves the painting. v2's scale(n) resamples instead of wiping (v1 wiped); dragging the resolution slider no longer erases the canvas.

v1.0.20 — engine 2.0 ready

Engine 2.0.0 landed the API rename batch. This page keeps working through its v1.0.19 compat1() pin — the wrapper is now the real v1 adapter — with one hand-fix below.

Changed

  • fix DOM event rename. The engine's rescaled CustomEvent became rescale (2.0's one-casing rule; DOM events can't be shimmed by compat1). The swatch/slider rebuild listener now subscribes to both names, so the page works against 2.0 and any cached pre-2.0 engine alike.

v1.0.19 — compat1 pin

The engine's API 2.0 arc has begun (all five design decisions are made; see docs/API_2_0_DESIGN.md). This page now pins itself to the v1 surface so the coming rename batch cannot break it.

Changed

  • infra Instance wrapped in Washes.compat1(). A documented passthrough today; when engine 2.0 renames the primary surface (normalized-as-default, chain-everywhere setters, exportImage), the wrapper becomes the v1 adapter and this page keeps working unchanged. At the time of this entry the live engine is 1.25.0, which also adds the additive v2 names: run(), drying(), wc.grid.*, splashNorm, exportImage.

v1.0.18 — live engine

The playground no longer embeds an engine snapshot. It loads the repository's live build — engine/dist/washes.standalone.js and the washes-timeline sidecar — so every engine release reaches this page the moment it lands, with no re-embedding step.

Changed

  • infra Engine via <script src>. Two classic script tags replace ~590 KB of inlined source (engine + timeline sidecar). Wiring is unchanged — the standalone build attaches the same window.Washes / window.Watercolor / window.WashesTimeline globals. At the time of this entry the live engine is 1.22.0 (see engine/CHANGELOG.md for everything between 1.12.1 and now: ~14× sim perf, worker backend, painting save/load).

v1.0.17 — auto performance by default

The playground now boots with quality('auto') — the engine's performance governor owns grid resolution from the first frame instead of waiting to be invited.

Changed

  • perf Auto quality is the default. On load the Quality dropdown reads Auto and the live governor level shows beside it (auto · full). Under sustained load the governor steps resolution down full → half → quarter without wiping the painting, and recovers while idle (engine 1.5–1.9 plumbing). Selecting any manual preset takes the knob back — v1.6 selection semantics are unchanged.

v1.0.16 — engine 1.12.1: pigments, masks, transparent, timeline

Embedded engine updated to 1.12.1, surfacing four new capabilities in the brush panel. The first three are engine features; the fourth is an optional sidecar (washes-timeline.js, inlined here).

Added

  • v1.10 Custom pigment palettes — palette(). Redefine the three working pigments from colors (Kubelka–Munk K/S derived per channel). Try Blurple / Lime / Forest, or Stock to reset — the pigment swatches above refresh to the new inks.
  • v1.11 Arbitrary masks — maskPath(). Freeze cells inside any SVG path. Toggle a circle or star mask and paint across it. maskImage() / maskInvert() ship too.
  • v1.12 Transparent canvas — transparent(). Unpainted paper falls to alpha 0 so the wash composites over the page (a checkerboard shows through behind the canvas); painted pigment stays opaque.
  • sidecar Stroke choreography — WashesTimeline. Declarative, eased, time-based strokes on the engine frame clock. Zero engine code; it only schedules public calls.

1.12.1 fix: swapping palette now refreshes the pigment swatches (the engine fires pigmentchange on palette()); the transparent control uses a unique id so it no longer collides with the existing Transparent toggle.

v1.0.15 — engine 1.9.0: governor recovers while idle

Embedded engine updated to 1.9.0. Fixes the report that Auto quality stayed parked at quarter even after the canvas was dried manually.

Fixed

  • fix Idle recovery. The governor only measured frames while the simulation was running, so the moment the canvas dried and the sim idled, measurements stopped and the resolution never climbed back. It now recovers while idle — one level per ~2.5s dwell, quarter → half → full — since idle is the cheapest time to restore resolution. If your next stroke genuinely can't sustain the restored level, the normal downshift takes it back down, art-preserved.

v1.0.14 — engine 1.8.0: governor unstuck, levels renamed

Embedded engine updated to 1.8.0. Two fixes from live use of Auto quality.

Fixed

  • fix Governor stuck at its lowest level. Upshift was judged by rAF intervals against a 13ms threshold — but vsync pins healthy intervals to ~16.7ms at 60Hz, so the threshold could never be met and the governor parked at the bottom regardless of headroom. Upshift is now driven by busy time (actual work per frame): sustained <~9ms of work steps back up. Downshift stays interval-driven — missed vsync remains the correct over-budget signal.

Changed

  • change Perf levels renamed full / half / quarter. The old names (high/medium/low) collided with the quality preset names. The vocabulary is now disjoint by design: presets are feature bundles; perf levels are resolution fractions (approximate cell count vs base). The dropdown's live label now reads auto · half etc.

v1.0.13 — engine 1.7.0: preset hops keep the canvas

Embedded engine updated to 1.7.0. Two changes, both born from drawing while the perf governor was live.

Fixed

  • fix Stroke teleport on rescale. A grid rebuild mid-stroke zeroed the brush's last stroke point, so the next pointer move interpolated a stroke from the top-left origin to the pointer — the diagonal line seen when Auto perf shifted while drawing. The last stroke point is now rescaled into the new grid (pointer brush and strokeTo pen alike), so strokes continue smoothly across any shift.

Changed

  • feature Quality presets keep the canvas. Switching between Auto / High / Medium / Low / Minimum now resamples the painting into the new grid instead of wiping it. The Resolution slider keeps its historical wipe; engine hosts can opt in there with scale(v, {preserve:true}).

v1.0.12 — Auto joins the Quality selector

Embedded engine updated to 1.6.0, which makes the performance governor a first-class quality preset: quality('auto'). The Quality dropdown gains an Auto option — select it and the engine shifts resolution between high/medium/low under load without wiping the canvas, with the live level shown beside the dropdown (auto · medium). Selecting any manual preset hands control back: the governor is disabled and the base resolution restored art-preservingly before the preset applies as it always has. The standalone Auto perf button from v1.0.11 is retired in favor of the selector — one control, one home.

Honest detail: the quality() getter now reports 'auto' whenever the governor is enabled, however it was turned on, so the dropdown stays truthful even if you toggle autoPerf() from the console. Governor rescales do not snap the dropdown to “Custom” — that snap is reserved for user-driven control changes.

v1.0.11 — engine 1.5.0: auto performance throttler

Embedded engine updated to 1.5.0. An Auto perf toggle joins the v1.3 row: when enabled, the engine watches frame cost while the sim is active and shifts grid resolution between high / medium / lowwithout destroying the canvas. Every shift resamples the painting (wetness, velocity, suspended and deposited pigment bilinearly; the mask nearest-neighbor) into the new grid, so the only visible change is the resolution itself. The button label shows the live level via the new perflevel event.

Added

  • feature autoPerf(). Hysteresis + cooldowns prevent flapping: sustained frames over ~26ms step down (3s cooldown); a much longer run under ~13ms steps back up (5s). Frames are measured only while the sim is running, so an idle canvas never baits an upshift it can't sustain. Disabling restores the original resolution, also preserving the art.
  • feature Art-preserving rescale. Internal: rebuildScale can resample rather than wipe. Explicit resolution changes (the Resolution slider) keep their existing wipe behavior.

Honest physics note: densities are preserved, totals are not — the painting looks the same, but detail finer than the coarser grid is genuinely lost on a downshift and does not return on the way back up. Paper texture regenerates per level, so granulation shifts subtly.

v1.0.10 — engine 1.4.0: the ergonomics release

Embedded engine updated to 1.4.0 — nine ergonomics improvements drawn from building real hosts (this playground, embedded product panels, generative pieces) against 1.2/1.3.

Fixed

  • fix Idle accounting. The auto-idle gate read wet/suspended totals that were never updated, so the sim idled on a fixed timer regardless of wetness — washes froze mid-dry. Totals are now maintained on idle-check frames, so auto-idle respects actual wetness. runUntilDry() remains the strict every-cell option.

Added

  • feature Host size safety. A host measuring ~0 at create() warns once and rebuilds automatically when it gains real size; size:{width,height} overrides measurement; remeasure() for manual rebuilds.
  • feature Lifecycle events. on('idle'|'active'|'dry'|'rescale') → unsubscribe; 'dry' fires once per wet episode — the long-requested onDryDone. state() now reports isIdle, totalWetness, totalSuspended.
  • feature Watercolor verbs. lift() (dried deposit → suspension — the hidden prerequisite for any rinse), flood(), blot(), pour()/endPour(), with Norm twins.
  • feature Diagnostics & testing. diagnose() answers “why is my canvas blank”; Washes.createHeadless() for tests/CI; Washes.tiers groups the 100+ methods into core / tuning / debug.

Scoped out, honestly: GPU first-frame health checks with automatic CPU fallback — pixel-readback heuristics could not be verified headless. diagnose() plus the degenerate-host rebuild cover the blank-canvas cases observed in practice.

v1.0.9 — stronger speed taper

Tuning only. The v1.0.8 speed taper was too gentle to read on a trackpad/mouse, so the width response is steepened: mapping goes from 1 − speed×0.2 (floor 0.3×) to 1 − speed×0.45 (floor 0.15×), and the per-move smoothing tracks the target faster (0.4 → 0.5). A brisk stroke now thins to roughly a sixth of the slider width instead of a third, and reaches the thin end at about half the previous speed. Pressure is unchanged (stylus only; a no-op on mouse/trackpad).

v1.0.8 — wet-brush Flow control + speed/pressure taper

Playground-only; embedded engine unchanged (1.3.0). Two controls for the wet (paint) brush, both surfacing or building on existing engine behavior.

Added

  • feature Flow slider. Exposes wc.flow() — stamp spacing along a stroke as a fraction of the brush radius. The engine already interpolated the paint brush; this just makes the knob reachable. Lower (toward 0.1) packs the stamps into a smooth continuous line and fills in the beaded centerline; higher spaces them into a dabbed look. Default 0.4.
  • feature Speed / pressure taper toggle. Thin-to-thick strokes. Speed drives width on any device — the overlay watches pointer velocity during a paint stroke and nudges the live brush size (fast = thin, slow = thick), then restores your slider size on lift. This works because the engine re-reads brushSize every frame when it lays the interpolated stamps. On a stylus it additionally enables usePointerPressure so pen pressure drives opacity.

The taper affects only the paint brush; it stands down while a fluid Stir/Dry/Rewet tool owns the overlay. Width mapping (1 − speed×0.2, floored at 0.3×) is a starting point and easy to retune.

v1.0.7 — smooth Dry / Rewet strokes

Playground-only; embedded engine unchanged (1.3.0). The Dry and Rewet fluid brushes stamped a single circular region per pointer-move event, so a quick drag left a row of discrete dabs with gaps between them rather than a continuous stroke. They now interpolate stamps along each drag segment — the same idea the pigment strokeTo()/line() path uses.

Changed

  • change Continuous Dry/Rewet. On drag, the overlay walks from the previous point to the current one and lays overlapping dry()/rewet() dabs spaced at 0.35 × the brush radius, so the stroke reads as one smooth wet (or dried) band regardless of how fast you move.

Stamp count per segment is capped (128) so a fast flick across the whole canvas can't spike into thousands of region writes. Stir is unchanged — it already injects velocity continuously along the drag.

v1.0.6 — fluid brushes follow the brush size

Playground-only tweak; the embedded engine is unchanged from v1.0.5 (1.3.0). The Stir, Dry, and Rewet fluid brushes previously used a fixed fraction of the grid (about 6–7% of width) for their footprint, ignoring the Brush size slider. They now derive their radius from brushSize(), so all three cover the same area as the paint brush and scale as you drag the slider.

Changed

  • change Stir / Dry / Rewet radius. Converted from a hardcoded grid fraction to a live read of brushSize() (a display-px diameter) mapped to grid cells via the canvas displayRect() — the same display→grid mapping the overlay already uses to place clicks. Floored at a 2-cell radius so the smallest brush still does something.

The size is read at the moment of each stroke, so moving the slider mid-session changes the next dab without needing to reselect the tool.

v1.0.5 — engine 1.3.0: runUntilDry knob

Embedded engine updated to 1.3.0, which adds a single knob: runUntilDry(). Normally the simulation auto-idles about a second after the last paint to save CPU — but that heuristic looks at the canvas-average wetness, so a small wet stroke on a large canvas can freeze mid-dry while it is still visibly wet. With runUntilDry(true) the sim keeps stepping until the paper is completely dry, then idles on its own.

Added

  • feature Run until dry toggle. A new v1.3 toolbar row wires wc.runUntilDry(). Toggle it on, lay a wash, and watch it carry through the full drying tail instead of freezing. Unlike keepSimulating (which runs forever), this stops by itself once dry, so it costs nothing afterward.

Implementation note: runUntilDry measures the wet field directly rather than trusting the engine's lastTotalWet idle accumulator, which is unmaintained in this build — the underlying reason washes idle a fixed grace period after any paint regardless of how wet they still are. The scan runs only on idle-check frames and breaks on the first wet cell, so it is effectively free and entirely opt-in.

v1.0.4 — live v1.2 panel in the toolbar

Surfaces the 1.2 engine methods in the playground UI — the engine itself is unchanged from v1.0.3. A new v1.2 toolbar row and a live Readout line.

Added

  • feature Stroke tool. Drag on the canvas to lay a continuous line with strokeTo() in the current pigment, instead of the engine's own dab brush.
  • feature Probe tool. Move over the canvas to read the cell under the cursor with sample() — wetness, ink density, velocity magnitude, and mask state.
  • feature Clear paint button. clearPaint() wipes pigment but keeps the freeze mask, unlike Reset.
  • feature Live readout. coverage() and the probed cell, refreshed each frame via onFrame().

Stroke and Probe reuse the existing fluid-brush overlay, so they are mutually exclusive with Stir / Dry / Rewet; picking a pigment or changing Texture returns to the normal brush.

v1.0.3 — embedded engine updated to 1.2.0 (ergonomics pass)

The playground now embeds washes 1.2.0. This is an engine swap plus a second round of authoring-ergonomics methods, drawn from building a series of generative pieces on 1.1; the playground UI itself is unchanged. Verified headless (functional + correctness suite, a 150-frame generative drive, full parse, tsc --strict). CPU visual behavior is unchanged from 1.1.

Added (engine 1.2.0)

  • feature Normalized motion/wetness twins. addVelocityNorm / stirNorm / rewetNorm / dryNorm mirror paintNorm and maskNorm — 0..1 coordinates everywhere, no grid-size arithmetic.
  • feature Continuous strokes. strokeTo / strokeToNorm / line / penUp lay interpolated dabs from the previous point, replacing the hand-rolled loop.
  • feature clearPaint(). Clears pigment, wetness and motion but keeps configuration and the freeze mask — the regenerating-loop counterpart to reset().
  • feature Managed frame loop. onFrame((dt, elapsed, instance) => …) returns an unsubscribe and wraps callbacks so a throw can't kill the render loop.
  • feature Field sampling. sample / sampleNorm return per-cell { wetness, mask, velocity, suspended, deposited, pigment, density }; coverage(threshold?) returns the inked fraction — pieces can read their own field instead of running open-loop.

Changed

  • change brushMode fails loud. An unknown mode now throws (with the valid list) instead of a silent warn-and-keep, so a typo can't masquerade as a no-op. Discoverable via wc.brushModes() and the static Washes.brushModes.

Fixed

  • fix create() no longer needs a positioned host. The canvas is position:absolute; inset:0; a static host let every instance's canvas escape to the nearest positioned ancestor and overlap. create() now promotes a static host to position:relative (and clips overflow), honoring any explicit value.

v1.0.2 — Fluid toolbar + embedded engine 1.1

Retroactively documented. This playground file embedded washes 1.1 and added a Fluid toolbar row driving the main canvas.

Added

  • feature Fluid toolbar. Stir, Dry and Rewet buttons drive the main instance through a transparent overlay, exercising 1.1's velocity injection and regional wet/dry.
  • feature Embedded engine 1.1. paintNorm, addVelocity/stir, regional rewet/dry, gravityVector, normalized masking, paperColor strings, and chained setters.

v1.0.1 — GPU texture parity + texture-brush crash fix

A patch release with one real bug fix and the “right long-term” version of the GPU texture brushes: the GPU backend now reproduces the CPU texture look exactly instead of approximating it.

Fixed

  • fix Texture brushes crashed on the CPU path. v1.0 kept the six call sites to _smoothNoise2D inside _ensureTextureNoise but dropped the function definition, so selecting any texture mode (crayon / dry brush / salt / splatter) and painting threw ReferenceError: _smoothNoise2D is not defined. Restored the helper (the v0.98 hash-based value-noise definition). Texture brushes work again, and the GPU path now has real fields to upload.

Added (verified headless)

  • feature GPU texture brushes match the CPU. The WebGL2 backend previously synthesized brush noise procedurally (hash / fbm / Worley) — a look-alike, not a match. It now samples the same precomputed noise fields the CPU uses, uploaded via a new GpuSimHandle.setBrushTexture(field, w, h), and applies the CPU's exact deposit-multiplier math (direct baseThresh ± bandHalf smoothstep, paper-height blend, motion anisotropy, per-index-hash bristle skip). The procedural-era per-stamp deposit cap is removed.
  • feature Optional GPU entry point. The package now exposes import … from "washes/gpu-sim" alongside the main import … from "washes".
  • feature Texture-parity test. texture-parity.test.mjs proves the deposit math the shader transliterates matches the real lib to < 1e-4 per cell across all four modes — painting a wet stamp and an identical textured stamp and checking the per-cell ratio against the reference.
  • feature GPU sim folded into the library (opt-in). washes-gpu-sim is now embedded inside washes.js as an internal factory, so the library is one self-contained file. GPU is opt-in ({ gpu: true }): auto-on was tried but rendered incorrectly in-browser, and since GPU init throws no error the failure is silent, so the CPU path stays the default until the GPU render is validated. Shader-compile failures still fall back to CPU automatically.

Pending browser validation

The deposit math is verified headlessly, but the GLSL compile/run and the GL wiring (setBrushTexture, sampler binds) need a real WebGL2 context — so the GPU runtime path is integrated but not marked validated. The shader is a line-for-line transliteration of the verified JS reference. Note: this playground runs the v1.0.1 engine on the CPU path. The GPU backend is folded into the library but is OFF by default: when auto-enabled it rendered incorrectly in-browser (a flat fill), and because GPU init succeeds without throwing, the failure is silent. Until the GPU render path is fixed and validated in a browser, CPU is the default. To experiment with GPU, create with Washes.create(el, { gpu: true }).

Known parity item (not addressed)

GPU pigment deposit uses linear falloff; the CPU uses falloff². This affects overall stroke softness for every pigment stamp (wet included), not the texture speckle (which is governed by the now-matched deposit multiplier). Aligning it is a one-token change that also shifts the plain-wet path, so it is left for a dedicated visual check.

v1.0 — pluggable simulation backend

The 1.0 milestone makes the simulation backend pluggable. The library now talks to a typed SimBackend contract; the existing CPU simulation becomes one implementation, and a WebGL2 GPU backend is being brought up as the primary fast path behind the same interface. The lib keeps owning the canvas, pointer events, brush state, public API, and render loop — only the simulation engine swaps.

Shipped (verified headless)

  • feature SimBackend interface. First-class typed contract — step, stampBrush, uploadState, downloadState, getTextures, destroy — with BrushStamp and SimParams exported as first-class types.
  • feature CPU backend adapter. Satisfies the interface by driving the existing simulation rather than relocating the core. Proven bit-identical to the v0.98 sim (probe cells and total mass), so introducing the seam changes nothing.
  • feature Backend selector + parity gate. Detects WebGL2 + EXT_color_buffer_float and chooses the GPU only when a parityOk predicate passes for the features an instance uses — so the default flips per-feature, never silently before sign-off.
  • feature Stamp batching. Splits any stamp list into ≤32-per-pass batches (the brush-stamp shader's MAX_STAMPS cap). The CPU path had no cap, so dense strokes, the deluge preset, and SVG tracing overflowed it.
  • fix Context-loss recovery. Handles webglcontextlost/restored, re-seeding from a coarse CPU shadow rather than a per-frame readback — a failure mode the CPU path never had.

In progress (specified, not yet shipped)

The GPU-side work is specified with concrete shader edits but needs a real WebGL2 GPU to validate, so it is deliberately not marked done (see MIGRATION.md): a paint-load uniform to retire the strength-folding hack, GPU texture brush modes (crayon / dry brush / salt / splatter rejection logic), the ink channel, a per-pass GPU↔CPU parity harness (the oracle every later step is checked against), GPU-direct K–M render with no readback, a shared GL context for multi-instance pages, and a build step folding the TypeScript sim module and shaders together (retiring convert-ts-to-js.py).

Note on determinism

CPU and GPU parity is tolerance-based, not bit-exact: GPU highp float diverges from CPU float64 through semi-Lagrangian advection. The CPU backend stays as the fallback and the numerical oracle that defines what “the GPU is correct” means.

v0.98.1 — packaging & types fixes

A maintenance release: no simulation behavior changes, but several shipped artifacts were out of sync with the v0.98 source. Caught in a bundle review.

  • fix Standalone washes.js was the wrong build. The drop-in washes.js was byte-identical to the original factory extraction — no texture-brush code, and it attached only window.Watercolor, so the documented window.Washes.create(…) resolved to undefined. Rebuilt from the actual v0.98 source: it now attaches both window.Washes and window.Watercolor and includes the texture brush modes. (The trailing ESM export lines are omitted from the standalone file, since a classic <script> tag can't contain top-level export; use the npm package for import.)
  • fix Version metadata. package.json still read 0.85.0; bumped to 0.98.0. CITATION.cff gained a version and date-released, and its author entry (previously Stephanie, Stephanie from duplicated name fields) was corrected to a single given-names.
  • fix TypeScript declarations missing the v0.98 surface. Added brushMode() (with a BrushMode union), dryness(), dryPaperReject(), dryAnisotropy(), dryBrushSkip(), and wetnessHeatmap() (three overloads, plus HeatmapColor). The standalone washes.d.ts copies were also stale — an older surface missing closed-gravity, radial-in, and the pause/quality types — and were synced to current. Verified against the type smoke test under --strict.
  • fix Test-harness hotspot readout. The hotspot scan printed the count but sliced the coordinate list to three, so a four-corner result reported "4" while listing only three. It now prints all hotspots (capped with an overflow marker), so the count always matches the list.

v0.98 — texture brush modes

The headline feature: brush texture modes that modulate deposition by paper tooth and bristle dynamics, moving beyond the smooth default wash. Four textured looks plus the original wet mode, selected through a single brushMode() setter.

Modes

  • feature 'wet' — the default smooth wash; no texture rejection.
  • feature 'crayon' — wax-resist look: paper-tooth peaks reject pigment, leaving the tooth showing through.
  • feature 'dryBrush' — directional bristle streaking with skipped cells for broken, dragged coverage.
  • feature 'salt' — granular salt-bloom texture.
  • feature 'splatter' — spattered droplets.

'dry' is kept as a deprecated alias for 'crayon' so older call sites keep working.

API

// Select a texture mode
wc.brushMode('crayon');     // wax-resist
wc.brushMode('splatter');   // spattered droplets
wc.brushMode();             // → current mode

// Texture tuning knobs (all 0..1)
wc.dryness(0.6);            // overall dryness; scales texture strength (no-op in 'wet')
wc.dryPaperReject(0.4);     // how strongly paper-tooth peaks reject pigment
wc.dryAnisotropy(0.8);      // directional bias of dryBrush streaking
wc.dryBrushSkip(0.3);       // fraction of skipped cells (broken coverage)

Demos & experimental GPU sim

v0.98 also added a texture-brush showcase demo, a three.js demo (paint on the left → textured 3D mesh on the right), a performance panel, and call-history UI. Alongside the shipping CPU path, an experimental WebGL2 GPU simulation backend landed as a separate track (ten GLSL shaders + a TypeScript sim module). The CPU path remains the default and the source of truth; the GPU path does not yet implement the texture brush modes (its brush-stamp shader has no rejection-mask logic), so brushMode() applies to the CPU renderer only.

v0.97 — ink pigment

New ink pigment alongside the existing K-M pigments (rose, yellow, blue). Designed for "print text on watercolor paper" use cases: crisp dark deposits that bloom only when external wet pigment reaches them.

What's different about ink

Ink lives in its own single-channel Float32Array outside the K-M system. Instead of getting K/S coefficients, ink composites as a darkening multiplier on top of the pigment-rendered color — ink at concentration 1.0 reduces R/G/B by 95% (near-black, not exactly zero).

Two key design choices flow from the spec "print text that doesn't follow pigment properties but blooms like pigment":

  • High default paint load (1.0) so a single stamp saturates the cell — letters appear dark and readable at default strength.
  • Low default water load (0.05) so ink stamps don't deposit much wet alongside the pigment — letters stay crisp by default, only blooming when another (wet) pigment splashes onto them.

API

// Basic usage
wc.pigment('ink');
wc.paintAt(gx, gy, gridR);             // strength = current pressure
wc.paintAt(gx, gy, gridR, 'ink', 1.0); // explicit

// Adjust loads
wc.inkPaintLoad(0.5);   // lighter ink (50% of max per stamp)
wc.inkWaterLoad(0.3);   // ink with self-bleed (6x default wetness)

// Read defaults
wc.inkPaintLoad();      // → 1.0 (or whatever was set)
wc.inkWaterLoad();      // → 0.05 (or whatever was set)

// Clear the ink field
wc.clearInk();

// Use ink with paintImage / SVG text
wc.paintImage(textImage, {
  bounds: { x, y, w, h },
  pigment: 'ink',  // auto-forces single-pigment dispatch
});

Bloom behavior

Ink advects with the shared velocity field exactly like the K-M pigments do. When a pigment splash, deluge, or wet brush stroke generates flow near an inked cell, the ink moves with the flow — this is the "bloom when other pigments hit it" property. Verified in both donor-cell and semi-Lagrangian advection modes.

paintImage `forcePigment` option

paintImage historically dispatched every pixel through a per-pixel RGB → rose/yellow/blue weight mix. For ink (and other special-sentinel pigments like water/mask/ paper/lift), that mix is meaningless: the caller wants every stamp routed to the chosen channel. v0.97 adds the forcePigment option, and auto-enables it when opts.pigment resolves to a special-index sentinel (negative index). For K-M pigments the previous behavior is preserved.

Architectural note

Ink lives outside the hardcoded 3-pigment loops scattered throughout the engine (render, evaporation, edge effects, K-M compositor). It only adds work in two places: its own ink-advection block inside the existing advection pass (gated on inkActive), and an ink-darkening multiplier in the render loop (also gated). When no ink has been deposited, both are zero-cost.

v0.96 — unmaskRect + paintImage bounds

Two additions, both supporting interactive masked regions.

unmaskRect — inverse of maskRect

wc.unmaskRect(x, y, w, h, radii) clears mask cells inside a rounded rectangle so subsequent paint operations can deposit there. Same argument shape as maskRect (display-pixel coords + roundRect-spec radii). Designed for "open a window" effects in larger masked regions: mask a card, then unmask a sub-region (a link, a button) so hover splashes can land on it while the rest of the card stays protected.

wc.maskRect(cardX, cardY, cardW, cardH, 8);   // mask whole card
wc.unmaskRect(linkX, linkY, linkW, linkH, 4); // open window for link hover

When unmaskRect clears the last masked cells in the field, the mask system flips off entirely. Otherwise the global maskRect bounding box is left alone (re-scanning to find a tight bbox would be O(N) for a small efficiency win; the per-cell mask check inside sim functions still gives correct results).

paintImage bounds

paintImage now accepts an optional bounds: {x, y, w, h} in grid coords — same shape as traceSVG's bounds. Lets you place rasterized image content (text rendered to SVG, icons, screenshots) at a specific canvas position instead of the default 70% canvas-centered fit.

wc.paintImage(img, {
  bounds: { x: 100, y: 50, w: 200, h: 30 },
  brushSize: 6,
  density: 0.6,
  strength: 0.85,
});

Other PaintImageOptions also documented in TypeScript now: density (stamp packing), strength, alphaThreshold, maxStamps. These existed but were undocumented.

v0.95 — maskRect API

New wc.maskRect(x, y, w, h, radii) stamps a rounded rectangle directly into the mask field. Designed for the "card over watercolor" pattern: masking arbitrary UI rects (cards, panels, dialogs) so the wash flows around them with crisp boundaries.

API

wc.maskRect(x, y, w, h)              // square corners
wc.maskRect(x, y, w, h, 12)          // uniform 12px radius
wc.maskRect(x, y, w, h, [8, 8, 0, 0]) // rounded top, square bottom

x/y/w/h are display pixels (same coord space as getBoundingClientRect, host.clientWidth, etc. — the lib does the display→grid conversion). radii matches the CanvasRenderingContext2D.roundRect() spec:

  • number → uniform radius all four corners
  • [a] → uniform radius all four corners
  • [a, b] → [top-left + bottom-right, top-right + bottom-left]
  • [a, b, c] → [top-left, top-right + bottom-left, bottom-right]
  • [a, b, c, d] → [top-left, top-right, bottom-right, bottom-left]

Each cell inside the rounded rect is set to mask = 1.0 (fully frozen). For partial / soft-edge masks, use paintAt(MASK_INDEX) with strength < 1.

Versus the old approach

Previously, masking a rectangular card area meant either stamping a single large paintAt(MASK_INDEX) circle that overshot the rect (wasteful and overlapped adjacent content), or approximating the rect with many overlapping small circles (slow and bumpy). Neither matched what you actually wanted.

maskRect is a single O(w·h) loop that sets mask cells inside the rounded-rect geometry directly. Faster, crisper, and the call site reads as the intent.

Invalid inputs

NaN coordinates, zero or negative width/height, and unrecognized radii shapes all log a console warning and no-op rather than corrupting the mask field. Radius values larger than half the smaller side are clamped to that maximum (matching CSS border-radius behavior).

v0.94 — toGrid return-shape fix

wc.toGrid(displayX, displayY) now returns { gx, gy } (was { x, y }). This matches the lib's internal _clientToGrid convention and paintAt's (gx, gy, ...) parameter names. The TypeScript declaration and playground documentation example already said { gx, gy }; this brings the runtime lib in line with them rather than the reverse.

Why the rename matters

Display coords (rect.x, e.clientX) and grid coords are easy to confuse. Returning { x, y } from toGrid let calling code look like it was working with display coords when it wasn't. With { gx, gy } the call site is unambiguous:

// Before — easy to mistake which reference frame
const { x, y } = wc.toGrid(displayX, displayY);
wc.paintAt(x, y, radius);   // grid? display? unclear at glance

// After — call site self-documents
const { gx, gy } = wc.toGrid(displayX, displayY);
wc.paintAt(gx, gy, radius); // unambiguously grid

Latent bug caught by this

I wrote a portfolio demo that used tl.gx / tl.gy after calling toGrid, on the (correct, but undocumented in code) assumption that the .d.ts I'd also written was accurate. Both the .d.ts and the playground docs example said { gx, gy } — but the lib actually returned { x, y }. The demo silently broke: tl.gx was undefined, so SVG bounds were NaN, so traceSVG painted nothing visible. Mask paintAt also silently failed for the same reason. The demo looked broken for three iterations before this caught the discrepancy.

Migration

If you previously read .x / .y from toGrid, update to .gx / .gy. No other API touched.

v0.93 — radial-in gravity direction

New 'radial-in' value for gravityDirection. Mirrors the existing 'radial' (outward) by flipping the sign: each cell's bias points TOWARD the canvas center instead of away from it. Pigment piles up toward the center over time.

How it differs from outward radial

  • Outward radial: divergent flow. Mass moves toward edges. With open edges (gravity mode), mass drains off the canvas. Used for "fade outward" backgrounds.
  • Inward radial: convergent flow. Mass moves toward center. Opening edges would be counterproductive (and physically wrong — pigment wouldn't reach an open edge anyway), so edge mode automatically treats this case as closed regardless of which mode you set. Most useful in closed-gravity mode where mass piles at center without loss.

Implementation

Shares the per-cell radial-vector math with outward radial, just flips the sign:

if (_gravityDir === 'radial' || _gravityDir === 'radial-in') {
  const cx = (GW - 1) * 0.5;
  const cy = (GH - 1) * 0.5;
  const rx = x - cx;
  const ry = y - cy;
  const rmag = Math.sqrt(rx * rx + ry * ry);
  if (rmag > 0.001) {
    const sign = _gravityDir === 'radial-in' ? -1 : 1;
    const radialBias = sign * _gravityStrength * VEL_CLAMP;
    const inv = radialBias / rmag;
    nu += rx * inv;
    nv += ry * inv;
  }
}

The rmag > 0.001 guard handles the singularity at canvas center where the displacement vector becomes zero. Same guard as outward radial — center cells get no bias.

Numerical verification

Painted a 30-cell blob at (150, 150) on a 648×540 grid (canvas center: (324, 270)) and ran 300 sim steps with gravityStrength 0.15 in closed-gravity mode:

radial     fraction near center (120-cell disk): 0.0% → 0.0%
radial-in  fraction near center (120-cell disk): 0.0% → 0.6%

Mass conservation in both cases is ~0.07% loss (evaporation only) — closed-gravity correctly prevents drainage in both directions.

UI

New ⊖ Radial (inward toward center) option at the bottom of the gravity direction dropdown, alongside the existing ⊕ Radial (outward from center).

v0.92 — pause + evaporation in UI, water-load range raised, TypeScript bounds

Four small additions, no breaking changes.

Pause + Accept Input in the UI

New Pause button in the Settings panel. Toggles pause / resume; the button label updates accordingly. Below it, an "Accept input while paused" checkbox that's enabled only when the simulation is paused. Flipping the checkbox mid-pause re-applies the pause with the new setting, so you can switch input-on / input-off without having to resume and re-pause.

Evaporation slider in the UI

Direct exposure of the existing wc.evaporation(value) API. Range 0.1 to 200, matching the API. Value below 1 is very wet; ~50 is bone- dry. The named paper-wetness presets (already exposed via the paperWetness dropdown elsewhere) snap to specific points in this range, but the slider lets you go between presets for fine-grained control over drying speed.

Water-load max raised to 8

Was 0.2 to 3, now 0.2 to 8. Useful for tests of brush dynamics at very high water loads (puddling, runs). The lib's underlying waterLoad() API never had an upper cap; this is purely a slider-range change.

TypeScript declarations: min/max bounds

Every numeric parameter in the public API surface now has JSDoc @minimum / @maximum tags documenting its valid range. TypeScript itself doesn't enforce numeric ranges (no integer subrange types), but these tags are picked up by IntelliSense in VS Code, documentation generators like TypeDoc, and runtime validators like Zod and ts-json-schema-generator.

Covered: brushSize, paintLoad, waterLoad, pressure, flow, evaporation, gravityStrength, velocityClamp, edgeFade, scale, canvasScale, paperColor, fadePainting, fadeHalfLife, and all the relevant option fields (SplashEpicenter, SplashOptions, TraceSVGOptions, PaintImageOptions, PaintTextOptions, SketchModeOptions, ObliterateOptions, CreateOptions, etc.).

v0.91 — pause / resume API

New wc.pause(), wc.resume(), and wc.paused() methods. Use case: pause canvases when their containing component unmounts (SPA navigation), resume when it re-mounts. State is preserved across pause (pigment, wetness, velocity, pressure all frozen at the moment of pause) so resume picks up exactly where it left off.

API

wc.pause()                          // freeze sim, ignore input
wc.pause({ acceptInput: true })     // freeze sim, accept paint input
wc.resume()                         // restart sim, adjust animation timers
wc.paused()                         // false | { acceptInput: boolean }

What pauses

  • simStep (advection, pressure, diffusion, edge effects)
  • fadeStep (pigment fade — wouldn't make sense to slowly disappear from a paused canvas)
  • timeWashStep (background washes)
  • animationStep (breathe, drift, pulse)
  • visualizationStep

What still runs

  • applyPendingPaint (commits pointer position to grid)
  • svgTraceStep (path-time animations stamp pigment but don't evolve it)
  • render (one-shot when there's a deposit; see _pauseDirty flag)
  • The rAF loop itself (so resume takes effect on the next frame)

acceptInput: true

Programmatic deposits (splash, paintAt, traceSVG) always work during pause — the pigment deposits visibly via a one-shot render and sits inert until resume(). With acceptInput: true, pointer input does the same thing: the user can paint on a paused canvas, see their strokes, and have them all start evolving the moment resume() is called. Useful for "compose a layout, then unfreeze and let it bleed" workflows.

Resume timer-shift

Time-based animations with wall-clock startTime references (currently just SVG traces with durationMs) are shifted forward by the elapsed pause duration on resume. The trace picks up at the same point in its easing curve instead of jumping ahead by the pause length.

What pause does NOT do

  • Does not stop the rAF loop (so resume is instant). Cost during pause: a handful of microseconds per frame for the early-return checks.
  • Does not remove pointer event listeners. The lib's handlers short-circuit on _paused && !_pauseAcceptInput, but any other event listeners on the canvas (your own, browser default scroll handling, etc.) continue normally.
  • Does not change CSS. If you want the canvas to look visually paused, dim it or add a pointer-events: none via your own CSS.
  • Does not auto-pause on tab hidden. The browser already throttles rAF in hidden tabs; if you want explicit pause on visibility change, listen to document.visibilitychange and call pause/resume yourself.

Not exposed in the playground UI (no useful interactive test — pause is for embedders, not for casual playing with the lib). API only.

v0.90 — background UI + traceSVG animate:false

Two small additions, no breaking changes.

Background UI controls

The wc.background() API from v0.89 is now exposed in the Settings panel: a type dropdown (None / Solid / Linear / Radial), two color pickers for the gradient stops, and an angle slider for linear gradients. The UI builds a CSS background string and pushes it to the API; for anything more complex (3+ stops, blur, image backgrounds), keep using the API directly.

traceSVG animate: false

New canonical user-facing flag for "draw the SVG instantly, no animation." Maps to the older internal instant: true option (kept for backward compat with code that uses it). When false, the lib stamps every point in one synchronous frame; the drawn SVG appears whole the instant the call returns. Any durationMs becomes a no-op since there's no animation to spread.

wc.traceSVG(svg, { animate: false, pigment: 'rose' })
// equivalent to:
wc.traceSVG(svg, { instant: true, pigment: 'rose' })

Bug fix surfaced by this work

The original instant: true path would hang if durationMs was also set. The synchronous loop called svgTraceStep() repeatedly, but svgTraceStep takes the time-based branch when durationMs > 0 — that branch computes targetIdx = elapsed/durationMs, and in a synchronous loop, elapsed stays 0, so the loop never advanced. Fixed by clearing durationMs on the internal state object for the synchronous run + adding a safety iteration counter that caps at points.length + 10. Any existing code using plain instant: true (without durationMs) was unaffected by the bug or the fix.

TypeScript declarations cleanup

TraceSVGOptions had two bogus fields (speed, translateX/Y) and was missing several real ones (strength, approximateColor, durationMs, easing, perStrokePauseMs, bounds, animate, instant). The return type was also wrong (declared Promise<void> but the lib returns a number). All fixed in v0.90.

v0.89 — quality presets, qualityHint, gradient backgrounds

Three additions, all opt-in. None of them change default behavior.

Quality presets

New API wc.quality(preset) bundles cost-vs-quality knobs into named tiers:

'high'    — scale 1.5,  all features, fine grain (best on desktop)
'medium'  — scale 2.0,  all features (library default)
'low'     — scale 2.75, heatmap forced off (good on phones)
'minimum' — scale 3.5,  donor-cell advection, no cursor preview, no heatmap
            (last-resort fallback; brings back cardinal-cross artifact)

New "Quality" dropdown in Settings exposes this. Changing any individual setting (Resolution, etc.) snaps the dropdown to "— Custom —" so the UI stays honest about whether the current state matches a preset.

The lib does NOT auto-adapt at runtime. This is a manual knob that embedders can expose or call once at init based on their own heuristic. Runtime adaptation is intentionally left out: it has real failure modes (degrades permanently on transient throttling, makes lib behavior unpredictable from embedder POV).

qualityHint option

New create-time option: Washes.create(host, { qualityHint: 'auto-mobile' }). Uses matchMedia('(pointer: coarse)') to decide whether to start at a lighter scale (2.5 for coarse-pointer devices, default 1.75 otherwise). One-shot: only affects the initial scale; doesn't enable runtime adaptation.

Any explicit scale option wins over the hint. Other quality knobs (advection, heatmap, cursor) aren't touched by the hint — only scale is, since that's the knob with the largest cost reduction and the smallest visual cost.

Gradient backgrounds

New API wc.background(value) accepts any valid CSS background value — solid color, linear gradient, radial gradient, url(...), etc. Sets the CSS background on the host element and auto-enables transparent canvas mode so the background shows through pigment-less areas. Pass null or '' to clear.

wc.background('linear-gradient(180deg, #fef3c7, #ddd6fe)')
wc.background('radial-gradient(circle at 30% 30%, #fef3c7, #f97316 80%)')
wc.background('#1a1a1a')
wc.background(null)

Pigment compositing (Kubelka–Munk) is unaffected. The gradient is purely a render-layer backdrop, not a physical paper tint — pigment still composites over the lib's internal paperColor. The gradient only shows where alpha is below ALPHA_FULL_AT (paper-thin or unpainted areas).

Not exposed in the playground UI (would clutter for a designer-only API), but works via the API directly.

v0.88 — closed-gravity edge mode + edge fade rendering

Two related features for getting gravity dynamics without the visible edge artifacts that gravity mode otherwise has.

New edge mode: closed-gravity

A fourth value for edgeMode: 'closed-gravity'. Closed-edge physics (no drainage, mass conserved) but gravity bias IS applied. Pigment drifts in the gravity direction and piles up at the downwind edge. Compared to 'gravity' mode: same dynamics inside the canvas, but no pigment leaves through the edges.

Numerical test (centered splash, downward gravity at Pull=0.10, 100 simSteps):

closed         | mass: 174960 → 166012  | centroid Y drift: -0.04  (no gravity)
closed-gravity | mass: 174960 → 170429  | centroid Y drift: +9.08  (gravity, no drain)
gravity        | mass: 174960 → 170432  | centroid Y drift: ~ same  (gravity + drain)

The centroid drift confirms the gravity bias is doing its job; the mass-loss number stays in the "evaporation only" range because no edges are open.

New render option: edge fade

Render-time alpha falloff in the last N grid cells at each edge, using a smoothstep curve. The pigment is still mathematically present at the edge cells; the render just makes it transparent. Independent of edge mode — composes freely with any of closed / closed-gravity / open / gravity.

Primary use case: pair with closed-gravity to get gravity dynamics without the visible edge buildup that closed-mode would otherwise show. The result is gravity behavior with the visual of pigment falling off the page — but without the actual mass-leaving physics that gravity mode requires.

API

wc.edgeMode('closed-gravity')   // new mode value
wc.edgeFade(24)                  // 24-cell smoothstep falloff at each edge
wc.edgeFade(0)                   // disable
wc.edgeFade()                    // read current value

Both CPU and WebGL render paths implement the fade. GLSL smoothstep() matches the CPU t*t*(3-2t) form to within float precision. Per-axis fractions are passed as a vec2 uniform so non-square canvases get geometrically correct falloff at both axes.

UI changes

  • The Edges button now cycles through four states: Closed → Closed+Gravity → Open → Gravity → Closed.
  • Gravity direction dropdown and Pull slider are now enabled in BOTH gravity modes (was previously only enabled in gravity).
  • New "Edge fade" slider just below Pull. Range 0-80 pixels, default 0 (off). Works regardless of edge mode.

v0.87 — type="module" scripts + a latent duplicate-declaration bug

Switched the playground's inlined <script> tags to <script type="module">. The lib file now contains ESM export statements at the bottom (added in v0.86 for the npm package), and classic script mode rejects export as a SyntaxError — which surfaced as "Uncaught SyntaxError: Unexpected token 'export'" followed by the cascading "Uncaught ReferenceError: Watercolor is not defined" (because the IIFE never ran, so the window.Watercolor assignment never happened).

What changed

  • Both inlined <script> tags now use type="module".
  • The wiring's bareword reference to Watercolor changed to window.Watercolor. In classic script mode, bareword Watercolor resolved through the global scope; in module mode, top-level variables aren't auto-attached to globals, so explicit access through window is required.
  • Fixed a duplicate function fmtMs(v) declaration in the wiring. Classic script mode silently kept the last definition (the perf-overlay one with toFixed(2)), which had been overwriting the SVG-duration formatter (with its "instant" special case for zero) since whenever the perf overlay was added. Renamed the perf one to fmtMsPerf so both formatters survive. The SVG duration slider now displays "instant" when set to 0 and "N ms" otherwise, as originally intended.

Module-mode semantics worth knowing

  • Deferred execution. Module scripts always run after DOM parsing completes, similar to defer. The wiring previously relied on the lib's IIFE running first to assign window.Watercolor — that still works in module mode because the lib script appears first in document order and module scripts execute in document order (with deferred timing).
  • Strict mode. Module scripts are strict-mode by default. The lib and wiring already had "use strict" so this didn't change runtime behavior, but strict mode is stricter about parse errors (duplicate function declarations, duplicate parameters, etc.), which is what surfaced the fmtMs bug.
  • Variables don't leak to window. Top-level const/let/var declarations in module scripts are module-scoped, not global. Anything the playground's wiring or any embedder relies on accessing from another script must be explicitly assigned to window.
  • CORS implications for file:// loading. Module scripts can't be loaded directly via file:// URLs in most browsers due to module resolution restrictions. If you save the playground HTML to disk and open it directly, the inline scripts still work (they're not external module imports), so this doesn't affect the playground. It would affect any future attempt to do <script type="module" src="./washes.js"> from a local file.

What this means for embedding

For embedders, the npm package's src/washes.js works in both contexts:

  • Modern bundlers (Vite, Webpack, esbuild, Rollup) treat it as ESM and the export statements are honored.
  • Direct <script type="module" src="./washes.js"> works in browsers.
  • Direct <script src="./washes.js"> (classic) does not work because of the export statements. If a classic-script form is needed (some legacy embedding contexts), a build step can strip the three export lines at the bottom of the file. The IIFE attaches Washes to window regardless of whether the exports are stripped.

Worth packaging a washes.classic.js alongside the ESM build for the legacy case. Future work.

v0.86 — Wetness heatmap, deluge cursor, full-width dryness bar

Three UX improvements from real-use testing feedback.

Wetness heatmap with user-selectable color stops

New overlay visualizes which cells are still wet, mapped through a user-configurable two-color gradient. Dry areas stay transparent so the painting underneath is still visible. Useful for staging wet-on-wet layering — you can see exactly where new paint will bleed vs where it'll dry as a hard edge.

Default stops are pale yellow (dry, #FCF3A7) to deep blue (wet, #296FA7). Both pickers in the UI (under the new "Heatmap stops" row); changes apply live. Public API:

wc.wetnessHeatmap()                        // returns boolean
wc.wetnessHeatmap(true)                    // enable, default colors
wc.wetnessHeatmap(true, '#fcf3a7', '#296fa7')  // enable with colors
wc.wetnessHeatmap({ enabled: true, low: '#abc', high: '#def' })

Colors accept '#rgb', '#rrggbb', or [r, g, b] 0..255 arrays.

Implementation: a separate overlay canvas is appended to the host element with z-index above the main canvas. Wetness data is rendered at simulation-grid resolution (much smaller than display resolution) into a tiny offscreen canvas, then upsampled via the browser's bilinear drawImage(). This is much faster than touching every display pixel and gives a smooth gradient that scales cleanly to retina displays. The overlay is created lazily on first enable, so disabled-mode users pay no cost.

Deluge cursor

When click-to-deluge mode is armed, the brush preview now shows a target reticle (three concentric rings + center dot) instead of the regular brush color. Previously the cursor still showed the last-used brush color, which made it hard to tell at a glance whether the next click would stamp paint or trigger a deluge.

Pure CSS — no JS changes. ::before / ::after pseudo-elements on the cursor preview render the outer and middle rings; a nested .deluge-target element renders the center dot (only shown when the body has the .ws-deluge-mode class). The system crosshair cursor that was previously also shown is now hidden — the custom CSS indicator is sufficient.

Full-width dryness bar

The dryness progress bar at the top of the viewport and the symmetric pigment bar at the bottom were positioned absolute, which bounded them to the nearest positioned ancestor instead of the full viewport. With the docs panel open, both bars stopped short of the right edge. Changed to position: fixed so they always span the full viewport regardless of containing layout.

v0.85 — Documentation pass for the v0.81-v0.84 boundary work

No simulation changes. Documents the four versions of boundary-mode work that landed since the last docs pass at v0.75.

New "Dig deeper" subsection: Open boundaries

  • Why semi-Lagrangian back-trace doesn't model outflow naturally (it pulls from upstream, doesn't push downstream).
  • The hybrid scheme: semi-Lagrangian for interior + explicit donor-cell drainage at edges.
  • Why radial gravity drains roughly 4× faster than directional gravity (one open edge vs four, plus per-cell radial bias geometry).
  • How the v0.84 Max velocity slider interacts with CFL bounds (semi-Lagrangian has none, donor-cell modes do).

New "For maintainers" diagnostic pattern: mass balance

Pattern 5 in the test harness. Verifies that closed mode conserves mass to within evaporation, open mode drains via splash velocity, gravity mode drains more with higher Pull, and radial drains ~4× faster than directional. Also tests a closed-mode regression: gravity Pull should have zero effect in closed mode regardless of value.

node washes-test-harness.js /path/to/lib.js mass-balance

Numerical results from the test (100 simSteps, centered splash):

closed   dir=down    Pull=0.00  →   5.1% loss  (evaporation only)
open     dir=down    Pull=0.00  →   7.2% loss  (splash velocity drainage)
gravity  dir=down    Pull=0.05  →   3.6% loss
gravity  dir=down    Pull=0.10  →   2.6% loss
gravity  dir=radial  Pull=0.05  →   9.6% loss
gravity  dir=radial  Pull=0.10  →   9.9% loss

Closed-mode regression (Pull should be ignored):
  Pull=0.00  →  5.1% loss
  Pull=0.10  →  5.1% loss
  Pull=0.50  →  5.1% loss

References update

Bridson 2015 already covered open boundary conditions in the Advection Algorithms chapter; no new citations needed. The Origin section's sibling-work framing (Mixbox, Flexoki, RYBitten, OKPalette) stands as-is.

v0.84 — Max velocity exposed in UI

The simulation's per-cell velocity magnitude cap (VEL_CLAMP) is now adjustable via a slider in the Settings section. Range 0.5–7.5, step 0.05. Default is auto-computed from Resolution and pinned to display- pixel invariance (so a faster brush at higher resolution moves the same display speed as at lower resolution). Once the user moves the slider, the value is preserved across Resolution changes; the ↻ button restores auto-coupled behavior.

CFL caveat (honest version)

The original cap of 1.5 wasn't arbitrary. The donor-cell advection modes (standard, clamp, substep) require VEL_CLAMP · (DT · 0.7) · 2 < 1 for stability — roughly VEL_CLAMP < 1.7. Above that, those modes will produce the cardinal-cross artifact again because cells over-pump per timestep.

Semi-Lagrangian (the default mode since v0.67) has no CFL bound — its backward-trace handles arbitrary velocity via bilinear sample. So high VEL_CLAMP values are safe in semi-Lagrangian and produce interesting effects: stronger radial drainage at high gravity Pull, more dramatic deluges, faster lift response. The lib does NOT enforce the 1.7 cap — the slider goes to 7.5 deliberately so you can see what high-velocity simulations look like. Switch advection mode in the Brush controls if you want to deliberately see the cross artifact return.

API

wc.velocityClamp()          // returns current value
wc.velocityClamp(2.5)       // override (manual, persists across rescales)
wc.velocityClamp(null)      // restore auto-computed default

The auto-coupled formula is min(1.5, 2 / SCALE) — same as before v0.84, just now skipped when the user has manually set a value.

v0.83 — Radial gravity direction

New "radial" option in the gravity direction dropdown. Instead of pulling in a single fixed direction, the velocity bias points outward from the canvas center per-cell. Each cell gets a bias vector pointing away from the center with magnitude scaled by the Pull slider, so pigment drifts toward whichever edge is closest to it.

In Gravity mode with direction = radial, all four edges open since the pull is omnidirectional. Combined with a centered splash, this produces a symmetric outflow that drains from every edge — useful for compositions where you want the watercolor to feel like it's pushing out of frame in all directions, not just one.

Numerical verification: at Pull = 10, a centered splash drains roughly 10% of canvas mass in 100 simSteps with radial, vs ~2.5% with a fixed direction. The radial mode is significantly more aggressive because every cell is contributing to outflow rather than just the cells downstream of the bias direction.

Implementation

_COMPASS['radial'] is a sentinel (0, 0) that doesn't map to a fixed bias. In updateVelocity, when _gravityDir === 'radial', each cell computes its own outward vector from the canvas center:

const cx = (GW - 1) * 0.5;
const cy = (GH - 1) * 0.5;
const rx = x - cx;
const ry = y - cy;
const rmag = Math.sqrt(rx * rx + ry * ry);
if (rmag > 0.001) {
  const radialBias = gravityStrength * VEL_CLAMP;
  const inv = radialBias / rmag;
  nu += rx * inv;
  nv += ry * inv;
}

The center cell (rmag < 0.001) gets no radial bias by definition. All other cells get a unit-radial vector (rx/rmag, ry/rmag) scaled by the same magnitude that directional modes use.

v0.82 — Open mode without ambient pull

Refinement of v0.81. The gravity bias is now gated specifically on edgeMode === 'gravity' instead of "any edge open." In Open mode, all four edges drain but no ambient velocity is added — matching the "infinite paper window" mental model. In Gravity mode, the bias is the whole point and stays active.

UI follows: the gravity direction dropdown and Pull slider are now grayed in both Closed AND Open modes. Only Gravity mode enables them.

For Pull behavior in Open mode (drain plus drift in a direction), use Gravity mode with the matching direction — "Gravity: down-right" with all four edges still drains all four edges, just with downward-right pull added.

v0.81 — Edge boundary modes: closed / open / gravity

The canvas can now behave as if paint can fall off the edges. Three modes selectable from a new toggle:

  • Closed (default) — current behavior. All four edges reflect; mass conserved.
  • Open — all four edges drain. Pigment, water, and pressure that flows past the boundary is discarded. Add a non-zero Pull value for ambient drift through the canvas.
  • Gravity — edges open in the direction of gravity (a "down" gravity opens just the bottom; "down-right" opens the bottom and right edges). Velocity bias is always active.

Two new controls accompany the mode toggle: an 8-compass dropdown for gravity direction and a "Pull" slider (0–100) for bias strength. Both are disabled and grayed when mode is Closed, since they're meaningless without an open edge.

Implementation

Semi-Lagrangian advection's back-trace pulls pigment from upstream, which doesn't naturally model mass leaving the grid (no destination cell to push to). For open boundaries, two changes were needed:

  1. When the back-trace lands outside an open edge, the cell receives zero pigment (paper background) instead of being clamped to interior.
  2. An explicit donor-cell-style drainage pass runs on cells adjacent to each open edge after the main advection. For each such cell, the outgoing flux (vnormal · adt · pigment) is subtracted. Drains g[], d[], wet[], and pressure[] uniformly.

Edge darkening is deliberately left on at open edges to see what it looks like. Real watercolor paper does have a slight edge, and the darkening at boundaries may read as a stylistic feature; if it looks like a bug we can attenuate it.

API

wc.edgeMode('closed' | 'open' | 'gravity')
wc.gravityDirection('up' | 'up-right' | 'right' | 'down-right' |
                    'down' | 'down-left' | 'left' | 'up-left')
wc.gravityStrength(0.0 to ~1.0)  // fraction of VEL_CLAMP

Lib defaults: edgeMode = 'closed' (backward compat for embedders), gravityDirection = 'down', gravityStrength = 0. The playground also defaults to closed.

Caveats and future work

Drainage is one cell deep at each open edge. The cell at y=GH-2 drains when it has positive v; the cell at y=GH-3 drains only as its downstream neighbor empties out via back-trace from upstream cells. This means mass loss is gradual and proportional to how much pigment has reached the edge region.

The gravity bias is uniform across all wet cells, which can dominate the splash's outward radial pattern at high Pull values. At Pull values above ~50 the canvas behaves more like a vertical pane than a tilted board. Within 0–25 the effect reads as natural drainage.

v0.80 — Pigment hex triggers, mask tint toggle, gouache Auto by default

Three UX improvements, no simulation changes.

Pigment-hex SVG triggers

SVG trigger colors now recognize the pigments' actual rendered hex values in addition to the chroma-key primaries. Either form works:

/* chroma-key (existing)            pigment-hex (new in v0.80) */
#FF0000  →  quinacridone rose       #A50E53  →  quinacridone rose
#FFFF00  →  hansa yellow            #E3AF08  →  hansa yellow
#0000FF  →  cerulean blue           #108BA0  →  cerulean blue
#00FF00  →  mask
#FF00FF  →  rainbow

The pigment-hex values are the K-M rendered colors at typical wash thickness (x=1.0 over white paper), exactly as listed in the Pigments docs. The motivation: when a designer pulls a "quinacridone rose" value from the docs and uses it as a fill color in their SVG, the lib should recognize that as a direct pigment assignment. The previous chroma-key system required them to know that #FF0000 meant rose — now either convention works. Mask (#00FF00) and rainbow (#FF00FF) remain chroma-key only since they don't have rendered-pigment equivalents.

Mask tint toggle

New "Mask tint" toggle button in the toggles row, on by default. When off, the amber overlay that visualizes masked cells is hidden — the cells are still frozen (flow continues to skip them) but they render as ordinary paper/pigment. Useful for taking screenshots where the mask shouldn't be visible, or for previewing what a final composition will look like with the mask in place but not shown.

Public API: wc.maskTint(v) — pass true/ false to set, no argument to read. Forces an immediate render on change so the toggle feels instantaneous.

Gouache: Auto by default

The Gouache toggle now starts at Auto instead of Off in the playground. Auto LERPs between the watercolor and gouache pigment sets based on paper darkness — so dark papers automatically get more opaque, light-toned pigments. This is the right default for a "just looks good on whatever paper" experience and removes a step from new users' first impression.

Embedding note: the lib's create() default for gouacheMode is still false (closed wash watercolor) for backward compatibility. The playground sets gouacheMode: 'auto' at instantiation. Embedders who want auto can do the same.

v0.79 — Origin section refinements

Revised the Origin section based on author feedback:

  • Softened the Recurse Center mention from a dedicated subsection to a passing acknowledgment in a "Context" subsection at the end. The intellectual lineage doesn't depend on the venue.
  • Corrected the description of the conversations with Evan, Iris, Lissa, and Jonathan: those discussions were specifically about the math and physics at a high level, plus other color tools doing adjacent work — not about the lib's API design or interaction shape. The previous draft mischaracterized the scope.
  • Added two color tools by David Aerne to the conversations subsection: RYBitten (translates color through Johannes Itten's RYB subtractive wheel from the Bauhaus tradition — "making digital color behave more like paint") and OKPalette (palette extraction in OKLCh perceptual space). Both fit the same theme as Mixbox and Washes: digital color tools that take physical and perceptual color seriously rather than defaulting to RGB.

The Origin section now situates Washes inside a broader conversation about color in software — Mixbox, Flexoki, RYBitten, OKPalette — rather than as a standalone reimplementation of a paper.

v0.78 — Origin section: Recurse Center lineage

New Origin section recording where the project came from — the nerd-snipe at the Recurse Center, the watercolor-paper-implementation by Dan Knutson via Cyrene Zhang, the Flexoki palette recommendation from Kanad Gupta, the Sochorová & Jamriška Mixbox paper that Flexoki links to, and the conversations with Evan Gedrich Pintado, Iris Fernandes Valdez, Lissa Hyacinth, and Jonathan King that turned interest into a project.

Also added Sochorová & Jamriška 2021 (Mixbox) to the References as a sibling work to Washes: same Kubelka-Munk lineage as Curtis et al. 1997, but a different design target (drop-in RGB approximation rather than full physical sim). The fast-forward presentation video for this paper is the conceptual catalyst that started this project.

v0.77 — Brush range to 2000px

Brush size slider max raised from 500 → 2000. At sizes above ~800px on a typical viewport the brush covers essentially the whole simulation grid, so it behaves more like a fill than a stroke — useful for flood-style underpainting, single-stamp wash bases, or covering the canvas in one motion. Performance stays fine (stamp is still one circular mask iteration), but expect the visual character to shift from "brush" to "wash."

v0.76 — Larger brush range

Single change: brush size slider max raised from 200 → 500 pixels. The lib's brushSize() API had no upper cap, only a floor of 1, so the slider's old ceiling was the only constraint. Useful for large coverage strokes and rough underpainting passes.

v0.75 — Similar projects survey

No simulation changes. New "Similar projects" docs section covering three pieces of related work in the watercolor-in-code space:

  • How to Hack a Painting — Tyler Hobbs's 2017 Strange Loop talk on a recursive-polygon-deformation approach to generative watercolor. The algorithm behind most of the "watercolor blob" libraries on GitHub. Different level of abstraction from Washes (geometric appearance vs. physical simulation).
  • p5.brush — Alejandro Campos Uribe's p5.js library with watercolor fills, flow fields, and natural brushes. MIT licensed, ~660 stars. The right tool if you want one-line watercolor brushes in a p5.js sketch; different shape from Washes' full interactive simulation.
  • THREE.Watercolor — mattatz's post-processing pass for three.js, based on Bousseau et al.'s 2006 NPAR paper on watercolor stylization. Stylizes rendered 3D scenes to look like paint; fundamentally different from Washes' fluid simulation of paint itself.

The section opens with an honest note that these projects were surveyed after Washes reached a shippable state. Washes was built directly from Curtis et al. 1997 without prior reference to any of them. Including them is a service to the reader, not an attempt to claim priority or comparison — each project occupies a different region of the same broad space, and if you're interested in one, you're probably interested in the others.

Also includes a "Where Washes fits" subsection noting the empty intersection it occupies: physically-based shallow-water watercolor sim, interactive, browser-native, JS library. The closest comparable is the Texas A&M GPU thesis (C++/GLSL, not a library), which is now mentioned in the references as well.

v0.74 — Maintainer guide and standalone test harness

No simulation changes. Captures the testing patterns and best practices that emerged across the v0.56-0.72 development arc, plus a runnable test harness that implements all of them.

New "For maintainers" section in the docs

  • Evidence trail — the timeline of user-reported bugs (screen recording of the cross, pinpoint screenshots, "every other click" reports), what each one triggered, and how each was diagnosed.
  • Headless test harness — how to stub the DOM/Canvas APIs so the lib runs in Node, including the modern-Node fix for read-only navigator and performance globals.
  • Instrumenting the lib — the string-replacement pattern that injects _debug_* methods into the lib's public API object without modifying the file. This is how every diagnostic in v0.69-0.72 accessed internal grid arrays.
  • Five diagnostic patterns — anisotropy detection (caught the v0.69 cross), per-cell trace (caught the v0.72 pinpoint), hotspot scan (regression check), CFL bound check (sanity check before changing velocity scales), and mass balance (verifies boundary-mode invariants, v0.84+). Each is implemented in the harness and described in prose.
  • Seven best practices — lessons distilled from chasing the cross for 13 versions and the pinpoint for 3, in roughly the order I should have applied each.

Standalone test harness file

washes-test-harness.js ships alongside this HTML page. Single file, ~400 lines, no dependencies. Implements the four diagnostic patterns as runnable CLI subcommands:

node washes-test-harness.js /path/to/watercolor-lib.js anisotropy
node washes-test-harness.js /path/to/watercolor-lib.js trace
node washes-test-harness.js /path/to/watercolor-lib.js hotspots
node washes-test-harness.js /path/to/watercolor-lib.js cfl
node washes-test-harness.js /path/to/watercolor-lib.js all

The harness is designed to be copied and adapted, not imported. Each pattern is a self-contained function with its own injected _debug_* helpers, so you can extract individual patterns and modify them for new diagnostics. The mock DOM is the same shape used in every diagnostic during the v0.56-0.72 arc; the loadLibWithHelpers utility is the same string-replacement trick that exposed internal state without modifying lib source.

v0.73 — Documentation pass: References + Dig Deeper

No simulation changes — just documentation. After the v0.72 root-cause pinpoint fix the sim is in a stable place, so this version captures what we learned during the v0.56-0.72 arc.

Expanded References section

Full bibliographic entries with DOIs and URLs for the six primary sources behind Washes:

  • Curtis et al. (1997) — the SIGGRAPH '97 watercolor paper this lib is built on. doi: 10.1145/258734.258896.
  • Kubelka & Munk (1931) — the optical compositing model. Published in Zeitschrift für Technische Physik; English translation by S. Westin at graphics.cornell.edu.
  • Foster & Metaxas (1996) — first NS-based fluid solver in graphics; basis for the velocity update. doi: 10.1006/gmip.1996.0039.
  • Stam (1999) — Stable Fluids, the semi-Lagrangian advection scheme that fixed the cross.
  • Bridson (2015) — the standard textbook for fluid simulation in graphics. ISBN 978-1482232837.
  • Courant, Friedrichs, Lewy (1928) — the CFL condition. doi: 10.1007/BF01448839.

New "Dig deeper" section

A walk-through of the math and physics that shaped the v0.56-v0.72 arc:

  • What's being simulated — Curtis et al.'s three-layer model (shallow water, pigment deposition, paper diffusion) and K-M compositing.
  • The advection equation — the ∂g/∂t + v·∇g = 0 PDE, donor-cell discretization, CFL bound, the cardinal-axis anisotropy, and how Stam's semi-Lagrangian sidesteps both.
  • Mass conservation — why plain semi-Lagrangian doesn't conserve mass in divergent flow, and the Jacobian-of-the-backward-map fix (the exp(-div·Δt) area ratio in v0.68).
  • Square vs circular clamp — the √2 anisotropy that the per-axis velocity clamp was injecting, with numerical evidence. The actual root cause of the cross artifact after 13 versions.
  • Singular cells — why the over-aggressive injection guard left cells in a degenerate state, the pressure-dip-induced velocity reversal that turned them into pigment sinks, and the one-character threshold fix.
  • Debugging lessons — five principles I should have applied earlier in this project, distilled from the artifacts I chased.

All citations cross-referenced inline.

v0.72 — Root-cause pinpoint fix (and why the v0.70-0.71 patches were wrong)

User report after v0.71: pinpoints still appear, about once every 3 clicks. The post-process smoothing was treating the symptom but not the cause — and worse, it was actively creating the conditions for a different, more damaging pinpoint.

What was really happening

A trace of the cells near the epicenter over 30 simSteps (with ec at (324.4, 270.7), which leaves two skipped cells at (324, 271) and (325, 271)):

Immediately after splash (post-v0.71-smoothing):
  (324,271):  g=0.490 d=0.010  u= 0.000 v=0.000  pres=14.97
  (323,271):  g=0.490 d=0.010  u=-39.0  v= 8.4   pres=19.95  ← strong OUTWARD

After 10 simSteps:
  (324,271):  g=0.783 d=0.091  u=+0.573 v= 0.101  pres= 8.06
  (323,271):  g=0.138 d=0.036  u=+1.115 v= 0.042  pres=10.74  ← reversed! INWARD
  (325,271):  g=0.227 d=0.035  u=-1.124 v= 0.048  pres=10.08  ← reversed! INWARD
  (324,270):  g=0.131 d=0.026  u=+0.025 v= 1.143  pres=10.76  ← INWARD

After 30 simSteps:
  (324,271):  g=0.784 d=0.297  ← pigment 5× the neighbor average

The neighbor velocities reverse direction after ~5-10 frames and start pointing INTO the skipped cell. That's because the v0.70-0.71 smoothing of pressure created a pressure dip at the skipped cell — neighbors at pressure ≈ 20, skipped cell averaged down to ≈ 15. Pressure gradients drive flow from high pressure to low pressure. Once the initial radial pressure injection decays (via the 0.94/frame decay), what's left is the dip, which pulls neighbors' velocity INWARD. Pigment then accumulates at the skipped cell over many frames, growing 5× larger than the cleared surrounding area. The smoothing fix was creating worse pinpoints than the original singularity.

The real root cause

The injection loop has a guard:

if (d2 > r2 || d2 < 0.5) continue;
const dist = Math.sqrt(d2);
const inv = 1 / dist;   // ← divide-by-zero risk if dist === 0
const radialX = dx * inv;
const radialY = dy * inv;

The d² < 0.5 threshold was a paranoid safety margin against 1/0 — but 1/0 only happens at d² = 0 exactly, which is possible only when ec lands at integer-valued coordinates AND a grid cell sits at exactly that integer. Cells with small but nonzero d² (like 0.25) have perfectly well-defined radial directions:

At (100, 51) with ec (100.4, 50.7):
  dx=-0.4, dy=0.3, d²=0.25
  dist = 0.5, inv = 2.0
  radialX = -0.4 × 2.0 = -0.8
  radialY =  0.3 × 2.0 =  0.6
  magnitude = √(0.64 + 0.36) = 1.0  ← perfectly fine unit vector

The 0.5 threshold was throwing away valid injections for ~1-2 cells around every epicenter, leaving them in the singular state. v0.70-0.71's post-process smoothing then made things worse by creating pressure dips.

Fix

if (d2 > r2 || d2 < 1e-10) continue;

1e-10 still catches the genuine divide-by-zero case with a comfortable float-precision margin — d² = 1e-10 means dist = 1e-5, inv = 1e5, which is well-behaved. For any fractional ec, no cells are within this threshold, so all near-epicenter cells now receive normal injection: real outward velocity, real pressure, real participation in the divergent flow. The post-process smoothing remains but is now usually a no-op (active only on the rare exact-integer ec case, where one cell still has d² = 0 exactly).

Verification

Re-ran the systematic hotspot scan over 15 fractional ec positions including the cases that produced pinpoints in v0.71:

v0.71:  ec=(324.4, 270.7) → 5 hotspots including (324,271) self=1.08 vs avg=0.22 ← PINPOINT
v0.72:  ec=(324.4, 270.7) → 4 hotspots, all at canvas corners ← no pinpoint near epicenter

v0.71:  ec=(324.6, 270.3) → 5 hotspots including (324,270) self=1.00 vs avg=0.22 ← PINPOINT
v0.72:  ec=(324.6, 270.3) → 4 hotspots, all at canvas corners ← no pinpoint near epicenter

v0.71:  ec=(324.5, 270.49) → 5 hotspots including (324,270) self=1.00 vs avg=0.22 ← PINPOINT
v0.72:  ec=(324.5, 270.49) → 4 hotspots, all at canvas corners ← no pinpoint near epicenter

The 4 remaining hotspots in every case are at the canvas corners (2,2), (645,2), (2,537), (645,537) — pigment accumulating at boundaries from mass-conservation, expected behavior, unrelated to the epicenter.

Lesson on chasing pinpoints

I should have started with the injection guard instead of post-processing the result. A guard that skips cells creates singular cells that downstream code can't recover. Fix the guard, the singularity never exists, no post-process needed. v0.70 smoothed 1 of N skipped cells; v0.71 smoothed all N; v0.72 makes N = 0 (or 1 for exact-integer ec) by not aggressively skipping in the first place. Each version was a step closer to "don't break it in the first place" and further from "patch the damage afterward."

v0.71 — Pinpoint fix for fractional epicenters (random deluge clicks)

The v0.70 pinpoint smoothing was tested on the centered "Deluge Now" button (epicenter at near-integer grid coordinates) and worked there. But random / click deluges land at arbitrary fractional grid coordinates, and v0.70's single-cell Math.round approach missed some of the cells the injection actually skipped.

How many cells get skipped?

Between 0 and 4, depending on where the fractional epicenter falls inside its enclosing 2×2 grid square. The injection skip is d² < 0.5 where d is the Euclidean distance from a grid cell center to the epicenter:

Epicenter (100.0, 50.0)  — integer:
  Only cell (100, 50): d² = 0.00  →  1 cell skipped

Epicenter (100.3, 50.3)  — moderate fractional:
  Only cell (100, 50): d² = 0.18  →  1 cell skipped

Epicenter (100.4, 50.7)  — offset fractional:
  Cell (100, 51): d² = 0.16 + 0.09 = 0.25  →  skipped
  Cell (101, 51): d² = 0.36 + 0.09 = 0.45  →  skipped
  Cell (100, 50): d² = 0.16 + 0.49 = 0.65  →  not skipped
  Cell (101, 50): d² = 0.36 + 0.49 = 0.85  →  not skipped
  →  2 cells skipped

Epicenter (100.5, 50.5)  — half-integer:
  All four corners at d² = 0.50 exactly  →  0 skipped
  (strict < 0.5 check passes them all through)

The Deluge Now button hits the container center, which is typically at near-integer grid coordinates (1 skipped cell); v0.70's Math.round approach caught that case. Random clicks at arbitrary positions can produce the offset-fractional case with 2 skipped cells; v0.70 caught one but not the other.

Fix

Scan all 4 candidates in the 2×2 grid around each epicenter, and smooth any cell whose d² < 0.5 matches the injection's skip threshold.

for (const ec of epicenters) {
  const fx = Math.floor(ec.x), cx = Math.ceil(ec.x);
  const fy = Math.floor(ec.y), cy = Math.ceil(ec.y);
  const candidates = [[fx, fy], [cx, fy], [fx, cy], [cx, cy]];
  for (const [ix, iy] of candidates) {
    const dxc = ix - ec.x, dyc = iy - ec.y;
    if (dxc*dxc + dyc*dyc >= 0.5) continue;  // same check the injection uses
    // ... smooth this cell with its 4 cardinal neighbors ...
  }
}

No dedup needed: when ec.x or ec.y is integer-valued, some candidates duplicate (floor === ceil). The smoothing operation is idempotent — replacing a cell's value with the average of its 4 cardinal neighbors, then repeating, produces the same value the second time because the neighbors haven't changed. Redundant work is wasted, not wrong.

Verification

Test: ec at (324.4, 270.7) — fractional with 2 skipped cells
Skipped: (324, 271) d²=0.25  AND  (325, 271) d²=0.45

After splash (post-smoothing):
  (324, 271) — first skipped cell:    g=0.490  d=0.010
  (325, 271) — second skipped cell:   g=0.490  d=0.010
  (324, 270) — not skipped:           g=0.490  d=0.010
  (325, 270) — not skipped:           g=0.490  d=0.010
  (323, 271) — reference neighbor:    g=0.490  d=0.010
  → all cells match. No pinpoint.

v0.70 — Pinpoint fix at splash epicenter

With v0.69's cross fix in place, the deluge produces a clean radial wash. But a single dark pixel remained at the exact click site — a "pinpoint" surviving long after the rest of the splash had cleared. User flagged it.

Root cause

The splash injection loop has a divide-by-zero guard for the exact epicenter cell:

if (d2 > r2 || d2 < 0.5) continue;
const dist = Math.sqrt(d2);
const inv = 1 / dist;
const radialX = dx * inv;
const radialY = dy * inv;

The d2 < 0.5 skip avoids 1/0 at the epicenter (where dx=dy=0), but it leaves exactly one cell (round-to-integer of the epicenter) in a singular state for the rest of the simulation:

  • No velocity injection. And updateVelocity can't fix it later — the Laplacian and pressure gradient are both symmetric about this cell (neighbors at ±d have equal-magnitude opposite-sign velocity), so they sum to zero and the cell never accumulates any push.
  • Residual deposited pigment. The lift step takes 98% of deposited pigment but leaves 2%. That 2% never moves because d[] doesn't advect.
  • Semi-Lagrangian samples from self. At u=v=0, the backward trace lands on the same cell, so g[] would persist too — except the divergence correction does evacuate it via exp(-div·adt) where div ≈ 2V from the surrounding outward flow.

Net effect: g[] at the center fades within a few frames but the d[] residual (the 2%) sits there indefinitely as a tiny dark dot. Visible in renders as a single dark pixel exactly where the click landed.

Fix

One-time spatial smoothing of the epicenter cell with its 4 cardinal neighbors at the end of the splash function. Applied to g (suspended), d (deposited), wet, and pressure. Velocity stays at 0 by symmetry, but that doesn't matter once the pigment has been smoothed out — the cell is no longer singular.

for (const ec of epicenters) {
  const ix = Math.round(ec.x);
  const iy = Math.round(ec.y);
  if (ix < 1 || ix >= GW-1 || iy < 1 || iy >= GH-1) continue;
  const i = iy * GW + ix;
  const e = i+1, w = i-1, n = i-GW, s = i+GW;
  for (let k = 0; k < 3; k++) {
    g[k][i] = (g[k][e] + g[k][w] + g[k][n] + g[k][s]) * 0.25;
    d[k][i] = (d[k][e] + d[k][w] + d[k][n] + d[k][s]) * 0.25;
  }
  wet[i]      = (wet[e]      + wet[w]      + wet[n]      + wet[s])      * 0.25;
  pressure[i] = (pressure[e] + pressure[w] + pressure[n] + pressure[s]) * 0.25;
}

Verification

Painted uniform d=0.5 across the grid, fired a default deluge, sampled the epicenter cell and its immediate neighbors:

Immediately after splash (post-smoothing):
  center cell:  g=0.490  d=0.010
  east-one:     g=0.490  d=0.010
  north-one:    g=0.490  d=0.010
  ← center now matches neighbors exactly

After 30 simSteps:
  center cell:  g=0.0002  d=0.0159
  east-one:     g=0.0003  d=0.0191
  east-5:       g=0.0022  d=0.0454
  ← center's d[] is now LESS than neighbors. No pinpoint.

Works for any number of epicenters (multi-point splashes and rays both supported). Skipped for cells outside the grid's interior or where the mask is active.

Cost

5 cardinal-neighbor reads × 5 fields per epicenter, once per splash. For a default single-epicenter splash that's 25 reads + 8 writes — negligible compared to the ~300k velocity injections in the same call.

v0.69 — Circular velocity clamp (the cross artifact's actual root cause, found after 13 versions)

The cross artifact has been visible since at least v0.56. Over thirteen versions I tried five different fixes targeting the pigment advection: flux clamp, substep mode, rotation distribution, spatial rays, per-cell jitter, and finally mass-conserving semi-Lagrangian. Some helped, some didn't, but the cross kept coming back at high velocity. The v0.68 numerical test that finally exposed the truth was so simple I could have run it in v0.56: dump the velocity field one step after a deluge and check cardinal vs diagonal cell magnitude.

After 1 simStep (v0.68):
  cardV  diagV   ratio
  1.14   1.62    1.42  ≈ √2

The velocity field itself was anisotropic upstream of any pigment work. Every pigment scheme — donor-cell, flux-clamped, substepped, semi-Lagrangian — was advecting through a velocity field that preferred diagonals over cardinals by √2×. Once I saw that number, the cause was obvious.

Root cause: per-axis velocity clamp

The velocity update has a clamping step to prevent runaway values. From v0 forward, it looked like this:

if (nu >  VEL_CLAMP) nu =  VEL_CLAMP; else if (nu < -VEL_CLAMP) nu = -VEL_CLAMP;
if (nv >  VEL_CLAMP) nv =  VEL_CLAMP; else if (nv < -VEL_CLAMP) nv = -VEL_CLAMP;

This bounds each axis independently. With VEL_CLAMP = 1.5, the allowed region is the SQUARE [-1.5, 1.5] × [-1.5, 1.5] in (u, v) space. The corners of that square — at (±1.5, ±1.5) — have magnitude 1.5·√2 ≈ 2.12, which is √2 larger than the cardinal-direction maximum of 1.5. Effective velocity along the four diagonal directions is √2 times faster than along the four cardinal directions, all the time, in every simulation. The cross was baked into the velocity field.

The fix

const mag = Math.sqrt(nu*nu + nv*nv);
if (mag > VEL_CLAMP) {
  const s = VEL_CLAMP / mag;
  nu *= s;
  nv *= s;
}

Magnitude-based clamping bounds the velocity to a CIRCLE of radius VEL_CLAMP, instead of a square. Direction is preserved (the scale factor is uniform across both components), magnitude is bounded isotropically, all eight compass directions get the same maximum speed.

Verification

After 1 simStep (v0.69):
  cardV  diagV   ratio
  1.14   1.14    1.00  ✓

After 60 simSteps (v0.69):
  At all distances 10-150 cells from epicenter,
  pigment cardinals/diagonals ratio = 0.92-1.00.

The cross is gone in every advection mode (standard, clamp, substep, semilag) because the velocity field feeding all of them is now isotropic. Donor-cell still has its own subtle cardinal preference in the pigment math, but it's much milder than the velocity-side anisotropy and shows only at very high coverage / many frames.

Side effects (small, all positive)

  • Max L1 velocity is lower. Previously L1 could reach 2·VEL_CLAMP = 3.0 at the square's corner; now bounded at √2·VEL_CLAMP ≈ 2.12 at 45° (smaller than max cardinal axis at 1.5 because magnitude clamp). Better CFL margin for donor-cell-family modes; semilag is unaffected as it has no CFL bound.
  • Slightly less aggressive splash propagation at extreme settings (since diagonals no longer get the √2 boost). Visually negligible at normal slider values; only matters for V>~20.
  • Cost: 1 sqrt() per cell per frame. ~5-10 ns on V8. On a 300k-active-cell frame, ~2 ms. Negligible vs. the ~30 ms total frame cost.

Why I missed this for thirteen versions

The cross looked like a pigment-advection issue (sharp white streaks aligned with grid axes — the donor-cell scheme's classic visual signature). It really was that, in part: donor-cell IS anisotropic. But the bulk of the cross was actually coming from the velocity field one step upstream. I kept tweaking the pigment math and the cross kept reappearing because the bigger source kept feeding it. The fix needed a one-line change to updateVelocity that I'd have made in v0.57 if I'd dumped the velocity field numerically instead of just looking at the rendered output. The semi-Lagrangian work in v0.67-0.68 is still good (real isotropy in the pigment math, real mass-conservation via divergence correction); v0.69 finally closes the upstream loop.

v0.68 — Mass-conserving semi-Lagrangian (lift and reveal)

User feedback on v0.67: the cross is gone but the deluge doesn't actually "lift" pigment or reveal the canvas anymore — it just smoothly redistributes pigment across the affected area. Numerical inspection confirmed: at default velocity 40 over 10 frames, pigment everywhere stays near its starting value (0.500 ± small noise). The deluge had become a uniform blur.

What was wrong with v0.67

Semi-Lagrangian as implemented in v0.67 preserves density (pigment-per-cell) but not mass (total pigment across an expanding fluid parcel). For a divergent flow like a deluge:

  • A fluid parcel at the splash center has high outward velocity in all directions — it's expanding.
  • As it expands, the SAME pigment now covers more area. So the density at any point should drop.
  • The donor-cell scheme captured this via over-pumping: cells donated more than they held and ended up at zero. That's wrong mathematically (mass not conserved) but right visually (center clears).
  • Semi-Lagrangian samples from a source position; for an expanding flow, neighboring destinations sample from nearby source cells with similar pigment. Density stays roughly constant. Mass increases. Non-physical, and visually wrong for the use case.

The fix: divergence correction

After the bilinear sample, multiply by the area-expansion factor exp(-∇·v · adt). For divergent flow (∇·v > 0), this reduces density proportionally to how much the fluid parcel expanded. For convergent flow (∇·v < 0), density increases as the parcel compresses. The exp() form handles the large divergence at splash centers (~2V/r as r→0) without going negative.

// In _advectStepSemiLagrangian, per cell:
const div = (u[i+1] - u[i-1]) * 0.5 + (v[i+GW] - v[i-GW]) * 0.5;
const areaRatio = Math.exp(-div * adt);
// ... bilinear sample as before ...
g_new[i] = bilinearSample * areaRatio;

Verification

At default velocity 40, sampled along cardinal and diagonal rays from epicenter:

After 3 frames:
  d=0-10:   pigment = 0.000  (center cleared, paper revealed)
  d=25:     pigment = 0.002  (still empty behind wave)
  d=50:     pigment = 0.211  (wave arriving here)
  d=75:     pigment = 0.312  (wave passing)
  d=100:    pigment = 0.365  (wave leading edge)
  d=125-150: pigment = 0.398-0.421 (untouched, full pigment)

After 30 frames:
  d=0-125:  pigment = 0.000  (all evacuated outward)
  d=150:    pigment = 0.001  (wave passed)

Isotropy check (card/diag ratio at all distances): 1.000 ± 0.02

Trade-offs

  • feature "Lift and reveal" transport behavior restored. Deluge now visibly clears the canvas at the impact site, with pigment accumulating at the wave front, as a real splash would.
  • Isotropy preserved. The bilinear interpolation function still has no per-axis bias; the divergence correction is computed via symmetric central differences. card/diag ratio stays at 1.000.
  • Cost: 1 extra Math.exp() per cell per frame. ~10ns × ~300k active cells = ~3ms. Negligible.
  • Mass conservation is approximate. The div-correction is first-order accurate; over very long simulations some drift can accumulate. For deluge-scale events (30-60 frames of high velocity) it's well below the noise floor.
  • What happens near the canvas edge: if the wave hits the boundary before the velocity field fully damps, pigment accumulates at the edge (bounded by sx/sy clamp in the bilinear sample). Same behavior as the v0.67 sample-clamp.

v0.67 — Semi-Lagrangian advection (real cross fix)

v0.66's "jitter at 0.5" workaround traded the cross artifact for visible radial striations. Both are bad. The correct fix — and the one I should have shipped four versions ago — is to replace donor-cell pigment advection with a semi-Lagrangian scheme, which is isotropic by construction.

How semi-Lagrangian works

For each cell, the scheme looks backward along the velocity vector by adt time-units to find where the pigment "came from" this step, then samples that source position via bilinear interpolation from the 4 surrounding cells:

// Per cell:
sx = x - u[i] * adt
sy = y - v[i] * adt
x0 = floor(sx), y0 = floor(sy)
fx = sx - x0,   fy = sy - y0
new_pigment[i] = (1-fx)(1-fy) * g[y0  ][x0  ]
               + fx    (1-fy) * g[y0  ][x0+1]
               + (1-fx)fy     * g[y0+1][x0  ]
               + fx    fy     * g[y0+1][x0+1]

Why it's isotropic: the bilinear interpolation function doesn't know which direction is which. It uses continuous source coordinates and weights neighbors by proximity. There's no per-axis donation logic, so no per-axis asymmetry. Direct numerical comparison at velocity=40 over 5 frames at d=100 cells from the epicenter:

Mode        cardinals  diagonals  ratio (1.0 = isotropic)
─────────   ─────────  ─────────  ──────────────────────
standard    -4.554     -2.021     ∞ (negative cells = CFL violation)
clamp        0.464      0.454     1.023 (cheap fix, mildly diffusive)
substep      0.431      0.252     1.713 (still very anisotropic!)
semilag      0.471      0.471     1.001 (PERFECT)

Note that substep at ratio 1.713 is still anisotropic. v0.65-0.66's belief that substep alone fixed the cross was wrong. Substep eliminates CFL violation (over-pumping → negative cells → bright streaks) but not the donor-cell scheme's underlying cardinal preference. That's why the v0.66 screenshot still had a visible cross even at substep cap 64.

Trade-offs

  • Isotropic ✓ — no cardinal preference. Ratio 1.001 in measurements.
  • No CFL limit ✓ — bilinear sampling works for any velocity. The cell can sample from arbitrarily far back along the velocity vector.
  • Slightly diffusive — bilinear interpolation smooths the field by averaging neighbor cells. For watercolor this is a feature: softens hard pixel edges, blends pigment naturally. The previous donor-cell scheme had a separate explicit diffusion step to compensate; semilag has it built in.
  • Not strictly mass-conserving — on a non-divergence-free velocity field (which ours isn't, since we don't enforce ∇·v=0), total mass can drift over many frames. For the short-duration deluge use case this is negligible; for ultra-long simulations would need correction. Donor-cell IS strictly conservative.
  • Cost ≈ substep at moderate velocity; significantly cheaper at high velocity since no substep needed. One pass through the active rect per frame, regardless of velocity.

Changes

  • feature New "Semi-Lagrangian" advection mode. Selectable from the Advection dropdown; available via wc.advectionMode('semilag').
  • feature Default advection mode: 'substep' → 'semilag'. Both the lib's internal state and the dropdown's selected option.
  • fix Deluge jitter default: 0.5 → 0.0. The v0.66 jitter-as-cross-fix workaround is no longer needed and was actively harmful (caused visible radial striations). Jitter remains available 0..1 for stylistic effects.
  • Three previous modes (standard, clamp, substep) remain for stylistic comparison. Standard at velocity 40 produces dramatic over-pumped cross; substep produces a softer but still anisotropic cross; clamp is the closest non-semilag mode visually.

What I got wrong across versions 0.57-0.66

I added five mechanisms over nine version bumps trying to fix the cross: flux clamp (v0.57), substep (v0.57), rotation distribution (v0.58), spatial rays (v0.61), per-cell jitter (v0.59 / 0.66). Each one addressed a different facet of donor-cell weirdness, and each one was subtly wrong in a different way. The numerical comparison above shows clearly: only semi-Lagrangian achieves ratio 1.001. The right fix was always to replace the advection scheme entirely, not patch around it. I should have shipped this in v0.57 instead of substep mode. Costs you eight versions of trial-and-error on an issue that had a standard textbook answer. Sorry about that.

v0.66 — Actually fix the cross artifact (two bugs, not one)

User reported the cross persisted even after v0.65's switch to substep advection. Numerical tracing revealed two stacked problems, only one of which v0.65 addressed:

Problem 1: substep cap was too low

The substep mode (v0.57) computes how many subdivisions of the timestep are needed to keep CFL ≤ 1. The cap was set at 16 substeps for performance safety. But at the v0.61 deluge defaults (velocity 40, peak L1 norm = 40√2 ≈ 56.6), the math wants 17 substeps:

cflProduct = maxL1 × baseAdt = 56.6 × 0.294 = 16.6
numSubsteps_needed = ceil(16.6) = 17
numSubsteps_actual = min(16, 17) = 16   ← CAPPED
per_substep_CFL = 56.6 × (0.294/16) = 1.04   ← STILL OVER 1

So substep mode was running at the cap (16 substeps), but per-substep CFL stayed at 1.04 — over the safety threshold by 4%. Diagonal cells over-pumped slightly each substep, and across many substeps the artifact compounded into visible bright streaks.

  • fix Substep cap raised 16 → 64. Handles velocity up to ~217 without violation. Beyond that, an inner-substep flux clamp engages automatically (cells can't donate more than they hold). At V=40 default, substep count is now 17 with per-substep CFL = 0.976 (safe).
  • Cost: at velocity 40, 17 substeps × ~5ms per substep = ~85ms for one post-deluge frame, drops to ×1 within 30-60 frames. At velocity 160, up to 64 substeps × 5ms = 320ms for one frame — noticeable hitch but only one frame.

Problem 2: donor-cell scheme has intrinsic cardinal bias

This one's not a bug, it's a property of the advection discretization. Even with CFL ≤ 1 perfectly enforced, donor-cell transports pigment more efficiently along cardinal axes than diagonals. The reason: cardinal cells have velocity (V, 0) or (0, V) which donates entirely along one grid axis, while diagonal cells have velocity (V/√2, V/√2) which splits across two axes. Inflow and outflow combine differently for the two cases:

Measured at d=100 grid cells, frame 5, default deluge:
  velocity=40, jitter=0   →   cardinals=0.43  diagonals=0.25  RATIO=1.71
  velocity=10, jitter=0   →   cardinals=0.47  diagonals=0.42  RATIO=1.10
  velocity=5,  jitter=0   →   cardinals=0.47  diagonals=0.45  RATIO=1.05
  velocity=40, jitter=0.5 →   cardinals=0.24  diagonals=0.26  RATIO=0.92

Lower velocity reduces the asymmetry directly (less motion per step = less compounded discretization error). Jitter at 0.5 fully neutralizes it: each cell's velocity gets a random angular perturbation in ±22.5°, which statistically averages out the cardinal preference within a single splash.

  • feature Deluge jitter default 0.0 → 0.5. The "Deluge jitter" slider now defaults to 0.5 instead of 0. This neutralizes the donor-cell cardinal bias at the v0.61 default deluge velocities without losing momentum or perceived strength. The cost is per-cell Math.random + cos/sin calls inside the splash injection — measured at <2 ms on a 2000 px radius splash, negligible vs. the post-splash sim work.
  • If you want the original clean radial field (and accept the cross), set the slider to 0 explicitly. The cross has a graphic-poster quality some users may prefer.

What I got wrong before

Through v0.57-0.65 I kept treating the cross as a single problem ("CFL violation") and added various mechanisms (substep, flux clamp, rotation, rays) that addressed one aspect or another. The reality is the cross is two distinct phenomena stacked: CFL over-pumping AND donor-cell anisotropy. Either alone produces a visible cross at high velocity; both together produce the harsh white bands the user kept seeing. Substep alone fixed only the first; the second needs jitter (or a different advection scheme entirely, e.g. semi-Lagrangian, which would be a major rewrite). v0.66 ships both fixes together.

v0.65 — Substep advection by default (cross-artifact mea culpa)

User-reported again: the cross-shaped streak artifact persists at the v0.60+ extreme deluge defaults, even with rays=8 from v0.61. I owe an explanation of why my v0.61 rays mechanism doesn't actually fix this, and a real fix via making substep advection the default.

What the cross actually is

The donor-cell advection scheme moves pigment from each cell to its downstream neighbor proportional to that cell's velocity component along each axis:

// Per-cell, per-axis donation
if (ux > 0) g_tmp[i+1]   += ux * gk * adt
if (vy > 0) g_tmp[i+GW]  += vy * gk * adt

The total per-cell outflux is at most (|ux|+|vy|) · adt. When that product exceeds 1, the cell donates more pigment than it holds — it goes negative. K-M's render math maps negative pigment to brighter-than-paper, which is exactly the white streaks visible in the screenshot.

For a radial outflow field at peak velocity V:

  • Cells on the E cardinal axis: velocity (V, 0). Per-axis CFL = V·adt.
  • Cells on the NE diagonal: velocity (V/√2, V/√2). Per-axis CFL = V·adt/√2 ≈ 0.707·V·adt.

Cardinal cells hit CFL violation first because their full velocity goes into one axis. As you crank up the velocity slider, the cardinal cells over-pump and go negative while the diagonals stay below the threshold — that's the asymmetric pattern that reads as a cross.

Why rays don't help (my v0.61 mistake)

I reasoned that adding rays at compass positions would break the cardinal preference by adding radial fields pointing in different directions. The geometric intuition was right but the physics is wrong: the cardinal preference is a property of the global grid, not of the velocity source direction. Every radial field, no matter where its center is, advects pigment cells through the same Cartesian grid where N/S/E/W cells over-pump and diagonal cells don't. Adding rays just adds more radial fields with the same global-grid cardinal pattern — the cardinal streaks reinforce where the rays' fields overlap, and the cross gets worse.

v0.62 removed the /rayCount normalization, so rays=8 now means 8× the total injected momentum — which means 8× the CFL violation. That made the cross worse, not better.

The actual fix

The CFL fix already exists in the lib: substep mode (added in v0.57) subdivides each sim step into N substeps when peak velocity is high, so each substep's CFL stays ≤ 1. It was off by default for back-compat. v0.65 makes it the default.

  • fix Default advection mode: 'standard' → 'substep'. Both the lib's internal state and the dropdown's selected option. New users get a CFL-safe simulation out of the box; the cross artifact at extreme deluge settings is suppressed correctly via dynamic substep count rather than via grid-aligned hacks that don't work.
  • Cost: substep is 2-3× the advection cost during the brief post-deluge frames where velocities are high, drops to 1× as velocities damp (the substep count visible in the dropdown's right readout shows this in real time). On a 1080×900 canvas at the v0.60 default deluge, you'll see ~6-12 substeps for the first frame, decaying to 1 over ~30-60 frames. Total cost is a fraction of one second of frame time.
  • For users who want the original 'standard' look: the dropdown still has it. Standard mode produces the cross at high velocity by design (it's the historical donor-cell behavior); 'clamp' is a cheap middle ground that kills the negative cells without fully correcting propagation distance.

Rays and rotation: keep, but with honest tooltips

Both still exist, both still work as documented for what they actually do — rays distributes spatially, rotation distributes temporally. Their tooltips now make clear that neither fixes the CFL-induced cross; that's substep's job. Rays + rotation are useful for organic / symmetric splash aesthetics independent of the cross issue. Per-cell jitter (v0.59) is still a single-frame alternative that smears the cross statistically, useful if substep cost is a problem.

v0.64 — Export PNG fix (Blob path + paper compositing)

User-reported: clicking "Export PNG" did nothing. Two separate issues in the v0.63 export wiring, both of which could fail silently:

Root cause #1: detached anchor click

The handler created an <a> element with a download attribute, set href to the data URL, and called .click() — but never attached the anchor to the DOM. Modern Chrome/Safari accept this; Firefox treats .click() on a detached anchor as a no-op for download intent (the click dispatches but the browser doesn't recognize it as a user-initiated download trigger). The button visibly flashed via the standard flash(el) animation, so nothing looked broken, but no download occurred.

Root cause #2: data URL size limits

canvas.toDataURL() on a 1080×900 canvas produces a 3–8 MB base64 string depending on pigment coverage. Some browsers refuse download on data URLs above ~2 MB — even when the anchor is properly attached. The user wouldn't see a console error; the click just gets dropped.

Fixes

  • fix Switch to Blob URL via toBlob(). New exportPNG({ asBlob: true }) returns Promise<Blob>; the wiring then creates a Blob URL with URL.createObjectURL(blob). Blob URLs are short (e.g. blob:https://example.com/uuid) and reliably honored by anchor downloads regardless of underlying file size. Revoked on a 0ms timeout after the click so memory isn't leaked.
  • fix Attach anchor to document.body before click. Cross-browser standard for programmatic downloads. Removed immediately after click via the same timeout that revokes the Blob URL.
  • fix Surface failures. Previous wiring silently swallowed errors. v0.64 catches the Promise rejection from toBlob (returns null on tainted canvas or out-of-memory) and shows an alert with the error message. Heavy-handed but better than the "click does nothing" experience.
  • feature Paper color composited into export. Previous exports preserved the canvas's actual alpha — unpainted regions were fully transparent. For "this is the painting I made" downloads, the user expects paper color to fill those regions. v0.64 builds a temporary export canvas, fills it with the current paper color via paperColor(), then composites the sim canvas on top. Pass exportPNG({ transparent: true }) to preserve the old behavior (or it'll honor the transparent toggle if that's on).

API surface

// Back-compat data URL form (unchanged signature):
const url = wc.exportPNG();           // → "data:image/png;base64,..."

// New Blob form (returns a Promise):
const blob = await wc.exportPNG({ asBlob: true });
const url = URL.createObjectURL(blob);

// Other opts (also new in v0.64):
wc.exportPNG({ transparent: true });  // skip paper-color compositing
wc.exportPNG({ mimeType: 'image/jpeg', quality: 0.9 });

v0.63 — Slider readout fix (rotation & rays got stuck)

User-reported bug: the Deluge rotation and rays sliders appeared to stop responding after one drag. Rays would "say 2 rays from min to max"; rotation would show "1 frame" and not return to 0. Both were the same DOM-handling bug introduced in v0.58 / v0.61.

Root cause

Both readouts use a label-with-suffix pattern (e.g. "0 (off)" / "1 frame" / "6 frames"), implemented by replacing the readout wrapper's innerHTML on each input event. The handler cached references at init:

const delugeRays = document.getElementById('deluge-rays');
const delugeRaysV = document.getElementById('deluge-rays-value');  // ← cached
delugeRays.addEventListener('input', () => {
  const parent = delugeRaysV.parentElement;  // ← null after first replace
  parent.innerHTML = '<span id="deluge-rays-value">...</span> rays';
});

When the first input event fired, the handler set readoutWrapper.innerHTML, which built a fresh inner span node and orphaned the original one that delugeRaysV still pointed at. From that frame on, delugeRaysV.parentElement returned null (detached nodes have no parent), and the if (parent) ... guard silently no-op'd on every subsequent drag. The slider's actual value updated correctly and reached the splash function — only the visible readout got stuck.

Fix

  • fix Resolve the readout wrapper via a stable parent reference instead of caching the soon-to-be-detached inner span. The handler now uses slider.parentElement.querySelector('.brush-value'), which walks from the slider (stable) to its enclosing .brush-control div (stable) to the .brush-value wrapper (stable across innerHTML rewrites of its own contents). Applied to both rotation and rays bindings.
  • Why other sliders weren't affected: velocity, pressure, radius, lift, jitter, and image stamp all use textContent writes on a permanent inner span — never rebuild the DOM via innerHTML. The bug only existed in handlers that needed conditional suffix text ("frame" vs "frames" vs "(off)"). Those were the only places I'd used innerHTML rewrites.

v0.62 — Slider responsiveness fixes

User feedback after v0.61: the deluge sliders — rotation, rays, and even velocity / pressure — felt inert. Bumping values produced little or no visible change. Root cause was two design choices in v0.58 and v0.61 that preserved total momentum across distributions at the cost of per-injection visible strength. The user's mental model is the latter: bigger number on the slider should produce a stronger splash. v0.62 inverts those choices.

Rotation: default 0 (off), explicit off state

  • fix Rotation slider min: 1 → 0; default: 6 → 0. Value 0 means single-frame splash at full strength (no temporal distribution). Was effectively the v0.60 behavior, now explicit and the default. Values 1–12 still work the same as before. The slider readout now shows "0 (off)", "1 frame", or "N frames".
  • Why this is the fix the user actually wanted: v0.61 defaulted rotation to 6, which split each click's momentum across 6 consecutive frames at 1/6 strength each. The total injection equaled a single-frame splash at full strength — mathematically equivalent. But subjectively, "the velocity slider didn't seem to do anything" because each visible frame was 1/6 of what the slider numbers implied. A user setting velocity to 80 saw a per-frame kick equivalent to velocity 13.3. The off-by-default state means slider numbers now match what the eye sees.

Rays: scale linearly, not normalized

  • fix Removed the /rayCount divisor in splash velocity / pressure injection. v0.61 divided each ray's strength by N so that "rays=4" injected the same total momentum as "rays=1" — just spatially distributed across 4 epicenters. The donor-cell cross was still smoothed out (which was the original goal), but bumping rays from 1 to 4 produced no visible magnitude change in the splash. Users reported "the rays slider does nothing." Numerically verified: with the divisor, rays=8 produced max |u|+|v| of 28; without it, rays=8 produces 226. The rays slider now scales linearly with N, so more rays = stronger splash AND smoother cardinal symmetry.
  • If you want the v0.61 momentum-preserving behavior back, pass strengthMult: 1 / rayCount in the splash opts explicitly. Default behavior is now linear scaling.

Velocity / pressure: bigger max

  • feature Velocity slider max: 80 → 160. Pressure slider max: 40 → 80. The CFL ceiling for the donor-cell scheme is around velocity 8 per cell per timestep; at velocity 160 you're in extreme over-pumping territory. Useful when you specifically want the chaotic over-pumped look (or you've combined rays + jitter to mitigate the cross). The substep advection mode (v0.57) becomes important at these values.

v0.61 — Multi-ray deluge & container-clearing defaults

User feedback after v0.60: v0.60's rescaled deluge still didn't fully evacuate the 400×400 container, and the rotation animation visibly "bounced" between orientations rather than reading as smooth motion. v0.61 addresses both with another preset bump and a new spatial distribution mechanism that fixes the rotation bounce.

Deluge preset bumped to 16× baseline

  • feature Built-in deluge preset: velocity 25 → 40, pressure 12 → 20, radius 1500 → 2000 px. Now 16× the v0.59 baseline (matches the user's original "16 clicks to clear" observation directly). The expanded radius (2000 px) is comfortably wider than the 400 px container, so outflow continues past the container edge and pigment fully evacuates rather than getting redeposited near the edge.
  • feature Slider maxes follow: velocity 0.5–80, pressure 0.2–40, radius 100–4000. New defaults align with preset (40 / 20 / 2000 / 0.98).

Multi-ray injection (the rotation-bounce fix)

The rotation mechanism in v0.58 split a single deluge across N frames, each injecting a rotated copy of the velocity field. The split is temporal — one frame fires at 0°, the next at 60°, the next at 120°, etc. Each frame, the velocity field damps slightly, then the next frame's injection arrives in a new direction. This reads visually as the splash "bouncing" between orientations rather than expanding smoothly — even though the time-averaged result is symmetric, the moment-to-moment motion has a discrete stutter.

The actual structure the user was describing — "the cross is 4 rays in N/E/S/W; add rays for the diagonals" — turns out to be a better mental model. The cross IS essentially the cardinal-axis donations of the donor-cell scheme. If the velocity field has explicit rays pointing in all 8 compass directions simultaneously, each ray contributes its own cardinal preference, and the diagonal rays' cardinal preferences fall on the in-between axes that were previously underdeveloped. The cross fills in spatially, in one frame, with no temporal stagger.

  • feature New "Deluge rays" slider: 1–8, default 1 (v0.60 behavior). Spatially distributes a single deluge across N epicenters arranged in a ring around the click position at evenly-spaced compass angles. Each ray is its own radial splash at 1/N strength, all injected in the same frame. Common settings: 4 = N/E/S/W (cardinal cross filled in), 8 = full compass (no preferred direction).
  • feature Ring offset 5% of splash radius: on a 2000 px deluge that's 100 px ring, ~50 grid cells. Small enough that the rays' splash zones overlap heavily so the composite reads as one big splash, large enough that each ray's donor-cell cardinal preference points outward from a slightly different center and they cancel against each other.
  • feature Composes with rotation multiplicatively. rays = 4 + rotation = 6 means 24 total injections across 6 frames (each frame fires 4 rays at 1/24 strength). You'd typically pick one or the other, but combining gives a slow-unfolding multi-direction splash if you want it.
  • feature New splash opts: rayCount (integer ≥ 1) and rayOffsetFraction (0..1, default 0.05). Existing splash calling forms unchanged.

Rotation default 1 → 6

  • The "Deluge rotation" slider now defaults to 6 frames instead of 1. New users get the symmetric splash out of the box. Combined with rays = 1 (still the default), this preserves the rotation-based fix for the cross artifact without surprising existing users with the new rays mechanism. Switch the rays slider to 4 or 8 if you prefer single-frame spatial distribution over the rotation animation.

Recommended settings

For your container-clearing workflow, I'd suggest either:

  • Smooth motion: rotation = 6, rays = 1, jitter = 0. (v0.61 default.) Splash unfolds across 6 frames at evenly-spaced rotations. Reads as deliberate motion.
  • Single-frame symmetric: rotation = 1, rays = 8, jitter = 0. Splash injects in one frame from all 8 compass directions simultaneously. No bounce, no inter-frame stutter.
  • Organic single-frame: rotation = 1, rays = 4, jitter = 0.3. Cardinal symmetry plus per-cell turbulence noise. Reads as natural chaos.

v0.60 — One-click deluge clear & dry-brush paint load

User feedback: clearing a flooded container with deluge took ~16 successive clicks. v0.60 bakes that cumulative momentum into one trigger by scaling the deluge preset's velocity and pressure ~10×. Separately, the paint load slider's minimum is lowered so dry-brush levels are reachable.

Deluge: one click = container clear

  • feature Deluge preset rescaled: velocity 2.5 → 25 (10×), pressure 1.2 → 12 (10×), radius 900 → 1500 px (wider coverage for reliable container clear), lift 0.95 → 0.98 (lifts effectively everything). The wet-field saturation phase was already running synchronously inside splash() before the velocity injection, so flooding the paper and pushing the pigment around have always happened in the same atomic call — no separate "flood first then splash" step ever existed. What changed in v0.60 is the magnitude of the momentum injection, not the sequence.
  • Slider rescaling to match: velocity max 10 → 50 (2× headroom over new default 25), pressure max 6 → 24 (2× over default 12), radius max stays 3000 px (already had headroom). Slider default values now align with the new preset defaults, so the displayed values match what the built-in deluge preset injects without slider override.
  • Why this works: the previous defaults (v0.52 through v0.59) were sized for one drop of water hitting a 900×900 region. Useful as a single visual event, but underpowered as a "clear the container" gesture. The 10× rescale matches the cumulative velocity/pressure that ~16 rapid clicks would have injected at the old values, accounting for the per-frame velocity damping that happens between clicks. The post-splash sim still damps the new velocities over ~30-60 frames so the canvas settles cleanly — you don't see a stuttering recovery period, just a vigorous one-shot wash.
  • Composes with rotation + jitter from v0.58/0.59: the 10× momentum is per-injection; rotation = 6 with the new defaults still distributes that 10× across 6 rotated frames at 1/6 strength each. So rotation users keep their symmetric splash; jitter users keep their single-frame turbulence; everyone gets the cleared container.

Paint load: reach for dry brush

  • feature Paint load slider minimum lowered 0.1 → 0.001. At 0.001 with a 200 px brush, a single stamp deposits ~0.0007 pigment per cell at brush center — well below the Kubelka–Munk renderer's effective visibility threshold (roughly 0.005 per cell). Strokes at this setting are invisible on a single pass and only barely accumulate over many overlapping passes. Sits next to the 1.0 default at the new slider's bottom edge for "I want texture but nearly no color" effects. The lib's paintLoad() setter clamp also lowered to 0.001 (was 0.1).
  • Readout precision: the value display now switches to 3 decimals below 0.1 (so the new range bottom reads "0.001×" rather than "0.0×"). Above 0.1 it stays at 2 decimals.

v0.59 — Paint load fix, splash wrapper fix, deluge jitter

Three fixes from real-use feedback. Two are latent bugs that slipped past previous releases; the third is the alternative to v0.58's deluge rotation.

  • fix Paint Load slider had no effect. paintLoadMult has been declared and exposed via the paintLoad() API since v0.22, but the variable was never actually read by paintAt. The pigment-deposit term was strength * f2 with no multiplier. Latent for 37 versions. Now scaled by paintLoadMult in both the standard pigment branch and the rainbow branch. Verified numerically: deposit ratios at paintLoad 0.5 / 1.0 / 2.0 are 2.000, 1.971 (slight saturation at MAX_PIGMENT ceiling), matching the slider value to within rounding. Wet + pressure stay water-side (driven by waterLoadMult) — that's why the two sliders exist as separate axes.
  • fix Splash opts were silently dropped. v0.58 added a third positional argument to the underlying splash() function for angleOffset / strengthMult / skipLift, but the instance-method wrapper on the public API only forwarded the first two args: splash(arg1, arg2) { splash(arg1, arg2); return api; }. So wc.splash(coords, 'deluge', { angleOffset: π/3 }) dropped the opts on the floor. The "Deluge rotation" slider was wired correctly but produced no visual change because the rotation values never reached the inject loop. Added the third arg to the wrapper. Both rotation and the new jitter now work as designed.
  • feature Deluge jitter slider. Alternative to rotation that fixes the cross in a single splash. Range 0..1, applies per-cell random angular perturbation to the outward-radial vectors. 0 = no jitter (pure radial). 1 = each cell's vector rotated by a uniformly-random angle in ±π/4 (±45°) — wide enough to fully homogenize the donor-cell cardinal preference while keeping the field predominantly outward. Composes with rotation if both are nonzero, but typically you pick one.
    • Visual: rotation gives a smooth, organized symmetric splash that unfolds over N frames; jitter gives a single-frame splash with visible turbulence texture in the velocity field. Jitter reads as natural noise; rotation reads as deliberate motion.
    • Cost: jitter adds two Math.cos / Math.sin calls + one Math.random per cell within the splash radius. On a 900-radius splash that's ~2.5M random calls, measured at <2 ms in V8 on commodity hardware — negligible vs. the post-splash sim work.
    • API: new splash(coords, preset, { jitterAmount: 0..1 }) opt. Default 0 (off) for back-compat.

v0.58 — Deluge rotation & image stamp size

Two unrelated fixes. The first is the real cause of the cross-shaped deluge artifact — v0.57's flux clamp / substep modes only fixed the negative-cell half of it; this version fixes the asymmetric-evacuation half. The second is a standalone stamp-size control for raster image loading, decoupled from the painting brush.

Deluge rotation

Even with the v0.57 advection modes the cross persisted because the donor-cell scheme has a built-in cardinal-axis preference that the v0.57 fixes don't address: cells along the cardinal axes (where velocity is pure (±V, 0) or (0, ±V)) donate 100% of their pigment to one neighbor; cells along the diagonals (velocity (V/√2, V/√2)) split 50/50 across two neighbors. The flux clamp prevents over-donation but doesn't change the asymmetry: cardinal lanes still evacuate faster than diagonals, leaving the residual cross.

  • feature New "Deluge rotation" slider in the SVG transition section, range 1–12 frames, default 1 (v0.57 behavior). At N ≥ 2, a single deluge is distributed across N consecutive frames via requestAnimationFrame. Each frame injects 1/N of the velocity field, rotated by 2πk/N radians around the epicenter. The deposited→suspended lift happens once on frame 0 (so pigment isn't depleted N times); subsequent frames pass skipLift: true. Total momentum is preserved across the N injections.
  • feature New splash() options: angleOffset (radians, rotates radial vectors around each epicenter), strengthMult (scales velocity + pressure injection), skipLift (omit the lift + wet-saturation phase). Existing splash(coords, presetName) and splash(presetName) calling forms unchanged.
  • Why it works: the cardinal preference doesn't disappear when you rotate the field — it just moves to whichever axis the field is currently rotated to. Across 6–12 successive frames at evenly-spaced angles, every direction takes a turn being the “cardinal” one. The resulting pigment distribution is the average of N rotated cross patterns, which is rotationally symmetric. The cross shape literally smears into a circle.
  • Cost: negligible. Each per-frame injection is 1/N the work of the v0.57 splash. Spread across N frames, the per-frame hitch is also 1/N — rotation mode is actually smoother than v0.57 for the user even though total work is the same.
  • Recommended setting: N = 6 for fast deluges, N = 12 for the smoothest. The slider is in the SVG section right under "Deluge lift" so you can compare with a fresh deluge between adjustments.

Image stamp size

  • feature New "Image stamp" slider in the Playground section, next to Paint Photo. Range 1–64 px, step 1, default 8 px. Drives sample spacing for raster reproduction independently from the painting brush size. Maps to paintImage(file, { brushSize: N }); the lib's paintImage already supported the brushSize override, so this is wiring-only.
  • Why decouple it: the painting brush size optimizes for stroke feel (28–80 px is comfortable); raster reproduction wants finer control (1–16 px for detailed photos, 32–64 px for impressionistic washes). Tying them together meant changing the painting brush every time you wanted to load an image differently. Now you can keep the painting brush at 40 px and load a photo at 4 px without touching anything else.
  • Performance: small stamp size is more expensive — 1 px sampling on a 1024×768 image generates ~786k samples × ~2 paintAt calls = ~1.5M paintAt calls. The lib's maxStamps cap (default 20k) prevents this from freezing the browser, but you'll see fewer total stamps than you might expect. 4–8 px is the practical sweet spot for detailed reproduction.

v0.57 — Advection mode comparison

Both candidate fixes for the v0.56 cross artifact are implemented as side-by-side modes, switchable via a dropdown in the SVG transition section. The cross-shaped pigment pattern after extreme deluges was a real numerical artifact — the donor-cell advection scheme over-pumps pigment when the per-cell (|ux|+|vy|)·adt > 1, leaving cells negative, which Kubelka–Munk renders as bright cardinal streaks. v0.57 keeps that behavior available (it's still the default) and adds two corrective modes you can flip to for visual comparison.

The three modes

  • Standard — v0.56 behavior. The cross appears at extreme deluge settings (velocity 5+, pressure 3+, radius 1500+, lift 1.0). Fastest of the three. Default for back-compat.
  • Flux clamp — per-cell effective adt capped at 1/(|ux|+|vy|) so total outflux can never exceed the cell's pigment. Kills the negative-cell artifact directly. Mass-conserving. Very-high-velocity strokes look slightly less aggressive because pigment can't outrun the wavefront, but the natural-look range (velocity ≤ 5) is visually identical to standard within measurement noise.
  • Substep — global substep count N = ceil(maxcell(|ux|+|vy|) · adt), capped at 16. Each sim step runs N donor-cell passes at adt/N, each of which stays CFL-safe. Physically correct propagation at any velocity — the wavefront reaches the same distance per step as the standard scheme intended to. Cost is N× the advection during the post-deluge recovery period; substep count is shown in the panel as ×N next to the dropdown.

Measured cost

Microbenchmarks on a 617×514 grid (1080×900 canvas at default 1.75 px/cell), post-deluge velocity field with peak L1 speed ~16:

Velocity peak | Standard | Flux clamp | Substep
              |          |            |
~16 (extreme) |  37.7 ms |   15.1 ms* |  79.9 ms (6 substeps)
 ~6 (high)    |  12.7 ms |   13.3 ms  |  39.2 ms (3 substeps)

* Flux clamp's apparent speedup at extreme velocity is real: the
  per-cell adt cap shrinks the flux values, which reduces work in
  the inner loops since fewer cells "wake up" with pigment moving.
  At moderate velocity the overhead is ~5%, matching the earlier
  estimate.

Substep cost stays well below N× standard in the microbench because each substep moves less pigment per cell (smaller adt → smaller flux → smaller downstream propagation), so the active-cell work doesn't fully N-multiply. In steady state the ratio approaches N as the active rect saturates. Bottom line: substep is ~2–3× the standard cost during the few frames after a deluge, then drops to 1× once velocities damp below the CFL threshold.

API

Watercolor.advectionMode(v?)
Watercolor.advectionLastSubsteps() → int

// Getter / setter for the mode. Values: 'standard' | 'clamp' | 'substep'.
// Default 'standard' for back-compat. Invalid values warn and no-op.
Watercolor.advectionMode();          // → 'standard'
Watercolor.advectionMode('clamp');   // → 'clamp'
Watercolor.advectionMode('substep'); // → 'substep'

// Most recent substep count from movePigment. 1 in standard/clamp;
// rises after a deluge in substep mode, decays as velocities damp.
Watercolor.advectionLastSubsteps();  // → 1..16

Captured in getPreset() / applyPreset() as advectionMode. Presets saved pre-v0.57 don't carry the field; applyPreset skips it and leaves the instance's current mode unchanged.

How to compare

Open the SVG transition section, push the deluge sliders to their new doubled maxes (velocity 10, pressure 6, radius 3000, lift 1.0). Trigger a deluge under standard — observe the cross. Reset. Switch to flux clamp via the dropdown, trigger again — the cross should be gone, the visual character is similar but the pigment doesn't reach quite as far. Reset. Switch to substep, trigger again — the cross is gone, the pigment reaches the same distance as standard intended, and the substep count (shown next to the dropdown) jumps to ~12 momentarily before decaying back to 1 over ~30 frames.

v0.56 — Workflow polish & deluge headroom

Five small fixes from real-use feedback. None of them are architectural — this is the kind of release that accumulates when you actually start using the thing.

  • fix Docked panel no longer auto-hides. Six event handlers used to call hideUI() when their thing fired: animation change, visualization change, background change, SVG load (non-instant), SVG replay (non-instant), and paintImage. Made sense in the bottom-centered overlay era (v0.52 and earlier) when the panel literally covered the canvas. With the right-docked panel (v0.54+), the canvas area is already stable — the panel doesn't intrude — and auto-hiding just made the user press H to bring it back so they could keep iterating. Removed all six calls. The H-key shortcut and the visible toggle button still hide manually when wanted.
  • feature Container W/H inputs match the panel's visual language. The <input type="number"> elements for container size were rendering with the browser's native chrome (rounded edges, system font, full-opacity spinners). Now they share the same monospace JetBrains Mono / transparent background / 1px --btn-border / 2px corner radius as the text inputs and selects. Spinners fade to 50% opacity at rest, full opacity on hover/focus — affordance present but quiet.
  • feature Pigment swatches 34px → 26px. Reclaims about 16px of vertical space at the top of the panel where the pigment row sits, so the section bodies below get more breathing room without scroll. Still comfortably above the 24px iOS touch-target minimum and reads clearly at this size; mobile override stays at 30px since touch needs slightly more room.
  • feature Deluge slider maximums doubled. Velocity 5 → 10, pressure 3 → 6, radius 1500 → 3000. Lift stays at 1 (it's a fraction of deposited pigment and can't exceed 1.0). The built-in 'deluge' preset defaults are unchanged — new sliders just let you push past the previous ceiling when you want a more aggressive out-transition. See the next note about what happens at the high end.
  • Note on the cross-shaped pigment pattern. At extreme deluge settings (velocity 5+, pressure 3+, radius 1500+, lift 1.0 — the previous maximums all pushed to the limit), the deluge produces a visible cross-shaped pattern: bright cardinal channels (N/S/E/W) and pigment concentrated in the four diagonal quadrants. This isn't the cursor crosshair fixed in v0.55 (that was a UI bug) — this is a real numerical artifact of the simulation. Quick anatomy:
    • The deluge velocity field at each cell points radially outward from the epicenter, scaled by distance falloff (velMag · f2). In continuous space this is rotationally symmetric.
    • On a Cartesian grid it isn't. Cells exactly on the cardinal axes get pure-axis unit vectors ((±1, 0) or (0, ±1)) which advect grid-aligned with no interpolation. Cells on the diagonals get (1/√2, 1/√2) which require bilinear interpolation and effectively get diffused during the advection step.
    • The diffusion stencil itself is 4-neighbor (von Neumann), not 8-neighbor (Moore), so spreading along cardinals is sharper than spreading along diagonals. Same bias as advection, same direction.
    • Pressure-driven flow uses forward/backward differences for the gradient — axis-aligned by construction. At pressure 3+ this adds another layer of cardinal bias on top of the advection bias.
    • When the resulting per-cell velocity exceeds ~1 grid cell per timestep (the CFL number > 1), the semi-Lagrangian advection backward-trace lands several cells away and the linear interpolation can't recover cleanly. Cardinal lanes evacuate faster than diagonals; diagonal pigment piles up; you get the cross.
    Mitigations exist (sub-step the advection within one sim step, switch the diffusion stencil to 8-neighbor, cap per-cell velocity injection, add a small angular perturbation to the velocity field). All are real lib work and they'd change the visual character of every deluge, not just the extreme ones. Left unimplemented for now because the maximum-doubled sliders are meant to let you reach the artifact zone if you want that look. If you want the cross gone at high settings, ping me and I'll wire up sub-stepping — the cleanest fix that doesn't touch the visual character of the deluge at moderate settings.

v0.55 — Cursor artifact fix

  • fix Crosshair cursor showing through the brush-preview ring. The CSS rule #canvas { cursor: none; } was orphaned in v0.51 when the build script started replacing the inline <canvas id="canvas"> with a <div id="canvas-host"> so the lib could mount its own canvas inside. The new canvas element didn't match the old #canvas selector, so it fell back to the browser default cursor for <canvas> elements — which historically is crosshair. The crosshair (a thin plus sign with a dot at the center) was visible through transparent brush-preview rings, looking like a stray artifact inside the cursor circle. Most visible with the rainbow brush whose preview is just a hollow ring. Fixed by retargeting the rule at #canvas-host canvas, and as belt-and-suspenders the lib itself now writes cursor:none directly onto its canvas element so the artifact can't recur even if surrounding CSS drifts. The deluge-mode crosshair override (intentional crosshair when click-to-deluge is armed) got the same retarget.

v0.54 — Right-docked panel & persistent Deluge mode

Two workflow-completion changes after dogfooding v0.53.

  • feature Controls panel is now right-docked. The bottom-centered overlay made sense when the panel was 3-4 controls wide; with the v0.53 section refactor stacking 30+ controls vertically, it ate canvas height. Now: 360px-wide fixed column on the right, full viewport height, scrollable. The canvas reclaims the left portion of the viewport (just under 1100px on a MacBook Air 13"). Press H or click the toggle to hide the panel — when hidden, .app-section reclaims the full viewport width so the canvas fills the whole screen.
  • feature Deluge mode is now a persistent toggle. v0.53 shipped it as a one-shot arm-and-fire; v0.54 makes it a sticky toggle. Click on once, then click anywhere on the canvas as many times as you want — every click triggers a fresh deluge from that position (clamped to container edges). Click the toggle again to disarm. The button label changes from "Click-to-deluge" to "Deluge mode" to make the persistence clearer.

v0.53 — Workflow UI & brand: Washes

A significant UI redesign organized around real workflows rather than feature parity. The controls panel is now four collapsible sections — Brush, SVG transition, Playground, Settings — grouped by what you're doing, not what each control technically does. Plus a new "container" concept that anchors the SVG in/out transition workflow and the Washes brand alias.

Section layout

  • feature Four collapsible workflow sections. Each uses HTML <details> with a styled <summary>. Brush and SVG transition open by default; Playground and Settings collapse to a single header line. Open/closed state is persisted to localStorage under watercolor.section-state.v1 so you get your layout back on reload.
  • Brush: pigments, paint load, water load, brush-only preset slot.
  • SVG transition (primary): container size + visibility, load SVG, in-transition controls (duration, easing, path pause, color toggles), out-transition controls (click-to-deluge mode + four tunable deluge sliders + manual trigger).
  • Playground: paper wetness, animation, background, visualization, text, photo, splash actions (big / fine / obliterate / re-wet / dry / remove mask / export / reset).
  • Settings: resolution, fade halflife, all toggles (edge darkening, drying, auto-dry, fade, transparent, WebGL, perf, mobile, continuous flow, gouache).

Container box

  • feature Container concept. A configurable rect (default 400×400 px, centered) that visually anchors the SVG transition workflow. Width / height inputs in the SVG section; a dashed outline overlays the canvas so you can see exactly where SVGs will land. Toggle "Show" to hide the outline without changing behavior.
  • feature SVG traces aspect-fit inside the container. The lib's traceSVG(svg, opts) now accepts an opts.bounds = { x, y, w, h } in grid coords. The wiring computes the bounds from the container rect each call, so SVGs scale to fit even if you resize the container between loads.
  • feature Replay button. The most recently loaded SVG is cached; "Replay" re-runs it through the current in-transition settings without re-picking the file. Iterating on duration / easing / pause is now a single click instead of a file dialog round-trip.

Deluge tuning

  • feature Four deluge param sliders right in the SVG section: velocity, pressure, radius, lift. Each overrides the corresponding built-in 'deluge' preset default per-trigger (the lib's splash() already supported per-point overrides via the coords array; the wiring just plumbs the slider values into that).
  • feature Click-to-deluge mode. Toggle on, then the next click on the canvas triggers a deluge from that position — clamped to the container's edges if the click landed outside it. Cursor changes to crosshair while armed; mode auto-disarms after one shot. The "Deluge now" button still works as before for triggering from the container's center.
  • Why click-to-deluge clamps to the container: the container is the design region; the deluge is supposed to feel like water rushing across that region, not the whole canvas. Clamping makes the click location set the direction of the radial flow without letting the epicenter drift off the design.

Brush presets

  • feature Brush-only preset slot in the Brush section, independent from the global Preset at the top. Captures pigment + brush size + paint load + water load + pressure + flow. Built-in starters: Default, Fine pen (SVG drawing), Wet wash, Dry brush. User-saved entries persist in watercolor.brush-presets.v1 localStorage. Separate from the global preset so you can swap brushes mid-session without resetting paper / animation / SVG / container state.

Branding

  • feature window.Washes alias. Same factory as window.Watercolor; both names continue to work. The page title, doc nav, and footer now refer to the library as "Washes." API-level naming (the methods on the instance, the Watercolor.create() call) is unchanged for back-compat.

v0.52 — Presets & deluge splash

Two unrelated features that fit in one release. First: a preset save/load system that captures every user-controllable setting into a serializable object and applies it cleanly to any instance. Second: a fourth splash preset, "deluge," which concentrates the chaos of a Big Splash into one overwhelming event — the visual of a cup of water violently spilled across the page, or a pressure washer hitting the surface.

Presets

  • feature New getPreset() / applyPreset(obj) APIs. Capture every user-controllable setting (brush size, pigment, pressure, flow, paint/water load, paper color, evaporation, gouache mode, fade settings, animation/visualization modes, edge darkening, drying, transparent bg, continuous flow — 18 fields total) into a plain serializable object. JSON-clean (~350 bytes), round-trips bit-exact, cross-instance compatible. Excludes painted artwork (the d[]/g[] arrays are content, not settings) and system-dependent toggles (WebGL, mobile, perf overlay).
  • feature New "Presets" control at the top of the panel. A <select> dropdown with two optgroups: built-in starter presets (Default, Dark mode, Wet & loose, Dry brush) and a "Saved" group of user-saved presets, persisted in localStorage['watercolor.presets.v1']. Picking a preset applies it immediately; "Save…" prompts for a name (overwrites if it matches an existing user preset, rejects if it collides with a built-in); "Delete" removes the selected user preset with a confirmation. Built-ins are protected from deletion.
  • feature Applied presets fire 'presetapplied' on the host element. The wiring listens and re-syncs all the affected DOM controls in one pass — brush slider, paint/water load sliders, fade halflife slider, paper color picker, the gouache tri-state button, and every toggle. So selecting "Dark mode (gouache, auto)" from the dropdown not only changes the lib state but also visibly flips every control in the panel to match.
  • Apply order matters and is enforced. Paper color is applied before gouache mode so that gouacheMode('auto') recomputes its LERP from the new paper darkness. Animation and visualization are applied last because they may inject state that depends on other settings. Missing fields are skipped (forward-compat with old presets); unknown fields are ignored (forward-compat with future-version presets).
  • fix Added the long-claimed evaporation(mult) method. The docs have referenced Watercolor.evaporation(value) since v0.30 (accepts a preset name string OR a numeric multiplier), but the actual API only had paperWetness(presetName) — the numeric path was never wired up. Now it is. evaporation('damp') forwards to paperWetness; evaporation(8) sets the multiplier directly; evaporation() returns the current effective multiplier (inverted from the stored rate). Required by applyPreset to round-trip evaporation cleanly.

Deluge splash

  • feature New "deluge" splash preset. Fourth entry in SPLASH_PRESETS, joining default / bigSplash / fineSpritz. Single epicenter (epiMin: 1, epiMax: 1), 900px radius covering most of the canvas, velocity 2.5 (~2.3× bigSplash), pressure 1.2 (~2.2× bigSplash), lift 0.95, wetLevel 1.0. The result: a flooded canvas with strong outward-radial flow that pushes pigment well beyond the visible splash ring. Mass conservation verified: 95% of deposited pigment ends up suspended after one deluge, total mass unchanged.
  • feature UI Deluge button next to Big Splash and Fine Spritz. Centers the epicenter at canvas center for a predictable "cup spilled in the middle" effect, rather than the rejection-sampler's pigment-biased random pick. Programmatic callers can still pass explicit coords: wc.splash([{x: 500, y: 300}], 'deluge').
  • When to use which splash: fineSpritz for soft texture-adding bursts; default or bigSplash for distributed chaos that mixes everything; deluge for a single dramatic event when you want a directional rush of water across a finished wash.

v0.51 — Auto-gouache LERPs with paper darkness

The gouache toggle gained a third state: Auto. When the toggle is on Auto, the active pigment set is a per-frame linear interpolation between the transparent and opaque sets, driven by the perceptual luminance of the current paper color. Dragging the paper picker from cream to charcoal continuously morphs the pigments from transparent watercolor to fully opaque gouache. The physical reasoning lines up: a near-white substrate barely needs scattering pigments (the paper itself supplies the reflected light); a near-black substrate requires high-S pigments to be visible at all; substrates in between get a proportional blend.

  • feature Tri-state gouache button. Cycles Off → Auto → On → Off on click. Label updates per state. Off and On are unchanged from v0.50 (and earlier). Auto is the new behavior.
  • feature Paper darkness drives the LERP. Computed as 1 − (0.2126R + 0.7152G + 0.0722B) (Rec. 709 luminance). Default cream paper has darkness ≈ 0.034 (almost pure transparent); black paper has darkness 1.0 (fully opaque). A 50% gray paper sits at the exact midpoint of the two pigment sets.
  • feature Per-channel LERP across all pigment fields. K (absorption, 3 channels), S (scattering, 3 channels), density, staining, and granulation all interpolate independently between the corresponding entries in PIGMENTS_TRANSPARENT and PIGMENTS_OPAQUE. Verified at the math layer — mid-gray paper produces K[0] = 0.1125, S[0] = 1.275, density = 0.11 for rose, exactly halfway between Quinacridone Rose (0.22 / 0.05 / 0.02) and Opaque Rose (0.005 / 2.5 / 0.20).
  • feature Live morph as you drag the paper picker. paperColor() is hooked: when called while in Auto mode, it recomputes the LERPed pigments and re-renders. Existing deposited pigment recomposites under the new K/S in the same frame, so dragging the picker through a gradient produces a smooth continuous change to the painting on screen.
  • feature New API methods.
    • gouacheMode('auto') — enable auto-LERP. Returns 'auto' for subsequent reads of gouacheMode(). Booleans true / false still mean fully-opaque / fully-transparent as before.
    • gouacheMode(0..1) — numeric override. 0 snaps to false; 1 snaps to true; values in between act as 'auto' with the LERP fixed at that value (paper changes won't re-drive it until you re-call gouacheMode('auto')).
    • gouacheLerpAmount() — read the current LERP value in [0, 1]. Reflects whatever value most recently populated PIGMENTS_LERPED.
    • The gouachechange CustomEvent on the host element now carries { enabled, lerpAmount }. Wiring listens and refreshes the pigment swatches as the LERP changes — the visible swatch colors track the active K/S, so they smoothly morph with the paper.
  • Why it's free at runtime: the LERP is recomputed only on (a) gouacheMode('auto'), (b) paperColor() while in auto mode, or (c) initialization — never per-frame. The CPU and GPU render paths read PIGMENTS[i].K at function entry, so they pick up the LERPed values for free on the next render. WebGL uniforms re-upload each frame anyway. Hot path cost: zero.
  • Defaults preserved: the default gouacheMode for new instances remains false (transparent) for backward compat with the existing API. Pass { gouacheMode: 'auto' } to Watercolor.create() to start in auto mode.
  • Soft breaking change: gouacheMode() now returns false | true | 'auto' instead of a strict boolean. Code that checked truthiness (if (wc.gouacheMode())) still works correctly — 'auto' is truthy. Code that did strict === true will now read 'auto' as "not true." If you need the old binary check, use wc.gouacheMode() !== false.

v0.50 — Spring fade

The Fade painting toggle has a new decay curve. The old curve was pure exponential (d[i] *= fadeFactor at each tick) — clean math, but visually it asymptotes: paint gets infinitely fainter but never quite lets go, then pops to zero when it crosses a hard floor. The new curve is a critically-damped spring pulling each cell's pigment value toward zero. Same half-life semantics; visibly cleaner finish.

  • feature Critically-damped spring decay. For a cell starting at rest with value x₀, the response is x(t) = x₀·(1 + ωt)·e−ωt where ω = 1.6783 / half-life (the constant solves (1+ωt)e−ωt = 0.5, so half-life semantics are exactly preserved). The curve is monotonically decreasing, starts with zero velocity, and lands cleanly at zero rather than asymptoting.
  • feature Shape difference from exponential. Same value at half-life (both pass through 0.5 at t = T1/2), but the spring lingers more at high values (0–0.5× half-life) and falls faster through the second half. With the default 4 s half-life, a saturated cell reaches the floor in about 22 s for the spring vs ~40 s for exponential — the painting decisively finishes fading instead of holding a faint residual.
  • Implementation: per-cell velocity arrays (dVel[0..2], Float32Array each, ~6.5 MB on the default grid) lazy-allocated when fade is first enabled. Closed-form one-step integration of the 2nd-order ODE ẍ + 2ωẋ + ω²x = 0 using precomputed coefficients — exp() is called once at slider-change time, not per cell. The integrator is exact (matches the analytical solution to machine epsilon, verified) and unconditionally stable.
  • Paint events reset the spring. When suspended pigment settles into the deposited layer (the evaporation step's d[k][i] += g[k][i]), the cell's spring velocity is reset to 0. Fresh paint starts at rest; the spring doesn't swallow new paint with leftover downward momentum. Hooks all three g→d sites (evap with and without mask, and dry()).
  • Re-enabling fade after disable zeroes all velocity arrays so the spring restarts everywhere from rest. Stale velocities from a prior fade session don't bleed into the next.
  • Floor handling preserved. When the spring's value falls below FADE_SKIP_BELOW (0.001), it snaps to exactly 0 along with its velocity. Same threshold the exponential fade used; bounds total fade completion time, prevents render-loop wake-ups on near-zero values.
  • Suspended pigment (g[]) is unchanged. Wet strokes don't fade — the spring only acts on the deposited layer. Same as exponential's scope.
  • Performance: spring step is ~3× the per-cell op count of exponential decay, but still bounded by memory bandwidth at this grid size. Stayed under 0.5 ms per tick on default settings; doesn't change the perf envelope.

v0.49 — Paint a raster photo as watercolor

  • feature New paintImage(source, opts?) API. Accepts a File, Blob, HTMLImageElement, or URL string (including data: URLs). Samples pixels at brush-radius spacing, decomposes each RGB into a rose+yellow+blue mix via subtractive color theory (same CMY algorithm as approximateColor in traceSVG), and stamps the corresponding pigment proportions through the existing _paintSvgPoint multi-stamp helper. The photo materializes as a watercolor reproduction.
  • feature UI "Load Image" button in the SVG controls section, paralleling "Load SVG." Accepts any browser-decodable image format (JPEG, PNG, WebP, GIF, AVIF on supporting browsers). Hides the panel on load so the wash isn't obscured.
  • feature Sample spacing scales with brush radius. A large brush (~50px) yields an impressionistic feel with broad bleeding washes; a small brush (~8px) recovers more detail at the cost of more paintAt calls. The same 0.85× brushR spacing as v0.45's SVG fill stamping.
  • feature Returns Promise<number> — count of stamps actually painted. Loading is async (waits for image decode); painting is synchronous once decoded, like v0.46's fillInstant. The promise resolves when the entire image has been stamped.
  • perf Built-in stamp cap. A pathological combination (huge image × tiny brush) could otherwise issue millions of paintAt calls and freeze the UI. opts.maxStamps (default 20000) defines an upper bound; if the brush-driven step would exceed it, the step is rounded up to fit. Worst case is now bounded regardless of input.
  • How RGB decomposes to pigment weights:
    • Pure white → weights [0, 0, 0] → no paint (white backgrounds vanish naturally, leaving the subject)
    • Pure red → weights [1, 1, 0] → rose + yellow at full strength (orange-red)
    • Pure green → weights [0, 1, 1] → yellow + blue (muted dark green)
    • Pure blue → weights [1, 0, 1] → rose + blue (more saturated than blue alone)
    • Pure black → weights [1, 1, 1] → all three at max (deep muddy watercolor black)
    • Greys → balanced mix of all three; brownish in the middle of the range
    • Semi-transparent pixels deposit less paint (strength multiplied by alpha)
  • Options (all optional): brushSize, strength, pigment (fallback for skipped pixels), alphaThreshold (default 0.05), density (spacing multiplier — <1 denser, >1 sparser), flipX / flipY, maxStamps (cap, default 20000).
  • CORS note: loading from a cross-origin URL requires the server to send Access-Control-Allow-Origin. Without that, the canvas becomes tainted and getImageData throws — the function returns 0 and logs a warning. Same-origin URLs, data: URLs, and Files / Blobs from <input type="file"> always work.
  • fix Useful error messages on image load failure. Initial v0.49 rejected with the raw Event object from Image's error listener, which logs as "[object Event]" — useless. Now rejects with Error objects whose .message describes what went wrong and (where possible) why: file name + MIME type + size, MIME-mismatch fail-fast for non-image files, zero-dimensions decode detection, and a hint listing the common culprits (HEIC / HEIF on some browsers, TIFF, RAW formats, corrupted files). The wiring also pops the controls panel back open on failure so the user can retry without having to hit the +/− toggle.
  • feature Total pigment metric in the perf overlay. A new pigmentTotal field on perfMetrics() sums the deposited and suspended pigment mass across every cell in the grid — including cells outside the active rect. Companion fields pigmentSuspended (in the water layer) and pigmentDeposited (fixed on paper) let you see where the mass is. The overlay shows all three in a single "pigment Σ" row.

    What this exposes that the active-cells metric was hiding: after dry(), the active rect goes to 0 (no flow, no pressure to simulate) but the deposited pigment is still on the paper. The pigment total stays flat across the dry transition — suspended converts to deposited. You can now verify paint conservation, diagnose residue from the shrink-scan, and track total user-applied paint, all from one row.

    O(N) per call (~540k cells linear scan), only computed when the perf overlay is open. Sub-millisecond on modern hardware.

v0.48 — Fill-attribute-aware fallback for SVG

  • fix The author's intent now wins, even when the color doesn't resolve. Before v0.48, any element whose stroke and fill both failed to resolve (unrecognized hex with approximateColor: false, or both flags off entirely) had its fallback brush routed to the OUTLINE slot — so <rect fill="#222222"> got outlined in the current brush even though the SVG author clearly wanted it filled. v0.48 routes the fallback to whichever slot the author specified: if there's a fill attribute, the shape gets filled with the active brush; if there's a stroke attribute, it gets outlined; if both, both happen.
  • feature New flags exposed by the resolver: strokeSeen / fillSeen on the return value of _resolveSvgPaints. These flip to true whenever a non-none stroke or fill value is found anywhere on the way up the DOM tree, regardless of whether the value resolves to a recognized paint. strokeBlocked / fillBlocked remain — they specifically mark none / transparent values that suppress paint entirely.
  • feature SVG cascade now matches CSS: a child's explicit attribute (recognized or not) takes precedence over a parent's. Before, an unrecognized child stroke would keep walking up looking for a recognized one; now it stops at the first non-none value, matching how browsers render SVG.
  • Behavior matrix (active brush = current pigment when fallback is used):
    • <rect/> (truly unstyled) → outline with active brush (unchanged)
    • fill="#FF0000" → fill rose only (unchanged)
    • stroke="#FF0000" → outline rose only (unchanged)
    • fill="#222222" (unrecognized) → fill with active brush (was: outline with active brush)
    • stroke="#222222" (unrecognized) → outline with active brush (unchanged)
    • fill="#FF0000" stroke="#222222" → fill rose AND outline with active brush
    • fill="#222222" stroke="#FF0000" → fill with active brush AND outline rose
    • fill="none" → nothing (explicit suppression respected)
    • fill="none" stroke="#FF0000" → outline rose only
  • All v0.47 spacing improvements preserved. All v0.45/v0.46 fill stamping + instant fill behavior preserved for recognized colors.

UI polish in the same release:

  • fix SVG easing dropdown was missing class="select-control" so it rendered with the browser's default style instead of matching Evaporation / Animation / Time of day / Visualization. Now it looks like the rest.
  • feature Brought back the bracket brush-size shortcuts. [ shrinks, ] grows; held Shift takes bigger jumps. Step size is multiplicative (10% per press, 25% with Shift) so it scales naturally across the full 4–200 range. Adjusts via wc.brushSize() and syncs the slider + value readout in one go. Disabled while typing into form fields.
  • fix "Active cells" in the perf overlay was stuck at 0 (0.0%). The perfActiveCells() function was reading activeRectMinX / activeRectMaxX / etc. — identifiers that have never existed anywhere in the lib (the real bindings are activeMinX / activeMaxX / etc., without the Rect infix). A typeof === 'undefined' guard masked the typo silently, so the readout has been stuck since the function shipped in v0.27. Now reads the correct bindings.
  • fix ...but the metric itself was also misleading. Once the typo was fixed, the readout reported the area of the bounding rectangle the sim iterates over per step — not the count of cells with actual pigment/pressure. Two small splotches in opposite corners would show ~60% even though <1% of cells had content. The overlay now reports BOTH metrics:
    • active cells — count of cells with pigment or pressure above threshold. What people intuitively mean by "active"; small for typical paintings.
    • active rect — bounding-box area the sim iterates over per step. The perf-relevant figure; gap between it and "active cells" tells you how loose the bbox is (large gap = paint spread out, sim doing wasted iterations).
    perfMetrics() exposes both as activeCells / activeRectCells with matching activePct / activeRectPct. The bbox metric was previously named activeCells, so the rename is a soft breaking change for anyone reading activeCells programmatically — check activeRectCells if you want the old number.

v0.47 — Brush-scaled SVG stamp spacing

  • fix Small brushes produced dotted outlines. The SVG outline trace used a fixed 2-grid-cell downsample threshold that worked for medium brushes but left visible gaps for small ones — a 4px brush produced stamps roughly 2.4× its own diameter apart, so strokes read as a row of dots rather than a continuous mark. v0.47 makes both the initial sample rate AND the downsample threshold scale with brush radius. Target spacing is now 0.5× brush radius (50% overlap between consecutive stamps), with a 0.5-cell floor for sub-cell brushes.
  • perf Large brushes are no longer oversampled. A 200px wash brush previously fired ~100 stamps per brush diameter (since the threshold was fixed at 2 cells regardless of size). Now it fires ~2 per diameter, matching the actual brush footprint. For a typical multi-path SVG the total stamp count drops 10-50× with no visible change to the trace.
  • The 10000-point cap per path is preserved (now applied to the initial sampling, before downsampling).
  • End-to-end measurements on a single horizontal line across the canvas:
    • brushSize=4px → 491 stamps at 0.86 cells apart (was: ~50 stamps with gaps)
    • brushSize=10px → 249 stamps at 1.69 cells apart
    • brushSize=24px → 83 stamps at 5.12 cells apart
    • brushSize=60px → 36 stamps at 11.76 cells apart
    • brushSize=120px → 20 stamps at 21.2 cells apart (was: ~50 stamps massively overlapped)
    All five sizes now produce ~50-70% stamp-to-brush overlap, the sweet spot for smooth strokes.
  • No regression in fill stamping (already scaled with brush since v0.45). No regression in instant fill (v0.46) or trigger color resolution. All 44 unit tests still passing.

v0.46 — Instant fill stamping

  • feature Filled regions appear as washes, not sweeps. v0.45 stamped fill interiors by sampling the bounding box in row-major order and painting each point as the trace animation marched through them. The result still read as "the brush is sweeping across the shape" — better than tracing an invisible perimeter, but not what a watercolorist sees. v0.46 paints fill stamps synchronously at traceSVG() call time. The filled region appears in one frame, like a wet brush stamped onto paper depositing its whole footprint at once.
  • feature New fillInstant option (default true). Off-switch back to the v0.45 row-major sweep if anyone wants it. Has no effect on stroke-only SVGs.
  • feature Outlines still animate. The trace animation is reserved for outline strokes (which actually benefit from sequential reveal — a pen-stroke really does follow a path). Filled+stroked elements now match watercolor practice: lay down a wash, then ink the edge.
  • feature UI toggle. "Instant fills" joins the SVG controls row alongside "Trigger colors", "Approximate color mix", and "Fill shapes." On by default.
  • fix Refactor: flipX, flipY, and strength reads were hoisted from after the geomEls loop to before it, so the fill-instant path can compute final grid coordinates during iteration. Identical behavior elsewhere.
  • Perf note: large filled SVGs can fire hundreds to thousands of paintAt calls in the first frame. Desktop hardware handles this fine; mobile or dense SVGs may show a brief stutter. fillInstant: false spreads the work over durationMs at the cost of the sweep look.

v0.45 — SVG filled shape stamping

  • feature Filled SVGs now actually paint. Before v0.45, the loader only traced perimeters — great for line drawings, useless for icons / logos / region maps. Filled-only SVGs traced an invisible outline in the fill color and the canvas looked empty. v0.45 adds a fill pass: for each element with a recognized fill paint, sample a grid of points across its bounding box, keep the ones inside the path via p.isPointInFill({x,y}), and stamp each with the fill paint. Sample spacing is 0.85× brush radius — tight, slightly overlapping coverage.
  • feature Stroke and fill resolve independently. The v0.41–v0.44 resolver collapsed both into a single paint (first hit wins, stroke preferred). v0.45 splits them. An element with stroke="blue" fill="yellow" resolves to {stroke: blue, fill: yellow} — the interior stamps in yellow and the outline traces in blue. Both happen for filled+stroked elements; pure-fill stamps; pure-stroke outlines.
  • feature Per-element order: fill stamps come BEFORE outline strokes in the trace queue, so the outline lands on top of the fill — the natural watercolor "wash, then ink the edge" sequence.
  • feature New fillShapes option (default true). The new behavior is opt-out, not opt-in — most users want filled SVGs to work. fillShapes: false falls back to v0.44 behavior (filled-only shapes have their outline traced in the fill color).
  • feature UI toggle. A "Fill shapes" button joins the SVG controls row alongside "Trigger colors" and "Approximate color mix." On by default.
  • feature SVG cascade semantics for none: an explicit fill="none" or stroke="none" at any ancestor blocks further inheritance for that property, matching how browsers render SVG.
  • perf 10000-sample hard cap per element protects against pathological SVGs (huge viewBoxes + tiny brush radius). Hitting the cap silently skips the fill for that element and continues with the outline trace.
  • fix Internal refactor: _resolveSvgPaint (single) replaced with _resolveSvgPaints (stroke + fill). New _resolveColorToPaint(color, triggerColors, approximateColor) helper shared by both. Cleaner code paths, identical behavior for the four trigger/approx combinations.
  • Behavior change worth noting: in v0.41–v0.44, a filled-only SVG (no explicit stroke) traced its outline in the fill color. In v0.45 with default fillShapes: true, that same SVG stamps its interior instead. The previous outline-only behavior is recoverable via fillShapes: false.

v0.44 — SVG color-handling UI toggles

  • fix UI catches up with the API. v0.43 shipped the triggerColors and approximateColor options on traceSVG() but didn't expose them in the controls panel — you had to call them from the console. v0.44 adds two toggle buttons in the SVG controls row:
    • SVG trigger colors — on by default. Recognize #FF0000 / #FFFF00 / #0000FF / #00FF00 / #FF00FF (chroma-key primaries) and #A50E53 / #E3AF08 / #108BA0 (pigment-rendered hex) in element fill/stroke as direct pigment / mask / rainbow assignments.
    • Approximate color mix — off by default. Decompose any other parseable color into a rose+yellow+blue mix via subtractive color theory. Combine with trigger colors for the most expressive behavior — chroma-key wins for exact triggers, approximation handles everything else.
  • Toggles read at trace time, like the existing duration/easing/pause controls — change them between loads, no need to re-load to apply. Loading the same SVG twice with different toggle states paints it differently.
  • Implementation: currentSVGTraceOpts() in the wiring now reads the buttons' aria-pressed states and passes triggerColors / approximateColor to traceSVG(). No lib changes.

v0.43 — Approximated color mixing for SVG

  • feature New approximateColor option (default false). When on, any SVG element with a parseable fill/stroke color that isn't an exact trigger primary gets decomposed into a mix of rose+yellow+blue and painted as such. Lets a multi-color SVG render with naturalistic watercolor mixing instead of forcing everything to one pigment.
  • feature The algorithm: subtractive color theory. Each pigment absorbs its complementary wavelength, so the weight needed for a target color is one minus the channel that pigment absorbs:
    rose weight   = 1 - g    // rose ≈ magenta, absorbs green
    yellow weight = 1 - b    // yellow,         absorbs blue
    blue weight   = 1 - r    // blue ≈ cyan,    absorbs red
    Worked examples: #FF8000 orange → 50% rose + 100% yellow; #800080 purple → 100% rose + 50% yellow + 50% blue; pure white → no paint; pure black → max of all three (dark muddy).
  • feature Deposition via multi-stamp. Each approximated point fires up to three paintAt calls (one per non-zero weight), each at strength scaled by the weight. The simulation's Kubelka-Munk compositor renders the combined deposited pigment as the approximated color naturally — no special blend logic, the physics handles it.
  • feature Renamed colorMaptriggerColors. Better name for "strict chroma-key matching of pure primaries to specific pigments." The old colorMap name still works as a deprecated alias for one release. Behavior is unchanged.
  • feature Both flags can be on simultaneously. Common pairing: triggerColors: true, approximateColor: true — chroma-key colors win for "I want this exact pigment" (rainbow accent at #FF00FF, etc.) and any other authored color gets naturalistic mixing. Best of both worlds.
  • feature Expanded color parser _parseSvgColor. Accepts 3-digit and 6-digit hex, rgb() and rgba() (alpha ignored, absolute and percentage forms), and ~35 common CSS named colors (orange, purple, pink, brown, gray/grey, gold, coral, salmon, khaki, turquoise, crimson, navy, teal, olive, maroon, indigo, violet, skyblue, lightblue, lightgreen, darkgreen, darkblue, darkred, plus black/white/silver and the trigger names). Unparseable colors fall through to opts.pigment like before.
  • fix Refactored _colorToPigment. Now uses the shared _parseSvgColor internally, then checks for exact trigger primary matches. Same strict-match semantics as v0.41/v0.42 — no behavior change for trigger paths.
  • Verified: 44 unit tests on parser + mix decomposition + trigger handling all passing.
  • Wetness caveat: a mix-paint stamp can fire 2–3 paintAt calls at the same point, which is 2–3× the wetness of a single-pigment stamp. Mid-saturation colors and darks bleed more. This is accurate to real watercolor — mixing pigments physically deposits more wet paint — but if it's too much for a given use case, scale waterLoad down or use paperWetness('damp').

v0.42 — SVG flipY default fixed, flipX added

  • fix flipY default changed from true to false. Standard SVG (Illustrator, Inkscape, Figma, web exports) uses Y-down, same as Canvas and CSS. Mirroring Y on a Y-down SVG produces an upside-down result — which is exactly what v0.28..v0.41's default did. The original comment claimed "most consumer SVGs are Y-down so we flip them," which is contradictory: if they're already Y-down, no flip should be needed for them to appear right-side-up. v0.42 reverts to the natural default. Standard SVGs now trace correctly without any options.
  • feature New flipX option (default false). Analogous to flipY but for horizontal mirroring. Useful when an SVG appears horizontally mirrored — most commonly because a parent <g> has a transform="scale(-1,1)" that pathStringFromSvgElement doesn't apply. Pass flipX: true to mirror at trace time without editing the SVG.
  • Migration:
    • If you were passing flipY: false explicitly to fix upside-down output — you can now omit it. Code continues to work; the option is just redundant.
    • If you were not passing flipY and your SVGs looked correct — you were using one of the rare Y-up sources (older CAD/plot exports). To preserve behavior, pass flipY: true at your call sites.
    • If you were not passing flipY and your SVGs looked upside-down — v0.42 fixes that without code changes on your end. This is the common case.
  • Pure data fix: no API surface changes beyond the new option. Same one-liner in the loader; defaults inverted.

v0.41 — SVG per-element color mapping

  • feature Multi-color SVG tracing. The SVG loader now inspects each geometry element's stroke and fill attributes (including inline style and inherited values from parent <g> groups) and routes recognized chroma-key colors to the matching pigment. A single SVG can now drive all three pigments plus mask and rainbow in one trace call — no per-element wiring needed on the caller side.
  • Recognized colors (strict match): #FF0000 → rose, #FFFF00 → yellow, #0000FF → blue, #00FF00 → mask, #FF00FF → rainbow. Accepts 3-digit and 6-digit hex (case-insensitive) plus the matching CSS named colors (red, yellow, blue, lime, magenta/fuchsia). See the SVG docs for the full reference.
  • Stroke takes precedence over fill (line-drawing sketches are the primary use case); both work. Walks up parent <g> elements to honor inheritance.
  • feature New colorMap option. Defaults to true. Setting colorMap: false on a traceSVG() call disables the chroma-key lookup and routes the entire SVG through opts.pigment — the v0.40 behavior. Useful escape hatch for SVGs that happen to use one of the pure primaries for non-pigment reasons.
  • feature Per-element pigments are strict: only the exact pure primaries match. #FE0000, #FF0001, "red-ish" hexes all fall through to the call's opts.pigment fallback. Backwards compatible — existing SVGs with black strokes, neutral fills, or arbitrary palette colors don't trip the new path.
  • Implementation: new helpers _colorToPigment(color) (string → pigment index or null) and _resolveSvgPigment(el, svgRoot, fallback) (walks DOM tree for first recognized color). The trace sampling loop now builds a parallel allPigments[] array that carries each point's pigment through downsampling. svgTraceStep() reads per-point pigment instead of a single st.pigment. Hot loops unaffected — same paintAt call shape, just with the pigment varying point-to-point.
  • The opts.pigment parameter changes meaning slightly: previously "the pigment for the entire trace," now "the fallback for elements without a recognized chroma-key color." For single-color SVGs with no fill/stroke attributes (or with black strokes), behavior is unchanged. For SVGs that explicitly use the trigger colors, those elements now paint with the matching pigment regardless of opts.pigment.
  • feature Small .docs-table CSS class added for the SVG color reference table.

v0.40 — Docs sweep (catch-up for v0.30 through v0.39)

  • feature Pigments section: transparent vs opaque. Added paragraph explaining the K/S physics difference, when each is invisible, and how gouacheMode() swaps between them. Links to v0.37/v0.38/v0.39 changelog entries for tuning history.
  • feature Special brushes: Paper brush. Documented the v0.32 paper brush properly (sentinel pigment index -5, dynamically follows paperColor(), contrasts with lift's behavior).
  • feature Toggles section: 4 new entries. Added Perf, Mobile, Continuous flow, and Gouache (v0.37+) to the panel toggles documentation. Updated Fade painting to note v0.34's hard-cut-to-zero fix.
  • feature Actions section: Obliterate (v0.30+). Documented the three-mode modifier-key behavior (water / pigment / paper) with reference to the Promise-returning API.
  • feature API: api-toggles example expanded. Added mobile, cursorPreview, gouacheMode, and perf to the toggle example block, with version notes.
  • feature API: api-actions covers sketchMode + obliterate. Both v0.30 features got new H3 subsections with full API signatures, settings the bundle calls under the hood, and the Promise contract for obliterate.
  • feature API: api-state updated to current shape. The documented shape was severely outdated — listed fields that don't exist (svgTracing, cursorsActive, maskActive) and missed fields that do (brushSize, pigment, canvasScale, displayWidth/Height, gouacheMode). Now matches what the lib actually returns.
  • fix Agents recipes: thumbnails example had the toGrid bug. Same pattern that bit the Multi-Watercolor demo: pre-subtracting rect.left/top from clientX/Y then destructuring {gx, gy}. The library's toGrid() does its own getBoundingClientRect() internally, and returns {x, y} — so the code was double-subtracting AND reading undefined properties. Anyone who copied the recipe would have seen obliterate always center on canvas regardless of click position. Fixed: const {x, y} = wc.toGrid(e.clientX, e.clientY).
  • fix Type signature: toGrid return type. Listed as { gx, gy } in agents-signatures; actually returns { x, y }. Same surface-area as the recipe bug above.
  • feature Agents recipes: dark-mode portfolio recipe. New worked example combining gouacheMode: true + dark paperColor(), including the runtime-toggle pattern (existing pigment recomposites under new physics) and the 'gouachechange' event for UI sync.
  • feature Concepts: transparent vs opaque physics paragraph. Explains how the K-M model handles both regimes with the same compositor — only the K/S values differ between transparent and gouache modes.
  • fix llms.txt template: paintAt params. Renamed (gx, gy, ...) to (x, y, ...) for consistency with the corrected coordinate-naming convention.
  • No lib changes. Purely documentation and example-code corrections.

v0.39 — Gouache density bump

  • feature Gouache pigments now deposit ~2.5× more aggressively. Density values:
    • Rose: 0.08 → 0.20
    • Yellow: 0.10 → 0.22
    • Blue: 0.08 → 0.20
  • What this changes mechanically: density controls how fast suspended pigment (the wet/mobile g[] state) settles into deposited pigment (the dry/locked d[] state) per frame. Higher density = faster settling = more thick deposit per stamp = strokes reach full opacity in fewer passes. This is what gives gouache its characteristic "lays down opaque" feel vs. watercolor's "builds up through layers."
  • Yellow gets a slightly higher density (0.22 vs 0.20 for rose/blue) because its perceived saturation peaks later in the deposition curve. Yellow's color comes from both red and green channels reflecting, so a thin layer reads as washed-out cream until enough pigment accumulates for both channels to register near peak. The extra density compensates.
  • For reference, the transparent watercolor density values (unchanged) are 0.02 / 0.06 / 0.01 — the v0.39 gouache values are 10× / 3.7× / 20× those, which feels about right for gouache vs watercolor.
  • Pure data change: only PIGMENTS_OPAQUE density values touched. No API, compositor, or per-pixel math changes.

v0.38 — Gouache vibrancy tuning

  • feature Gouache pigments retuned for more saturation. The v0.37 initial values were conservative starting points. v0.38 pushes them harder: wider K/S spread between dominant and non-dominant channels (so each pigment registers more saturated, less "muddy"), plus a global S bump from 2.0 to 2.5 (stronger surface coverage per stamp). The visual difference, computed from K-M reflectance:
    • Rose: #d13c68#ef204d (dusty rose → deep magenta)
    • Yellow: #ddba25#f7ea1a (muted ochre → electric lemon)
    • Blue: #3071d1#2059ef (teal-leaning blue → saturated cobalt)
  • Saturation analysis (dominant channel ÷ mean of the other two): rose went from 2.55 to 4.39 (+72%), blue from 2.60 to 3.95 (+52%). Yellow's saturation ratio looks similar by that metric but the dominant channels are both brighter (247, 234 vs 221, 186), so it reads as substantially more vibrant in practice — the ratio metric undercounts yellow because both red AND green contribute to its perceived color.
  • Density also bumped (rose/blue 0.04 → 0.08, yellow 0.06 → 0.10) so each stamp deposits more pigment per unit brush stroke, giving thicker apparent layers. Staining and granulation unchanged.
  • Implementation: only the values in PIGMENTS_OPAQUE changed; no API surface, no compositor logic, no per-pixel math. Pure data tuning. Transparent (default) mode is unaffected.

v0.37 — Gouache mode (opaque pigments for dark paper)

  • feature Gouache mode. New gouacheMode(v?) API getter-setter plus { gouacheMode: true } in create-options. Swaps the active pigment set between two arrays kept in module scope: PIGMENTS_TRANSPARENT (the canonical Curtis et al. 1997 watercolor K/S values) and PIGMENTS_OPAQUE (high-scattering values tuned to render vibrantly on dark paper). The real Kubelka-Munk model handles both cases naturally — same compositor, different K/S constants flowing through it. Toggle on, set a dark paperColor(), and the three pigments render as opaque pink / yellow / blue that show clearly against the dark background — the gouache-on-black-paper aesthetic.
  • feature UI toggle in the controls panel. Sits in the toggles row next to Continuous flow. Hovering shows the explainer for why traditional watercolor pigments are invisible on dark paper and what gouache mode changes.
  • feature Affects new AND existing pigment. The CPU and GPU render paths both read PIGMENTS[i].K and PIGMENTS[i].S fresh at function entry every frame, so swapping the array reference takes effect for already-deposited pigment as well as new strokes — paint the canvas in transparent mode, toggle gouache on, and watch the existing strokes recomposite as opaque. The setter forces a full re-render (expands active rect to full + invokes render(true) or gpuRender() directly) so the change is visible immediately.
  • feature Event for host UI. Toggling gouacheMode dispatches a 'gouachechange' CustomEvent on targetEl with { detail: { enabled } }. The demo wiring listens for it to rebuild the pigment swatches (whose visible colors depend on the active K/S values) and refresh the cursor preview.
  • feature Opaque pigment definitions. Three new entries in PIGMENTS_OPAQUE: Opaque Rose (K ≈ [0.04, 2.5, 0.85], S ≈ [2.0, 2.0, 2.0]), Opaque Yellow (K ≈ [0.02, 0.10, 5.0]), Opaque Blue (K ≈ [3.5, 0.7, 0.04]). Density bumped up (real gouache settles more aggressively), staining and granulation reduced (gouache sits on the surface rather than migrating into paper fiber). Values are starting points tuned by eye against a near-black paper color — further refinement will probably happen as the mode sees use.
  • Implementation note: idempotent — setting the same mode twice doesn't re-render or re-fire the event. The lib hot paths weren't touched; they reference the mutable top-level PIGMENTS binding and the function-local const P0 = PIGMENTS[0] caches in the inner loops still work fine because they're function-scoped and get refreshed on next call. Only the WebGL render path needed thought (uniforms are uploaded every frame, not cached, so swapping is free there too).

v0.36 — Multi-Watercolor quickstart in the docs

  • feature Multi-Watercolor quickstart, integrated. The standalone multi-watercolor-quickstart.md is now a first-class section in the docs at Multi-Watercolor quickstart, sitting between Agents and Concepts. Top-to-bottom walkthrough for building the four-canvas portfolio pattern: HTML bones, responsive flex layout, the touch-action: none requirement for touch painting, hero booting with pointer: true, randomized animation+background on load, sketch-mode thumbnails with penStroke easing and per-path pauses, the click-to-obliterate handler (with the toGrid bug from v0.1–v0.2 documented as a teaching moment), keyboard-shortcut-only UI, perf overlay polling, and five "going further" extension ideas. Same content as the standalone markdown; sidebar accordion exposes ten jump links so readers can hop into any subsection directly. The scroll spy + active-link logic from v0.30 work as expected with the new section — no JS changes needed.

v0.35 — Transparent pigment cursors + Multi-Watercolor demo

  • feature Pigment cursor previews are now translucent. Rose / yellow / blue cursors had opaque hex fills (#c95e84, #e8c44a, #5089b0) — they occluded the canvas underneath while painting, which made it hard to see what you'd just put down. Now they match the alpha treatment of the water and paper brushes: same hue, 0.35 alpha, so the color hint is still legible but the painting in progress stays visible.
  • feature Multi-Watercolor v0.1 — a separate multi-instance demo. Shipped as multi-watercolor-v0.1.html. Four independent Watercolor instances on one page: a full-viewport hero canvas running a partlyCloudy animation, plus three thumbnail cards over it (each traced from a separate SVG via sketchMode() with a 1500ms penStroke trace and 200ms inter-path pauses). Click a card to obliterate its sketch (500ms water rinse centered at the click point) — for now it logs to console, but the pattern is wired up for "click → obliterate → navigate" portfolio transitions. Desktop layout is a 3×1 vertical column of thumbnails; mobile (≤ 760px) flips to 1×3 horizontal row anchored near the bottom. Each instance owns its own RAF loop and state arrays; the thumbnails go idle after their initial trace so total CPU is dominated by the hero animation.

v0.34 — Cursor preview API + fade-erases-canvas

  • fix Cursor preview was invisible on desktop. The .cursor-preview element had display: none as its default CSS — but opacity transitions don't work from display: none (the browser short-circuits before computing opacity). So the cursor was never visible regardless of mobile-mode state. The wiring's pointer-move handler was setting opacity = '1' on a hidden element, which did nothing. Fix: when cursor preview is enabled, the wiring now sets display: 'block', allowing opacity to transition. Initial opacity stays 0 until first pointer movement so the element doesn't flash on page load.
  • feature Cursor preview is now a library option. The lib owns the canonical "is the cursor preview on?" boolean. Set via { cursorPreview: true|false } in create-options, read/write via inst.cursorPreview() / inst.cursorPreview(v). Default: ON on desktop, OFF on touch devices (auto-detected via matchMedia('(pointer: coarse)') same as mobile-mode auto-detection). Flipping the value at runtime dispatches a 'cursorpreviewchange' CustomEvent on targetEl with { detail: { enabled } }; the wiring listens for this to enable/disable its cursor element.
  • feature Paper brush gets its own cursor styling. When the paper brush (pigment index -5) is active, the cursor preview renders as a hollow ring with paper-tinted fill, distinguishing it from the colored pigments and the dashed water/lift/mask cursors.
  • fix Fade-painting could never fully erase the canvas. Pure multiplicative decay (d[k][i] *= fadeFactor where fadeFactor < 1) asymptotes to zero but never reaches it — a faded canvas held a faint residual forever, and the fade-on-dry render forced from v0.33 kept running indefinitely. Fix: after each multiplicative decay step, hard-cut any channel below FADE_SKIP_BELOW = 0.001 to exactly zero. With the default 4-second half-life, a saturated cell (d ≈ 0.5) reaches the floor in about 9 half-lives (~36 seconds) and snaps to zero. Total fade completion is bounded; the canvas eventually returns to clean paper. The unused FADE_SKIP_BELOW constant has been in the source since v0.18 waiting for exactly this; finally wired up.
  • Refactor: the wiring's cursor-preview block now has two flags — _cursorVisible (current opacity state) and _cursorEnabled (allowed to render at all). Previously conflated, which is part of why the display:none bug went unnoticed. The mobile-mode UI handler now routes through wc.cursorPreview(!on) instead of touching the element directly, so the lib's flag and the wiring's element stay in sync no matter which side of the API the flip comes from.

v0.33 — Fade-on-dry fix + UX polish

  • fix Fade painting stopped working on a fully dry canvas. The fade pass runs every frame regardless of sim state — but rendering does not. When the canvas was completely dry, shouldRunSim() returned false, the per-frame render() was skipped, and the visible canvas stopped updating even though fadeStep() kept decaying the d[] arrays underneath. Result: the painting looked frozen. Fix: when fade ran AND the sim was idle this frame, force a full re-render and expand the active rect so the entire canvas redraws. Routes through the WebGL path when enabled, CPU otherwise. Costs one render per fade tick (10 Hz) instead of zero, but that's the cost of actually seeing the fade.
  • fix Docs navigation could render behind main-column content. The sticky .docs-nav had no z-index and no background — on desktop, wide code blocks or elements creating their own stacking context in the main column could end up rendering over the nav. Mobile already had a panel surface from v0.30. Desktop now matches: z-index: 2 plus a --canvas-bg backing so anything sliding under gets occluded, plus a 12px right gutter so content can't kiss the nav backing.
  • feature Controls auto-hide on scene change. When you load an SVG with an animation (any non-instant trace), pick an Animation, pick a Visualization, or pick a Time of Day, the controls panel auto-hides so the animation is immediately visible without the panel competing for attention. Bring it back with the corner + button or the H keyboard shortcut. Instant SVG traces don't trigger the auto-hide — there's no animation to watch. Picking "off" from a dropdown also doesn't hide (you're stopping a thing, not starting one).
  • Refactor: the hidden UI flag is now lifted out of the toggle's closure into module-scope (_uiHidden) with hideUI(), showUI(), toggleUI() helpers so other handlers can control panel visibility programmatically. Same observable behavior for the toggle button and H shortcut.

v0.32 — Paper brush + paper obliterate mode

  • feature Paper brush. New 'paper' pigment (sentinel index -5), joining water / lift / rainbow / mask as a special brush. Painting with it clears deposited + suspended pigment in the brush footprint and adds wetness, so the cell renders as clean paper (whatever paperColor() returns). Visually equivalent to painting opaque paper-color paint over the sketch — but dynamically follows the current paper color, no separate "white" pigment to retune if you change paper color. Available via pigment('paper'), paintAt(..., 'paper', ...), traceSVG(svg, { pigment: 'paper' }), etc.
  • feature Obliterate 'paper' mode. Adds a third mode to obliterate() alongside water and pigment. Uses the new paper brush to wipe the sketch back to canvas color across five overlapping dabs. Reads as "erase" or "reveal page" — sketch disappears, paper remains. In the demo panel, Alt-click the Obliterate button to trigger this mode (Shift-click is pigment, plain click is water).
  • fix API pigment() setter inconsistency. The pigment(name) setter only accepted 'water' / 'lift' / 'rainbow' / 'mask' — not the regular colors like 'rose' / 'yellow' / 'blue', even though traceSVG's pigment option (resolved through _resolvePigmentOption) accepted all of them. Now consistent: pigment('rose') works, matching every other API surface that accepts a pigment name.
  • fix API paintAt didn't accept pigment names. Was integer-only. Now resolves strings the same way every other entry point does, so wc.paintAt(x, y, r, 'rose', 0.7) works.
  • Implementation: the paper brush lives in a single new branch in paintAt just after the LIFT_INDEX branch. Per-cell math: keep = max(0, 1 - strength * falloff²) for the clear amount, plus wet += falloff² * strength * 0.35 * waterLoadMult for the wash effect. Masked cells are frozen as usual — paper brush respects mask. The compositor doesn't need any changes because cleared pigment values render as paper through the existing Kubelka-Munk path.

v0.31 — SVG trace easing + panel controls

  • feature Easing curves for SVG trace animation. The trace step is now time-based when durationMs is set — it tracks elapsed wall-clock time, computes a target point index via an easing curve, and paints up to that point each frame. Five curves available: 'linear' (uniform), 'easeIn' (slow start), 'easeOut' (fast start, slows to finish — reads as confident), 'easeInOut' (slow at both ends — reads as deliberate), 'penStroke' (15% ramp-up + 70% steady middle + 15% ramp-down — mimics how a hand actually moves a pen). Default 'linear' in the API for backwards compatibility; the demo panel defaults to 'penStroke'.
  • feature Per-path stroke pauses. New perStrokePauseMs option adds a pause between SVG path elements during the trace. The library tracks path boundaries through the downsample step, then pauses for the specified milliseconds when one path completes before starting the next. Pairs with 'penStroke' easing to produce the rhythm of an actual drawing: smooth strokes punctuated by pen-lift moments. Default 0 (continuous trace).
  • feature SVG controls in the panel. The control panel now exposes the trace settings as live controls: a duration slider (0..8000ms, where 0 = instant), an easing dropdown (the five curves above), and a per-path-pause slider (0..800ms). Settings are read at trace time, so adjustments take effect for the next Load.
  • Implementation notes: the trace step has two code paths now — the original "N points per frame" mode when neither durationMs nor instant is set, and a new time-based path when durationMs is set. Time-based mode reads performance.now() at the start of each frame, subtracts accumulated pause-debt from per-path pauses, applies the easing curve to get a target point index, then paints from the current index forward. Caps per-frame work at max(SVG_POINTS_PER_FRAME, ceil(total / 20)) to keep frame times bounded even after a long pause-then-resume.

v0.30 — Sketch mode + obliterate

  • feature Sketch mode. New sketchMode(opts?) method bundles the settings needed for crisp fine-line drawings — like a fine-tipped felt pen on dry paper. Internally applies brushSize(7), paintLoad(1.6), waterLoad(0.25), pressure(0.95), paperWetness('boneDry'), continuousFlow(false), edgeDarkening(false), fadePainting(false). All defaults overridable via the opts argument. Pure setter — chains: wc.sketchMode().traceSVG(svgText).
  • feature SVG trace speed control. Two new traceSVG options. instant: true paints every point synchronously before the call returns — the trace finishes inside the same frame, no animation visible. For thumbnails on page load this is the right default. durationMs: <number> spreads the trace over that many milliseconds at 60 fps by dividing total points across the available frames. Calling with neither preserves the original ~10-points-per-frame behavior. instant takes precedence over durationMs if both are set.
  • feature Obliterate. New obliterate(opts?) method that destroys the current drawing via a wet wash. Two modes: 'water' (splash-style rinse — lifts deposited pigment back into suspension, swirls it outward) and 'pigment' (opaque wash of a chosen color covers the drawing). Returns a Promise that resolves after durationMs (default 500). Intended for thumbnail → click → transition UI patterns where you want to visually "destroy" a sketch before navigating away. Accepts x/y grid coords to center the wash where the user clicked.
  • feature Obliterate button in the controls panel. Sits next to Fine spritz in the action-button row. Click = water mode (default). Shift-click = pigment mode (opaque wash of the currently-selected pigment).
  • feature Intensity scale. The obliterate function maps an intensity: 'gentle' | 'normal' | 'extreme' option onto the existing splash presets (fineSpritz, default, bigSplash) and onto deposit strength multipliers in pigment mode. Same physics under the hood — just a named lever for the common cases.
  • feature Thumbnail recipe. Added a worked example in the Agents docs showing the full project-thumbnail pattern: mount 4 independent instances via the factory, apply sketchMode(), trace an SVG into each with { instant: true }, attach a click handler that calls await obliterate({ mode: 'water', durationMs: 500, x, y }) at the click position, then navigate. Each instance owns its own RAF loop and state arrays — no coordination needed between them. A fifth instance can simultaneously run a hero animation via setAnimation() with no interference.
  • Implementation notes: sketchMode just calls the underlying setters in sequence — no new physics, no separate mode flag, no state to restore. obliterate wraps splash() for water mode and a sequence of large paintAt calls plus a leading splash for pigment mode, then returns setTimeout-based Promise. Concurrency-safe across multiple instances and within a single instance (overlapping calls interleave correctly through the lib state). SVG speed control stores pointsPerFrame on the trace state object so each trace honors its own pace.
  • fix Docs navigation accordion was broken — click handlers + scroll spy + active-link logic were lost in the v0.27 extraction. Restored from v0.26.2: clicking section headers toggles their child lists; scrolling between docs sections auto-expands the visible one; the heading nearest the activation line gets the .active highlight in the sidebar.
  • fix Docs navigation was transparent and unbounded on mobile. Below 760px, the nav was position: static with no background — it became a full-height block of links at the top of the docs section that users had to scroll through before reaching content. Now: capped at 40vh, visible panel surface with rounded border and backdrop-blur, internally scrollable. Reads as a distinct widget alongside the controls panel and perf overlay.

v0.29 — Brush dynamics + SVG fixes

  • fix SVG tracing painted upside-down. Many SVG sources — CAD exports, plot-style diagrams, scientific figures, and files originally drawn in Y-up Cartesian convention — produce inverted output through the standard SVG Y-down spec when traced. Added a flipY option to traceSVG() defaulting to true: y = offsetY + (vbH - (pt.y - vbY)) × s. Opt out with traceSVG(svg, { flipY: false }) if your SVG is in standard Y-down convention.
  • fix SVG trace used hardcoded constants. The pre-v0.29 implementation traced with SVG_TRACE_BRUSH_PX = 9 and SVG_TRACE_STRENGTH = 0.45 regardless of what brush you had selected in the panel. Now reads currentPigment, brushSize, and pressure() from live state at trace time, so the same SVG paints differently depending on your active brush settings — same way the mouse brush does.
  • feature Continuous flow. When you press and hold a pointer in place, pigment and water now keep flowing into the wet area each frame — like a marker or wet brush pressed against paper. Before v0.29, holding still after the initial dab produced nothing further. Works for all brush types: pigments add color + water, water adds water + lifts, lift draws pigment back into suspension continuously, rainbow drips through its hue cycle, mask paints freezing zones the longer you hold. Per-frame deposit strength is CONTINUOUS_FLOW_STRENGTH = 0.12 — about weaker than a single stamp.
  • feature Brush dynamics exposed as API. Three previously-hardcoded constants in the pointer-event handlers and SVG tracer are now first-class settings:
    • pressure(v) — base stamp strength, range 0..1, default 0.7. Was hardcoded as the 0.7 in every paintAt(..., 0.7) call. Lower values produce lighter strokes; higher saturates faster.
    • flow(v) — stamp spacing along a drag, as a fraction of brush radius. Range 0.05..2.0, default 0.4. Was hardcoded as r × 0.4. Smaller = more stamps = smoother continuous strokes (and more sim work). Larger = fewer stamps = dotted/dabbed look.
    • usePointerPressure(v) — when true, multiplies stamp strength by PointerEvent.pressure. Useful for stylus + tablet users. Default false because mouse browsers commonly report a neutral 0.5 that would create unwanted variability. Captured at pointer-down and held for the rest of the stroke for consistency.
    • continuousFlow(v) — getter-setter for the continuous-flow toggle. Default true. Accepts { continuousFlow: false } in create options to start off.
  • feature SVG trace accepts per-call overrides. The full signature is now traceSVG(svgText, opts?) where opts is { pigment, brushSize, strength, flipY }. Each option falls through to live state if not specified, so traceSVG(svg) still uses the active brush. Pigment accepts a name string ('rose', 'yellow', 'blue', 'cerulean', 'rainbow', 'water', 'lift', 'mask') or numeric index.
  • feature Agents & automation docs. New section in the docs aimed at coding agents (Cursor, Claude Code, ChatGPT, Antigravity, etc.) and automation harnesses. Covers: a structured one-page spec, TypeScript-style signatures for every public method, six recipes for common tasks, a "gotchas & invariants" reference, three patterns for autonomous canvas driving (sequence, control loop, eval harness), and template llms.txt + AGENTS.md snippets for embedding apps. New option { pointer: false } on create() was already supported but is now documented as the canonical way to make a pointer-free instance for test harnesses.
  • Implementation: introduced _resolvePigmentOption() shared between traceSVG, paintText, and any future API surface that accepts named pigments. _strokeStrength captured at pointer-down so a stroke has consistent feel even when usePointerPressure is on and individual move events report varying e.pressure values.

v0.28 — Mobile mode + palm rejection

  • feature Mobile mode. New toggle in the toggles row plus a library option { mobile: true } on Watercolor.create(). Auto-enabled on touch-primary devices via matchMedia('(pointer: coarse)') detection at create time; auto-detection result is exposed at inst.mobileDetected() while the live state is inst.mobile(). The toggle is independent of detection, so users on desktop with a touchscreen can opt in, and users on a tablet can opt out.
  • feature Long touch strokes. Already worked at the pointer-event level (the per-frame applyPendingPaint() interpolates from last-known to current with stamp spacing of r × 0.4, producing continuous strokes for any drag speed including fast finger swipes). v0.28 ensures touch events get through reliably by handling palm rejection upstream.
  • feature Palm rejection (three heuristics, combined). Active only when mobile mode is on; mouse and pen-only flows are untouched.
    1. Contact size. PointerEvent.width and height give the contact area in CSS pixels. Touches wider than PALM_CONTACT_PX = 40 in either dimension are dropped. (Many browsers report 1×1 for touches with no real area information; those bypass this filter to avoid false rejections.)
    2. Pen lockout. Once _activePointerType === 'pen' latches, any concurrent pointerType === 'touch' events are dropped. This is the standard iPad-with-Apple-Pencil pattern: the palm rests on the screen while the stylus draws.
    3. First-touch-wins. When painting starts on pointerId X, the lib remembers X and ignores additional pointer events with different IDs until X ends. Filters multi-touch palm landings on phones, where the palm lands a fraction of a second after the finger.
  • feature Mobile-aware UI. When mobile mode is on, the floating cursor preview is hidden (you can see your finger; the preview was designed for mouse hover) and the body picks up a wc-mobile class so any host CSS can react. On auto-detection, the control panel comes up collapsed to give the canvas more room — tap the toggle in the corner to expand it.
  • API additions: inst.mobile() / inst.mobile(true|false) getter-setter, inst.mobileDetected() for the one-time detection snapshot.

v0.27 — Library extraction + UI on top

  • feature The simulation core is now a standalone JavaScript library, watercolor-lib.js. This page (v0.27) is the same HTML/CSS as v0.26.2, but the inline ~5,800-line <script> block is replaced by <script src="watercolor-lib.js"></script> + a ~390-line wiring script that hooks each UI element to a method on the library instance. State isolation is via JavaScript closure semantics — every variable inside the factory's create() call is unique per instance.
  • feature The library can also be used to mount multiple independent watercolor instances on one page: Watercolor.create(targetEl) returns a fresh handle whose state is fully isolated from other instances. See watercolor-multi-example.html and watercolor-simple-example.html alongside the library.
  • feature Auto canvas-scale: brushes from animations and visualizations stay visually proportional on small canvases. A 320px-wide embedded canvas auto-downscales brush radii by ~0.27× relative to the standalone tuning. Override per-instance via { canvasScale: 1.0 } in the create options.
  • feature WebGL render path on by default when available (was opt-in via toggle in v0.20–v0.26.2). Falls back silently to CPU on browsers without WebGL2.
  • feature New Perf toggle in the toggles row. Same overlay as v0.19 (FPS, p50/p95/p99 frame times, per-phase ms, active cells, heap delta, Long Animation Frames). Library exposes perfMetrics() as a snapshot method; UI polls it every 150ms.
  • fix Brush sizes in animations + splash were tuned for a ~1200px-wide canvas and looked disproportionate on smaller hosts. Fixed by the auto canvas-scale above.
  • fix The pigment swatches in v0.27's first build were flat CSS buttons; v0.26.2 used real KM-compositor-rendered <canvas> swatches (paper texture, edge fade, granulation). Extracted the original buildPigmentSwatches() as inst.buildPigmentSwatches(rootEl); the wiring calls it and the swatches now look identical to v0.26.2.
  • fix Major perf bug in the first library draft: only framesSincePaint incremented per frame, not framesSinceIdleCheck or framesSinceFade. The idle-skip gate in shouldRunSim() was permanently stuck at 0 < 30, so the sim never skipped frames even on settled canvases. Lib now matches v0.26.2's three-counter loop exactly.
  • fix Main loop referenced FADE_INTERVAL_FRAMES and applyPigmentFade — neither exists. Real names are FADE_TICK_INTERVAL and fadeStep. Without this, fade silently threw every frame when fade was on.
  • fix Cursor preview TDZ error: wireSlider fires an initial input event to sync the readout, which called updateCursorPreview(), which referenced cursorEl — declared later in the script. Moved cursor preview setup ahead of the swatches and sliders.

v0.26 — Multi-instance bridge (deprecated)

  • feature First attempt at multi-instance support via a postMessage bridge: each iframe loaded a standalone Watercolor and the parent page called methods over the postMessage protocol. Worked in principle, fragile in practice — file:// origin restrictions varied between Chrome / Firefox / Safari and the bridge often hung silently.
  • feature v0.26.1 added belt-and-suspenders ready signaling: wc:ready announced on script eval, again on next tick, and on receipt of a parent ping. Reply targetOrigin set to '*' instead of echoing e.origin.
  • feature v0.26.2 added a body.wc-embedded CSS class auto-applied when running inside an iframe. Collapses the inner control panel, dryness bar, ui-toggle, and docs section — the parent owns the UI.
  • Superseded by v0.27's library approach. The bridge architecture survives only in watercolor-v0.26.2.html if you specifically need iframe isolation.

v0.25 — Splash presets + explicit coordinates

  • feature Splash now has two presets exposed as separate buttons: Big splash (large radii, strong velocity, whole-canvas chaos) and Fine spritz (small radii, gentle velocity, many epicenters — like flicking water from a brush).
  • feature Watercolor.splash() now accepts a preset name and/or an array of explicit epicenter coordinates. Each coordinate can include per-point radius, velocity, pressure, lift overrides for choreographed splashes.
  • Watercolor.splashPresets() lists the available presets.

v0.24 — Splash

  • feature New Splash button next to Re-wet. Dramatic re-wet that creates real pigment dispersal: saturates the canvas, lifts 80% of deposited pigment back into suspension, and injects outward-radial velocity + pressure spike from 3–7 epicenters.
  • Epicenters are biased toward high-pigment cells via rejection sampling, so the dispersal happens where there's actually pigment to disperse.
  • Sim's advection + diffusion carries the dispersal forward over ~60–120 frames (1–2 seconds at 60fps). One-shot action, not a mode.
  • Exposed on the API as Watercolor.splash(). Chainable.

v0.23 — Changelog

  • feature This page. Reverse-chronological release history with feature/fix/perf tags.

v0.22 — Brush load

v0.22.2 — Hoisted brush gains perf

  • Moved 0.45 * waterLoadMult and 0.18 * waterLoadMult computations out of the pigment + rainbow brush inner loops. Now computed once per paintAt call instead of per cell.
  • Same code pattern as the water brush in v0.22. Math bit-identical; pressure scaling at waterLoad=2 verified exactly 2× on both pigment and rainbow.

v0.22.1 — Water load combines with pigment brushes feature

  • Water load now also scales the wet + pressure contribution of pigment and rainbow brushes — not just the water brush.
  • Paint load and Water load combine on pigment brushes for a physical "loading the brush" model: saturated dry brush, sparse wet brush, dramatic wet wash, etc.
  • Updated tooltip and docs to explain the combined behavior.

v0.22 — Paint load + Water load sliders feature

  • New Paint load slider (0.1×–3×, default 1×): scales the per-dab pigment strength of pigment + rainbow brushes.
  • New Water load slider (0.2×–3×, default 1×): scales the per-dab water + pressure + lift of the water brush.
  • Both exposed on the API as Watercolor.paintLoad() / Watercolor.waterLoad().

v0.21 — Documentation

  • feature Full documentation section below the canvas. Sticky sidebar navigation with collapsible accordion sections, code examples, conceptual explanations, complete API reference.
  • feature Scroll-spy auto-expands the current section as you read; active heading gets a visual marker in the nav.
  • fix Sticky sidebar wasn't sticking — overflow-x: hidden on the body was making it the scroll container. Switched to overflow-x: clip on the .docs wrapper instead.
  • fix Removed the on-load console.log hint that printed the API teaser. The console stays clean now.

v0.20 — WebGL render path

  • feature Optional GPU render via WebGL2 fragment shader. Per-pixel Kubelka-Munk + paper compositing moves off the CPU.
  • feature WebGL render toggle in the controls row. Falls back silently to CPU if WebGL2 is unavailable.
  • feature Debug tint mode (Watercolor.webglDebugTint(true)): blends a faint green over the GPU output so you can verify the GPU path is actually running.
  • fix Y-axis was flipped on first ship — WebGL textures have Y=0 at the bottom while 2D canvas has Y=0 at the top. One-line fix in the shader: sample at (vUv.x, 1.0 - vUv.y).
  • GLSL kmReflect verified bit-identical to JS implementation (max diff 3.3e-16 across 792 sweep points).

v0.19 — Perf overlay + paper wetness

v0.19.1 — Mask-rect optimization perf

  • Tracking bounding box of masked cells eliminates the per-cell mask[i] load outside the mask region.
  • Small mask (625 cells in a 462,681-cell active rect) now adds −1.1% overhead to movePigment — within measurement noise. Previously would have added 80–100%.

v0.19 — Performance overlay feature

  • Optional perf overlay (?perf=1 or Watercolor.perf(true)). Shows FPS, p50/p95/p99 frame times, per-phase ms breakdown, active cells, heap delta, Long Animation Frames count.
  • Paper-wetness preset dropdown: Flooded / Wet-on-wet / Damp / Dry-brush / Bone-dry. Replaces what was briefly an evaporation slider.
  • fix Rectangle artifact when fade painting was on — fade tick mutated d[] across the whole canvas but only the active rect was being re-rendered. Added fadeFullRender flag (default on) that forces a full render on fade ticks.

v0.18 — Programmatic API

  • feature window.Watercolor namespace. Everything in the UI — cursors, animations, visualizations, backgrounds, text, SVG, toggles, actions — reachable from the console or external scripts.
  • Cursors are pure data objects with .move(), .set(), .stamp(), .remove(). Caller drives motion; cursors never auto-paint.
  • SVG tracing accepts markup, URL, or File. Text painting accepts pigment options.
  • Coordinate helpers: Watercolor.grid, Watercolor.toGrid(), Watercolor.toDisplay().

Earlier versions

v0.17 — SVG tracing

Load an SVG file; the parser walks path, line, polyline, polygon, rect, circle, and ellipse, samples each via SVGPathElement.getPointAtLength, paints 10 points per frame.

v0.16 — Lissajous, Flow Field, Pulse + Text painting

Three new visualizations and a text-painting feature. Text renders to an offscreen canvas, then each ink pixel becomes a paintAt call — uses whatever brush is currently selected.

v0.15 — Visualizations dropdown + Auto-dry

Kaleidoscope visualization with six-fold rotational symmetry. Auto-dry background toggle settles the canvas after a wash to reclaim CPU.

v0.14 — Tornado animation + modular backgrounds

Funnel-shaped 14-cursor orbital animation with a meandering vertical axis. Modular Background system: Day, Dawn, Sunset, Night, Tornado.

v0.13 — Masking fluid

Amber-yellow brush that paints a translucent reservation layer over the paper. Cells under the mask freeze pigment in place. v0.13.1 made the mask diffused/translucent so you can see through to the painting beneath.

v0.12 — Weather presets + Time of Day washes

Rainy, Sunny, Windy, Thunderstorm, Partly cloudy, Snowing, and Snowing (additive) animation modes. Time-of-day washes paint pigment dabs across the canvas.

v0.11 — Animation presets dropdown

First autonomous painters: the AI painter strategy weights, plus rainy.

v0.10 — Active rect optimization perf

Bounding box of cells with active dynamics. Sim phases iterate only inside this rect — 29× speedup on lightly-painted canvases. PNG export added.

v0.9 — SCALE knob

Resolution slider exposes the internal simulation grid size. 1× for fast performance; 6× for high-detail bleeds at the cost of CPU.

v0.8 — Wet diffusion + Rainbow brush

Capillary wet diffusion via Laplacian (water spreads through paper fibers). Rainbow brush cycles through rose/yellow/blue over time, splits its strength weighted across all three pigment channels.

v0.7 — Performance pass

Idle detection (skips sim when nothing is happening), fused sim passes, the fade subsystem.

v0.6 — UI shell

Floating control panel, dark theme, transparent toggle, paper-color picker, dryness/pigment bars.

v0.1–v0.5 — Initial simulation

Three pigments (Quinacridone Rose, Hansa Yellow, Cerulean Blue) with K/S triplets from Curtis et al. Figure 5. Full Kubelka-Munk compositing, capillary bleeding, edge darkening, granulation via paper-height noise.