Workspace / component / shaders

Plasma Shader

Classic sine-interference plasma remapped to a two-stop brand palette. Canvas 2D per-pixel render with a resolution governor for large surfaces.

Workspacecomponentshaders

paxfx add blocks/component-shaders-plasmalocal only

Tier: medium. Reduced motion: Plasma freezes on a composed frame.

"use client";

import { useEffect, useRef, type ReactNode } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";

export interface PlasmaShaderProps {
  /** Playback speed multiplier. */
  speed?: number;
  /** Spatial frequency of the interference pattern. */
  scale?: number;
  className?: string;
  children?: ReactNode;
}

/** Starting internal downscale; the governor raises it if frames run long. */
const BASE_DOWNSCALE = 4;
const MAX_DOWNSCALE = 10;
/** Frame budget for the per-pixel pass, in milliseconds. */
const FRAME_BUDGET_MS = 8;

type Rgb = readonly [number, number, number];

function readToken(el: HTMLElement, name: string, fallback: Rgb): Rgb {
  const raw = getComputedStyle(el).getPropertyValue(name).trim();
  if (raw.startsWith("#")) {
    const hex = raw.length === 4 ? [...raw.slice(1)].map((c) => c + c).join("") : raw.slice(1, 7);
    const n = Number.parseInt(hex, 16);
    if (!Number.isNaN(n)) return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  const match = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(raw);
  if (match) return [Number(match[1]), Number(match[2]), Number(match[3])];
  return fallback;
}

/**
 * Classic sine-interference plasma: four sin terms over x, y, and time,
 * summed and remapped to the night-to-acid brand palette. Rendered per pixel
 * on Canvas 2D at reduced internal resolution, with a governor that shrinks
 * the internal buffer further whenever a frame exceeds its budget.
 */
export function PlasmaShader({ speed = 1, scale = 1, className, children }: PlasmaShaderProps) {
  const reduced = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d", { alpha: false });
    if (!ctx) return;

    const night = readToken(root, "--pax-night", [11, 13, 16]);
    const acid = readToken(root, "--pax-acid", [200, 255, 0]);

    const low = document.createElement("canvas");
    const lowCtx = low.getContext("2d");
    if (!lowCtx) return;

    let width = 0;
    let height = 0;
    let lowW = 0;
    let lowH = 0;
    let downscale = BASE_DOWNSCALE;
    let image: ImageData | null = null;
    let elapsed = 0;
    let raf = 0;
    let running = false;
    let last = 0;
    let calmFrames = 0;

    const sizeLow = () => {
      lowW = Math.max(1, Math.round(width / downscale));
      lowH = Math.max(1, Math.round(height / downscale));
      low.width = lowW;
      low.height = lowH;
      image = lowCtx.createImageData(lowW, lowH);
    };

    const render = () => {
      if (!image || lowW === 0 || lowH === 0) return;
      const started = performance.now();
      const data = image.data;
      const t = elapsed;
      const freq = (6.2 * scale) / Math.max(lowW, lowH);
      const cx = lowW * freq * (0.5 + Math.sin(t * 0.21) * 0.18);
      const cy = lowH * freq * (0.5 + Math.cos(t * 0.17) * 0.18);
      let i = 0;
      for (let y = 0; y < lowH; y += 1) {
        const py = y * freq;
        const rowA = Math.sin(py * 2.1 - t * 0.62);
        for (let x = 0; x < lowW; x += 1) {
          const px = x * freq;
          const v =
            Math.sin(px * 1.7 + t) +
            rowA +
            Math.sin((px + py) * 1.3 + t * 0.45) +
            Math.sin(Math.hypot(px - cx, py - cy) * 2.9 - t * 0.8);
          // v is in [-4, 4]; shape it toward the dark end so acid reads as highlight.
          const v01 = (v + 4) / 8;
          const g = v01 * v01 * (0.25 + 0.75 * v01);
          data[i] = night[0] + (acid[0] - night[0]) * g;
          data[i + 1] = night[1] + (acid[1] - night[1]) * g;
          data[i + 2] = night[2] + (acid[2] - night[2]) * g;
          data[i + 3] = 255;
          i += 4;
        }
      }
      lowCtx.putImageData(image, 0, 0);
      ctx.imageSmoothingEnabled = true;
      ctx.imageSmoothingQuality = "high";
      ctx.drawImage(low, 0, 0, lowW, lowH, 0, 0, width, height);

      const cost = performance.now() - started;
      if (cost > FRAME_BUDGET_MS && downscale < MAX_DOWNSCALE) {
        downscale += 1;
        calmFrames = 0;
        sizeLow();
      } else if (cost < FRAME_BUDGET_MS * 0.35 && downscale > BASE_DOWNSCALE) {
        calmFrames += 1;
        if (calmFrames > 120) {
          downscale -= 1;
          calmFrames = 0;
          sizeLow();
        }
      }
    };

    const tick = (now: number) => {
      if (!running) return;
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += dt * speed;
      render();
      raf = requestAnimationFrame(tick);
    };

    const start = () => {
      if (running || reduced) return;
      running = true;
      last = performance.now();
      raf = requestAnimationFrame(tick);
    };

    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      width = Math.max(1, Math.round(rect.width * dpr));
      height = Math.max(1, Math.round(rect.height * dpr));
      canvas.width = width;
      canvas.height = height;
      downscale = BASE_DOWNSCALE;
      sizeLow();
      if (reduced) {
        // Freeze on a composed frame; no loop.
        elapsed = 9;
        render();
      }
    };

    const onVisibility = () => {
      if (document.hidden) stop();
      else start();
    };

    resize();
    const observer = new ResizeObserver(resize);
    observer.observe(root);
    document.addEventListener("visibilitychange", onVisibility);
    start();

    return () => {
      stop();
      observer.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [speed, scale, reduced]);

  return (
    <div
      ref={rootRef}
      className={cn(
        "relative isolate min-h-[280px] overflow-hidden rounded-2xl border border-[var(--pax-border,rgba(18,17,15,0.12))]",
        "bg-[var(--pax-night,#0b0d10)] text-[var(--pax-paper,#f6f2ea)]",
        className,
      )}
      data-pax-asset="component-shaders-plasma"
    >
      <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 h-full w-full" />
      <span className="sr-only">
        A retro plasma interference pattern in the Pax night and acid colors.
      </span>
      <div className="relative z-[1]">{children}</div>
    </div>
  );
}