Fable clean up

This commit is contained in:
Andras Schmelczer 2026-06-11 21:35:45 +01:00
parent 3441a7e4af
commit 4ce8a4f41d
46 changed files with 642 additions and 911 deletions

View file

@ -1,6 +1,6 @@
---
title: A WebGPU Drawing Garden Where Agents Rewrite Your Strokes
description: A single-file WebGPU drawing toy. You stroke a colour, agents follow it, and a 3×3 matrix per vibe gives each preset its personality.
description: A single-file WebGPU drawing toy. You stroke a colour, a million agents follow it, and a 3×3 matrix of -1s, 0s, and 1s gives each vibe its personality.
date: 2026-05-22
period: '2026'
thumbnail:
@ -16,7 +16,7 @@ article:
tags: ['graphics', 'simulation', 'web']
role: Graphics and shader author
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
scale: One HTML file, ~10 WGSL shaders, 6 vibe presets, 60 FPS target on consumer hardware
scale: One HTML file, 10 WGSL shaders, 6 vibe presets, up to 1.5M agents, 60 FPS target on consumer hardware
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
audience: technical
media:
@ -26,51 +26,53 @@ article:
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
project:
title: Fleeting Garden
description: A single-file WebGPU drawing toy. Your strokes seed a swarm; nine numbers per vibe give each preset its personality.
description: A single-file WebGPU drawing toy. Your strokes seed a swarm of up to a million agents; nine numbers per vibe give each preset its personality.
selected: true
---
Nine numbers in `{-1, 0, 1}` arranged in a 3×3 matrix decide an entire vibe's personality. That constraint is what kept me up: proving simplicity can be expressive, that you don't need a behaviour function per preset. A WebGPU drawing toy where you stroke a colour, agents spawn along it, and the garden slowly overwrites the patch you laid down. One static HTML file, six compute stages, none of them skippable.
Fleeting Garden is a WebGPU drawing toy: you stroke a colour onto the canvas, agents spawn along the stroke, and over the next minute the garden slowly overwrites the patch you laid down. The question that kept me at it was smaller and stranger than the graphics: whether nine numbers, each of them -1, 0, or 1, could be an entire personality.
## Why physarum needed a knob
Physarum-style agent sims are everywhere and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough; the input has to keep being a force inside the loop, otherwise you're just watching the attractor settle.
Physarum-style agent simulations are everywhere, and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough; the user's input has to stay a live force inside the loop, or you're just watching an attractor settle.
My second self-imposed constraint was that one engine had to produce six visibly different presets without forking. The first prototype had a `switch (preset)` with one behaviour function per vibe and it was already painful at vibe two. I needed the personality to live in data, not code.
I also wanted one engine to produce six visibly different moods without forking. The first prototype had a `switch (preset)` with a behaviour function per vibe, and it was already painful by vibe two. The personality had to move out of the code and into data.
## The reaction matrix
## Nine numbers
Each vibe is a 3×3 table of colour-to-colour affinities. When an agent of colour `i` looks at the trail in front of it, it weights the three channels of that sample by row `i` of the matrix, then uses the sign to pick left, right, or straight. That's it. The whole behaviour rule.
Each vibe is a 3×3 table of colour-to-colour affinities, every entry -1, 0, or 1. When an agent of colour `i` samples the trail ahead of it, it weights the three channels by row `i` of the matrix and uses the sign to pick left, right, or straight. That's the entire behaviour rule, and it turns out to be enough:
Three examples of what nine numbers can do:
- **Aurora Mycelium** is cyclic: each colour chases the next, and agents wind into ribbons.
- **Velvet Observatory** has every off-diagonal entry negative, so the colours repel into separate islands.
- **Paper Lantern Fog** is all ones; the colours collapse into one slow cooperative blob.
- **Aurora Mycelium:** cyclic, each colour chases the next. Agents wind into ribbons.
- **Velvet Observatory:** every off-diagonal entry negative. Colours repel into separate islands.
- **Paper Lantern Fog:** matrix filled with ones. Colours collapse into one cooperative blob.
The other three (Lichen Signal, Tidepool Lantern, Chrome Pollen) sit between those poles, separated as much by their tuning as by their matrices: Chrome Pollen's agents look 14 pixels ahead and spawn densely, while Paper Lantern Fog's look 66 pixels ahead and drift at a fifth of the speed. Adding a tenth number would tax every existing vibe; tuning the nine I have is a text edit. Six presets in, I haven't needed to extend it.
Adding a tenth number to the matrix would tax every existing vibe. Tuning the nine I have is a text edit. Six presets in, I haven't extended it.
## Each vibe also has a key signature
## The compute work, broken into small jobs
The part the screenshots can't show: the garden plays piano. Strokes trigger sampled notes (velocity-layered recordings, loaded six at a time so the page doesn't stall), and every vibe carries its own scale and four-chord progression. Aurora Mycelium ambles through a sus2majorminorsus4 progression in Lydian at 60 BPM, others get Dorian or Mixolydian or natural minor. It's the same trick as the matrix in a different domain: the music engine is one piece of code, and each vibe is just data that flavours it.
Six stages, ten WGSL files, each one short enough that I can hold it in my head when something breaks:
## The compute work, in small jobs
1. **Agent step:** sample the trail at a sensor offset, pick a turn, move, deposit colour. ~300 lines, the longest one.
2. **Diffusion:** blur and decay so old marks soften. The boring one, and the one you can't skip: without it, strokes stay forever and the garden collapses into noise.
3. **Brush:** write user strokes into both the trail texture and a separate "source" texture the agents can read.
4. **Eraser:** two variants: one clears a region of the trail, the other kills agents in a radius.
5. **Agent generation:** spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't waste GPU time.
6. **Render:** read the trail, apply palette and grain.
Six stages across ten WGSL files, each short enough to hold in my head when something breaks:
The bind-group setup overhead from running more pipelines was lost in the noise next to the simulation cost. The win was that when the eraser shader started killing the wrong agents, I opened one file and reasoned about it without touching anything else.
1. **Agent step.** Sample the trail at the sensor offsets, pick a turn, move, deposit colour. At ~300 lines, the longest.
2. **Diffusion.** Blur and decay so old marks soften. The boring stage, and the one you can't skip: without it, strokes never fade and the garden hardens into noise.
3. **Brush.** Write user strokes into the trail texture and a separate source texture the agents can read.
4. **Eraser.** Two variants, one clearing a region of trail, the other killing agents in a radius.
5. **Agent generation.** Spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't burn GPU time.
6. **Render.** Read the trail, apply palette and grain.
Splitting it this finely costs some bind-group setup, which disappears in the noise next to the simulation itself. What I got back: when the eraser started killing the wrong agents, the bug had exactly one file to hide in.
## Smaller calls
- **Adaptive cap, circular buffer.** If FPS drops, the cap shrinks; if there's headroom, it grows. When the cap is hit, new agents overwrite older ones. The decay you see, a stroke vanishing thirty seconds after you drew it, isn't an explicit eraser, it's the buffer wrapping around.
- **URL is the share format.** The chosen vibe is in the query string. The "send your friend this preset" link is just a URL with `?vibe=tidepool-lantern` on it. The parser is tolerant about accents and casing because people retype these.
- **One HTML file.** All CSS and JS inline. The piano samples sit beside it. Self-contained enough to email or drop on a USB stick.
- **An adaptive cap over a circular buffer.** The population starts allowed up to a million agents; if the frame rate dips, the cap shrinks (never below 50,000), and when the cap is hit, new agents overwrite the oldest. The melancholy effect, your stroke dissolving thirty seconds after you drew it, isn't an explicit eraser. It's the buffer wrapping around.
- **The URL is the share format.** "Send your friend this preset" is a link with `?vibe=tidepool-lantern` on it, and the parser forgives accents and casing because people retype these by hand.
- **One HTML file.** All CSS and JS inline, the piano samples beside it. Self-contained enough to email.
## What I'd change
- The intro animation (agents fly in to spell the title, then transition to steady state) couples three shaders through a single `progress: 0 → 1` value. It's the bit I'd least want to refactor today. Next time I'd model the intro as its own dispatch with its own buffer and hand off cleanly.
- Mobile works, but the toolbar fights the canvas for screen and the agent cap has to shrink hard to keep frame time down. A proper fix means rethinking the toolbar and exposing the cap-vs-resolution tradeoff to the user.
- The simulation has invariants that proptest would falsify in minutes: agent count under the cap, every stroke produces a positive-coloured deposit on the next frame, and the eraser doesn't leak agents past its radius. Snapshot tests aren't the right tool here.
- The intro (180,000 agents fly in from the rim, spell out "Fleeting" over four seconds, then disperse into the steady state) couples three shaders through a single `progress` value, and it's the code I'd least like to touch today. There's a workable apology in the agent shader (a second entry point, `mainSteady()`, that drops the intro's dead branches once it's over), but the honest fix is giving the intro its own dispatch and buffer and a clean handoff.
- Mobile works, but the toolbar fights the canvas for space and the agent cap has to shrink hard to keep frame time down. A real fix means rethinking the toolbar and letting the user trade resolution against population themselves.
- The simulation has invariants a property-based tester would falsify in minutes: population stays under the cap, every stroke deposits colour on the next frame, the eraser never leaks agents past its radius. Snapshot tests aren't the right tool here, and I haven't written the right one yet.