Catalog / Recipe
Automaton fill
A Life-like automaton grows across the element as a pixel texture and, at the Maze rule, freezes.
- Family
- feedback
- Status
- lab
- Used for
- BackgroundSection introFigureLab page
- Size
- 4,913 of 5,300 bytes, gzipped
Shape
Custom:
Size
×px
mount(element)One bit a cell, eight neighbours counted, and a rule read from two bit masks, so the same shader runs every Life-like rule. Integer state and exact fetches: the same start gives the same field on every GPU, to the bit. The second channel holds an after-image that softens the flicker of single cells.
Use
import { mount } from './automaton-fill';
mount(document.querySelector('.band'));
How it ends
The budget, with the generation rate eased to zero over the last half
second, then the field is left as a plain picture. Maze freezes by
itself well inside the default budget; Coral settles. Life and Anneal
never stop, which is what budget and paused are for on a lab page.
Options
| Option | Default | Meaning |
|---|---|---|
rule |
0 | Maze, Coral, Life or Anneal |
cellSize |
3 | One cell, in CSS pixels. Whole numbers |
rate |
20 | Generations a second |
density |
0.35 | Share of cells alive at the start |
afterImage |
0.03 | How fast a dead cell’s ghost fades |
seedNumber |
1 | Which start |
tint |
0 | The ink, or the accent |
budget, paused, keepStill, maxPixelRatio, maxPixels |
4000, false, true, 1.5, 2,500,000 |
As every stateful recipe |
Versions
| Version | What it is |
|---|---|
| Maze | The defaults: corridors that fill the frame and stop |
| Coral | A half-full start that grows into coral-like masses and settles |
| Life | The familiar soup. Never stops: lab only |
| Anneal | Grainy domains that coarsen. Never stops: lab only |
Where it must not go
- Behind text. Cells near stroke width are unreadable to read through.
- Beside photographs or soft imagery. It belongs with monochrome, terminal-like pages.
- Large cells at a high rate. Regions toggling at half the rate can cross the flash rule.
Learned while building it
- Rules differ completely in whether they stop. Maze froze with no cell changing by generation 2,000; Life still flipped 0.9% of cells at 8,000, forever.
- Pace generations on the clock. A generation a frame is twice as fast on a 120 Hz screen.
/**
* automaton-fill
*
* A Life-like cellular automaton grows across the element as a pixel
* texture. At the Maze rule it fills and freezes; Life and Anneal never
* stop and are for a lab. One bit of state a cell, an after-image in a
* second channel, and a rule as two bit masks, so one shader runs every
* Life-like rule. Exactly the same on every GPU.
*
* Copy this file. It imports nothing.
*
* import { mount } from './automaton-fill';
* mount(document.querySelector('.band'));
*/
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 ends, slowing into the end. 0 never ends: for a lab, not a page. */
budget: number;
/** Stops the frames. Nothing is lost; unpausing carries on. */
paused: boolean;
/** When the budget ends it, leave the frozen field as a plain picture. */
keepStill: boolean;
/** Not used: the field is sized by cellSize. Kept so every stateful recipe has the same six. */
simSize: number;
/** Which rule: 0 Maze, 1 Coral, 2 Life, 3 Anneal. Only the first two stop. */
rule: number;
/** Size of one cell, in CSS pixels, 2 to 12. */
cellSize: number;
/** Generations a second. Above about 30 large cells flicker. */
rate: number;
/** Share of cells alive at the start, 0 to 1. */
density: number;
/** How fast a dead cell's after-image fades, per generation. 0 is hard cells. */
afterImage: number;
/** Which start. The same number is the same field, on any GPU. */
seedNumber: number;
/** What colours it: 0 the ink, 1 the accent. */
tint: number;
}
export const DEFAULTS: Options = {
maxPixelRatio: 1.5,
maxPixels: 2_500_000,
budget: 4000,
paused: false,
keepStill: true,
simSize: 256,
rule: 0,
cellSize: 3,
rate: 20,
density: 0.35,
afterImage: 0.03,
seedNumber: 1,
tint: 0,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
/** Named rules in B/S notation. Maze and Coral freeze; Life and Anneal do not. */
export const RULES: readonly (readonly [name: string, rule: string])[] = [
['Maze', 'B3/S12345'],
['Coral', 'B3/S45678'],
['Life', 'B3/S23'],
['Anneal', 'B4678/S35678'],
];
/** A rule string to its birth and survival masks: bit n set means "with n neighbours". */
export function ruleMasks(rule: string): [birth: number, survival: number] {
const match = /^B([0-8]*)\/S([0-8]*)$/.exec(rule.trim());
if (!match) return [0, 0];
const mask = (digits: string): number => [...digits].reduce((bits, digit) => bits | (1 << Number(digit)), 0);
const out: [number, number] = [mask(match[1] ?? ''), mask(match[2] ?? '')];
return out[0] === 0 && out[1] === 0 ? [0, 0] : out;
}
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
// STEP counts the eight neighbours with texelFetch, wrapping at the
// edges, and looks the next state up in the rule's bit masks. The
// green channel is the after-image. SEED fills from an integer hash.
// SHOW reads the field with nearest filtering, so cells are squares.
const PROGRAMS: Record<string, string> = {
step: `uniform sampler2D s;uniform vec2 bs;uniform float fall;
void main(){ivec2 p=ivec2(gl_FragCoord.xy),n=textureSize(s,0);int c=0;
for(int j=-1;j<=1;j++)for(int i=-1;i<=1;i++)if(i!=0||j!=0)c+=int(texelFetch(s,(p+ivec2(i,j)+n)%n,0).x>.5);
vec4 a=texelFetch(s,p,0);float x=float((int(a.x>.5?bs.y:bs.x)>>c)&1);o=vec4(x,max(x,a.y-fall),0,1);}`,
seed: `uniform float dens,num;
float H(ivec2 p,float s){uint x=uint(p.x)*1597334677u^uint(p.y)*3812015801u^uint(s*977.);x*=2654435769u;x^=x>>16;x*=2246822519u;x^=x>>13;return float(x)/4294967296.;}
void main(){float x=step(H(ivec2(gl_FragCoord.xy),num),dens);o=vec4(x,x,0,1);}`,
show: `uniform sampler2D s;uniform vec3 col;void main(){float x=texture(s,v).y;o=vec4(col*x,x);}`,
};
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
['uInk', '--ink', '#000'],
['uAccent', '--accent', '#00f'],
];
/** One bit a cell: 8 bits is exact. */
const NEEDS_FLOAT = false;
const makeSim = (kit: Kit, first: Options): Sim => {
let options = first;
let css: [number, number] = [0, 0];
let field: Pair | null = null;
let acc = 0;
const size = (): [number, number] => {
const cell = clamp(Math.round(options.cellSize), 2, 12);
const box = kit.host?.getBoundingClientRect();
css = [box?.width ?? 256, box?.height ?? 256];
return [Math.max(4, Math.ceil(css[0] / cell)), Math.max(4, Math.ceil(css[1] / cell))];
};
const reseed = (): void => {
const [w, h] = size();
if (field) kit.drop(field);
field = kit.pair(w, h, 0, false, true);
kit.pass(kit.program('seed'), { dens: clamp(options.density, 0, 1), num: clamp(Math.round(options.seedNumber), 0, 9999) }, field.r);
acc = 0;
};
reseed();
return {
step(dt, o, input) {
options = o;
if (!field) return false;
const rule = RULES[clamp(Math.round(o.rule), 0, RULES.length - 1)]?.[1] ?? 'B3/S12345';
const [birth, survival] = ruleMasks(rule);
acc += dt * clamp(o.rate, 0, 120) * input.ease * (input.late ? 0.5 : 1);
const count = Math.min(8, Math.floor(acc));
acc -= count;
for (let i = 0; i < count; i += 1) {
kit.pass(kit.program('step'), { s: { tex: field.r.tex }, bs: [birth, survival], fall: clamp(o.afterImage, 0, 1) }, field.w);
field.swap();
}
return true;
},
show(o, tokens) {
if (!field) return;
kit.pass(kit.program('show'), { s: { tex: field.r.tex }, col: tokens[Math.round(o.tint) === 1 ? 'uAccent' : 'uInk'] ?? [0, 0, 0] }, null);
},
resize(width, height) {
// A new size is a new field: cells are whole CSS pixels.
if (Math.abs(width - css[0]) > 1 || Math.abs(height - css[1]) > 1) reseed();
},
update(previous, next) {
options = next;
if (previous.cellSize !== next.cellSize || previous.density !== next.density || previous.seedNumber !== next.seedNumber) reseed();
},
};
};
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Coral)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Maze: {},
Coral: { rule: 1, density: 0.5, rate: 30 },
Life: { rule: 2, afterImage: 0.08, budget: 0 },
Anneal: { rule: 3, density: 0.5, cellSize: 2, budget: 0 },
};
// ---- 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 --------------------------------------------------