Ink Diffusion Letterforms
A raw WebGL2 fluid field holds the word INK as a mask. Pointer strokes inject dye and momentum inside the letterforms; arrow keys drive a labeled injection cursor and holding Space feeds it; advection spreads the dye, curl turbulence disturbs it, and decay lets it settle back toward paper. The same dye can be read as chromatic ink, soft smoke, or a scanlined CRT trace.
How this is built
- Stable-fluids core
- Velocity and dye live in ping-ponged half-float textures. Each step semi-Lagrangian advects velocity, adds a curl-derived turbulence force, computes divergence, solves20 Jacobi pressure iterations, subtracts the pressure gradient, then advects and decays dye. Simulation resolution is capped at 512 on its long edge and remains independent of the display’s device-pixel ratio.
- Letterform mask
- The word INK is rasterized on an offscreen 2D canvas through the lab’s one canvas-font loader — the already-shipped Roboto Flex subset, at weight 950 and semi-expanded width — then uploaded as a luminance texture. The upload is one tightly packed byte per texel and its first row is the top of the raster, so both unpack modes are set explicitly rather than left at defaults that reject a portrait upload outright and mirror the word where they do not. Dye injection and advection use a soft mask threshold, and the raster is regenerated when the viewport changes aspect. No new font bytes are sent.
- Render modes
- Ink, smoke, and scanline are three branches of one fragment shader selected by a mode uniform. Changing the radio only changes that uniform and redraws; it never clears or recreates the dye field. The selection is saved under
lab.ink-diffusion-letterforms.v1as a versioned envelope and restored on the next load; storage failures leave the default ink mode unchanged. The key this route shipped with,lab.ink-diffusion-letterforms.mode, is still read and deliberately never deleted, so reverting this change finds the mode where it left it. The reset beside the radios arms on the first press and acts on the second: it clears the dye and the momentum and writes the default mode back. - Reduced motion
- Under
prefers-reduced-motion: reducethe pointer is inert and no continuous loop starts. What is drawn is a composition, not a paused first frame: a 26×16 grid of deterministic deposits washes the whole field, the dye splat masks every one of them to the letterforms, and coverage ramps down the field so the strokes carry more ink at their feet than at their tops. 3 passes go down with 16 simulation steps of bleed between them, none of them presented, and only the final composition is drawn. Arrow keys may reposition the labeled cursor and Enter places one blot — carrying momentum as well as dye, so it drags a streak that reads even where the wash has already reached the clip — followed by a burst of 8 steps and one final draw. The field is parked again afterwards, so the still stays a still and a second press starts from it. Space is inert. A mode change may redraw that final state once. The preference is watched live. - Context and visibility
- A lost WebGL context is prevented from destroying the canvas, the loop stops, and a restored context fully rebuilds programs, framebuffers, textures, and the mask. Hidden tabs pause the loop; returning drops stale frame intervals before measurement resumes. Unmount deletes every simulation resource and listener.
- Measurement
- The HUD reads this experiment’s own animation loop: FPS is a rolling one-second average, and ms/frame is the p95 of inter-frame deltas over a rolling window of recent frames. KB transfer sums real navigation and resource
transferSizeentries, so a warm cache legitimately reports less than a cold load.