Catalog / Recipe
Feedback trails
The pointer leaves a trail that streams, spirals or drifts and fades to exactly nothing.
- Family
- feedback
- Status
- lab
- Used for
- HeroCursorFigureLab page
- Size
- 4,706 of 5,000 bytes, gzipped
mount(element)The Feedback TOP, on a page: each frame is the last one read through a small transform, faded, with the pointer’s dot laid over it. The transform is what gives the trail its character. A zoom under 1 streams it outward, over 1 pulls it into a tunnel, a spin makes spirals, a drift is wind. With no transform at all the trail is hard and only fades.
Use
import { mount } from './feedback-trails';
mount(document.querySelector('.hero'));
How it ends
It runs only while driven. After tail seconds without input, and once
the fade has taken every texel to exactly zero, it rests: no frames, and
the pointer wakes it. A budget is a second way to rest: that much
running time since the last touch, whatever the field holds. A recipe
the pointer can bring back is never released by a budget, only by
destroy().
Options
| Option | Default | Meaning |
|---|---|---|
halfLife |
0.4 | Seconds for the trail to fade by half |
zoom |
0.7 | Scale per second: under 1 streams outward, over 1 is a tunnel, 1 is a hard trail |
spin |
0.35 | Turn per second, in radians |
drift |
0 | Sideways drift, as a share of the width a second |
radius |
0.035 | The dot’s radius, as a share of the height |
composite |
0 | 0 maximum, 1 over, 2 add. Add glows and clips |
tint |
0 | 0 accent, 1 ink |
tail |
3 | Seconds after the last input before it rests |
idlePath |
false |
With no pointer yet, draws a path by itself for four seconds |
simSize |
512 | Long side of the simulation, in texels |
budget, paused, keepStill, maxPixelRatio, maxPixels |
0, false, false, 1.5, 2,500,000 |
As every stateful recipe. Here a budget rests rather than releases, and keepStill has nothing to keep |
Versions
| Version | What it is |
|---|---|
| Spiral | The defaults: a slow outward spiral |
| Comet | A short hard tail, no transform |
| Wind | A long tail blown sideways |
| Tunnel | The image falls inward |
Where it must not go
- Behind or beside text. It draws where the pointer is, and the pointer is over the text.
- Over an image that is evidence. A trail across a chart is a line on the chart.
- As the only thing on a page for a touch visitor, who has no
pointer. Turn
idlePathon, or give the page something else.
Learned while building it
- A multiplicative fade never reaches zero in 8 bits. At decay 0.97 every value of 16/255 or less rounds back to itself. Subtracting 1/255 each step takes the field to exactly zero, which is what lets the loop rest.
- With no transform there is no softening. Texels map to themselves. The blur people expect from feedback is resampling under a small zoom or turn.
- Half-life is the honest unit. A decay per frame is twice as fast at 120 Hz.
/**
* feedback-trails
*
* The pointer leaves a trail that streams, spirals or drifts and fades
* to nothing. Each frame is last frame read through a small transform,
* faded, and the pointer's dot laid over it: the Feedback TOP, in two
* textures and a swap.
*
* Copy this file. It imports nothing.
*
* import { mount } from './feedback-trails';
* mount(document.querySelector('.hero'));
*
* It runs only while driven. With nothing happening it is at rest: no
* frames, nothing drawn, and the pointer wakes it. A budget is a second
* way to rest; only destroy releases it.
*/
export interface Options {
/** Ceiling on canvas pixels per CSS pixel. */
maxPixelRatio: number;
/** Ceiling on canvas pixels in total. */
maxPixels: number;
/** Running time in ms since the last touch before it rests. 0 rests only when the field is empty. */
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 simulation in texels. The cost dial: a phone runs the same size as a laptop. */
simSize: number;
/** Time in seconds for a trail to fade by half. */
halfLife: number;
/** Scale of the image per second. Under 1 it streams outward, over 1 it falls into a tunnel, 1 is a hard trail that only fades. */
zoom: number;
/** Turn of the image, in radians a second. Spirals. */
spin: number;
/** Drift of the image, as a share of the width a second. Wind. */
drift: number;
/** Radius of the dot the pointer lays down, as a share of the height. */
radius: number;
/** How the dot joins the trail: 0 maximum, 1 over, 2 add. Add glows and clips. */
composite: number;
/** Which token colours the trail: 0 accent, 1 ink. */
tint: number;
/** Seconds after the last input before it goes to rest. */
tail: number;
/** With no pointer yet, draws a short path by itself for four seconds after mounting. */
idlePath: boolean;
}
export const DEFAULTS: Options = {
maxPixelRatio: 1.5,
maxPixels: 2_500_000,
budget: 0,
paused: false,
keepStill: false,
simSize: 512,
halfLife: 0.4,
zoom: 0.7,
spin: 0.35,
drift: 0,
radius: 0.035,
composite: 0,
tint: 0,
tail: 3,
idlePath: false,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// STEP reads the previous frame at a transformed coordinate, fades it,
// subtracts a floor so that 8-bit values reach exactly zero, and lays the
// dot over it. SHOW colours the field in a token, premultiplied over
// transparent. Both bodies get the harness's head: version, precision,
// `v` in 0..1 and `o` out.
const PROGRAMS: Record<string, string> = {
step: `uniform sampler2D s;uniform vec4 m;uniform vec3 inj;uniform vec2 shift,asp;uniform float decay,rad,mode;
void main(){vec2 q=mat2(m.xy,m.zw)*(v-.5)+.5+shift;
vec4 p=max(texture(s,q)*decay-1./255.,0.);
float e=inj.z*smoothstep(rad,rad*.5,length((v-inj.xy)*asp));
o=mode<.5?max(p,vec4(e)):mode<1.5?mix(p,vec4(1),e):min(p+e,1.);}`,
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'],
];
/** Trails live in 8 bits. */
const NEEDS_FLOAT = false;
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
/** The fade per frame for a half-life in seconds and a frame of `dt` seconds. */
export function decay(halfLife: number, dt: number): number {
if (!(halfLife > 0)) return 0;
return halfLife === Infinity ? 1 : Math.pow(0.5, dt / halfLife);
}
/** The 2 by 2 lookup transform for one frame: a zoom per second and a spin in radians a second. */
export function matrix(zoom: number, spin: number, dt: number): [number, number, number, number] {
const z = Math.pow(clamp(zoom, 0.05, 20), dt);
const c = Math.cos(spin * dt) * z;
const s = Math.sin(spin * dt) * z;
return [c, s, -s, c];
}
/** Where the idle path is at `t` seconds: a Lissajous figure inside the box. */
const path = (t: number): [number, number] => [0.5 + 0.3 * Math.cos(t * 1.7), 0.5 + 0.3 * Math.sin(t * 2.3)];
const makeSim = (kit: Kit, first: Options): Sim => {
let options = first;
const size = (): [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 [w, h] = size();
let field = kit.pair(w, h, 0, true, false);
let quiet = 0;
let sinceInput = 0;
return {
step(dt, o, input) {
options = o;
const self = o.idlePath && !input.seen && input.time < 4;
const at = self ? path(input.time) : [input.x, input.y];
const on = self || input.active ? 1 : 0;
if (on) {
quiet = 0;
sinceInput = 0;
} else {
quiet += dt;
sinceInput += 1;
}
// Nothing coming in, and the floor has had time to take every texel to zero: rest.
if (quiet > clamp(o.tail, 0, 60) && sinceInput > 255) return false;
kit.pass(
kit.program('step'),
{
s: { tex: field.r.tex },
m: matrix(o.zoom, o.spin, dt),
shift: [-clamp(o.drift, -1, 1) * dt, 0],
asp: [kit.aspect, 1],
inj: [at[0] ?? 0.5, at[1] ?? 0.5, on],
decay: decay(clamp(o.halfLife, 0.02, 30), dt),
rad: clamp(o.radius, 0.004, 0.5),
mode: clamp(Math.round(o.composite), 0, 2),
},
field.w,
);
field.swap();
return true;
},
show(o, tokens) {
kit.pass(kit.program('show'), { s: { tex: field.r.tex }, col: tokens[Math.round(o.tint) === 1 ? 'uInk' : 'uAccent'] ?? [0, 0, 1] }, null);
},
listens: () => true,
update(previous, next) {
options = next;
if (previous.simSize === next.simSize) return;
[w, h] = size();
kit.drop(field);
field = 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.Wind)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Spiral: {},
Comet: { zoom: 1, spin: 0, halfLife: 0.25 },
Wind: { zoom: 0.92, spin: 0, drift: 0.25, halfLife: 0.8 },
Tunnel: { zoom: 1.6, spin: 0.1, halfLife: 0.5 },
};
// ---- 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 --------------------------------------------------