Skip to main content

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.

Usage

registry/loud/edm-crater-field/demo.tsx
"use client";

import { EdmCraterField } from "./component";

// The plate is already at its steady simmer of craters before anything is
// touched. Overlaid type sits on a token scrim, since the field spans most
// of the value range and a crater can land under any line of it.
export default function EdmCraterFieldDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <EdmCraterField>
        <div className="pointer-events-none flex h-full w-full flex-col items-center justify-end gap-4 px-6 pb-14 text-center sm:pb-20">
          <div className="flex flex-col items-center gap-3 rounded-lg bg-background/70 px-7 py-5 backdrop-blur-md">
            <p className="font-mono text-[11px] uppercase tracking-[0.28em] text-ns-muted">
              ns-ui / edm-crater-field
            </p>
            <h1 className="text-2xl font-medium tracking-tight text-foreground sm:text-3xl">
              Eroded by spark
            </h1>
            <p className="max-w-sm text-sm text-ns-muted">
              Move over the plate to bring the electrode closer — discharges cluster wherever you linger.
            </p>
          </div>
          <a
            href="#docs"
            className="pointer-events-auto mt-1 inline-flex w-fit items-center justify-center rounded-sm bg-ns-accent px-5 py-2.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            Read the docs
          </a>
        </div>
      </EdmCraterField>
    </main>
  );
}

Component

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(isRestore = false): 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.abandon(isRestore);
        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.abandon(isRestore);
        return false;
      }
    } catch {
      this.abandon(isRestore);
      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;
  }

  // A compile/link failure above happens on a context getContext() already
  // handed over, and the caller's `if (!surface.init()) return` never
  // registers the effect cleanup that hands a context back — so the release
  // belongs here, while this.gl still reaches it. The condition is the
  // inverse of that cleanup's `!canvas.isConnected`: the canvas is still
  // mounted on this path, and the context that must NOT be released is one
  // the browser has just restored, since onRestored re-enters init() and a
  // release there would feed its own loss/restore/fail cycle.
  private abandon(isRestore: boolean) {
    const lose = isRestore ? null : this.gl?.getExtension("WEBGL_lose_context");
    this.destroy();
    lose?.loseContext();
  }

  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(true)) {
        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;
      // destroy() frees the GL objects but not the context holding them, and a
      // detached canvas keeps its context until GC gets round to it —
      // browsers cap live contexts per renderer process and evict the oldest
      // first, which kills fields still on screen. Hand it back explicitly,
      // but only on a real unmount: this cleanup also runs when a dep changes,
      // and a canvas returns its one lost context to every later getContext(),
      // so releasing too eagerly would blank the rebuilt field.
      const lose = surface.gl?.getExtension("WEBGL_lose_context");
      surface.destroy();
      if (!canvas.isConnected) lose?.loseContext();
    };
  }, []);

  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";

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/edm-crater-field.tsx, so point the import there.

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.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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

PropTypeDefaultDescription
paused?booleanfalseFreezes 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——