---
title: A WebGPU Drawing Garden Where Agents Rewrite Your Strokes
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:
  src: ./_assets/fleeting-garden.jpg
  alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
links:
  - label: Demo
    url: /fleeting/
  - label: Source
    url: https://home.schmelczer.dev/git/andras/webgpu
article:
  iframeThumbnail: true
  tags: ['graphics', 'simulation', 'web']
  stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
  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
  media:
    - type: image
      src: ./_assets/fleeting-garden.jpg
      alt: Close-up of intertwining cyan, violet, and yellow agent trails radiating into a kaleidoscopic central knot.
      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
  selected: true
---

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

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.

## Nine numbers

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:

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

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.

## Each vibe also has a key signature

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 sus2–major–minor–sus4 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.

## The compute work, in small jobs

Six stages across ten WGSL files, each short enough to hold in my head when something breaks:

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

- **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 (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.
