Catalog / Recipe
Contour field
A contour map of a noise field, in the page's own ink, that reveals itself over the page and leaves.
- Family
- topology
- Status
- lab
- Used for
- Page loadHeroSection introBackground
- Size
- 4,401 of 4,500 bytes, gzipped
mount(element)A terrain that is not there, surveyed in the page’s own line. The lines are drawn by the fragment shader from a height field, so there is no geometry, no texture and nothing to load.
Use
import { mount } from './contour-field';
mount(document.body);
The four reveals
reveal |
Name | What happens | Ends on |
|---|---|---|---|
| 0 | Tide | A level rises through the terrain. Lines exist only below it, and the level itself is one heavier line in the accent. Islands appear, grow and join | nothing, or the full map |
| 1 | Survey | Index contours fade in, then the rest. Nothing moves | nothing, or the full map |
| 2 | Drain | The relief falls away and the lines leave through the summits and pits | nothing |
| 3 | Rule | The terrain relaxes into evenly spaced horizontal rules | nothing, or the rules |
A line cannot be “drawn on” along its length here: an implicit contour has no start and no end. Tide is the honest version of that idea.
Options
| Option | Default | Meaning |
|---|---|---|
reveal |
0 | Which reveal, as above |
featureSize |
180 | Size of one hill, in CSS px |
octaves |
3 | Layers of detail, 1 to 5 |
gain |
0.5 | Strength of each finer layer |
warp |
0 | How far the terrain is folded over itself |
levels |
11 | How many contour levels cross the field |
lineWidth |
1.25 | Width of an ordinary contour, in CSS px |
indexWidth |
2 | Width of an index contour, in CSS px |
index |
true |
Every fifth contour is heavier |
ink |
0.6 | Opacity of the lines while it plays |
stillInk |
0.2 | Opacity of the lines in the still |
drift |
0.25 | How fast the terrain turns over while it plays. 0 freezes it |
accent |
true |
The tide’s edge is in the accent colour |
keepStill |
false |
Leave the last frame behind |
under |
false |
Draw behind the element’s content |
seed |
7 | Which terrain |
tint |
0 | What colours the lines: 0 the ink, 1 the accent, 2 ink in the low ground rising to accent on the heights |
duration, arrivalOnly, maxPixelRatio, maxPixels |
900, true, 2, 2,500,000 |
As every one-shot shader recipe |
With tint at 1 the lines are already the accent, so the tide’s edge
is drawn in ink instead: an accent edge on accent lines is no edge.
Versions
PRESETS holds sets of options that belong together. One goes where the
options go: mount(document.body, PRESETS.Blueprint).
| Version | What it is |
|---|---|
| Survey | The defaults: ink lines, a tide with an accent edge |
| Blueprint | Lines in the accent colour, finer and closer together |
| Relief | Ink in the valleys rising to the accent on the heights, over folded ground |
| Quiet | Fewer, fainter and larger, for where it has to stay in the background |
| Strata | Relaxes into horizontal rules and leaves them behind as a still |
None of them has been judged in motion on a real page. They are starting points, and the colour ones in particular depend on the skin’s accent.
Where it must not go
- Behind body text, at any strength. Lines through 16 px type are busy even at 18%.
- At full strength in the same region as any text.
- Beside real data. Contour lines next to a chart or a map read as part of it.
Learned while building it
- Size the terrain in CSS pixels. Scaled to the element, settings that looked right in a 300 px tile were far too busy in a band behind a headline.
- Differentiate the continuous height, not the wrapped one, and use
the length of the derivative.
fwidthmakes diagonal lines fatter. - Drop intermediate lines on steep ground. Otherwise a steep slope is a black smear.
- Output premultiplied ink on a transparent canvas. There is then no ground colour to match, and the same shader is right on both themes.
- A noise field does not fill its own range. Layered gradient noise stayed within about -0.4 to 0.4, so levels spread over -1 to 1 drew four lines where eleven were asked for, and the tide crossed the whole map in a fifth of its time. The field is scaled before the levels are laid over it. This only showed in a still.
- Stills are taken with linear easing. Under the skin’s own easing a frame at 35% of the time is at 50% of the reveal, and the two were being confused.
/**
* contour-field
*
* A contour map of a seeded noise field, drawn in the page's own ink on
* a transparent canvas, that reveals itself over the page and leaves.
* The ground is the page's, so it reads the same on a light theme and a
* dark one.
*
* Copy this file. It imports nothing.
*
* import { mount } from './contour-field';
* mount(document.body);
*
* Four reveals: a tide that rises through the terrain (the default), a
* survey that fills the map in, a drain that empties it, and a rule
* that relaxes it into the page's own lines.
*/
export interface Options {
/** How long the reveal takes, in ms. Falls back to --load-duration. */
duration: number;
/** Ceiling on canvas pixels per CSS pixel. Lines need pixels more than a colour field does. */
maxPixelRatio: number;
/** Ceiling on canvas pixels in total. */
maxPixels: number;
/** Play only when the reader arrived from outside the site. */
arrivalOnly: boolean;
/** Which reveal: 0 tide, 1 survey, 2 drain, 3 rule. */
reveal: number;
/** Size of one hill, in CSS px. Never a share of the element: a band and a tile need the same terrain. */
featureSize: number;
/** Layers of detail, 1 to 5. Cost is linear in this. */
octaves: number;
/** How strong each finer layer is, against the one before. */
gain: number;
/** How far the terrain is folded over itself. 0 is plain hills. */
warp: number;
/** How many contour levels cross the field. */
levels: number;
/** Width of an ordinary contour, in CSS px. */
lineWidth: number;
/** Width of an index contour, in CSS px. */
indexWidth: number;
/** Every fifth contour is heavier, as on a printed map. */
index: boolean;
/** Opacity of the lines while the reveal plays. */
ink: number;
/** Opacity of the lines in the still that is left behind. Over 0.3 costs reading. */
stillInk: number;
/** How fast the terrain turns over while it plays, in radians a second. 0 freezes it. */
drift: number;
/** The tide's leading edge is drawn in the accent colour. */
accent: boolean;
/** Leave the last frame behind as a still. Otherwise it ends on nothing. */
keepStill: boolean;
/** Draw behind the element's content instead of over it. */
under: boolean;
/** Which terrain. The same number is the same map. */
seed: number;
/** What colours the lines: 0 the ink, 1 the accent, 2 ink in the low ground rising to accent on the heights. */
tint: number;
}
export const DEFAULTS: Options = {
duration: 900,
maxPixelRatio: 2,
maxPixels: 2_500_000,
arrivalOnly: true,
reveal: 0,
featureSize: 180,
octaves: 3,
gain: 0.5,
warp: 0,
levels: 11,
lineWidth: 1.25,
indexWidth: 2,
index: true,
ink: 0.6,
stillInk: 0.2,
drift: 0.25,
accent: true,
keepStill: false,
under: false,
seed: 7,
tint: 0,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// H: an integer hash of a lattice corner, with the terrain number mixed
// in. Integers, not a sine: the sine hash differs between GPUs.
// G: the gradient at a corner, turning with time. That turn is the drift.
// N: gradient noise, quintic fade, scaled to about -1..1.
// F: octaves of N, normalised by the sum of their weights, so the range
// does not change when the octave count does. In practice it stays
// within about -0.4..0.4, not -1..1: main scales it by 1.25 so that
// the levels are spread over the height the terrain really has.
// Without that, "11 levels" drew four, and the tide crossed the whole
// map in a fifth of its time.
// W: how fast a value changes per pixel. The length of the derivative,
// not fwidth: fwidth makes diagonal lines thicker than upright ones.
// S: one set of level lines at constant width. `d` is the distance in
// pixels to the nearest level. Below one pixel a line fades instead
// of thinning, and where levels come closer than a few pixels the
// set drops out, which is what keeps steep ground from going black.
//
// main: v is the height in level units. Each reveal is a different
// function of v and progress t:
// 0 tide lines exist only below a level L that rises to the top by
// t = 0.7; L itself is one heavier line in the accent.
// 1 survey index contours fade in, then the rest.
// 2 drain the relief falls to nothing with levels at half steps, so
// every line leaves through a summit or a pit.
// 3 rule the height blends into a plain ramp, so the contours
// become evenly spaced horizontal rules.
// The last 28% fades the ink to nothing, or to the still's strength.
// T picks the line colour: the ink, the accent, or one into the other
// by height. With accent lines the tide's edge is drawn in ink instead,
// or it would vanish into them.
const FRAGMENT = `#version 300 es
precision highp float;precision highp int;
uniform float uProgress,uTime,uPixelRatio;
uniform vec3 uInk,uAccent;
uniform vec4 uField,uLines,uLook,uMore;
uniform int uTerrain;
out vec4 o;
float H(ivec2 c){uint h=uint(c.x)*0x9E3779B1u+uint(c.y)*0x85EBCA6Bu+uint(uTerrain)*0xC2B2AE35u;
h^=h>>15;h*=0x2C1B3C6Du;h^=h>>12;h*=0x297A2D39u;h^=h>>15;return float(h>>8)/16777216.;}
vec2 G(ivec2 c){float a=H(c)*6.2831853+uTime*uLook.z;return vec2(cos(a),sin(a));}
float N(vec2 p){vec2 i=floor(p),f=p-i,u=f*f*f*(f*(f*6.-15.)+10.);ivec2 c=ivec2(i);
return mix(mix(dot(G(c),f),dot(G(c+ivec2(1,0)),f-vec2(1,0)),u.x),
mix(dot(G(c+ivec2(0,1)),f-vec2(0,1)),dot(G(c+ivec2(1,1)),f-1.),u.x),u.y)*1.41421356;}
float F(vec2 p){float a=.5,s=0.,n=0.;for(int i=0;i<5;i++){if(i>=int(uField.y))break;
s+=a*N(p);n+=a;p=mat2(.8,.6,-.6,.8)*p*2.;a*=uField.z;}return s/n;}
float W(float v){return length(vec2(dFdx(v),dFdy(v)));}
float S(float v,float w){float g=W(v),d=abs(fract(v-.5)-.5)/max(g,1e-6),k=max(w,1.);
return(1.-smoothstep(k*.5-.5,k*.5+.5,d))*min(w,1.)*(1.-smoothstep(.18,.36,g));}
void main(){
vec2 p=gl_FragCoord.xy/(uPixelRatio*uField.x);
if(uField.w>0.)p+=uField.w*vec2(F(p+vec2(3.1,7.7)),F(p+vec2(-5.3,1.9)));
float t=uProgress,n=uLines.x,f=F(p)*1.25,v=(f+.5)*n;
float w=uLines.y*uPixelRatio,x=uLines.w*uPixelRatio,e=uLines.z;
int m=int(uLook.w);float a=0.,c=0.;
if(m==0){float L=mix(-.5,n+.5,min(t/.7,1.));
a=max(S(v,w),e>.5?S(v/e,x):0.)*(1.-smoothstep(L-.5,L,v));
c=(1.-smoothstep(x*.5-.5,x*.5+.5,abs(v-L)/max(W(v),1e-6)))*smoothstep(0.,.06,t)*(1.-smoothstep(.62,.7,t));}
else if(m==1){a=max((e>.5?S(v/e,x):0.)*smoothstep(0.,.25,t),S(v,w)*smoothstep(.3,.65,t));}
else if(m==2){a=S(f*n*(1.-t)*(1.-t)+.5,w);}
else{float s=t*t*(3.-2.*t);v=mix(v,gl_FragCoord.y/(uPixelRatio*28.),s);
a=max(S(v,w),e>.5&&s<.999?S(v/e,x):0.);}
float k=mix(uLook.x,uMore.y>.5?uLook.y:0.,smoothstep(.72,1.,t));
float A=a*k,C=c*uMore.x*step(.001,k),al=max(A,C),T=uMore.z;
vec3 b=mix(uInk,uAccent,T<.5?0.:T<1.5?1.:clamp(f+.5,0.,1.));
o=vec4(mix(b,T>.5&&T<1.5?uInk:uAccent,C)*al,al);
}`;
/** CSS tokens the shader gets as vec3 uniforms, with what stands in for a missing one. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
['uInk', '--ink', '#000'],
['uAccent', '--accent', '#00f'],
];
type Quad = [number, number, number, number];
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
/**
* The options as the shader reads them, made safe. A threshold the
* shader cannot survive is not an error to report: the panel can send
* any number, and the answer is the nearest one that works.
*/
export function dials(o: Options): { field: Quad; lines: Quad; look: Quad; more: Quad; terrain: number } {
return {
field: [clamp(o.featureSize, 20, 2000), clamp(Math.round(o.octaves), 1, 5), clamp(o.gain, 0.1, 0.9), clamp(o.warp, 0, 1.5)],
lines: [clamp(o.levels, 2, 40), clamp(o.lineWidth, 0.25, 6), o.index ? 5 : 0, clamp(o.indexWidth, 0.25, 8)],
look: [clamp(o.ink, 0, 1), clamp(o.stillInk, 0, 1), clamp(o.drift, 0, 2), clamp(Math.round(o.reveal), 0, 3)],
more: [o.accent ? 1 : 0, o.keepStill ? 1 : 0, clamp(Math.round(o.tint), 0, 2), 0],
terrain: Math.abs(Math.round(o.seed)) % 65536,
};
}
const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
const d = dials(o);
gl.uniform4f(at('uField'), ...d.field);
gl.uniform4f(at('uLines'), ...d.lines);
gl.uniform4f(at('uLook'), ...d.look);
gl.uniform4f(at('uMore'), ...d.more);
gl.uniform1i(at('uTerrain'), d.terrain);
};
/** A still is worth keeping only for a reveal that ends on a drawing: drain ends empty. */
const keepsStill = (options: Options): boolean => options.keepStill && Math.round(options.reveal) !== 2;
/** Drain and rule begin from the whole map, which must not appear in a single frame. */
const fadeInMs = (options: Options): number => (Math.round(options.reveal) >= 2 ? 100 : 0);
const layerUnder = (options: Options): boolean => options.under;
/** Nothing on the canvas beyond what the harness sets. */
const canvasStyle = (_options: Options): string => '';
/** One triangle that covers the canvas; the fragment shader does the rest. */
const VERTEX = `#version 300 es
void main(){gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);}`;
const geometry = (gl: WebGL2RenderingContext, _options: Options): [mode: number, count: number] => [gl.TRIANGLES, 3];
/** No depth buffer: one pass, nothing in front of anything. */
const DEPTH = false;
/** Nothing to set up before the first frame. */
const prepare: Prepare = () => {};
/** Starts after the first paint, wherever the element is. */
const startsOnView = (_options: Options): boolean => false;
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Blueprint)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Survey: {},
Blueprint: { tint: 1, ink: 0.7, levels: 14, lineWidth: 1 },
Relief: { tint: 2, warp: 0.35, octaves: 4, ink: 0.75 },
Quiet: { ink: 0.35, levels: 7, featureSize: 260, index: false, accent: false },
Strata: { reveal: 3, keepStill: true, stillInk: 0.25, levels: 9 },
};
// ---- HARNESS one-shot: begin ------------------------------------------------
//
// Identical in every one-shot 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
// duration, maxPixelRatio, maxPixels and arrivalOnly), DEFAULTS, Handle,
// VERTEX, FRAGMENT, TOKENS, uploadDials, keepsStill, fadeInMs,
// layerUnder, canvasStyle, geometry (the draw mode and vertex count),
// DEPTH (whether the context has a depth buffer and tests with it),
// prepare (the recipe's own setup after the program is in use, which
// may return a promise the start waits on) and startsOnView (whether
// nothing is made until the element enters the viewport).
//
// What it promises: it never throws, never hides or moves content, and
// changes nothing on the page until its first frame is ready. It never
// asks the GPU a blocking question where a non-blocking one exists.
// When the effect ends, or on destroy, nothing of it remains.
/** The recipe's setup, given the context, the uniform locations, the host and the options; a promise delays the start. */
type Prepare = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, element: HTMLElement, options: Options) => void | Promise<void>;
const NOOP: Handle = { update() {}, destroy() {} };
/** One mount to an element. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();
/** 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))];
}
/** True when this page load came from outside the site, and is not a reload or a history move. */
export function isArrival(type: string | undefined, referrer: string, origin: string): boolean {
if (type && type !== 'navigate') return false;
if (!referrer) return true;
try {
return new URL(referrer).origin !== origin;
} catch {
return true;
}
}
/** `color(srgb r g b)` as the browser serialises it, to three numbers in 0..1. */
export function parseSrgb(value: string): [number, number, number] | 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') 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();
const fromToken = parseTime(getComputedStyle(element).getPropertyValue('--load-duration'));
let options = mergeOptions(fromToken === null ? DEFAULTS : { ...DEFAULTS, duration: fromToken }, explicit);
if (options.arrivalOnly) {
const entry = performance.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined;
if (!isArrival(entry?.type, document.referrer, location.origin)) return NOOP;
}
const page = element === document.body || element === document.documentElement;
const under = layerUnder(options);
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:${under ? -1 : 2147483647};${canvasStyle(options)}`;
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let observer: ResizeObserver | null = null;
let clock: Animation | null = null;
let still: HTMLCanvasElement | null = null;
let seen: IntersectionObserver | null = null;
let hostStyle: string | null | undefined;
let frame = 0;
let timer = 0;
let slow = 0;
let last = 0;
let seconds = 0;
let done = false;
const locations = new Map<string, WebGLUniformLocation | null>();
const at = (name: string): WebGLUniformLocation | null => {
if (!locations.has(name)) locations.set(name, gl && program ? gl.getUniformLocation(program, name) : null);
return locations.get(name) ?? null;
};
/** Ends the effect. 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();
seen?.disconnect();
clock?.cancel();
document.removeEventListener('visibilitychange', wake);
removeEventListener('pagehide', stop);
removeEventListener('pageswap', stop);
canvas.removeEventListener('webglcontextlost', stop);
if (keep && gl && 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) {
gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
canvas.width = canvas.height = 0;
gl = program = 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 = (): void => {
if (!gl) return;
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%)`;
const rgb = parseSrgb(getComputedStyle(canvas).color) ?? [0, 0, 0];
gl.uniform3f(at(uniform), rgb[0], rgb[1], rgb[2]);
}
probe.color = '';
};
const draw = (now: number): void => {
frame = 0;
if (done || !gl || !clock) return;
const progress = clock.effect?.getComputedTiming().progress ?? 1;
const gap = last ? now - last : 0;
last = now;
seconds += Math.min(gap, 100) / 1000;
// Four frames in a row slower than 15 a second: this is not a GPU worth keeping.
slow = gap > 66 ? slow + 1 : 0;
gl.uniform1f(at('uProgress'), progress);
gl.uniform1f(at('uTime'), seconds);
uploadDials(gl, at, options);
// Cleared first: a fragment that discards, above a horizon, must not
// show the last frame through the hole.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const [mode, count] = geometry(gl, options);
gl.drawArrays(mode, 0, count);
if (progress >= 1) finish(keepsStill(options));
else if (slow > 3) finish(false);
else frame = requestAnimationFrame(draw);
};
const resize = (entries: ResizeObserverEntry[]): void => {
const entry = entries[0];
if (done || !gl || !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;
gl.viewport(0, 0, width, height);
}
gl.uniform2f(at('uResolution'), width, height);
gl.uniform1f(at('uPixelRatio'), width / Math.max(1, cssWidth));
if (!clock) {
// The clock is a Web Animation with nothing in it: the browser
// keeps the time and applies the easing token.
const easing = getComputedStyle(element).getPropertyValue('--load-ease').trim() || 'linear';
const timing = { duration: Math.max(options.duration, 1), fill: 'forwards' as const };
try {
clock = canvas.animate(null, { ...timing, easing });
} catch {
clock = canvas.animate(null, timing);
}
const fade = fadeInMs(options);
if (fade > 0) canvas.animate({ opacity: [0, 1] }, { duration: fade });
}
// Sizing clears the buffer, so draw before this frame is shown.
cancelAnimationFrame(frame);
draw(performance.now());
};
const attach = (): void => {
if (done || !gl) return;
if (under || !page) {
// The host becomes the canvas's box, and behind the content a
// stacking context too, so the canvas sits above its background
// and below everything in it.
hostStyle = element.getAttribute('style');
if (getComputedStyle(element).position === 'static') element.style.position = 'relative';
}
if (under) {
element.style.isolation = 'isolate';
element.prepend(canvas);
} else {
element.append(canvas);
}
canvas.dataset.state = 'running';
// In the document first: an element outside it has no computed style.
tokens();
observer = new ResizeObserver(resize);
try {
observer.observe(canvas, { box: 'device-pixel-content-box' });
} catch {
observer.observe(canvas);
}
};
const ready = (): void => {
if (done || !gl || !program) return;
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return stop();
gl.useProgram(program);
if (DEPTH) gl.enable(gl.DEPTH_TEST);
gl.uniform1f(at('uSeed'), Math.random());
// The recipe's own setup, which may wait: for a font, for a picture.
// A throw, sync or async, ends the effect with nothing shown.
const context = gl;
Promise.resolve()
.then(() => prepare(context, at, element, options))
.then(attach, stop);
};
const build = (): void => {
if (done || gl) return;
if (document.visibilityState !== 'visible') return;
document.removeEventListener('visibilitychange', wake);
gl = canvas.getContext('webgl2', {
alpha: true,
antialias: false,
depth: DEPTH,
stencil: false,
powerPreference: 'low-power',
// A software renderer would burn a weak machine's cores for decoration.
failIfMajorPerformanceCaveat: true,
});
if (!gl) return stop();
canvas.addEventListener('webglcontextlost', stop);
program = gl.createProgram();
for (const [type, source] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!shader) return stop();
gl.shaderSource(shader, source);
gl.compileShader(shader);
gl.attachShader(program, shader);
gl.deleteShader(shader);
}
gl.linkProgram(program);
const parallel = gl.getExtension('KHR_parallel_shader_compile');
let waited = 0;
const poll = (): void => {
frame = 0;
if (done || !gl || !program) return;
waited += 1;
// With the extension, ask without blocking. 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.
if (parallel ? gl.getProgramParameter(program, parallel.COMPLETION_STATUS_KHR) : waited > 2) ready();
else frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
};
function wake(): void {
build();
}
const handle: Handle = {
update(next) {
options = mergeOptions(options, next);
},
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.
const begin = (): void => {
frame = requestAnimationFrame(() => {
frame = 0;
timer = window.setTimeout(build, 0);
});
};
if (startsOnView(options) && typeof IntersectionObserver === 'function') {
// Nothing is made until the element is in view: no context held
// below the fold, and the recipe prepares against the layout and
// fonts of the moment it starts.
seen = new IntersectionObserver((entries) => {
if (done || !entries.some((entry) => entry.isIntersecting)) return;
seen?.disconnect();
seen = null;
begin();
});
seen.observe(element);
} else {
begin();
}
return handle;
}
// ---- HARNESS one-shot: end --------------------------------------------------