ns-ui / loud
EDM Crater Field
A full-bleed metal plate eroded by electrical discharge machining: sparks continuously pit the surface with small raised-rim craters that fade back to base as later discharges erode the recast layer, holding the plate at a steady simmer of pockmarks rather than ever saturating.
Use when Pick edm-crater-field when the backdrop should read as a machined metal plate under continuous, low-grade erosion — a steady field of raised-rim pits that never accumulates toward a finished/saturated look. Pick weld-pool instead when the headline itself should be embedded IN a molten reflective surface (a mirror-bright pool, not a pitted plate) and the type needs to be lit and dented by the same field it sits in; weld-pool's environment needs five light sources to read as chrome, this one needs far fewer because a pocked surface only has to cross one or two reflection bands per crater to read as machined metal.
Install
npx shadcn add https://design.helpmarq.com /r/edm-crater-field.jsonSource
registry/loud/edm-crater-field/component.tsx"use client";
import { useEffect, useId, useRef } from "react";
// ---------------------------------------------------------------------------
// EdmCraterField — a full-bleed metal-surface hero eroded by electrical
// discharge machining: repeated sparks between an electrode and the
// workpiece vaporize material, each discharge leaving a small crater with a
// raised recast rim. Under continuous machining later discharges erode and
// flush away older recast material, so a real EDM surface reaches a
// statistical steady state — birth rate balanced by removal — rather than
// filling in solid. That steady state is the whole point of the component:
// it is never allowed to accumulate toward a saturated frame the way a
// literal "craters pile up" reading would.
//
// IMPLEMENTATION: unlike weld-pool's per-pixel analytic height field, a
// crater field is stamped discretely — a spark either happened somewhere or
// it didn't — so the height field lives in a small CPU-side buffer (a
// LUMINANCE texture, one erosion "cell" of resolution derived from the
// container's SMALLER dimension) rather than being evaluated in the
// fragment shader. Each discharge additively stamps a crater shape (a
// recessed bowl plus a raised outer rim) into that buffer; every real frame
// the whole buffer is multiplied toward zero by an exponential decay factor
// (birth rate is constant, decay is constant, so the resident population is
// a steady simmer, never a monotonic fill). The fragment shader only has to
// sample that texture, central-difference it for a surface normal, and light
// it — the same normal-from-heightfield + achromatic multi-band studio
// approach as weld-pool, simplified to fewer light sources since a pocked
// surface needs less environment structure to read as metal than a mirror
// pool does.
//
// Colours: five stops derived from --background, --foreground, --ns-muted
// and --border (getComputedStyle at mount, re-read on a MutationObserver
// watching documentElement's class) — no literals anywhere, including this
// comment's numbers are unitless ramp positions, not colours. --ns-accent is
// never touched: there is no interactive climactic moment here (a pointer
// only shifts *where* sparks land, never their colour), and that is a
// deliberate reading of the showpiece recipe's standing defect — the exact
// bug edge-yield/granule-churn/shear-billow shipped.
// ---------------------------------------------------------------------------
export interface EdmCraterFieldProps {
/** Freezes the surface on a composed steady-state frame without unmounting. */
paused?: boolean;
/** Rendered in the DOM over the field — eyebrow, headline, CTA. */
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
const VERT_SRC = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;
const FRAG_SRC = `
precision highp float;
uniform vec2 u_size; // css px
uniform float u_dpr;
uniform sampler2D u_height;
uniform vec2 u_texRes; // erosion buffer size in texels
uniform float u_relief;
uniform float u_time;
uniform vec3 u_c0;
uniform vec3 u_c1;
uniform vec3 u_c2;
uniform vec3 u_c3;
uniform vec3 u_c4;
uniform float u_bias;
uniform float u_contrast;
// texture stores signed height packed 0..1 -> -1..1
float sampleH(vec2 uv) {
return texture2D(u_height, uv).r * 2.0 - 1.0;
}
float strip(float el, float at, float width) {
float d = (el - at) / width;
return exp(-d * d);
}
// A three-source achromatic studio. Fewer bands than a mirror pool needs —
// a pocked, mostly-flat plate only has to cross one or two reflection bands
// per crater rim to read as machined metal rather than paper.
float env(vec3 r) {
float el = r.y;
float az = atan(r.x, r.z);
float L = 0.40;
L += 0.22 * smoothstep(0.05, 0.62, el);
L -= 0.20 * smoothstep(0.0, -0.55, el);
L += 0.30 * strip(el, 0.12, 0.05);
L += 0.16 * strip(el, 0.44, 0.08);
L += 0.10 * exp(-pow(sin(az * 1.0 + u_time * 0.045) / 0.42, 2.0));
return L;
}
vec3 ramp(float x) {
vec3 c = mix(u_c0, u_c1, smoothstep(0.0, 0.30, x));
c = mix(c, u_c2, smoothstep(0.28, 0.58, x));
c = mix(c, u_c3, smoothstep(0.55, 0.85, x));
c = mix(c, u_c4, smoothstep(0.82, 1.0, x));
return c;
}
void main() {
vec2 p = vec2(gl_FragCoord.x, u_size.y * u_dpr - gl_FragCoord.y) / u_dpr;
vec2 uv = p / u_size;
vec2 texel = 1.0 / u_texRes;
float h0 = sampleH(uv);
float hx = sampleH(uv + vec2(texel.x, 0.0));
float hy = sampleH(uv + vec2(0.0, texel.y));
vec3 n = normalize(vec3(-(hx - h0) * u_relief, (hy - h0) * u_relief, 1.0));
vec2 vp = (p - u_size * 0.5) / min(u_size.x, u_size.y);
vec3 v = normalize(vec3(vp.x * 0.5, -vp.y * 0.5, 1.0));
vec3 r = reflect(-v, n);
float L = env(r);
vec3 l1 = normalize(vec3(-0.30, 0.72, 0.58));
float s1 = pow(max(dot(r, l1), 0.0), 130.0);
L += s1 * 0.85;
float fres = pow(1.0 - max(dot(n, v), 0.0), 5.0);
L += fres * 0.12;
float Lc = clamp((L - 0.5) * u_contrast + 0.5 + u_bias, 0.0, 1.0);
vec3 col = ramp(Lc);
gl_FragColor = vec4(col, 1.0);
}
`;
type RGB = [number, number, number];
function parseHex(raw: string): RGB | null {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
if (!m) return null;
let h = m[1];
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const n = parseInt(h, 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
function mixRGB(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function luminance([r, g, b]: RGB): number {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(s);
gl.deleteShader(s);
throw new Error(`edm-crater-field: shader compile failed: ${info ?? ""}`);
}
return s;
}
// Minimal full-bleed fragment-shader host, mirroring weld-pool's GLSurface:
// one program, one fullscreen triangle pair, lazily-resolved uniform
// locations.
class GLSurface {
gl: WebGLRenderingContext | null = null;
private program: WebGLProgram | null = null;
private vs: WebGLShader | null = null;
private fs: WebGLShader | null = null;
private buffer: WebGLBuffer | null = null;
private locs = new Map<string, WebGLUniformLocation | null>();
constructor(private canvas: HTMLCanvasElement, private frag: string) {}
init(): boolean {
const gl = this.canvas.getContext("webgl", {
alpha: false,
antialias: false,
premultipliedAlpha: false,
powerPreference: "high-performance",
}) as WebGLRenderingContext | null;
if (!gl) return false;
this.gl = gl;
try {
this.vs = compile(gl, gl.VERTEX_SHADER, VERT_SRC);
this.fs = compile(gl, gl.FRAGMENT_SHADER, this.frag);
const program = gl.createProgram();
if (!program) {
this.destroy();
return false;
}
this.program = program;
gl.attachShader(program, this.vs);
gl.attachShader(program, this.fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
this.destroy();
return false;
}
} catch {
this.destroy();
return false;
}
gl.useProgram(this.program);
this.buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW
);
const loc = gl.getAttribLocation(this.program!, "a_pos");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
this.locs.clear();
return true;
}
private loc(name: string): WebGLUniformLocation | null {
if (!this.locs.has(name)) {
this.locs.set(name, this.gl!.getUniformLocation(this.program!, name));
}
return this.locs.get(name) ?? null;
}
f(name: string, x: number) {
this.gl?.uniform1f(this.loc(name), x);
}
i(name: string, x: number) {
this.gl?.uniform1i(this.loc(name), x);
}
v2(name: string, x: number, y: number) {
this.gl?.uniform2f(this.loc(name), x, y);
}
v3(name: string, c: RGB) {
this.gl?.uniform3f(this.loc(name), c[0], c[1], c[2]);
}
draw(pixelW: number, pixelH: number) {
const gl = this.gl;
if (!gl || !this.program) return;
gl.viewport(0, 0, pixelW, pixelH);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
destroy() {
const gl = this.gl;
if (!gl) return;
if (this.buffer) gl.deleteBuffer(this.buffer);
if (this.program) gl.deleteProgram(this.program);
if (this.vs) gl.deleteShader(this.vs);
if (this.fs) gl.deleteShader(this.fs);
this.buffer = null;
this.program = null;
this.vs = null;
this.fs = null;
this.locs.clear();
this.gl = null;
}
}
// Deterministic small PRNG so the reduced-motion freeze frame (and the warm
// seed every mount starts from) is reproducible rather than one-shot noise.
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---------------------------------------------------------------------------
// Real numbers (from docs/specs/r9/edm-crater-field.md):
// - discharge (birth) rate: 14/s, uniform-random position
// - crater radius: 0.9%-2.2% of the smaller container dimension
// - crater lifetime: 5.5s birth-to-fully-faded — modeled as exponential
// decay with a time constant of lifetime/3 (~95% faded by three time
// constants, which is the "fully faded" read)
// - steady-state resident population target: 14/s * 5.5s ~= 77 craters
// - rim: raised band at 1.15x the crater's own depth across the outer 15%
// of its radius
// ---------------------------------------------------------------------------
const BIRTH_RATE = 14; // craters/s, baseline uniform-random
const LOCAL_BOOST_RATE = 28; // additional craters/s inside the pointer radius (3x local)
const CRATER_LIFETIME = 5.5;
const DECAY_TAU = CRATER_LIFETIME / 3;
const RADIUS_MIN_PCT = 0.009;
const RADIUS_MAX_PCT = 0.022;
const RIM_FRACTION = 0.15;
const RIM_HEIGHT_MULT = 1.15;
const DEPTH = 0.34; // normalized bowl depth, buffer is clamped to [-1, 1]
const TEX_TEXELS_PER_MIN_DIM = 220; // erosion buffer resolution across the smaller dimension
const POINTER_RADIUS_CSS = 150;
const POINTER_BOOST_DECAY_MS = 600;
const WARMUP_SECONDS = 10; // ~5.5x the decay time constant — reaches steady state before first paint
const WARMUP_STEP = 0.2;
// A fixed seed so the reduced-motion / paused frame is the same composed
// steady-state field every time, not whatever the live RNG happened to draw.
const STATIC_SEED = 20260827;
export function EdmCraterField({
paused = false,
children,
className = "",
style,
}: EdmCraterFieldProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const uid = useId();
const pausedRef = useRef(paused);
pausedRef.current = paused;
useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const surface = new GLSurface(canvas, FRAG_SRC);
let raf = 0;
let running = false;
let staticMode = false;
let cssW = 0;
let cssH = 0;
let dpr = 1;
let disposed = false;
let lastMs = performance.now();
// erosion buffer: JS-side signed-height field, decayed and stamped on
// the CPU, uploaded as a LUMINANCE texture every real frame.
let texW = 0;
let texH = 0;
let heightBuf: Float32Array = new Float32Array(0);
let uploadBuf: Uint8Array = new Uint8Array(0);
let heightTex: WebGLTexture | null = null;
let rng = mulberry32(Date.now() & 0xffffffff);
let spawnAcc = 0;
let localSpawnAcc = 0;
let hoverX = 0;
let hoverY = 0;
let boostAmt = 0; // eased 0..1
let boostTarget = 0;
// Simple two-tier adaptive DPR scale: the fragment cost here is a single
// texture sample plus a handful of exp()/pow() calls per pixel, far
// cheaper than weld-pool's warped fbm, so one step down is plenty of
// insurance rather than a full ladder.
const SCALES = [1, 0.7];
const BUDGET_OVER = 24;
let scaleIdx = 0;
let frameEma = 16.7;
let overMs = 0;
let underMs = 0;
let upWindow = 8000;
let simTime = 0;
let c0: RGB = [0.03, 0.03, 0.03];
let c1: RGB = [0.18, 0.18, 0.18];
let c2: RGB = [0.56, 0.56, 0.56];
let c3: RGB = [0.93, 0.93, 0.93];
let c4: RGB = [1, 1, 1];
let bias = 0;
let contrast = 1.15;
const readColors = () => {
const cs = getComputedStyle(document.documentElement);
const bg = parseHex(cs.getPropertyValue("--background")) ?? [1, 1, 1];
const fg = parseHex(cs.getPropertyValue("--foreground")) ?? [0.09, 0.09, 0.09];
const muted = parseHex(cs.getPropertyValue("--ns-muted")) ?? [0.55, 0.55, 0.55];
const border = parseHex(cs.getPropertyValue("--border")) ?? [0.18, 0.18, 0.18];
const black: RGB = [0, 0, 0];
const white: RGB = [1, 1, 1];
if (luminance(bg) < 0.5) {
c0 = mixRGB(bg, black, 0.5);
c1 = mixRGB(border, bg, 0.15);
c2 = muted;
c3 = fg;
c4 = mixRGB(fg, white, 0.82);
bias = -0.08;
contrast = 1.18;
} else {
c0 = mixRGB(fg, black, 0.32);
c1 = mixRGB(fg, muted, 0.55);
c2 = mixRGB(muted, bg, 0.6);
c3 = mixRGB(bg, muted, 0.16);
c4 = bg;
bias = 0.02;
contrast = 1.22;
}
};
readColors();
// ---- erosion buffer -------------------------------------------------
const allocBuffer = () => {
const minDim = Math.max(1, Math.min(cssW, cssH));
const pxPerTexel = minDim / TEX_TEXELS_PER_MIN_DIM;
texW = Math.max(24, Math.round(cssW / pxPerTexel));
texH = Math.max(24, Math.round(cssH / pxPerTexel));
// a resize allocates a fresh buffer rather than resampling the old one:
// a real resize is rare relative to the 5.5s crater lifetime, and the
// caller always re-seeds to steady state right after allocating (see
// `resize`), so there is never a visible empty-surface frame.
heightBuf = new Float32Array(texW * texH);
uploadBuf = new Uint8Array(texW * texH);
};
const stampCrater = (cx: number, cy: number, radiusCss: number, pxPerTexel: number) => {
const rTex = radiusCss / pxPerTexel;
const cxTex = cx / pxPerTexel;
const cyTex = cy / pxPerTexel;
const rOuter = Math.ceil(rTex) + 1;
const x0 = Math.max(0, Math.floor(cxTex - rOuter));
const x1 = Math.min(texW - 1, Math.ceil(cxTex + rOuter));
const y0 = Math.max(0, Math.floor(cyTex - rOuter));
const y1 = Math.min(texH - 1, Math.ceil(cyTex + rOuter));
for (let y = y0; y <= y1; y++) {
const row = y * texW;
for (let x = x0; x <= x1; x++) {
const dx = x - cxTex;
const dy = y - cyTex;
const d = Math.sqrt(dx * dx + dy * dy) / rTex;
if (d > 1.0) continue;
let h: number;
if (d < 1.0 - RIM_FRACTION) {
// recessed bowl, smooth toward the floor at the centre
const t = d / (1.0 - RIM_FRACTION);
h = -DEPTH * (1.0 - t * t);
} else {
// raised recast rim, peaking mid-band then falling to 0 at d=1
const t = (d - (1.0 - RIM_FRACTION)) / RIM_FRACTION; // 0..1
h = DEPTH * RIM_HEIGHT_MULT * Math.sin(Math.min(1, t) * Math.PI);
}
heightBuf[row + x] += h;
}
}
};
// Advance the erosion buffer by dt: decay everything toward zero, then
// stamp however many new discharges dt's worth of the birth rate (plus
// any local pointer boost) earns. Called both by the live per-frame loop
// (small dt) and by the mount/reduced-motion warm-up (larger dt chunks)
// so a component never opens on an empty, freshly-machined-looking
// surface.
const simulate = (dt: number, r: () => number) => {
if (dt <= 0 || texW === 0) return;
const decay = Math.exp(-dt / DECAY_TAU);
for (let i = 0; i < heightBuf.length; i++) heightBuf[i] *= decay;
const minDim = Math.max(1, Math.min(cssW, cssH));
const pxPerTexel = minDim / TEX_TEXELS_PER_MIN_DIM;
spawnAcc += BIRTH_RATE * dt;
let n = Math.floor(spawnAcc);
spawnAcc -= n;
while (n-- > 0) {
const cx = r() * cssW;
const cy = r() * cssH;
const radius = (RADIUS_MIN_PCT + r() * (RADIUS_MAX_PCT - RADIUS_MIN_PCT)) * minDim;
stampCrater(cx, cy, radius, pxPerTexel);
}
if (boostAmt > 0.01) {
localSpawnAcc += LOCAL_BOOST_RATE * boostAmt * dt;
let ln = Math.floor(localSpawnAcc);
localSpawnAcc -= ln;
while (ln-- > 0) {
const ang = r() * Math.PI * 2;
const rad = Math.sqrt(r()) * POINTER_RADIUS_CSS;
const cx = hoverX + Math.cos(ang) * rad;
const cy = hoverY + Math.sin(ang) * rad;
const radius = (RADIUS_MIN_PCT + r() * (RADIUS_MAX_PCT - RADIUS_MIN_PCT)) * minDim;
stampCrater(cx, cy, radius, pxPerTexel);
}
}
};
const uploadTexture = () => {
const gl = surface.gl;
if (!gl || texW === 0) return;
for (let i = 0; i < heightBuf.length; i++) {
const v = Math.max(-1, Math.min(1, heightBuf[i]));
uploadBuf[i] = Math.round((v * 0.5 + 0.5) * 255);
}
if (!heightTex) {
heightTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, heightTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
} else {
gl.bindTexture(gl.TEXTURE_2D, heightTex);
}
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.LUMINANCE,
texW,
texH,
0,
gl.LUMINANCE,
gl.UNSIGNED_BYTE,
uploadBuf
);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, heightTex);
};
const draw = () => {
if (!surface.gl || cssW <= 0 || cssH <= 0) return;
uploadTexture();
surface.v2("u_size", cssW, cssH);
surface.f("u_dpr", dpr);
surface.v2("u_texRes", texW, texH);
surface.f("u_relief", 6.5);
surface.f("u_time", simTime);
surface.i("u_height", 0);
surface.v3("u_c0", c0);
surface.v3("u_c1", c1);
surface.v3("u_c2", c2);
surface.v3("u_c3", c3);
surface.v3("u_c4", c4);
surface.f("u_bias", bias);
surface.f("u_contrast", contrast);
surface.draw(canvas.width, canvas.height);
};
const loop = (nowMs: number) => {
const rawMs = nowMs - lastMs;
const dt = Math.min(0.05, Math.max(0, rawMs / 1000));
lastMs = nowMs;
simTime += dt;
// per-frame exponential ease toward the target — driven by dt, not by
// elapsed-since-last-pointer-event, so continuous movement (which
// fires pointermove far more often than once per frame) still ramps
// boostAmt up instead of re-arming a "just changed" timer every event.
boostAmt += (boostTarget - boostAmt) * (1 - Math.exp(-dt / (POINTER_BOOST_DECAY_MS / 1000)));
simulate(dt, rng);
draw();
const clamped = Math.min(50, rawMs);
frameEma += (clamped - frameEma) * (1 - Math.exp(-clamped / 120));
if (frameEma > BUDGET_OVER) {
overMs += clamped;
underMs = 0;
} else {
underMs += clamped;
overMs = 0;
}
const down = overMs > 900 && scaleIdx < SCALES.length - 1;
const up = underMs > upWindow && scaleIdx > 0;
if (down || up) {
scaleIdx += down ? 1 : -1;
if (down) upWindow = Math.min(64000, upWindow * 2);
overMs = 0;
underMs = 0;
frameEma = 16.7;
applyBacking();
}
raf = requestAnimationFrame(loop);
};
const wake = () => {
if (running || disposed) return;
running = true;
lastMs = performance.now();
raf = requestAnimationFrame(loop);
};
const sleep = () => {
cancelAnimationFrame(raf);
running = false;
};
// DPR capped at 1.5, matching weld-pool's full-bleed-area-dominates
// rationale, applied here even though the per-pixel cost is lighter.
const applyBacking = () => {
if (cssW < 2 || cssH < 2) return;
dpr = Math.min(window.devicePixelRatio || 1, 1.5) * SCALES[scaleIdx];
const pw = Math.round(cssW * dpr);
const ph = Math.round(cssH * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
draw();
};
const warmup = (seed: number, seconds: number) => {
const r = mulberry32(seed);
spawnAcc = 0;
localSpawnAcc = 0;
heightBuf.fill(0);
let remaining = seconds;
while (remaining > 0) {
const step = Math.min(WARMUP_STEP, remaining);
simulate(step, r);
remaining -= step;
}
};
const resize = () => {
const rect = wrap.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) return;
cssW = rect.width;
cssH = rect.height;
scaleIdx = 0;
overMs = 0;
underMs = 0;
upWindow = 8000;
frameEma = 16.7;
allocBuffer();
// a resize is a new grid, so re-seed to steady state on it too rather
// than momentarily showing an empty freshly-resized surface
if (staticMode) warmup(STATIC_SEED, WARMUP_SECONDS);
else warmup(Date.now() & 0xffffffff, WARMUP_SECONDS);
applyBacking();
draw();
};
let rectLeft = 0;
let rectTop = 0;
let rectDirty = true;
const syncRect = () => {
if (!rectDirty) return;
const rect = wrap.getBoundingClientRect();
rectLeft = rect.left;
rectTop = rect.top;
rectDirty = false;
};
const markRectDirty = () => {
rectDirty = true;
};
const onPointerMove = (e: PointerEvent) => {
syncRect();
hoverX = e.clientX - rectLeft;
hoverY = e.clientY - rectTop;
boostTarget = 1;
if (staticMode) draw();
};
const onPointerLeave = () => {
boostTarget = 0;
};
if (!surface.init()) return; // no WebGL: children still render over the page bg
const ro = new ResizeObserver(resize);
ro.observe(wrap);
resize();
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
window.addEventListener("scroll", markRectDirty, { passive: true, capture: true });
window.addEventListener("resize", markRectDirty, { passive: true });
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduced = mq.matches;
const applyMode = () => {
if (reduced || pausedRef.current) {
staticMode = true;
sleep();
simTime = 0;
warmup(STATIC_SEED, WARMUP_SECONDS);
draw();
} else {
staticMode = false;
warmup(Date.now() & 0xffffffff, WARMUP_SECONDS);
wake();
}
};
const onMq = () => {
reduced = mq.matches;
applyMode();
};
mq.addEventListener("change", onMq);
let onScreen = true;
const io = new IntersectionObserver(
(entries) => {
onScreen = entries.some((en) => en.isIntersecting);
if (!onScreen) sleep();
else if (!staticMode && !document.hidden) wake();
},
{ threshold: 0 }
);
io.observe(wrap);
const onVis = () => {
if (document.hidden) sleep();
else if (!staticMode && onScreen) wake();
};
document.addEventListener("visibilitychange", onVis);
applyMode();
let lastPolledPaused = pausedRef.current;
let poll = 0;
const tick = () => {
if (pausedRef.current !== lastPolledPaused) {
lastPolledPaused = pausedRef.current;
applyMode();
}
poll = window.setTimeout(tick, 140);
};
tick();
const themeObserver = new MutationObserver(() => {
readColors();
if (staticMode) draw();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const onLost = (e: Event) => {
e.preventDefault();
sleep();
};
const onRestored = () => {
heightTex = null;
if (surface.init()) {
resize();
applyMode();
}
};
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("webglcontextrestored", onRestored);
return () => {
disposed = true;
ro.disconnect();
io.disconnect();
mq.removeEventListener("change", onMq);
document.removeEventListener("visibilitychange", onVis);
themeObserver.disconnect();
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("scroll", markRectDirty, { capture: true } as EventListenerOptions);
window.removeEventListener("resize", markRectDirty);
window.clearTimeout(poll);
sleep();
if (heightTex && surface.gl) surface.gl.deleteTexture(heightTex);
heightTex = null;
surface.destroy();
};
}, []);
return (
<div
ref={wrapRef}
data-edm-crater-field={uid}
className={`relative isolate h-full w-full touch-none overflow-hidden bg-background ${className}`}
style={style}
>
<canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
{children ? <div className="relative z-[1] h-full w-full">{children}</div> : null}
</div>
);
}
EdmCraterField.displayName = "EdmCraterField";
Build spec
Build <EdmCraterField paused? children? className? style?> as a full-bleed WebGL1 hero whose surface is a small CPU-side signed-height erosion buffer (a Float32Array sized off the container's SMALLER dimension at TEX_TEXELS_PER_MIN_DIM=220 texels across that dimension, so texels are uniform px in both axes regardless of aspect), decayed and stamped on the CPU every real frame, uploaded as a single-channel gl.LUMINANCE texture (0..255 packing signed height -1..1 via v*0.5+0.5), and read back in the fragment shader for a normal-from-heightfield + achromatic environment lighting pass — the same technique family as registry/loud/weld-pool/component.tsx's height field, simplified to a 3-source studio (env()) since a mostly-flat pitted plate needs less environment structure than a mirror pool to read as metal. REAL NUMBERS (from docs/specs/r9/edm-crater-field.md): discharge (birth) rate 14 craters/s uniform-random position; crater radius 0.9%-2.2% of the smaller container dimension per discharge; crater lifetime 5.5s birth-to-fully-faded, modeled as exponential decay of the WHOLE buffer each frame by exp(-dt/DECAY_TAU) with DECAY_TAU = 5.5/3 (~95% faded after three time constants); steady-state resident population target 14/s * 5.5s ~= 77 craters, which falls out of birth-rate=decay-rate rather than being enforced as a cap. STAMP SHAPE: each discharge additively stamps into the buffer — a recessed bowl (h = -DEPTH*(1-t^2), t = d/(1-RIM_FRACTION), for the inner 85% of the radius) plus a raised recast rim across the outer RIM_FRACTION=0.15 of the radius peaking at RIM_HEIGHT_MULT=1.15x the bowl depth via a half-sine profile — never a simple disc, the rim is what makes it read as EDM rather than a generic pit. STEADY STATE IS THE WHOLE POINT: this is the fix for the exact 'process that finishes and stops' Filter-2 failure the spec was written against — birth rate (14/s) and decay rate (buffer-wide exponential, constant regardless of occupancy) are BOTH constant and independent, so the field can never accumulate toward a saturated frame; it turns its population over continuously instead. WARM-UP: an erosion buffer has no honest t=0 (an empty plate reads as unloaded, not as freshly machined), so on every mount, every real resize, and every reduced-motion/paused transition, `warmup()` runs the exact same `simulate()` step function used by the live per-frame loop in fixed 0.2s chunks for WARMUP_SECONDS=10 (~5.5 decay time-constants, comfortably past steady state) BEFORE the first paint — this is the same pattern registry/loud/dye-whorl/component.tsx uses (its 150-260 fixed-step spin), scaled down because this sim is a decay+stamp buffer rather than a Navier-Stokes solver. The live (non-reduced) warm-up seeds from Date.now()-derived entropy (so different mounts show different compositions); the reduced-motion/paused warm-up seeds from a FIXED constant, STATIC_SEED=20260827, through the SAME mulberry32 PRNG and the SAME simulate() path, so the freeze frame is byte-reproducible rather than one-shot noise — named FREEZE_PHASE=steady-state-lock in the spec. POINTER: pointermove sets a target boost (boostTarget=1) that eases toward boostAmt every frame via a per-dt exponential (1 - exp(-dt/(POINTER_BOOST_DECAY_MS/1000)), POINTER_BOOST_DECAY_MS=600) rather than an elapsed-since-last-event timer, so continuous hovering (which fires pointermove far more often than once per rAF) still ramps the boost up instead of re-arming a 'just changed' clock every event; while boostAmt>0.01 an ADDITIONAL local stream of craters spawns at LOCAL_BOOST_RATE=28/s scaled by boostAmt, positioned by a uniform-disc sample (angle * sqrt(radial) so density is uniform, not centre-biased) within POINTER_RADIUS_CSS=150px of the pointer — reading as the electrode dwelling there, 3x the local density of the ambient 14/s field. pointerleave eases boostTarget back to 0 over the same curve. MUST NOT: tint craters or rims with --ns-accent (the exact standing defect registry/loud/edge-yield, granule-churn and shear-billow shipped, called out explicitly in docs/showpiece-recipe.md) — this component never reads --ns-accent at all, the pointer's only effect is WHERE sparks land, never their colour; there is no interactive control on this component, so accent has no legitimate use here either. TOKENS: five luminance stops (c0 darkest .. c4 lightest) derived from --background, --foreground, --ns-muted, --border via getComputedStyle(document.documentElement) at mount, re-read on a MutationObserver watching documentElement's class (redraw only when staticMode, since the live loop already redraws every frame) — NO PAINT BEFORE THE FIRST READ: readColors() runs synchronously before the first resize()/draw() call. Dark theme biases the ramp toward true black/white (bias=-0.08); light theme (checked first, per the recipe's 'light theme is the harder case') compresses toward --ns-muted/--background with a smaller positive bias so pit floors and rim highlights both stay inside the ramp without crossing into pure white/black or --border-adjacent values. RENDER HOST: an internal GLSurface class (mirroring weld-pool's) owns the WebGL1 context, one program, one fullscreen triangle pair, lazily-resolved uniform locations. DPR is capped at 1.5 (matches weld-pool's full-bleed-area-dominates rationale) with a simple two-tier adaptive scale (SCALES=[1,0.7]) that steps down only after ~900ms of a sustained over-budget (24ms) frame-time EMA and climbs back only after a much longer clean stretch that doubles per failure — lighter than weld-pool's three-tier ladder since this shader's per-pixel cost (one texture sample, a handful of exp()/pow() calls) is far cheaper than a warped fbm field. `ResizeObserver` reallocates the erosion buffer (a fresh Float32Array, not a resampled one — a resize is rare relative to the 5.5s crater lifetime) and re-runs warmup(); `IntersectionObserver` (threshold 0) and `visibilitychange` both pause the single rAF loop; `webglcontextlost`/`restored` rebuild the texture and context cleanly. The canvas is `aria-hidden` decoration with `w-full h-full`; children render over the field in a `relative z-[1]` layer and need a token scrim, since the field spans most of the value range and a crater can land under any line of type.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| paused? | boolean | false | Freezes the surface on a composed steady-state frame without unmounting. |
| children? | React.ReactNode | — | Rendered in the DOM over the field — eyebrow, headline, CTA. |
| className? | string | — | — |
| style? | React.CSSProperties | — | — |