Catalog / Recipe
Point field
Points ride a slow curl-noise field that drifts in time and deposit into a trail that fades, for a bounded time; the pointer can stir the field.
- Family
- point system
- Status
- passed
- Used for
- HeroLab pageFigure
- Size
- 5,516 of 6,000 bytes, gzipped
mount(element)The family’s field recipe, and the stateful one: a point responds to where it is. Positions live one point per texel in a 32-bit float texture and are stepped by a fragment pass through the curl of a value noise, which is divergence-free, so points neither pile up nor thin out. The points are drawn into a trail that fades with a floor, so rest is exactly zero, and the trail is what the reader sees: points alone read as noise in a still, and forty frames of trail read as flow at once. A field never empties, so it ends on its budget.
Use
import { mount } from './point-field';
mount(document.querySelector('.hero'), { budget: 8000 });
The element is a band or a hero with a flat ground. The canvas is laid
over it, transparent, and leaves at the end of the budget; with
keepStill the last trail stays as a plain picture.
The field
Each frame, three passes and a draw:
- Update: each texel is read with
texelFetch, moved by the curl of the noise at its place (the field’s aspect in the noise, so gyres are round), aged, and respawned by a hash of its index and the time once it is past its life or outside the box. Withstir, a push away from the pointer withinradius. - Fade: the trail times the half-life’s decay for the frame, minus one 8-bit level, so it reaches exactly zero.
- Deposit: one point per texel, drawn additively into the trail by a
vertex shader that fetches its texel by
gl_VertexID, as a soft dot ofstrength. - Show: the trail coloured in a token over a transparent canvas.
The seed pass runs once, with ages already part way through a life so the first frames are not a burst.
Options
| Option | Default | What it does |
|---|---|---|
count |
20000 | Points, 5,000 to 100,000 |
scale |
2.5 | Frequency of the field over the box; low is a few big gyres |
speed |
0.08 | How far a point moves a second, as a share of the box |
life |
3 | Mean life in seconds; each point’s own is within half of it |
halfLife |
0.4 | Seconds for the trail to fade by half; long is streamlines |
evolve |
0.05 | How fast the field drifts; zero is frozen |
size |
1.5 | A point in CSS px |
strength |
0.5 | What a point adds a frame; crossings brighten |
tint |
0 | 0 accent, 1 ink |
stir |
false | The pointer pushes the field; it then listens and rests between touches |
radius |
0.15 | The stir’s reach, as a share of the height |
budget |
6000 | ms of running time before it rests; 0 runs until destroy |
keepStill |
false | Keep the last trail as a still when the budget ends it |
simSize, paused, maxPixelRatio, maxPixels |
512, false, 1.5, 2,500,000 | As every stateful recipe |
Versions
| Version | What it is |
|---|---|
| Field | The defaults |
| Gyres | A few slow eddies with long tails |
| Turbulence | Fine and busy |
| Streamlines | A frozen field drawn as lines |
| Stirred | The pointer pushes the field; it listens and rests between touches |
Where it must not go
Never behind text: it is motion for as long as its budget, and a field behind a paragraph is the research’s perpetual motion beside text with a timer on it. Not over data, a listing or a card grid. A hero or a band with a budget, a figure about the technique, and the lab.
What it does when things are missing
| Situation | What the reader sees |
|---|---|
| No WebGL2, a software renderer | The field, empty |
| No complete float target, or no complete 32-bit float target | The field, empty; nothing is attached |
| Reduced motion, forced colours | The field, empty |
| Off screen, hidden, paused | No frames; the state waits |
| The budget ends | Nothing, or the last trail as a plain still with keepStill; with stir, rest with the context kept and the pointer restarts it |
| Context lost | The still if one was kept, else nothing |
| Destroy | The field as served |
Learned while building it
- The trail reads as flow at once: at two seconds four fifths of the field’s pixels carry trail and the eddies are plain in a still, as the research saw with forty frames of trail. The same field drawn as points alone was noise there, which is why there is no trails-off toggle.
- An unpreserved drawing buffer reads black between frames. The browser check reads the canvas inside an animation frame, after the recipe’s own draw, and tries a few frames before it believes an empty field.
- The harness eases the last half second of a budget and says so in its input, and the first build ignored it: a full, moving field cut in one frame beside text. The deposit now winds down with that ease, so the trail fades before the canvas goes, and half the points are drawn once frames come late. The review caught it.
- The check for a device with a half-float target but no complete 32-bit one first failed every framebuffer, which the harness’s own probe stops before the recipe’s check is reached. It now fails only a framebuffer whose texture was stored as RGBA32F, and was seen to fail with the recipe’s check removed and pass with it back.
- Stirred froze under a real mouse. The harness woke the loop on every pointer event and restarted its frame clock each time, so with events arriving faster than frames every step was a step of nothing: no advection, no fade, the field frozen while the pointer moved. The owner saw it on the bench; the headless check had sent events slower than the frames. The clock now restarts only from rest, and the check sends one event a frame.
- The three existing stateful recipes declare
PROGRAMSasRecord<string, string>; the region reads them through a wider type so the pair branch typechecks, and a test holds the three to strings so the branch stays inert for them.
/**
* point-field
*
* Points ride a slow curl-noise field that drifts in time and deposit
* into a trail that fades, for a bounded time, in a band or a hero. The
* pointer can stir the field. Positions live one point per texel in a
* 32-bit float texture, stepped by a fragment pass; the trail is the
* Feedback TOP of `feedback-trails` with the points as its source.
*
* Copy this file. It imports nothing.
*
* import { mount } from './point-field';
* mount(document.querySelector('.hero'));
*
* A field never empties, so it ends on its budget, six seconds by
* default; with `keepStill` the last trail stays as a plain picture.
* With `stir` it listens: the budget ends in rest and the pointer
* brings it back. Without WebGL2, a complete float target and a
* complete 32-bit float target, nothing is added.
*/
export interface Options {
/** Ceiling on canvas pixels per CSS pixel. */
maxPixelRatio: number;
/** Ceiling on canvas pixels in total. */
maxPixels: number;
/** Running time in ms before it rests; 0 runs until destroy. A field never empties, so this is the end. */
budget: number;
/** Stops the frames. Nothing is lost; unpausing carries on. */
paused: boolean;
/** When the budget ends it, leave the last frame as a plain picture. */
keepStill: boolean;
/** Long side of the trail in texels. The cost dial for the trail; the count is the dial for the points. */
simSize: number;
/** How many points, 5,000 to 100,000. */
count: number;
/** Frequency of the field over the box, 1 to 6. Low is a few big gyres, high is busy turbulence. */
scale: number;
/** How far a point moves a second, as a share of the box, 0.02 to 0.3. */
speed: number;
/** Mean life of a point in seconds, 1 to 10; each point's own is within half of it. Short lives keep the cloud even. */
life: number;
/** Seconds for the trail to fade by half, 0.05 to 3. Long is streamlines. */
halfLife: number;
/** How fast the field itself drifts, 0 to 0.3. Zero is a frozen field that settles into fixed lines. */
evolve: number;
/** A point's size in CSS px, 1 to 4. */
size: number;
/** What one point adds to the trail a frame, 0.1 to 1. Paths that cross brighten. */
strength: number;
/** Which token colours the trail: 0 accent, 1 ink. */
tint: number;
/** The pointer pushes the field within its radius; the recipe then listens and rests between touches. */
stir: boolean;
/** The stir's reach, as a share of the height, 0.05 to 0.4. */
radius: number;
}
export const DEFAULTS: Options = {
maxPixelRatio: 1.5,
maxPixels: 2_500_000,
budget: 6000,
paused: false,
keepStill: false,
simSize: 512,
count: 20000,
scale: 2.5,
speed: 0.08,
life: 3,
halfLife: 0.4,
evolve: 0.05,
size: 1.5,
strength: 0.5,
tint: 0,
stir: false,
radius: 0.15,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// Hashes by index and stream, one value noise, and its curl by finite
// difference: the rotated gradient, which is divergence-free, so points
// neither pile up nor thin out.
const NOISE = `uint hh(uint x){x^=x>>16u;x*=0x7feb352du;x^=x>>15u;x*=0x846ca68bu;x^=x>>16u;return x;}
float h(uint i,uint k){return float(hh(hh(i)+k*0x9e3779b9u))/4294967295.;}
float r2(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5);}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(r2(i),r2(i+vec2(1,0)),f.x),mix(r2(i+vec2(0,1)),r2(i+1.),f.x),f.y);}
vec2 curl(vec2 q){float e=.01;float dx=vn(q+vec2(e,0))-vn(q-vec2(e,0)),dy=vn(q+vec2(0,e))-vn(q-vec2(0,e));return vec2(dy,-dx)/(2.*e);}`;
// Four passes and a draw. SEED fills the positions once: a place, an age
// already part way through a life so the first frames are not a burst,
// and a life within half of the mean. UPDATE steps each texel through
// the curl of the noise, with the field's aspect in the noise so gyres
// are round, ages it, and respawns it by a hash of the index and the
// time once it is past its life or outside the box; with stir on, a push
// away from the pointer within the radius. FADE is the trails recipe's
// floor-subtracting fade. DRAW is the point program: its vertex shader
// fetches its texel by gl_VertexID and the fragment lays a soft dot.
// SHOW colours the trail in a token, premultiplied over transparent.
const PROGRAMS: Record<string, string | { vertex: string; fragment: string }> = {
seed: `precision highp int;uniform float side,life;${NOISE}
void main(){uint i=uint(gl_FragCoord.x)+uint(gl_FragCoord.y)*uint(side);
o=vec4(h(i,0u),h(i,1u),h(i,2u)*life,life*(.5+h(i,3u)));}`,
update: `precision highp int;uniform sampler2D pos;uniform float side,dt,time,asp,life;uniform vec4 fld,stir;${NOISE}
void main(){ivec2 c=ivec2(gl_FragCoord.xy);uint i=uint(c.x)+uint(c.y)*uint(side);
vec4 s=texelFetch(pos,c,0);
vec2 vel=curl(vec2(s.x*asp,s.y)*fld.x+time*fld.z)*fld.y;
if(stir.z>0.){vec2 d=vec2((s.x-stir.x)*asp,s.y-stir.y);float l=length(d);vel+=d/max(l,1e-4)*(1.-smoothstep(0.,stir.w,l))*.5;}
s.x+=vel.x*dt/asp;s.y+=vel.y*dt;s.z+=dt;
if(s.z>s.w||s.x<0.||s.x>1.||s.y<0.||s.y>1.){uint k=uint(time*7.)*101u;s=vec4(h(i,4u+k),h(i,5u+k),0.,life*(.5+h(i,6u+k)));}
o=s;}`,
fade: `uniform sampler2D s;uniform float decay;void main(){o=max(texture(s,v)*decay-1./255.,0.);}`,
draw: {
vertex: `#version 300 es
precision highp float;precision highp int;precision highp sampler2D;
uniform sampler2D pos;uniform float side,size;out vec2 v;
void main(){int i=gl_VertexID,sd=int(side);vec4 s=texelFetch(pos,ivec2(i%sd,i/sd),0);v=s.xy;gl_Position=vec4(s.xy*2.-1.,0,1);gl_PointSize=size;}`,
fragment: `uniform float strength;void main(){float a=strength*(1.-smoothstep(.3,.5,length(gl_PointCoord-.5)));o=vec4(a);}`,
},
show: `uniform sampler2D s;uniform vec3 col;void main(){float x=texture(s,v).x;o=vec4(col*x,x);}`,
};
/** CSS tokens read into `kit.tokens` by these uniform names, with what stands in for a missing one. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
['uInk', '--ink', '#000'],
['uAccent', '--accent', '#00f'],
];
/** Positions need a float target; the 32-bit one is checked by the recipe itself. */
const NEEDS_FLOAT = true;
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
const whole = (value: number, low: number, high: number): number => clamp(Math.round(Number.isFinite(value) ? value : low), low, high);
export interface Dials {
count: number;
scale: number;
speed: number;
life: number;
halfLife: number;
evolve: number;
size: number;
strength: number;
tint: number;
radius: number;
}
/** Every dial inside the range the shaders survive. */
export function dials(o: Options): Dials {
return {
count: whole(o.count, 5000, 100000),
scale: clamp(o.scale, 1, 6),
speed: clamp(o.speed, 0.02, 0.3),
life: clamp(o.life, 1, 10),
halfLife: clamp(o.halfLife, 0.05, 3),
evolve: clamp(o.evolve, 0, 0.3),
size: clamp(o.size, 1, 4),
strength: clamp(o.strength, 0.1, 1),
tint: whole(o.tint, 0, 1),
radius: clamp(o.radius, 0.05, 0.4),
};
}
/** The side of the square texture that holds the points, the count clamped first. */
export const sideOf = (count: number): number => Math.ceil(Math.sqrt(whole(count, 5000, 100000)));
/**
* What one frame draws: the deposit winds down with the harness's ease
* over the last half second of the budget, so the end is a fade and
* not a cut, and half the points are drawn once frames come late.
*/
export function work(d: Dials, input: { ease: number; late: boolean }): { strength: number; count: number } {
return { strength: d.strength * clamp(input.ease, 0, 1), count: Math.ceil(d.count * (input.late ? 0.5 : 1)) };
}
/** The fade per frame for a half-life in seconds and a frame of `dt` seconds. */
const decay = (halfLife: number, dt: number): number => Math.pow(0.5, dt / halfLife);
const makeSim = (kit: Kit, first: Options): Sim => {
let options = first;
let d = dials(first);
const trailSize = (): [number, number] => {
const long = clamp(Math.round(options.simSize), 64, 2048);
return kit.aspect >= 1 ? [long, Math.max(2, Math.round(long / kit.aspect))] : [Math.max(2, Math.round(long * kit.aspect)), long];
};
let side = sideOf(d.count);
let positions = kit.pair(side, side, 2, false, false);
// Rendering to 32-bit float is the one thing the harness's probe does not test.
if (!positions.r.ok || !positions.w.ok) throw new Error('no 32-bit target');
let [w, h] = trailSize();
let trail = kit.pair(w, h, 0, true, false);
// A point's size is given in CSS px and drawn in trail texels.
const cssWidth = Math.max(1, kit.host?.getBoundingClientRect().width ?? w);
let seeded = false;
let time = 0;
return {
step(dt, o, input) {
options = o;
d = dials(o);
time += dt;
if (!seeded) {
kit.pass(kit.program('seed'), { side, life: d.life }, positions.w);
positions.swap();
seeded = true;
}
kit.pass(
kit.program('update'),
{
pos: { tex: positions.r.tex },
side,
dt,
time,
asp: kit.aspect,
life: d.life,
fld: [d.scale, d.speed, d.evolve, 0],
stir: [input.x, input.y, o.stir && input.active ? 1 : 0, d.radius],
},
positions.w,
);
positions.swap();
kit.pass(kit.program('fade'), { s: { tex: trail.r.tex }, decay: decay(d.halfLife, dt) }, trail.w);
const frame = work(d, input);
kit.points(kit.program('draw'), { pos: { tex: positions.r.tex }, side, size: Math.max(1, (d.size * w) / cssWidth), strength: frame.strength }, frame.count, trail.w);
trail.swap();
return true;
},
show(o, tokens) {
kit.pass(kit.program('show'), { s: { tex: trail.r.tex }, col: tokens[dials(o).tint === 1 ? 'uInk' : 'uAccent'] ?? [0, 0, 1] }, null);
},
listens: (o) => o.stir,
update(previous, next) {
options = next;
d = dials(next);
// Another count is another texture: rebuilt and reseeded.
if (sideOf(previous.count) !== sideOf(next.count)) {
kit.drop(positions);
side = sideOf(d.count);
positions = kit.pair(side, side, 2, false, false);
seeded = false;
}
if (previous.simSize !== next.simSize) {
kit.drop(trail);
[w, h] = trailSize();
trail = kit.pair(w, h, 0, true, false);
}
},
};
};
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Gyres)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Field: {},
Gyres: { scale: 1.2, speed: 0.05, halfLife: 0.8, count: 12000 },
Turbulence: { scale: 5, speed: 0.18, halfLife: 0.2, count: 40000 },
Streamlines: { evolve: 0, halfLife: 2.5, speed: 0.06, life: 8 },
Stirred: { stir: true, halfLife: 0.6 },
};
// ---- HARNESS stateful: begin ------------------------------------------------
//
// Identical in every stateful shader recipe. `npm run check` fails if a
// copy differs, so change it in one recipe and copy it to the others.
//
// Above this line a recipe declares, by these names: Options (with
// maxPixelRatio, maxPixels, budget, paused, keepStill and simSize),
// DEFAULTS, Handle, PROGRAMS (fragment bodies, or { vertex, fragment }
// for a program with its own vertex shader), TOKENS, NEEDS_FLOAT and
// makeSim. The kit draws passes and points, and makes RGBA8, RGBA16F
// and RGBA32F targets (kinds 0, 1 and 2; the last is never filtered).
//
// What it promises: it never throws, never hides or moves content, and
// changes nothing on the page until its first frame is ready. It runs no
// frame while off screen, hidden, paused or at rest. When the budget ends
// it, or on destroy, nothing of it remains but a kept still.
const VERTEX = `#version 300 es
out vec2 v;void main(){vec2 p=vec2(gl_VertexID<<1&2,gl_VertexID&2);v=p;gl_Position=vec4(p*2.-1.,0,1);}`;
const HEAD = `#version 300 es
precision highp float;precision highp sampler2D;in vec2 v;out vec4 o;`;
/** A program is a fragment body under the shared head, or a pair with its own vertex shader. */
type Programs = Record<string, string | { vertex: string; fragment: string }>;
const NOOP: Handle = { update() {}, destroy() {} };
/** One mount to an element. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();
export interface Target {
tex: WebGLTexture;
fbo: WebGLFramebuffer;
w: number;
h: number;
/** Whether the framebuffer is complete: the real feature test for the format. */
ok: boolean;
}
export interface Pair {
/** Read this one. */
r: Target;
/** Write that one, then swap. */
w: Target;
swap(): void;
}
export type Uniform = number | readonly number[] | { tex: WebGLTexture };
export interface Program {
p: WebGLProgram;
u: Map<string, WebGLUniformLocation | null>;
}
export type Rgb = [number, number, number];
/** What the harness hands a recipe to build and run its simulation with. */
export interface Kit {
gl: WebGL2RenderingContext;
/** Whether an RGBA16F target is complete here. */
float: boolean;
/** Width over height of the host when the simulation is built. */
aspect: number;
/** The element the recipe is mounted on, for what it may read from the page. */
host: HTMLElement | null;
/** The token colours, by uniform name, read when the canvas is attached. */
tokens: Record<string, Rgb>;
program(name: string): Program;
/** kind 0 is RGBA8, 1 is RGBA16F, 2 is RGBA32F (never filtered). */
target(w: number, h: number, kind: 0 | 1 | 2, linear: boolean, repeat: boolean): Target;
pair(w: number, h: number, kind: 0 | 1 | 2, linear: boolean, repeat: boolean): Pair;
/** An RGBA8 texture from a canvas or an image, flipped so y is up. */
image(source: TexImageSource, linear: boolean): WebGLTexture;
/** One full-target pass. `dst` null draws to the canvas. Textures are bound in the order given. */
pass(program: Program, uniforms: Record<string, Uniform>, dst: Target | null): void;
/** A POINTS draw of `count` vertices, added into `dst` or the canvas, blending on for this draw only. */
points(program: Program, uniforms: Record<string, Uniform>, count: number, dst: Target | null): void;
/** Deletes a target, a pair or a texture before its time. */
drop(made: Target | Pair | WebGLTexture): void;
}
export interface Input {
/** Pointer within the host, 0 to 1, y up. */
x: number;
y: number;
/** True while the pointer moved over the host within the last 100 ms. */
active: boolean;
/** Whether a pointer has ever been over the host. */
seen: boolean;
/** Seconds since the last pointer event. Infinity if none, or it left. */
idle: number;
/** 1 while running, falling to 0 over the last half second of the budget. Scale the work a frame by it. */
ease: number;
/** Seconds of running time. */
time: number;
/** True once frames have come late: do about half the work a frame. */
late: boolean;
}
export interface Sim {
/** Runs the simulation forward by `dt` seconds. False when there is nothing left to draw: the harness then rests until input or an update. */
step(dt: number, options: Options, input: Input): boolean;
/** Draws the state to the canvas. */
show(options: Options, tokens: Record<string, Rgb>): void;
/** The host's size changed, in CSS pixels. */
resize?(width: number, height: number): void;
/** Options changed while mounted. */
update?(previous: Options, next: Options): void;
/** True while the pointer can bring it back: the budget then ends in rest, not release, and a touch restarts the clock. */
listens?(options: Options): boolean;
}
/** The buffer size for a box measured in device pixels, under both ceilings. */
export function bufferSize(
deviceWidth: number,
deviceHeight: number,
pixelRatio: number,
maxPixelRatio: number,
maxPixels: number,
): [width: number, height: number] {
const area = deviceWidth * deviceHeight;
if (!(area > 0)) return [1, 1];
const scale = Math.min(1, maxPixelRatio / Math.max(pixelRatio, 0.01), Math.sqrt(maxPixels / area));
return [Math.max(1, Math.round(deviceWidth * scale)), Math.max(1, Math.round(deviceHeight * scale))];
}
/** `color(srgb r g b)` as the browser serialises it, to three numbers in 0..1. */
export function parseSrgb(value: string): Rgb | null {
const match = /color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)/.exec(value);
if (!match) return null;
const channel = (text: string | undefined): number => Math.min(1, Math.max(0, Number(text) || 0));
return [channel(match[1]), channel(match[2]), channel(match[3])];
}
/** A CSS time, in ms. `null` for anything that is not one. */
export function parseTime(value: string): number | null {
const match = /^\s*(\d*\.?\d+)(ms|s)\s*$/.exec(value);
if (!match) return null;
return match[2] === 's' ? Number(match[1]) * 1000 : Number(match[1]);
}
/**
* Options over a base, keeping only what is the right type. A control
* panel, a message from another frame and a careless caller can all
* send anything, and none of them may reach the GPU unchecked.
*/
export function mergeOptions(base: Options, next: Partial<Options>): Options {
const merged: Record<string, unknown> = { ...base };
for (const [key, standard] of Object.entries(DEFAULTS)) {
const value: unknown = (next as Record<string, unknown>)[key];
if (typeof value === typeof standard && (typeof value !== 'number' || Number.isFinite(value))) merged[key] = value;
}
return merged as unknown as Options;
}
export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
try {
return start(element, options);
} catch {
return NOOP;
}
}
function start(element: HTMLElement | null, explicit: Partial<Options>): Handle {
if (
!element ||
typeof WebGL2RenderingContext === 'undefined' ||
typeof ResizeObserver === 'undefined' ||
typeof IntersectionObserver === 'undefined'
) {
return NOOP;
}
// The one place motion is decided, so a host can override matchMedia.
const asks = (query: string): boolean => typeof matchMedia === 'function' && matchMedia(query).matches;
if (asks('(prefers-reduced-motion: reduce)') || asks('(forced-colors: active)')) return NOOP;
mounted.get(element)?.destroy();
let options = mergeOptions(DEFAULTS, explicit);
const page = element === document.body || element === document.documentElement;
const canvas = document.createElement('canvas');
canvas.setAttribute('aria-hidden', 'true');
canvas.style.cssText = `position:${page ? 'fixed' : 'absolute'};inset:0;width:100%;height:100%;pointer-events:none;z-index:2147483647`;
let gl: WebGL2RenderingContext | null = null;
let kit: Kit | null = null;
let sim: Sim | null = null;
let observer: ResizeObserver | null = null;
let seer: IntersectionObserver | null = null;
let still: HTMLCanvasElement | null = null;
let hostStyle: string | null | undefined;
const programs = new Map<string, Program>();
const made: (() => void)[] = [];
let frame = 0;
let timer = 0;
let slow = 0;
let last = 0;
let elapsed = 0;
let built = false;
let done = false;
let visible = document.visibilityState === 'visible';
let onScreen = true;
let pointerAt = -Infinity;
const input: Input = { x: 0.5, y: 0.5, active: false, seen: false, idle: Infinity, ease: 1, time: 0, late: false };
/** Ends the effect for good. With `keep`, the last frame stays as a plain 2D canvas. */
const finish = (keep: boolean): void => {
if (done) return;
done = true;
cancelAnimationFrame(frame);
clearTimeout(timer);
observer?.disconnect();
seer?.disconnect();
document.removeEventListener('visibilitychange', wake);
removeEventListener('pagehide', stop);
removeEventListener('pageswap', stop);
canvas.removeEventListener('webglcontextlost', stop);
for (const type of ['pointermove', 'pointerdown', 'pointerleave'] as const) element.removeEventListener(type, pointer);
if (keep && gl && built && canvas.isConnected) {
// Copied in the same task as the last draw: after the next
// composite the drawing buffer is gone.
still = document.createElement('canvas');
still.width = canvas.width;
still.height = canvas.height;
still.setAttribute('aria-hidden', 'true');
still.style.cssText = canvas.style.cssText;
still.dataset.state = 'rest';
still.getContext('2d')?.drawImage(canvas, 0, 0);
canvas.replaceWith(still);
} else {
// Removed first, then released: a lost canvas left in the page
// shows a broken-image mark.
canvas.remove();
}
if (gl) {
for (const free of made) free();
for (const program of programs.values()) gl.deleteProgram(program.p);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
canvas.width = canvas.height = 0;
gl = kit = sim = null;
if (!still) restoreHost();
};
const stop = (): void => finish(false);
const restoreHost = (): void => {
if (hostStyle === undefined) return;
// Read it before removing it. In Chrome, a style set through
// `.style` and removed before anyone has read the attribute leaves
// `style=""` behind, and "as it was" would be nearly true.
element.getAttribute('style');
if (hostStyle === null) element.removeAttribute('style');
else element.setAttribute('style', hostStyle);
hostStyle = undefined;
};
const tokens = (): Record<string, Rgb> => {
const out: Record<string, Rgb> = {};
const probe = canvas.style;
for (const [uniform, property, fallback] of TOKENS) {
// Any colour syntax in, one serialisation out: mixing a colour
// with itself in sRGB makes the browser do the conversion.
probe.color = `color-mix(in srgb, var(${property}, ${fallback}) 100%, #000 0%)`;
out[uniform] = parseSrgb(getComputedStyle(canvas).color) ?? [0, 0, 0];
}
probe.color = '';
return out;
};
const running = (): boolean => !done && built && visible && onScreen && !options.paused;
const schedule = (): void => {
if (frame === 0 && running()) frame = requestAnimationFrame(draw);
};
function draw(now: number): void {
frame = 0;
if (!running() || !gl || !kit || !sim) return;
const gap = last ? Math.min(now - last, 100) : 0;
last = now;
const dt = gap / 1000;
elapsed += gap;
input.time += dt;
// Four frames in a row slower than 15 a second: do less from now on.
slow = gap > 66 ? slow + 1 : 0;
if (slow > 3) input.late = true;
const left = options.budget > 0 ? options.budget - elapsed : Infinity;
input.ease = Math.max(0, Math.min(1, left / 500));
input.idle = (now - pointerAt) / 1000;
input.active = input.idle < 0.1;
const more = sim.step(dt, options, input);
sim.show(options, kit.tokens);
const listens = sim.listens?.(options) ?? false;
if (left <= 0 && !listens) return finish(options.keepStill);
if (more && left > 0) schedule();
else {
// At rest: nothing to draw until something comes in. A recipe that
// listens for the pointer rests at its budget too, so the pointer
// can bring it back.
canvas.dataset.state = 'rest';
last = 0;
}
}
/**
* Something came in: a pointer, an update, the page back on screen.
* The frame clock starts afresh only from rest: reset on every event,
* a pointer that never stops (a mouse at 125 Hz or more) gave every
* frame a step of nothing, and the field froze under it.
*/
const wakeLoop = (): void => {
if (!built || done) return;
canvas.dataset.state = 'running';
if (frame === 0) last = 0;
schedule();
};
const pointer = (event: PointerEvent): void => {
if (event.type === 'pointerleave') {
pointerAt = -Infinity;
return;
}
const box = element.getBoundingClientRect();
input.x = (event.clientX - box.left) / Math.max(1, box.width);
input.y = 1 - (event.clientY - box.top) / Math.max(1, box.height);
input.seen = true;
pointerAt = performance.now();
// A touch is a fresh start for the clock of a recipe that answers it.
if (sim?.listens?.(options)) elapsed = 0;
wakeLoop();
};
const resize = (entries: ResizeObserverEntry[]): void => {
const entry = entries[0];
if (done || !gl || !sim || !entry) return;
const ratio = devicePixelRatio || 1;
const exact = entry.devicePixelContentBoxSize?.[0];
const box = entry.contentBoxSize?.[0];
const cssWidth = box ? box.inlineSize : entry.contentRect.width;
const cssHeight = box ? box.blockSize : entry.contentRect.height;
// The device-pixel box is for snapping, not for scale: it is trusted
// only where it agrees with devicePixelRatio to within a pixel.
// Under DevTools device emulation it reports CSS pixels.
const snapped = exact && Math.abs(exact.inlineSize - cssWidth * ratio) < 2 && Math.abs(exact.blockSize - cssHeight * ratio) < 2;
const [width, height] = bufferSize(
snapped ? exact.inlineSize : cssWidth * ratio,
snapped ? exact.blockSize : cssHeight * ratio,
ratio,
options.maxPixelRatio,
options.maxPixels,
);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
sim.resize?.(cssWidth, cssHeight);
// Sizing clears the buffer, so draw before this frame is shown.
if (kit) sim.show(options, kit.tokens);
schedule();
};
const makeKit = (g: WebGL2RenderingContext): Kit => {
const filter = (tex: WebGLTexture, linear: boolean, repeat: boolean): void => {
g.bindTexture(g.TEXTURE_2D, tex);
const f = linear ? g.LINEAR : g.NEAREST;
const r = repeat ? g.REPEAT : g.CLAMP_TO_EDGE;
g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, f);
g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, f);
g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_S, r);
g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_T, r);
};
const drop: Kit['drop'] = (thing) => {
if ('r' in thing) {
drop(thing.r);
drop(thing.w);
} else if ('fbo' in thing) {
g.deleteFramebuffer(thing.fbo);
g.deleteTexture(thing.tex);
} else {
g.deleteTexture(thing);
}
};
/** Destination, viewport, program and uniforms for a draw; textures take units in the order given. */
const bind = (program: Program, uniforms: Record<string, Uniform>, dst: Target | null): void => {
g.bindFramebuffer(g.FRAMEBUFFER, dst ? dst.fbo : null);
g.viewport(0, 0, dst ? dst.w : canvas.width, dst ? dst.h : canvas.height);
g.useProgram(program.p);
let unit = 0;
for (const [name, value] of Object.entries(uniforms)) {
if (!program.u.has(name)) program.u.set(name, g.getUniformLocation(program.p, name));
const at = program.u.get(name) ?? null;
if (typeof value === 'number') g.uniform1f(at, value);
else if ('tex' in value) {
g.activeTexture(g.TEXTURE0 + unit);
g.bindTexture(g.TEXTURE_2D, value.tex);
g.uniform1i(at, unit);
unit += 1;
} else if (value.length === 2) g.uniform2fv(at, value);
else if (value.length === 3) g.uniform3fv(at, value);
else g.uniform4fv(at, value);
}
};
const target: Kit['target'] = (w, h, kind, linear, repeat) => {
const tex = g.createTexture();
const fbo = g.createFramebuffer();
g.bindTexture(g.TEXTURE_2D, tex);
g.texStorage2D(g.TEXTURE_2D, 1, kind === 2 ? g.RGBA32F : kind === 1 ? g.RGBA16F : g.RGBA8, w, h);
filter(tex, linear && kind !== 2, repeat);
g.bindFramebuffer(g.FRAMEBUFFER, fbo);
g.framebufferTexture2D(g.FRAMEBUFFER, g.COLOR_ATTACHMENT0, g.TEXTURE_2D, tex, 0);
const ok = g.checkFramebufferStatus(g.FRAMEBUFFER) === g.FRAMEBUFFER_COMPLETE;
if (ok) {
g.clearColor(0, 0, 0, 0);
g.clear(g.COLOR_BUFFER_BIT);
}
const out = { tex, fbo, w, h, ok };
made.push(() => drop(out));
return out;
};
// Float capability is the completeness of a real target, never the
// extension list: WebKit has shipped releases that advertised what
// the hardware did not have.
g.getExtension('EXT_color_buffer_float');
const probe = target(2, 2, 1, true, false);
const float = probe.ok;
drop(probe);
return {
gl: g,
float,
aspect: 1,
host: null,
tokens: {},
program: (name) => {
const program = programs.get(name);
if (!program) throw new Error(name);
return program;
},
target,
pair: (w, h, kind, linear, repeat) => {
const pair: Pair = {
r: target(w, h, kind, linear, repeat),
w: target(w, h, kind, linear, repeat),
swap() {
const t = pair.r;
pair.r = pair.w;
pair.w = t;
},
};
return pair;
},
image: (source, linear) => {
const tex = g.createTexture();
g.bindTexture(g.TEXTURE_2D, tex);
g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL, true);
g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, g.RGBA, g.UNSIGNED_BYTE, source);
g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL, false);
filter(tex, linear, false);
made.push(() => g.deleteTexture(tex));
return tex;
},
pass: (program, uniforms, dst) => {
bind(program, uniforms, dst);
g.drawArrays(g.TRIANGLES, 0, 3);
},
points: (program, uniforms, count, dst) => {
bind(program, uniforms, dst);
g.enable(g.BLEND);
g.blendFunc(g.ONE, g.ONE);
g.drawArrays(g.POINTS, 0, count);
g.disable(g.BLEND);
},
drop,
};
};
const ready = (): void => {
if (done || !gl) return;
for (const program of programs.values()) {
if (!gl.getProgramParameter(program.p, gl.LINK_STATUS)) return stop();
}
kit = makeKit(gl);
if (NEEDS_FLOAT && !kit.float) return stop();
const box = element.getBoundingClientRect();
kit.aspect = box.width / Math.max(1, box.height);
kit.host = element;
try {
sim = makeSim(kit, options);
} catch {
return stop();
}
if (!page && getComputedStyle(element).position === 'static') {
// An absolutely placed canvas covers the nearest positioned box:
// the host, not the page.
hostStyle = element.getAttribute('style');
element.style.position = 'relative';
}
element.append(canvas);
canvas.dataset.state = 'running';
built = true;
// In the document first: an element outside it has no computed style.
kit.tokens = tokens();
observer = new ResizeObserver(resize);
try {
observer.observe(canvas, { box: 'device-pixel-content-box' });
} catch {
observer.observe(canvas);
}
seer = new IntersectionObserver((entries) => {
onScreen = entries.some((entry) => entry.isIntersecting);
if (onScreen) wakeLoop();
else last = 0;
});
seer.observe(element);
for (const type of ['pointermove', 'pointerdown', 'pointerleave'] as const) element.addEventListener(type, pointer);
};
const build = (): void => {
if (done || gl) return;
if (document.visibilityState !== 'visible') return;
gl = canvas.getContext('webgl2', {
alpha: true,
antialias: false,
depth: false,
stencil: false,
powerPreference: 'low-power',
// A software renderer would burn a weak machine's cores for decoration.
failIfMajorPerformanceCaveat: true,
});
if (!gl) return stop();
const g = gl;
canvas.addEventListener('webglcontextlost', stop);
for (const [name, body] of Object.entries(PROGRAMS as Programs)) {
const p = g.createProgram();
const [vert, frag] = typeof body === 'string' ? [VERTEX, HEAD + body] : [body.vertex, HEAD + body.fragment];
for (const [type, source] of [[g.VERTEX_SHADER, vert], [g.FRAGMENT_SHADER, frag]] as const) {
const shader = g.createShader(type);
if (!shader) return stop();
g.shaderSource(shader, source);
g.compileShader(shader);
g.attachShader(p, shader);
g.deleteShader(shader);
}
g.linkProgram(p);
programs.set(name, { p, u: new Map() });
}
const parallel = g.getExtension('KHR_parallel_shader_compile');
let waited = 0;
const poll = (): void => {
frame = 0;
if (done || !gl) return;
waited += 1;
// With the extension, ask without blocking, and wait for the
// slowest. Without it, give the driver two frames, then ask and
// take the wait. Asking early moved first paint by more than a
// second in testing.
const linked = parallel
? [...programs.values()].every((program) => g.getProgramParameter(program.p, parallel.COMPLETION_STATUS_KHR))
: waited > 2;
if (linked) ready();
else frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
};
function wake(): void {
visible = document.visibilityState === 'visible';
if (!gl) build();
else if (visible) wakeLoop();
else last = 0;
}
const handle: Handle = {
update(next) {
const previous = options;
options = mergeOptions(options, next);
try {
sim?.update?.(previous, options);
} catch {
return stop();
}
if (options.paused) last = 0;
else wakeLoop();
},
destroy() {
stop();
still?.remove();
still = null;
restoreHost();
if (mounted.get(element) === handle) mounted.delete(element);
},
};
mounted.set(element, handle);
addEventListener('pagehide', stop);
addEventListener('pageswap', stop);
document.addEventListener('visibilitychange', wake);
// After a paint, never before one: the page is on screen first, and
// creating a context is a blocking call of tens of milliseconds.
frame = requestAnimationFrame(() => {
frame = 0;
timer = window.setTimeout(build, 0);
});
return handle;
}
// ---- HARNESS stateful: end --------------------------------------------------