Catalog / Recipe
Plane settle
The contour map, or the page's grid, on a tilted plane that swings flat and lands on the page to the pixel.
- Family
- topology
- Status
- lab
- Used for
- Page loadHeroPage transition
- Size
- 4,464 of 4,800 bytes, gzipped
mount(element)The flat map of contour-field, seen at an angle, swinging flat. It is
honest about what it is: a flat drawing on a tilted plane, with no
relief. The camera is eight lines in the fragment shader and there is
no matrix code anywhere; at zero tilt a ray through a pixel meets the
page at that pixel, so the frame it lands on is the flat map exactly.
Use
import { mount } from './plane-settle';
mount(document.body);
Same seed, same map: plane-settle at seed 7 settles into the picture
contour-field draws at seed 7.
How it moves
The lean eases to zero over the first 72% of the run, about the bottom edge or the centre. Lines hold their pixel width into the distance, because the derivative that sets the width already includes the projection; where the plane recedes a pixel’s footprint on the page grows, and the lines fade out between one and three times their rest footprint, so the horizon never breaks into specks. The last 28% fades the lines to nothing, or to the still’s strength.
Options
| Option | Default | Meaning |
|---|---|---|
tilt |
55 | The lean at the start, in degrees |
distance |
2.4 | The eye’s distance, in page heights; a 45 degree field of view |
pivot |
0 | The line it swings about: 0 the bottom edge, 1 the centre |
horizon |
1 | Where the far ground starts to fade, as a multiple of the rest footprint |
content |
0 | 0 the contour map, 1 the page’s grid, 2 both |
cell |
56 | The grid’s pitch, in CSS px |
featureSize, octaves, gain, levels, lineWidth, indexWidth, index |
as contour-field |
The map |
ink, stillInk, drift, keepStill, under, seed, tint |
as contour-field |
The look |
duration, arrivalOnly, maxPixelRatio, maxPixels |
1200, true, 2, 2,500,000 |
As every one-shot shader recipe |
There is no warp: the camera already triples what a steep slope costs near the horizon.
Versions
| Version | What it is |
|---|---|
| Settle | The defaults: the map, from the bottom edge |
| Grid | The page’s own grid, fainter |
| Sheet | Map and grid together, a finer pitch |
| Low | A slight lean, a telephoto eye |
| Steep | Near the ground, about the centre, fading sooner |
None of them has been judged in motion on a real page.
Where it must not go
- Under reduced motion. A camera move is the motion most likely to
cause discomfort, so the recipe does not start at all. A page that
wants a map there mounts
contour-fieldinstead. - Over content that should be read during the move. The lines cross the words at full strength while the plane is leaning.
- Behind body text, at any strength, and beside real data, as for the flat map.
What it does when things are missing
Without WebGL2, on a software renderer, under reduced motion or forced
colours: nothing, and the page is as it was. When it ends, the canvas
is removed and the context released, or the flat drawing stays as a
plain 2D canvas when keepStill is on.
Learned while building it
- The horizon fade must be relative. Fading on an absolute footprint wiped out a correct render at a small canvas size. It is a multiple of the footprint at rest, which is one device pixel.
- Take the footprint before the discard, and hold the ray’s depth away from zero. A neighbour that is infinite reaches the derivative of a pixel that is not, and the line’s edge goes black.
- Clear before every frame. A fragment that discards above the horizon leaves the previous frame in the hole otherwise. This is why the one-shot harness now clears.
/**
* plane-settle
*
* The contour map, or the page's own grid, drawn on a tilted plane
* that swings flat and lands on the page: at the end of the swing it
* is the flat map of `contour-field`, to the pixel. Fragment only, in
* the page's ink on a transparent canvas, so the ground is the page's
* and it reads the same on a light theme and a dark one.
*
* Copy this file. It imports nothing.
*
* import { mount } from './plane-settle';
* mount(document.body);
*
* A camera move is the kind of motion most likely to cause discomfort,
* so under reduced motion this does not start at all. A page that wants
* a map there mounts `contour-field` instead.
*/
export interface Options {
/** How long the whole run takes, in ms. Falls back to --load-duration. The swing is the first 72% of it. */
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;
/** The lean at the start, in degrees. 20 is a slight lean; 65 is near the ground with the horizon high in the frame. */
tilt: number;
/** How far the eye sits from the page, in page heights. Small is wide and dramatic, large is flat and telephoto. */
distance: number;
/** The line the page swings about: 0 the bottom edge, 1 the centre. */
pivot: number;
/** Where the far ground starts to fade, as a multiple of a pixel's footprint at rest. Lower fades sooner. */
horizon: number;
/** What is drawn on the plane: 0 the contour map, 1 the page's grid, 2 both. */
content: number;
/** The grid's pitch, in CSS px. */
cell: 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, and the camera triples it near the horizon. */
octaves: number;
/** How strong each finer layer is, against the one before. */
gain: number;
/** How many contour levels cross the field. */
levels: number;
/** Width of an ordinary line, 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 it 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;
/** Leave the flat map or grid 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, and the same as contour-field's. */
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: 1200,
maxPixelRatio: 2,
maxPixels: 2_500_000,
arrivalOnly: true,
tilt: 55,
distance: 2.4,
pivot: 0,
horizon: 1,
content: 0,
cell: 56,
featureSize: 180,
octaves: 3,
gain: 0.5,
levels: 11,
lineWidth: 1.25,
indexWidth: 2,
index: true,
ink: 0.6,
stillInk: 0.2,
drift: 0.25,
keepStill: false,
under: false,
seed: 7,
tint: 0,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// The field and the lines are contour-field's, unchanged: H an integer
// hash, G a gradient that turns with time (the drift), N gradient noise
// with the quintic fade, F octaves of it normalised and scaled by 1.25
// to the height the terrain really has, W the length of the derivative,
// S one set of level lines at constant pixel width that drops out
// where levels crowd. R rotates a pair about the origin.
//
// The camera, in eight lines. The page lies in z = 0 with y from -1 to
// 1 and the eye at distance D with a field of view whose tangent is
// 1 / D, so at zero tilt the ray through a pixel meets the plane at
// that pixel and the rest frame is the flat map exactly. A tilt is one
// rotation of the eye and the ray about the pivot line; `pg` is where
// the ray meets the page, `px` the same in device pixels. `g` is a
// pixel's footprint on the page relative to rest, taken before the
// discard above the horizon so no undefined neighbour reaches it, and
// `dz` is held away from zero so nothing is infinite.
//
// progress t: the tilt eases to zero by t = 0.72; the far ground fades
// between one and three times the rest footprint (relative, never
// absolute: an absolute footprint wiped out a small canvas); the last
// 28% fades the ink to nothing or to the still's strength. T picks the
// line colour as in contour-field.
const FRAGMENT = `#version 300 es
precision highp float;precision highp int;
uniform vec2 uResolution;
uniform float uProgress,uTime,uPixelRatio;
uniform vec3 uInk,uAccent;
uniform vec4 uField,uLines,uLook,uCam,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));}
vec2 R(vec2 v,float a){float c=cos(a),s=sin(a);return vec2(v.x*c-v.y*s,v.x*s+v.y*c);}
void main(){
vec2 r=uResolution,uv=(gl_FragCoord.xy-.5*r)/(.5*r.y);
float t=uProgress,e=smoothstep(0.,1.,min(t/.72,1.)),tl=uCam.x*(1.-e),D=uCam.y,pv=uCam.z;
vec3 E=vec3(0.,-pv,D),d=vec3(uv,-D);
E.yz=R(E.yz,tl);d.yz=R(d.yz,tl);E.y+=pv;
float dz=min(d.z,-1e-4);
vec2 pg=(E-d*E.z/dz).xy,px=pg*.5*r.y+.5*r;
float g=length(vec2(dFdx(px.y),dFdy(px.y)));
if(d.z>-1e-4)discard;
float hz=1.-smoothstep(uCam.w,uCam.w*3.,g);
vec2 p=px/(uPixelRatio*uField.x);
float n=uLines.x,f=F(p)*1.25,v=(f+.5)*n,w=uLines.y*uPixelRatio,x=uLines.w*uPixelRatio,ix=uLines.z,c=uMore.y,a=0.;
if(c<.5||c>1.5)a=max(S(v,w),ix>.5?S(v/ix,x):0.);
if(c>.5){vec2 q=px/(uField.w*uPixelRatio);a=max(a,max(S(q.x,w),S(q.y,w)));}
float k=mix(uLook.x,uMore.x>.5?uLook.y:0.,smoothstep(.72,1.,t)),A=a*hz*k,T=uLook.w;
vec3 b=mix(uInk,uAccent,T<.5?0.:T<1.5?1.:clamp(f+.5,0.,1.));
o=vec4(b*A,A);
}`;
/** 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. The panel can send
* any number, and the answer is the nearest one that works. The pivot
* reaches the shader as a y coordinate in page units: -1 is the bottom
* edge, 0 the centre.
*/
export function dials(o: Options): { field: Quad; lines: Quad; look: Quad; cam: 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.cell, 8, 400)],
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.tint), 0, 2)],
cam: [(clamp(o.tilt, 0, 80) * Math.PI) / 180, clamp(o.distance, 1, 8), clamp(Math.round(o.pivot), 0, 1) - 1, clamp(o.horizon, 0.25, 4)],
more: [o.keepStill ? 1 : 0, clamp(Math.round(o.content), 0, 2), 0, 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('uCam'), ...d.cam);
gl.uniform4f(at('uMore'), ...d.more);
gl.uniform1i(at('uTerrain'), d.terrain);
};
/** The swing ends on the flat drawing, which is worth keeping. */
const keepsStill = (options: Options): boolean => options.keepStill;
/** It starts from a whole drawing at a lean, which must not appear in a single frame. */
const fadeInMs = (_options: Options): number => 100;
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 camera is in the fragment shader. */
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: a flat drawing seen at an angle has 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.Grid)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Settle: {},
Grid: { content: 1, ink: 0.5 },
Sheet: { content: 2, cell: 40, ink: 0.45, levels: 9 },
Low: { tilt: 30, distance: 3.2 },
Steep: { tilt: 65, distance: 1.8, pivot: 1, horizon: 0.75 },
};
// ---- 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 --------------------------------------------------