Signature
An animated SVG signature effect that draws out text as if hand-written.
Preview supplied by Arlan Marat; live previews were recorded from Arlan's Vault when no published video file was available. Preview: platform recorded · rights cleared.
Spray burst is an animated canvas study published directly in the Vault gallery.
VSource LandVaultWhy it stands out
Spray burst is an animated canvas study published directly in the Vault gallery.
Build this: a screenprint that keeps reprinting itself: a coloured plate on cream paper, an ink drawing on the plate, every edge a sprayed dither rather than a clean line, and the whole thing boiling at 24fps. SIX drawings, three seconds each, every one in its own three inks — a starburst touring its plate, a set of rings each broken by a turning gap, thick bars sliding across each other, a crowd of discs drifting together into blobs, a pinwheel of hard-edged wedges, and a halftone field of dots swelling under a travelling wave. THE WHOLE PIECE HANGS ON ONE RULE: the drawings change and THE PRINT DOES NOT. Every scene resolves to a signed distance, every distance goes through the same coverage-and-dither, and the paper stays the same warm off-white in all six so the stock reads as one sheet and only what is printed on it changes. Get that wrong and it is six cards; get it right and it is one press running six jobs. Change the paper per scene and the conceit collapses. EVERY PIXEL IS EXACTLY ONE OF THREE INKS. No anti-aliasing and no alpha blending anywhere. Build a signed distance for the plate and for the drawing, turn each into a coverage probability, and DITHER it against a per-pixel hash. That dither is the entire texture. The same shapes with smooth edges are a clean vector graphic that looks nothing like a print, because the sprayed fringe is most of what the eye is reading. Coverage against distance is a logistic (a cheap stand-in for a normal CDF, they agree to about 1% over the range that matters), PLUS a sparse far tail that carries isolated specks well past the edge. Drop that tail and you lose the overspray that sells it. ONE HASH FUNCTION, SEVEN INDEPENDENT STREAMS. The plate dither, the ink dither, the two boundary wobbles and the three interior grains each offset the seed. Share one stream between them and the plate and ink edges correlate, which puts visible seams exactly where the two cross. SEED THE HASH FROM THE FRAME INDEX, not from a clock. Every frame re-rolls the grain and the wobble and holds them steady within itself. That is the boil, and it is the reason the thing looks drawn rather than rendered. STEP THE PLAYBACK AT 24fps AND DO NOT INTERPOLATE. This is the opposite of the right answer for most looping canvases, where a held frame reads as jank; here no frame is ever held, all of them are different drawings, and the step IS the film character. Run the rAF loop at display rate and simply skip any frame where the 24fps index has not advanced, so the stepping costs LESS than a smooth version rather than more. LET THE PRINT'S INTENSITY VARY PER DRAWING, and only the print's intensity. One measured spray width across six drawings is wrong in both directions: a fringe that is right on a fat tapered spike eats most of a thin ring stroke, and is far too tight on a field of small dots where it is the only thing giving them weight. A real press behaves the same way, since the same screen and ink lay down differently depending on how much open area the stencil has. So give each drawing its own multiplier on the ink spray and on the interior grain, and BOUND IT around 0.7x to 1.5x. That range is a printer leaning on the squeegee; wider and the scenes stop reading as one press. Index those multipliers by DRAWING, not by playback position, or reordering the loop reshuffles which drawing gets which fringe. NOTHING DRIFTS LINEARLY. Straight-line motion at three seconds a scene is a screensaver. Every drawing moves on a sampled table with the same character: a long hold, a fast crossing, another long hold. Keep two of them at different severities — a hard one that jumps most of the distance in a fifth of its time, for drawings that should look re-registered by hand between two rests, and a softer one that leans in and out of the crossing, for orbits and waves where a hard step reads as a dropped frame rather than as a decision. Sample them CONTINUOUSLY with a fractional index: the drawing already steps at 24fps and stepping the motion too doubles the stutter. Keep them as TABLES, because the shape is one steep step bracketed by two flat runs and every analytic ease either softens the step or lifts the flats, which are the two things that matter. OFFSET THE ELEMENTS WITHIN THE BEAT. Six bars that all jump on the same frame is a cut, not a movement. Give each its own offset so the gesture ripples through the set. USE FIXED TABLES FOR EVERY ELEMENT'S BEHAVIOUR, never a per-element hash. This is the single most tempting mistake in a piece with this many moving parts: pick each ring's rate or each bar's speed procedurally and a dozen independent motions read as noise rather than as choreography. Five hand-picked rates that are not multiples of each other look designed; five random ones look broken. Make some of them negative so sets counter-rotate instead of turning as one body. CENTRE THE CONCENTRIC DRAWING. Pushing the rings off centre on the theory that a bullseye is dull is wrong on a wide plate: the plate is a strong rectangle and the rings are the only concentric thing in the piece, so any offset reads as the print being misregistered rather than as a composition. The other drawings carry their own asymmetry; this one is the still point and should look like it meant to be. The interest comes from the gaps turning at different rates, not from where the set sits. CUT THE GAPS WITH A max() AGAINST A WEDGE, NEVER BY CLIPPING THE ARC. A clipped arc has hard vector ends, and hard ends are the one thing that would announce a drawing as different machinery from the rest. Taken as a max against an angular wedge converted to an arc length, the two cut ends carry a real signed distance and spray exactly like every other edge. The same reasoning applies anywhere a shape needs bounding. CLIP THE HALFTONE TO THE PLATE, AND NOTHING ELSE. The other drawings are objects sitting on the plate, and letting their spikes and bars overrun onto the paper is what a screenprint does when the stencil is bigger than the plate. A halftone is not an object, it is a FILL, and a fill that runs to the edges of the frame swallows the plate and the paper margin with it: the plate disappears and the scene stops belonging to the set. Clip it with a max against the plate's own distance, inset slightly so it stops just short of the edge rather than dying exactly on it, which reads as a clipping bug. CAP THE DOT RADIUS BELOW HALF A CELL. That is what lets an infinite lattice be evaluated from a single mod() rather than a search across neighbouring cells, because no dot can ever reach into its neighbour's territory. MIN-COMBINE THE DISCS SO THEY FUSE. A min on signed distance is a union, and a union of overlapping circles is a compound blob you cannot get by drawing circles. That fusing is the entire scene; drawn as outlines it is a diagram. MAKE THE CORE OF THE STARBURST A STAR, NOT A DISC. Its radius should peak exactly at the ray bearings and dip to a constant trough midway between them, with a shoulder beside each ray rather than a sharp point. A circular core is the single most obvious tell that a starburst like this is synthetic. Taper the spikes just off straight, around (1-t)^1.05, so the sides are faintly concave; a plain linear taper is a touch too blunt. Join the ray bases with a band thick enough to bury them, or each flat base shows as a facet and the core reads as a cog instead of a blob. DO NOT CUT THE RAYS AT A POSE BOUNDARY. Sort each pose's bearings and match them index by index to the next pose's, then interpolate along the SHORTEST angular path so a ray crossing zero does not spin the long way round. Built at each pose's own station and then blended — not at the moving centre, which makes the fan swing as the object travels and reads as the drawing sliding around underneath itself. What should look like a cut is really two frames of very fast travel. CHANGE SCENES ON A HARD CUT, no crossfade. The drawing is already reprinted from scratch every frame, so a cut lands as the next print rather than as a glitch; it is the one transition the piece can make without inventing a mechanism it does not otherwise have. A crossfade needs two distance fields per pixel and spends its middle in a muddy half-state belonging to neither drawing. DERIVE THE REDUCED-MOTION STILL FROM THE PLAYBACK ORDER rather than hard-coding a frame number. Hard-coded, it silently becomes an arbitrary frame of the wrong drawing the moment the order changes. It should be the centred starburst mid-hold: a frozen mid-transit frame looks like the loop was stopped by accident. One WebGL1 program for all six drawings, dispatched on a uniform branch — the branch is the same for every fragment in a draw, so it costs nothing, where four separate programs mean four compiles at mount and four sets of uniform lookups. Pack every drawing's per-frame numbers into the SAME two general-purpose float arrays with per-scene meaning, documented at the top of the shader, rather than growing a uniform per drawing. Constant tables belong in the shader as folded lookup functions, not const arrays: GLSL ES 1.0 has no array initialisers and only guarantees constant-expression indexing, and uniform arrays would mean uploading unchanging numbers every frame. Colours are uniforms so one program serves every palette. Reuse the per-frame scratch arrays instead of reallocating; this runs 24 times a second on a page that may hold dozens of live cards. Scale the plate inset by HEIGHT so the paper border stays even instead of stretching on a wide card. Cap DPR at 1.5, pause offscreen and when the tab is hidden. GIVE EACH DRAWING ITS OWN CURSOR GESTURE, AND KEEP THEM MAINTAINABLE BY MAKING THE GESTURES GEOMETRIC PRIMITIVES RATHER THAN SCENE-SPECIFIC CODE. The obvious cheap answer is one shared interaction — a single scalar nudging whatever beat each drawing already runs — and it scales but it is boring, because the pointer then does the same thing to a starburst as to a halftone, which means it is responding to neither. The obvious expensive answer is a bespoke interaction per drawing, which is one more thing to maintain every time a drawing is added. Take the third option: name a small vocabulary of gestures, implement each ONCE, and let a drawing pick one and an amount. A new drawing then writes no interaction code at all. A vocabulary that covers this kind of piece: REACH (elements extend toward the pointer), FACE (each element turns to point at it, independently), AIM (the whole body turns as one), DRAG (elements are pulled along their own axis of travel), GATHER (elements are drawn bodily toward it), SWELL (a field term centred on it). Choose each drawing's gesture from what that drawing already IS, never from what would look busiest: spikes reach, gaps face, bars that already travel on one axis get raked along it, a cluster whose whole point is discs FUSING gets crowded so it fuses more, a stencil aims as one body because a stencil does not shear, and a halftone swells because that is what a halftone does under pressure. THE DETAILS THAT MAKE EACH GESTURE READ, all of which are easy to get subtly wrong. For REACH, weight by the SQUARE of the alignment between an element's bearing and the bearing to the pointer: linear, every spike grows a little and it reads as a scale rather than as one spike reaching. For FACE, BLEND toward the pointer's bearing rather than setting it, so elements keep their own unequal spins and merely lean — the difference between the drawing answering the pointer and obeying it — and take the SHORTEST angular path or an element at 0.02 turns will travel the long way round to reach one at 0.98. For AIM, turn to bring the NEAREST element onto the pointer, not a fixed one, or the body spins most of a turn to reach a bearing another element is already sitting on. For DRAG, weight by distance on the PERPENDICULAR axis only, so dragging across the set shears it instead of translating it. For GATHER, scale the pull by the inverse of each element's size: the small ones come running and the largest barely moves, staying the anchor the others fuse onto, and without that weighting the whole cluster simply translates and nothing fuses at all. For SWELL, measure the distance from each CELL CENTRE and not from the fragment, or the gradient across each dot turns every one near the pointer into a teardrop; and clamp the swollen radius below half a cell or it reaches into its neighbour and breaks the single-mod() shortcut the field depends on. Keep every amount well under half. The pointer should look like it is being ANSWERED, not obeyed; at full strength every drawing collapses onto the cursor and the piece stops being a print and becomes a toy. Index the gestures by DRAWING and not by playback position, or reordering the loop reshuffles which drawing responds how. Alongside the gestures, keep ONE effect that is identical everywhere and needs no per-drawing code: ink spraying wider and graining harder near the pointer, as if that part of the sheet took more ink. That is the half a new drawing inherits for free, and it is what stops the interaction feeling bolted on to some drawings and absent from others. Ease the pointer's strength on the DISPLAY clock rather than the stepped one so it stays connected to the hand, and force a repaint while it is easing or the print lags the hand by up to a frame and a half. Give it a generous radius; a tight one reads as a spotlight following the mouse, which is a cheaper idea. Use pointer events so a desktop cursor and a dragged finger are one code path, and do not preventDefault or dragging over the card eats the page's scroll. A NOTE ON VERIFYING ANY OF THIS, because it cost more time than building it: do not try to prove a cursor gesture from pixels or from intercepted graphics calls. The drawing is moving on its own, the interaction eases in over hundreds of milliseconds, and both will swamp or alias the signal — every plausible-looking harness gave a confident wrong answer, including several that reported no effect at all from working code. Expose the computed per-element values behind a temporary debug hook, key them by the exact frame index, and compare pointer-on against pointer-off at MATCHING frames. That is unambiguous, it takes one line, and it is the only version that was right. One last trap, in the shader itself:One last trap, in the shader itself: GLSL ES 1.0 requires a variable to be declared before any use, so hoist the pointer term to the top of main(). Declared between its two uses it fails to compile, the card silently never sizes its canvas, and you get a blank box with no error anywhere.
The complete, self-contained implementation follows, one file per block. It is framework-agnostic core logic — wire it into your own component and mount it on an element.
### spray-burst/params.ts
```ts
export const FPS = 24;
export const BEAT = 24;
export const SCENE_MS = 3000;
export const SCENE_FRAMES = (SCENE_MS / 1000) * FPS;
export const SCENE_COUNT = 6;
export const TOTAL = SCENE_FRAMES * SCENE_COUNT;
export const STAR_STATIONS = 3;
export const ORDER = [1, 4, 0, 5, 2, 3];
export const TRANSIT = [
0.022, 0.055, 0.1, 0.187, 0.679, 0.882, 0.937, 0.97, 0.987, 1.0,
];
export const TRANSIT_OUT = 20;
export const TRANSIT_SPLIT = BEAT - TRANSIT_OUT;
export const STATION = 0.553;
export interface Pose {
u: number;
v: number;
corner: boolean;
}
export const POSES: Pose[] = [
{ u: -1, v: -1, corner: true },
{ u: 1, v: -1, corner: true },
{ u: 1, v: 1, corner: true },
{ u: -1, v: 1, corner: true },
{ u: 0, v: 0, corner: false },
];
export const FAN = [-100.4, -67.6, -28.4, 0, 28.4, 67.6, 100.4];
export const MARGIN = 0.108;
export const CORE_TROUGH = 0.388;
export const CORE_A = 0.167;
export const CORE_B = 0.252;
export const CORE_CAP = 0.62;
export const CORE_TIP_MIN = 1.15;
export const CORE_TIP_MAX = 1.6;
export const BAND = 0.143;
export const CORE_FALLOFF = 1.6;
export const RAY_W0 = 0.388;
export const RAY_WTIP = 0.026;
export const RAY_TAPER = 1.05;
export const TIP_GROW = 0.051;
export const SPRAY_INK = 0.028;
export const SPRAY_PLATE = 0.033;
export const SPRAY_SCALE = [
1.0,
0.72,
1.15,
1.3,
0.85,
1.5,
];
export const GRAIN_SCALE = [1.0, 0.9, 1.35, 1.1, 0.8, 0.6];
export const SPATTER = 0.02;
export const SPATTER_DECAY = 0.056;
export const WOBBLE_INK = 0.0128;
export const WOBBLE_PLATE = 0.0153;
export const WOBBLE_SCALE = 0.0714;
export const GRAIN_PAPER = 1;
export const GRAIN_PLATE = 4;
export const GRAIN_INK = 1;
export interface Palette {
paper: readonly [number, number, number];
plate: readonly [number, number, number];
ink: readonly [number, number, number];
}
export const PALETTES: Palette[] = [
{ paper: [244, 239, 230], plate: [232, 65, 46], ink: [16, 16, 16] },
{ paper: [237, 232, 220], plate: [27, 63, 216], ink: [242, 183, 5] },
{ paper: [242, 237, 226], plate: [14, 14, 14], ink: [255, 92, 42] },
{ paper: [239, 233, 222], plate: [23, 160, 122], ink: [32, 26, 74] },
{ paper: [243, 238, 228], plate: [246, 196, 24], ink: [138, 30, 96] },
{ paper: [238, 233, 223], plate: [66, 48, 158], ink: [232, 233, 226] },
];
export const CARD_BG = PALETTES[0].paper;
export const SNAP = [0.0, 0.012, 0.03, 0.055, 0.09, 0.42, 0.78, 0.9, 0.955, 0.985, 1.0];
export const GLIDE = [0.0, 0.03, 0.08, 0.17, 0.34, 0.62, 0.82, 0.92, 0.97, 1.0];
export function ease(table: number[], u: number): number {
const n = table.length - 1;
const x = Math.min(Math.max(u, 0), 1) * n;
const i = Math.min(Math.floor(x), n - 1);
return table[i] + (table[i + 1] - table[i]) * (x - i);
}
export const RING_BEATS = 2;
export const BAR_BEATS = 2;
export const DISC_BEATS = 1;
export const WEDGE_BEATS = 3;
export const DOT_BEATS = 2;
export const RING_R = [0.3, 0.47, 0.64, 0.82, 1.02];
export const RING_W = [0.055, 0.038, 0.062, 0.03, 0.045];
export const RING_GAP_AT = [0.0, 0.31, 0.62, 0.14, 0.79];
export const RING_GAP_W = [0.12, 0.2, 0.09, 0.16, 0.13];
export const RING_SPIN = [0.35, -0.52, 0.81, -0.29, 0.63];
export const RING_OFF: [number, number] = [0, 0];
export const BAR_Y = [-0.78, -0.44, -0.12, 0.19, 0.5, 0.84];
export const BAR_H = [0.028, 0.062, 0.021, 0.045, 0.034, 0.055];
export const BAR_L = [0.52, 0.78, 0.34, 0.95, 0.61, 0.44];
export const BAR_V = [0.9, -1.4, 1.9, -0.7, 1.2, -1.6];
export const BAR_OFF = [0.0, 0.17, 0.34, 0.5, 0.67, 0.83];
export const DISC_R = [0.42, 0.16, 0.11, 0.2, 0.09, 0.14, 0.07];
export const DISC_OX = [0.05, 0.62, 0.78, 0.52, 0.88, 0.7, 0.95];
export const DISC_OY = [0.04, 0.4, 0.62, 0.72, 0.3, 0.55, 0.48];
export const DISC_SPIN = [0.18, 0.62, -0.44, 0.35, -0.71, 0.51, -0.28];
export const DISC_PHASE = [0.0, 0.12, 0.38, 0.55, 0.71, 0.84, 0.93];
export const WEDGE_COUNT = 7;
export const WEDGE_W = [0.052, 0.031, 0.068, 0.024, 0.045, 0.06, 0.037];
export const WEDGE_AT = [0.0, 0.13, 0.27, 0.41, 0.56, 0.7, 0.86];
export const WEDGE_SPIN = 0.22;
export const WEDGE_R0 = 0.22;
export const WEDGE_R1 = 1.25;
export const DOT_INSET = 0.05;
export const DOT_STEP = 0.26;
export const DOT_MAX = 0.44;
export const DOT_MIN = 0.06;
export const DOT_FREQ = 1.35;
export const DOT_ANGLE = 0.08;
export const DOT_SPEED = 1.0;
export const CURSOR_R = 1.6;
export const CURSOR_SPRAY = 0.35;
export const CURSOR_GRAIN = 0.5;
export type Gesture = "reach" | "face" | "aim" | "drag" | "gather" | "swell";
export const CURSOR_GESTURE: Gesture[] = [
"reach",
"face",
"drag",
"gather",
"aim",
"swell",
];
export const CURSOR_AMOUNT = [0.55, 0.7, 0.45, 0.5, 0.6, 0.8];
export const CURSOR_LOCAL_R = 1.1;
export const CURSOR_EASE = 0.28;
```
### spray-burst/engine.ts
```ts
import {
BAND,
BAR_BEATS,
BAR_H,
BAR_L,
BAR_OFF,
BAR_V,
BAR_Y,
BEAT,
CORE_A,
CORE_B,
CORE_CAP,
CORE_FALLOFF,
CORE_TIP_MAX,
CORE_TIP_MIN,
CORE_TROUGH,
CURSOR_AMOUNT,
CURSOR_GESTURE,
CURSOR_LOCAL_R,
CURSOR_EASE,
CURSOR_GRAIN,
CURSOR_R,
CURSOR_SPRAY,
DISC_BEATS,
DISC_OX,
DISC_OY,
DISC_PHASE,
DISC_R,
DISC_SPIN,
DOT_ANGLE,
DOT_BEATS,
DOT_FREQ,
DOT_INSET,
DOT_MAX,
DOT_MIN,
DOT_SPEED,
DOT_STEP,
FAN,
FPS,
GLIDE,
GRAIN_INK,
GRAIN_SCALE,
GRAIN_PAPER,
GRAIN_PLATE,
MARGIN,
ORDER,
PALETTES,
POSES,
RAY_TAPER,
RAY_W0,
RAY_WTIP,
RING_BEATS,
RING_GAP_AT,
RING_GAP_W,
RING_OFF,
RING_R,
RING_SPIN,
RING_W,
SCENE_COUNT,
SNAP,
SCENE_FRAMES,
SPATTER,
SPATTER_DECAY,
SPRAY_INK,
SPRAY_PLATE,
SPRAY_SCALE,
STAR_STATIONS,
STATION,
TIP_GROW,
TOTAL,
TRANSIT,
TRANSIT_OUT,
TRANSIT_SPLIT,
WEDGE_AT,
WEDGE_BEATS,
WEDGE_R0,
WEDGE_R1,
WEDGE_SPIN,
WEDGE_W,
WOBBLE_INK,
WOBBLE_PLATE,
WOBBLE_SCALE,
ease,
type Pose,
} from "./params";
const DPR_CAP = 1.5;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
const STILL_FRAME = ORDER.indexOf(0) * SCENE_FRAMES + 6;
const STAR_TOUR = [4, 0, 2];
const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`);
const table = (name: string, vals: readonly number[]) => {
const chain = vals
.slice(0, -1)
.map((v, i) => `i == ${i} ? ${f(v)} : `)
.join("");
return `float ${name}(int i) { return ${chain}${f(vals[vals.length - 1])}; }`;
};
const VERT = `
attribute vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
`;
const FRAG = `
precision highp float;
uniform vec2 uRes;
uniform vec2 uC;
uniform vec4 uRect;
uniform float uUnit;
uniform float uSeed;
uniform int uScene;
uniform vec3 uPaper;
uniform vec3 uPlate;
uniform vec3 uInk;
uniform vec3 uGrain;
uniform float uSprayI;
uniform vec3 uCursor;
uniform float uA[8];
uniform float uT[8];
const float TAU = 6.28318530718;
${table("RING_R", RING_R)}
${table("RING_W", RING_W)}
${table("RING_GAP_W", RING_GAP_W)}
${table("BAR_Y", BAR_Y)}
${table("BAR_H", BAR_H)}
${table("BAR_L", BAR_L)}
${table("DISC_R", DISC_R)}
${table("WEDGE_W", WEDGE_W)}
float hash(vec2 p, float s) {
vec3 p3 = fract(vec3(p.x, p.y, p.x + s) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float vnoise(vec2 p, float s) {
vec2 i = floor(p), fr = fract(p);
fr = fr * fr * (3.0 - 2.0 * fr);
return mix(mix(hash(i, s), hash(i + vec2(1.0, 0.0), s), fr.x),
mix(hash(i + vec2(0.0, 1.0), s), hash(i + vec2(1.0, 1.0), s), fr.x), fr.y);
}
float sdBox(vec2 p, vec2 c, vec2 h) {
vec2 d = abs(p - c) - h;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float angDist(float a, float b) {
float d = mod(abs(a - b), TAU);
return min(d, TAU - d);
}
float corePeak(float tip) {
return min(uUnit * (${f(CORE_A)} + ${f(CORE_B)} * clamp(tip / uUnit, ${f(CORE_TIP_MIN)}, ${f(CORE_TIP_MAX)})),
${f(CORE_CAP)} * uUnit);
}
float rayDist(vec2 q, float a, float tip, float base) {
vec2 dir = vec2(cos(a), sin(a));
float along = dot(q, dir);
float perp = abs(q.x * dir.y - q.y * dir.x);
float t = clamp((along - base) / max(1.0, tip - base), 0.0, 1.0);
float w0 = ${f(RAY_W0)} * uUnit, wt = ${f(RAY_WTIP)} * uUnit;
float hw = 0.5 * (wt + (w0 - wt) * pow(1.0 - t, ${f(RAY_TAPER)}));
vec2 d = vec2(max(base - along, along - tip), perp - hw);
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float ringsDist(vec2 p) {
vec2 q = p - uC;
float th = atan(q.y, q.x) / TAU;
float r = length(q);
float d = 1e5;
for (int i = 0; i < 5; i++) {
float ri = RING_R(i) * uUnit;
float wi = RING_W(i) * uUnit;
float ring = abs(r - ri) - wi;
float g = th - uA[i];
g = g - floor(g + 0.5);
float halfGap = RING_GAP_W(i) * 0.5;
float cut = (halfGap - abs(g)) * TAU * ri;
d = min(d, max(ring, cut));
}
return d;
}
float barsDist(vec2 p) {
float d = 1e5;
for (int i = 0; i < 6; i++) {
vec2 c = vec2(uA[i], uC.y + BAR_Y(i) * uUnit);
d = min(d, sdBox(p, c, vec2(BAR_L(i) * uUnit, BAR_H(i) * uUnit)));
}
return d;
}
float discsDist(vec2 p) {
float d = 1e5;
for (int i = 0; i < 7; i++) {
d = min(d, length(p - vec2(uA[i], uT[i])) - DISC_R(i) * uUnit);
}
return d;
}
float wedgeDist(vec2 p) {
vec2 q = p - uC;
float r = max(length(q), 1e-4);
float th = atan(q.y, q.x) / TAU;
float rad = max(${f(WEDGE_R0)} * uUnit - r, r - ${f(WEDGE_R1)} * uUnit);
float d = 1e5;
for (int i = 0; i < 7; i++) {
float g = th - uA[i];
g = g - floor(g + 0.5);
float ang = (abs(g) - WEDGE_W(i)) * TAU * r;
d = min(d, max(ang, rad));
}
return d;
}
float dotsDist(vec2 p) {
float step = ${f(DOT_STEP)} * uUnit;
vec2 q = p - uC;
vec2 cell = floor(q / step);
vec2 local = q - (cell + 0.5) * step;
float phase = dot(cell + 0.5, vec2(uA[1], uA[2])) + uA[0];
float wave = 0.5 + 0.5 * sin(phase * TAU);
float rr = mix(${f(DOT_MIN)}, ${f(DOT_MAX)}, wave);
vec2 cc = uC + (cell + 0.5) * step;
float swell = uA[3] * (1.0 - smoothstep(0.0, ${f(CURSOR_LOCAL_R)} * uUnit,
distance(cc, uCursor.xy)));
rr = min(rr * (1.0 + swell), 0.48) * step;
float dots = length(local) - rr;
vec2 rc = vec2((uRect.x + uRect.z) * 0.5, (uRect.y + uRect.w) * 0.5);
vec2 rh = vec2((uRect.z - uRect.x) * 0.5, (uRect.w - uRect.y) * 0.5)
- ${f(DOT_INSET)} * uUnit;
return max(dots, sdBox(p, rc, rh));
}
float starDist(vec2 p) {
vec2 q = p - uC;
float r = length(q);
float th = atan(q.y, q.x);
float d1 = 99.0, d2 = 99.0, t1 = 0.0;
for (int i = 0; i < 8; i++) {
float dd = angDist(th, uA[i]);
if (dd < d1) { d2 = d1; d1 = dd; t1 = uT[i]; }
else if (dd < d2) { d2 = dd; }
}
float bump = pow(1.0 - clamp(d1 / max(d2, 1e-4), 0.0, 1.0), ${f(CORE_FALLOFF)});
float coreR = mix(${f(CORE_TROUGH)} * uUnit, corePeak(t1), bump);
float d = max(coreR - r, r - (coreR + ${f(BAND)} * uUnit));
for (int i = 0; i < 8; i++) {
d = min(d, rayDist(q, uA[i], uT[i], corePeak(uT[i])));
}
return d;
}
float coverage(float d, float sigma) {
float c = 1.0 / (1.0 + exp(clamp(d * 1.702 / sigma, -20.0, 20.0)));
if (d > 0.0) {
c = max(c, ${f(SPATTER)} * exp(-d / (${f(SPATTER_DECAY)} * uUnit)));
}
return c;
}
float grainStep(vec2 p, float s) {
float h = hash(p, s);
return floor(h * h * 3.0);
}
float sceneDist(vec2 p) {
if (uScene == 0) return starDist(p);
if (uScene == 1) return ringsDist(p);
if (uScene == 2) return barsDist(p);
if (uScene == 3) return discsDist(p);
if (uScene == 4) return wedgeDist(p);
return dotsDist(p);
}
void main() {
vec2 p = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y);
float wob = ${f(WOBBLE_SCALE)} * uUnit;
float near = uCursor.z * (1.0 - smoothstep(0.0, ${f(CURSOR_R)} * uUnit,
distance(p, uCursor.xy)));
vec3 col = uPaper - grainStep(p, uSeed + 71.3) * uGrain.x;
vec2 rc = vec2((uRect.x + uRect.z) * 0.5, (uRect.y + uRect.w) * 0.5);
vec2 rh = vec2((uRect.z - uRect.x) * 0.5, (uRect.w - uRect.y) * 0.5);
float dp = sdBox(p, rc, rh)
+ (vnoise(p / wob, uSeed + 11.0) - 0.5) * 2.0 * ${f(WOBBLE_PLATE)} * uUnit;
if (hash(p, uSeed + 3.7) < coverage(dp, ${f(SPRAY_PLATE)} * uUnit)) {
col = uPlate - grainStep(p, uSeed + 91.1) * uGrain.y * (1.0 + ${f(CURSOR_GRAIN)} * near);
}
float db = sceneDist(p)
+ (vnoise(p / wob, uSeed + 29.0) - 0.5) * 2.0 * ${f(WOBBLE_INK)} * uUnit;
if (hash(p, uSeed + 5.1) < coverage(db, uSprayI * (1.0 + ${f(CURSOR_SPRAY)} * near))) {
col = uInk - grainStep(p, uSeed + 53.9) * uGrain.z * (1.0 + ${f(CURSOR_GRAIN)} * near);
}
gl_FragColor = vec4(col, 1.0);
}
`;
interface Rect {
x0: number;
y0: number;
x1: number;
y1: number;
}
function lerpAngle(a: number, b: number, t: number): number {
const d = (((b - a + Math.PI) % TAU) + TAU) % TAU - Math.PI;
return a + d * t;
}
function turnDelta(a: number, b: number): number {
return b - a - Math.round(b - a);
}
function poseCentre(pose: Pose, rect: Rect): [number, number] {
const hw = (rect.x1 - rect.x0) * 0.5;
const hh = (rect.y1 - rect.y0) * 0.5;
return [
rect.x0 + hw + pose.u * STATION * hw,
rect.y0 + hh + pose.v * STATION * hh,
];
}
function poseRays(pose: Pose, cx: number, cy: number, rect: Rect): number[] {
const norm = (a: number) => ((a % TAU) + TAU) % TAU;
let out: number[];
if (pose.corner) {
const fx = pose.u > 0 ? rect.x0 : rect.x1;
const fy = pose.v > 0 ? rect.y0 : rect.y1;
const far = Math.atan2(fy - cy, fx - cx);
out = [...FAN.map((o) => far + o * DEG), far + Math.PI];
} else {
const corners: [number, number][] = [
[rect.x1, rect.y1],
[rect.x0, rect.y1],
[rect.x0, rect.y0],
[rect.x1, rect.y0],
];
out = [
0,
Math.PI / 2,
Math.PI,
-Math.PI / 2,
...corners.map(([x, y]) => Math.atan2(y - cy, x - cx)),
];
}
return out.map(norm).sort((a, b) => a - b);
}
function tipRadius(cx: number, cy: number, a: number, r: Rect): number {
const dx = Math.cos(a);
const dy = Math.sin(a);
let t = Infinity;
if (dx > 1e-6) t = Math.min(t, (r.x1 - cx) / dx);
else if (dx < -1e-6) t = Math.min(t, (r.x0 - cx) / dx);
if (dy > 1e-6) t = Math.min(t, (r.y1 - cy) / dy);
else if (dy < -1e-6) t = Math.min(t, (r.y0 - cy) / dy);
return Number.isFinite(t) ? t : 0;
}
export class SprayBurst {
ok = false;
private canvas: HTMLCanvasElement;
private gl: WebGLRenderingContext | null = null;
private prog: WebGLProgram | null = null;
private quad: WebGLBuffer | null = null;
private u: Record<string, WebGLUniformLocation | null> = {};
private raf = 0;
private running = false;
private start0 = 0;
private lastFrame = -1;
private rect: Rect = { x0: 0, y0: 0, x1: 0, y1: 0 };
private unit = 1;
private angles = new Float32Array(8);
private tips = new Float32Array(8);
private px = 0;
private py = 0;
private over = false;
private cursor = 0;
private lastT = 0;
private star = { cx: 0, cy: 0, a: new Float32Array(8), t: new Float32Array(8) };
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
const gl =
(canvas.getContext("webgl", {
antialias: false,
alpha: false,
depth: false,
stencil: false,
preserveDrawingBuffer: false,
}) as WebGLRenderingContext | null) ??
(canvas.getContext("experimental-webgl") as WebGLRenderingContext | null);
if (!gl) return;
this.gl = gl;
const prog = this.link(VERT, FRAG);
if (!prog) return;
this.prog = prog;
this.quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]),
gl.STATIC_DRAW,
);
const loc = gl.getAttribLocation(prog, "aPos");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
for (const n of [
"uRes", "uC", "uRect", "uUnit", "uSeed",
"uScene", "uPaper", "uPlate", "uInk", "uGrain", "uSprayI", "uCursor",
]) {
this.u[n] = gl.getUniformLocation(prog, n);
}
this.u.uA = gl.getUniformLocation(prog, "uA[0]");
this.u.uT = gl.getUniformLocation(prog, "uT[0]");
this.ok = true;
this.resize();
}
private compile(type: number, src: string): WebGLShader | null {
const gl = this.gl;
if (!gl) return null;
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
gl.deleteShader(sh);
return null;
}
return sh;
}
private link(vs: string, fs: string): WebGLProgram | null {
const gl = this.gl;
if (!gl) return null;
const v = this.compile(gl.VERTEX_SHADER, vs);
const f2 = this.compile(gl.FRAGMENT_SHADER, fs);
if (!v || !f2) return null;
const p = gl.createProgram();
if (!p) return null;
gl.attachShader(p, v);
gl.attachShader(p, f2);
gl.linkProgram(p);
gl.deleteShader(v);
gl.deleteShader(f2);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
gl.deleteProgram(p);
return null;
}
gl.useProgram(p);
return p;
}
resize() {
const gl = this.gl;
if (!gl || !this.ok) return;
const dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);
const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr));
const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr));
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w;
this.canvas.height = h;
}
gl.viewport(0, 0, w, h);
const m = h * MARGIN;
this.rect = { x0: m, y0: m, x1: w - m, y1: h - m };
this.unit = (this.rect.y1 - this.rect.y0) * 0.5;
this.lastFrame = -1;
}
private starFrame(k0: number, out: { cx: number; cy: number; a: Float32Array; t: Float32Array }) {
const beat = Math.floor(k0 / BEAT) % STAR_STATIONS;
const k = k0 % BEAT;
let from = beat;
let to = beat;
let p = 1;
if (k >= TRANSIT_OUT) {
to = (beat + 1) % STAR_STATIONS;
p = TRANSIT[k - TRANSIT_OUT];
} else if (k < TRANSIT.length - TRANSIT_SPLIT) {
from = (beat + STAR_STATIONS - 1) % STAR_STATIONS;
p = TRANSIT[k + TRANSIT_SPLIT];
}
const [ax, ay] = poseCentre(POSES[STAR_TOUR[from]], this.rect);
const [bx, by] = poseCentre(POSES[STAR_TOUR[to]], this.rect);
out.cx = ax + (bx - ax) * p;
out.cy = ay + (by - ay) * p;
const ra = poseRays(POSES[STAR_TOUR[from]], ax, ay, this.rect);
const rb = poseRays(POSES[STAR_TOUR[to]], bx, by, this.rect);
const grow = TIP_GROW * this.unit;
const tipRect: Rect = {
x0: this.rect.x0 - grow,
y0: this.rect.y0 - grow,
x1: this.rect.x1 + grow,
y1: this.rect.y1 + grow,
};
for (let i = 0; i < 8; i++) {
const a = lerpAngle(ra[i], rb[i], p);
out.a[i] = a;
out.t[i] = tipRadius(out.cx, out.cy, a, tipRect);
}
}
private render(frame: number) {
const gl = this.gl;
if (!gl || !this.prog) return;
const slot = Math.floor(frame / SCENE_FRAMES) % SCENE_COUNT;
const scene = ORDER[slot];
const k = frame % SCENE_FRAMES;
const u = k / SCENE_FRAMES;
const pal = PALETTES[slot];
const paced = (table: number[], beats: number) => {
const b = u * beats;
return Math.floor(b) + ease(table, b % 1);
};
const amt = this.cursor * CURSOR_AMOUNT[scene];
const gesture = CURSOR_GESTURE[scene];
const localR = CURSOR_LOCAL_R * this.unit;
const nearness = (x: number, y: number) =>
Math.max(0, 1 - Math.hypot(x - this.px, y - this.py) / localR);
const angles = this.angles;
const tips = this.tips;
let cx = (this.rect.x0 + this.rect.x1) * 0.5;
let cy = (this.rect.y0 + this.rect.y1) * 0.5;
if (scene === 0) {
this.starFrame(k, this.star);
cx = this.star.cx;
cy = this.star.cy;
angles.set(this.star.a);
tips.set(this.star.t);
if (gesture === "reach" && amt > 0) {
const toCur = Math.atan2(this.py - cy, this.px - cx);
for (let i = 0; i < 8; i++) {
const align = Math.max(0, Math.cos(angles[i] - toCur));
tips[i] *= 1 + amt * align * align;
}
}
} else if (scene === 1) {
cx += RING_OFF[0] * this.unit;
cy += RING_OFF[1] * this.unit;
const g = paced(SNAP, RING_BEATS);
const toCur = Math.atan2(this.py - cy, this.px - cx) / TAU;
for (let i = 0; i < RING_SPIN.length; i++) {
const own = RING_GAP_AT[i] + RING_SPIN[i] * g;
angles[i] = own + amt * turnDelta(own, toCur);
}
} else if (scene === 2) {
const reach = Math.max(...BAR_L) * this.unit;
const span = this.rect.x1 - this.rect.x0 + 2 * reach;
for (let i = 0; i < BAR_V.length; i++) {
const b = u * BAR_BEATS + BAR_OFF[i];
const g = Math.floor(b) + ease(SNAP, b % 1);
const t = ((BAR_V[i] * g) % 1 + 1) % 1;
const x = this.rect.x0 - reach + t * span;
angles[i] = x + amt * nearness(x, this.rect.y0 + (this.rect.y1 - this.rect.y0) * 0.5 + BAR_Y[i] * this.unit) * (this.px - x);
}
} else if (scene === 3) {
const g = paced(GLIDE, DISC_BEATS);
for (let i = 0; i < DISC_R.length; i++) {
const th = (DISC_PHASE[i] + DISC_SPIN[i] * g) * TAU;
let x = cx + Math.cos(th) * DISC_OX[i] * this.unit;
let y = cy + Math.sin(th) * DISC_OY[i] * this.unit;
const pull = amt * nearness(x, y) * (DISC_R[0] / DISC_R[i]);
x += pull * (this.px - x);
y += pull * (this.py - y);
angles[i] = x;
tips[i] = y;
}
} else if (scene === 4) {
let g = WEDGE_SPIN * paced(SNAP, WEDGE_BEATS);
if (amt > 0) {
const toCur = Math.atan2(this.py - cy, this.px - cx) / TAU;
let best = 0;
for (let i = 0; i < WEDGE_AT.length; i++) {
const d = turnDelta(WEDGE_AT[i] + g, toCur);
if (Math.abs(d) < Math.abs(best) || i === 0) best = d;
}
g += amt * best;
}
for (let i = 0; i < WEDGE_AT.length; i++) angles[i] = WEDGE_AT[i] + g;
} else {
const th = DOT_ANGLE * TAU;
angles[0] = DOT_SPEED * paced(GLIDE, DOT_BEATS);
angles[1] = (Math.cos(th) * DOT_FREQ * DOT_STEP);
angles[2] = (Math.sin(th) * DOT_FREQ * DOT_STEP);
angles[3] = amt;
}
gl.useProgram(this.prog);
gl.uniform2f(this.u.uRes!, this.canvas.width, this.canvas.height);
gl.uniform2f(this.u.uC!, cx, cy);
gl.uniform4f(
this.u.uRect!,
this.rect.x0,
this.rect.y0,
this.rect.x1,
this.rect.y1,
);
gl.uniform1f(this.u.uUnit!, this.unit);
gl.uniform1i(this.u.uScene!, scene);
gl.uniform3f(this.u.uPaper!, pal.paper[0] / 255, pal.paper[1] / 255, pal.paper[2] / 255);
gl.uniform3f(this.u.uPlate!, pal.plate[0] / 255, pal.plate[1] / 255, pal.plate[2] / 255);
gl.uniform3f(this.u.uInk!, pal.ink[0] / 255, pal.ink[1] / 255, pal.ink[2] / 255);
gl.uniform1f(this.u.uSprayI!, SPRAY_INK * this.unit * SPRAY_SCALE[scene]);
gl.uniform3f(this.u.uCursor!, this.px, this.py, this.cursor);
const gs = GRAIN_SCALE[scene];
gl.uniform3f(
this.u.uGrain!,
(GRAIN_PAPER * gs) / 255,
(GRAIN_PLATE * gs) / 255,
(GRAIN_INK * gs) / 255,
);
gl.uniform1f(this.u.uSeed!, frame * 7.13 + 0.5);
gl.uniform1fv(this.u.uA!, angles);
gl.uniform1fv(this.u.uT!, tips);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
private frame = (now: number) => {
if (!this.running) return;
if (!this.start0) this.start0 = now;
const dt = this.lastT ? Math.min((now - this.lastT) / 1000, 0.05) : 0;
this.lastT = now;
const target = this.over ? 1 : 0;
if (dt > 0) this.cursor += (target - this.cursor) * (1 - Math.exp(-dt / CURSOR_EASE));
const moved = Math.abs(this.cursor - target) > 0.001;
const idx = Math.floor(((now - this.start0) / 1000) * FPS) % TOTAL;
if (idx !== this.lastFrame || moved) {
this.lastFrame = idx;
this.render(idx);
}
this.raf = requestAnimationFrame(this.frame);
};
start() {
if (!this.ok || this.running) return;
this.running = true;
this.start0 = performance.now() - (this.lastFrame < 0 ? 0 : (this.lastFrame / FPS) * 1000);
this.raf = requestAnimationFrame(this.frame);
}
stop() {
this.running = false;
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
setPointer(x: number | null, y: number | null) {
if (x == null || y == null) {
this.over = false;
return;
}
const scale = this.canvas.width / Math.max(1, this.canvas.clientWidth);
this.px = x * scale;
this.py = y * scale;
this.over = true;
}
renderStill() {
if (!this.ok) return;
this.lastFrame = STILL_FRAME;
this.render(STILL_FRAME);
}
destroy() {
this.stop();
const gl = this.gl;
if (gl) {
if (this.quad) gl.deleteBuffer(this.quad);
if (this.prog) gl.deleteProgram(this.prog);
gl.getExtension("WEBGL_lose_context")?.loseContext();
}
this.gl = null;
this.prog = null;
this.ok = false;
}
}
```
### spray-burst/SprayBurstCard.tsx
```ts
"use client";
import { useEffect, useRef } from "react";
import { SprayBurst } from "./engine";
import { CARD_BG } from "./params";
import { onTransitionChange } from "../../lib/view-transition";
export function SprayBurstCard({
bare = false,
viewTransitionName,
}: {
bare?: boolean;
viewTransitionName?: string;
} = {}) {
void bare;
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let engine: SprayBurst | null = null;
let onScreen = false;
let hidden = false;
let inTransition = false;
const sync = () => {
if (!engine || reduced) return;
if (onScreen && !hidden && !inTransition) engine.start();
else engine.stop();
};
const raf = requestAnimationFrame(() => {
if (!canvasRef.current) return;
engine = new SprayBurst(canvas);
if (!engine.ok) return;
if (reduced) engine.renderStill();
else sync();
});
const io = new IntersectionObserver(
(es) => {
onScreen = es[0]?.isIntersecting ?? false;
sync();
},
{ threshold: 0.2 },
);
io.observe(canvas);
const onVis = () => {
hidden = document.hidden;
sync();
};
document.addEventListener("visibilitychange", onVis);
const offTransition = onTransitionChange((active) => {
inTransition = active;
sync();
});
const onMove = (e: PointerEvent) => {
const r = canvas.getBoundingClientRect();
engine?.setPointer(e.clientX - r.left, e.clientY - r.top);
};
const onLeave = () => engine?.setPointer(null, null);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerdown", onMove);
canvas.addEventListener("pointerleave", onLeave);
canvas.addEventListener("pointercancel", onLeave);
let rt = 0;
const onResize = () => {
window.clearTimeout(rt);
rt = window.setTimeout(() => {
engine?.resize();
if (engine && (reduced || !onScreen || hidden)) engine.renderStill();
}, 120);
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(raf);
io.disconnect();
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerdown", onMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("pointercancel", onLeave);
document.removeEventListener("visibilitychange", onVis);
offTransition();
window.removeEventListener("resize", onResize);
window.clearTimeout(rt);
engine?.destroy();
};
}, []);
return (
<div
data-canvas-card
role="img"
aria-label="A screenprint on cream paper that changes every three seconds, each time in new colours: rings each broken by a turning gap, a pinwheel of wedges, a many-pointed starburst jumping around its plate, a field of dots swelling under a passing wave, thick bars sliding across each other, and a crowd of circles drifting together into blobs. Every edge is a fine spray of dots rather than a clean line, and the whole print shivers as if redrawn by hand on every frame. Moving the pointer across it lays the ink on more heavily under your hand."
style={{
...(viewTransitionName ? { viewTransitionName } : null),
backgroundColor: `rgb(${CARD_BG[0]}, ${CARD_BG[1]}, ${CARD_BG[2]})`,
}}
className="relative mx-auto aspect-[1344/820] w-full select-none overflow-hidden rounded-[12px] border border-[var(--border-line)]"
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
);
}
```Discovery vocabulary
Related by governed terms
An animated SVG signature effect that draws out text as if hand-written.
More from Vault