NEMAWASHI LAB

Chromatic

WebGL

RGB channel splitting with depth-of-field blur in a single WebGL pass. The image (a bundled poster or any dropped file) is sampled three times per fragment -- red shifted forward along the split direction, blue mirrored, green unshifted -- and each sample is optionally blurred by a 16-tap golden-angle Poisson disc whose radius grows with distance from a draggable focus point (circle-of-confusion model with adjustable range and aperture falloff). A per-frame hash rotation on the disc prevents banding, and luminosity film grain finishes the look. Linear mode fixes the split angle; radial mode derives it from the fragment's direction to the focus point, scaling intensity by distance.

Chromatic

WebGL · drag to split · drop an image

Controls
Amount0.16
Angle-25
Max blur48
Focus range0.18
Aperture1.60
Grain0.08
Focus point
Mode

Source

Chromatic.tsx705 lines
"use client";

import { useState, useRef, useEffect, useCallback } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelSelect, PanelToggle } from "../_kansei/controls";

// Chromatic aberration playground (after the RGB-split lens tools): the image
// is decomposed into R/G/B layers offset along a direction, run through a
// focus-point lens blur (poisson disc, radius grows away from focus), and
// finished with luminosity film grain. Drag to aim the split, move the cursor
// to pull focus, drop in your own image. Default plate is a bundled walking
// silhouette (Fredrik Öhlander on Unsplash, unsplash.com/photos/ETpNY3AoDMo);
// a painted GIRAGIRA poster covers the frame until it resolves.
const INK = "#17121a";
const PRESET_SRC = "/lab/chromatic-walk.jpg";
// FontFace-registered copy of KT Flux so the 2D texture canvas can use it.
const TEX_FONT = "KT Flux Chromatic";

const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
  v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);
  gl_Position = vec4(a_pos, 0.0, 1.0);
}`;

const FRAG = `
precision highp float;

uniform sampler2D u_tex;
uniform vec2 u_resolution;
uniform vec2 u_focus;
uniform float u_maxDist;
uniform float u_range;
uniform float u_falloff;
uniform float u_maxBlur;
uniform float u_amount;
uniform float u_angle;
uniform float u_mode;
uniform float u_grain;
uniform float u_time;

varying vec2 v_uv;

float hash21(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

void main() {
  vec2 uv = v_uv;
  float aspect = u_resolution.x / u_resolution.y;

  vec2 p = uv - u_focus;
  p.x *= aspect;
  // Normalised against the plate half-diagonal so Focus range spans the
  // composition, not the letterboxed canvas.
  float d = clamp(length(p) / u_maxDist, 0.0, 1.0);

  // Circle of confusion: sharp inside u_range, ramps to u_maxBlur px at the
  // frame corner; u_falloff is the aperture curve.
  float coc = u_maxBlur * pow(smoothstep(u_range, 1.0, d), u_falloff);
  vec2 cocUv = vec2(coc) / u_resolution;

  // Per-channel offsets in pixels: R forward, B mirrored, G holds the plate.
  vec2 dir;
  float amt = u_amount;
  if (u_mode < 0.5) {
    dir = vec2(cos(u_angle), sin(u_angle));
  } else {
    dir = (length(p) > 0.001) ? normalize(p) : vec2(0.0);
    amt *= d * 1.6;
  }
  vec2 off = dir * amt / u_resolution;
  vec2 uvR = uv + off;
  vec2 uvB = uv - off;

  // In-focus pixels skip the disc entirely — one read per channel.
  vec3 color;
  if (coc < 0.75) {
    color = vec3(texture2D(u_tex, uvR).r, texture2D(u_tex, uv).g, texture2D(u_tex, uvB).b);
  } else {
    // Golden-angle spiral disc, rotated per pixel so undersampling reads as
    // noise instead of banding (the grain hides the rest).
    float rot = hash21(gl_FragCoord.xy) * 6.2831853;
    vec3 acc = vec3(0.0);
    for (int i = 0; i < 16; i++) {
      float fi = float(i);
      float r = sqrt((fi + 0.5) / 16.0);
      float th = fi * 2.39996323 + rot;
      vec2 disc = vec2(cos(th), sin(th)) * r * cocUv;
      acc.r += texture2D(u_tex, uvR + disc).r;
      acc.g += texture2D(u_tex, uv + disc).g;
      acc.b += texture2D(u_tex, uvB + disc).b;
    }
    color = acc / 16.0;
  }

  // Film grain, luminosity mode: one monochrome value nudges all channels.
  float n = hash21(gl_FragCoord.xy + vec2(fract(u_time * 0.73) * 917.0, fract(u_time * 1.31) * 533.0));
  color += (n - 0.5) * u_grain;

  gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}`;

const DEFAULTS = { amount: 0.16, angle: -25, maxBlur: 48, range: 0.18, falloff: 1.6, grain: 0.08 };
const DRIFT_SPEED = 0.21; // rad/s, slow orbit of the split angle
// The effect is soft by nature — rendering at 1x instead of retina quarters
// the fill cost and the blur/grain hide the difference.
const RENDER_DPR = 1;

type SplitMode = "linear" | "radial";

function roundedClip(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
  ctx.clip();
}

interface PlateRect {
  x: number;
  y: number;
  w: number;
  h: number;
}

// Default plate: vivid gradient poster with the wordmark in silhouette ink —
// bright field on black so the split channels glow like gels. Returns the
// painted rect so the focus pad can map onto the plate, not the letterbox.
function paintPoster(ctx: CanvasRenderingContext2D, w: number, h: number, dpr: number): PlateRect {
  ctx.fillStyle = "#050505";
  ctx.fillRect(0, 0, w, h);

  const ph = h * 0.72;
  const pw = Math.min(w * 0.62, ph * 0.82);
  const px = (w - pw) / 2;
  const py = (h - ph) / 2;

  ctx.save();
  roundedClip(ctx, px, py, pw, ph, 18 * dpr);

  const base = ctx.createLinearGradient(px, 0, px + pw, 0);
  base.addColorStop(0, "#ff9a1f");
  base.addColorStop(0.45, "#ff4468");
  base.addColorStop(0.78, "#a43bff");
  base.addColorStop(1, "#5d2bff");
  ctx.fillStyle = base;
  ctx.fillRect(px, py, pw, ph);

  const gx = px + pw * 0.5;
  const gy = py + ph * 0.42;
  const glow = ctx.createRadialGradient(gx, gy, 0, gx, gy, pw * 0.58);
  glow.addColorStop(0, "rgba(255,240,235,0.95)");
  glow.addColorStop(0.45, "rgba(255,170,190,0.5)");
  glow.addColorStop(1, "rgba(255,170,190,0)");
  ctx.fillStyle = glow;
  ctx.fillRect(px, py, pw, ph);

  const floor = ctx.createLinearGradient(0, py + ph, 0, py + ph * 0.6);
  floor.addColorStop(0, "rgba(70,0,45,0.55)");
  floor.addColorStop(1, "rgba(70,0,45,0)");
  ctx.fillStyle = floor;
  ctx.fillRect(px, py, pw, ph);

  // Registration crosses give the blur and split something fine to bite on.
  const step = 56 * dpr;
  const arm = 4 * dpr;
  ctx.strokeStyle = INK;
  ctx.lineWidth = Math.max(1, dpr * 0.9);
  ctx.globalAlpha = 0.16;
  for (let y = py + step * 0.5; y < py + ph; y += step) {
    for (let x = px + step * 0.5; x < px + pw; x += step) {
      ctx.beginPath();
      ctx.moveTo(x - arm, y);
      ctx.lineTo(x + arm, y);
      ctx.moveTo(x, y - arm);
      ctx.lineTo(x, y + arm);
      ctx.stroke();
    }
  }
  ctx.globalAlpha = 1;

  const word = "GIRAGIRA";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.font = `600 100px "${TEX_FONT}", sans-serif`;
  const at100 = ctx.measureText(word).width;
  let size = (pw * 0.86 * 100) / Math.max(1, at100);
  ctx.font = `600 ${size}px "${TEX_FONT}", sans-serif`;
  // Re-measure at the final size: if the face resolved between the two
  // measures the first fit is off — rescale so the word never overflows.
  const atSize = ctx.measureText(word).width;
  if (atSize > pw * 0.86) {
    size *= (pw * 0.86) / atSize;
    ctx.font = `600 ${size}px "${TEX_FONT}", sans-serif`;
  }
  ctx.fillStyle = INK;
  ctx.fillText(word, px + pw / 2, py + ph * 0.5 - size * 0.06);

  const spaced = ctx as CanvasRenderingContext2D & { letterSpacing: string };
  if ("letterSpacing" in ctx) spaced.letterSpacing = `${3 * dpr}px`;
  ctx.font = `500 ${10 * dpr}px "${TEX_FONT}", sans-serif`;
  ctx.fillText("RGB SPLIT — TEST SHEET 01", px + pw / 2, py + ph * 0.5 + size * 0.52);
  if ("letterSpacing" in ctx) spaced.letterSpacing = "0px";

  ctx.restore();
  return { x: px, y: py, w: pw, h: ph };
}

function paintImage(ctx: CanvasRenderingContext2D, w: number, h: number, img: HTMLImageElement): PlateRect {
  ctx.fillStyle = "#050505";
  ctx.fillRect(0, 0, w, h);
  const fit = Math.min((w * 0.74) / img.naturalWidth, (h * 0.74) / img.naturalHeight);
  const dw = img.naturalWidth * fit;
  const dh = img.naturalHeight * fit;
  const dx = (w - dw) / 2;
  const dy = (h - dh) / 2;
  ctx.save();
  roundedClip(ctx, dx, dy, dw, dh, Math.min(dw, dh) * 0.03);
  ctx.drawImage(img, dx, dy, dw, dh);
  ctx.restore();
  return { x: dx, y: dy, w: dw, h: dh };
}

// Focus-point picker after the reference tool: a dot-grid pad with a
// draggable white dot; position maps straight to the blur focus in UV space.
function PanelFocusPad({ x, y, set }: { x: number; y: number; set: (x: number, y: number) => void }) {
  const padRef = useRef<HTMLDivElement>(null);
  const apply = (e: React.PointerEvent) => {
    const r = padRef.current!.getBoundingClientRect();
    set(
      Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
      Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
    );
  };
  return (
    <div style={{ marginBottom: 12 }}>
      <div style={{
        fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase",
        color: "rgba(255,255,255,0.5)", fontVariationSettings: "'wght' 300, 'SRIF' 100",
        marginBottom: 4,
      }}>
        Focus point
      </div>
      <div
        ref={padRef}
        onPointerDown={(e) => {
          try {
            (e.target as HTMLElement).setPointerCapture(e.pointerId);
          } catch {}
          apply(e);
        }}
        onPointerMove={(e) => {
          if (e.buttons & 1) apply(e);
        }}
        style={{
          position: "relative", height: 90, borderRadius: 8, cursor: "crosshair",
          border: "1px solid rgba(255,255,255,0.15)", touchAction: "none",
          backgroundImage: "radial-gradient(rgba(255,255,255,0.28) 1px, transparent 1.4px)",
          backgroundSize: "13px 13px", backgroundPosition: "center",
        }}
      >
        <div style={{
          position: "absolute", left: `${x * 100}%`, top: `${y * 100}%`,
          width: 12, height: 12, borderRadius: 999, background: "#fff",
          transform: "translate(-50%, -50%)", boxShadow: "0 0 6px rgba(255,255,255,0.6)",
          pointerEvents: "none",
        }} />
      </div>
    </div>
  );
}

// Full-width action row matching the PanelToggle look (scene is always dark).
function PanelButton({ label, onClick }: { label: string; onClick: () => void }) {
  return (
    <button
      onClick={onClick}
      style={{
        width: "100%", marginBottom: 12, padding: "6px 0", borderRadius: 6, cursor: "pointer",
        border: "1px solid rgba(255,255,255,0.2)", background: "transparent", color: "#fff",
        fontSize: 9, letterSpacing: "0.06em", textTransform: "uppercase",
        fontFamily: "var(--font-flux), sans-serif",
      }}
    >
      {label}
    </button>
  );
}

// No-WebGL fallback: static three-layer wordmark, screen-blended.
function CSSFallback() {
  const layers: { color: string; dx: number; dy: number }[] = [
    { color: "#f00", dx: -14, dy: 6 },
    { color: "#0f0", dx: 0, dy: -8 },
    { color: "#00f", dx: 14, dy: 6 },
  ];
  return (
    <div className="absolute inset-0 flex items-center justify-center bg-black">
      <div className="relative">
        {layers.map(({ color, dx, dy }) => (
          <span
            key={color}
            className="font-[family-name:var(--font-flux)] text-[11vw] leading-none"
            style={{
              fontVariationSettings: "'wght' 600, 'SRIF' 200",
              color,
              mixBlendMode: "screen",
              position: color === "#f00" ? "relative" : "absolute",
              inset: color === "#f00" ? undefined : 0,
              transform: `translate(${dx}px, ${dy}px)`,
              filter: "blur(1.5px)",
            }}
          >
            GIRAGIRA
          </span>
        ))}
      </div>
    </div>
  );
}

export default function Chromatic() {
  const [amount, setAmount] = useState(DEFAULTS.amount);
  const [angle, setAngle] = useState(DEFAULTS.angle);
  const [maxBlur, setMaxBlur] = useState(DEFAULTS.maxBlur);
  const [range, setRange] = useState(DEFAULTS.range);
  const [falloff, setFalloff] = useState(DEFAULTS.falloff);
  const [grain, setGrain] = useState(DEFAULTS.grain);
  const [mode, setMode] = useState<SplitMode>("linear");
  const [drift, setDrift] = useState(true);
  const [focus, setFocus] = useState({ x: 0.5, y: 0.45 });

  const [renderMode, setRenderMode] = useState<"gl" | "css" | null>(null);
  const [glError, setGlError] = useState<string | null>(null);
  const [dragOver, setDragOver] = useState(false);

  const canvasRef = useRef<HTMLCanvasElement>(null);
  const fileRef = useRef<HTMLInputElement>(null);
  const drawRef = useRef<(() => void) | null>(null);
  const retexRef = useRef<(() => void) | null>(null);
  const imageRef = useRef<HTMLImageElement | null>(null);
  // Pointer/drift state lives outside React — the rAF loop mutates it. The
  // focus dot eases toward tx/ty in plate-relative coords, which the pad sets
  // directly; draw() maps them onto the letterboxed plate via plateRef.
  const animRef = useRef({ fx: 0.5, fy: 0.45, tx: 0.5, ty: 0.45, driftAngle: 0, dragging: false });
  const plateRef = useRef({ x: 0, y: 0, w: 1, h: 1 });
  const dirtyRef = useRef(true);

  const paramsRef = useRef({ amount, angle, maxBlur, range, falloff, grain, mode, drift });
  // Keep the imperative draw's snapshot of the controls current (synced after each render).
  useEffect(() => {
    paramsRef.current = { amount, angle, maxBlur, range, falloff, grain, mode, drift };
    dirtyRef.current = true;
  });

  const loadFile = useCallback((file: File) => {
    if (!file.type.startsWith("image/")) return;
    const url = URL.createObjectURL(file);
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(url);
      imageRef.current = img;
      retexRef.current?.();
    };
    img.src = url;
  }, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    let disposed = false;
    let raf = 0;
    let cleanup: (() => void) | null = null;

    (async () => {
      // preserveDrawingBuffer so Save PNG can read the frame back.
      const gl = canvas.getContext("webgl", { antialias: false, premultipliedAlpha: false, preserveDrawingBuffer: true }) as WebGLRenderingContext | null;
      if (!gl) {
        setGlError("No WebGL context available");
        setRenderMode("css");
        return;
      }

      const compile = (type: number, src: string, label: string) => {
        const s = gl.createShader(type)!;
        gl.shaderSource(s, src);
        gl.compileShader(s);
        if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
          const log = gl.getShaderInfoLog(s) || "(empty)";
          gl.deleteShader(s);
          throw new Error(`${label}: ${log}`);
        }
        return s;
      };

      let vs: WebGLShader, fs: WebGLShader, prog: WebGLProgram;
      try {
        vs = compile(gl.VERTEX_SHADER, VERT, "VERT");
        fs = compile(gl.FRAGMENT_SHADER, FRAG, "FRAG");
        prog = gl.createProgram()!;
        gl.attachShader(prog, vs);
        gl.attachShader(prog, fs);
        gl.linkProgram(prog);
        if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
          throw new Error(`LINK: ${gl.getProgramInfoLog(prog) || "(empty)"}`);
        }
      } catch (err) {
        setGlError(err instanceof Error ? err.message : String(err));
        setRenderMode("css");
        return;
      }
      gl.useProgram(prog);

      const vbo = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
      const posLoc = gl.getAttribLocation(prog, "a_pos");
      gl.enableVertexAttribArray(posLoc);
      gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);

      gl.uniform1i(gl.getUniformLocation(prog, "u_tex"), 0);
      const locs = {
        res: gl.getUniformLocation(prog, "u_resolution"),
        focus: gl.getUniformLocation(prog, "u_focus"),
        maxDist: gl.getUniformLocation(prog, "u_maxDist"),
        range: gl.getUniformLocation(prog, "u_range"),
        falloff: gl.getUniformLocation(prog, "u_falloff"),
        maxBlur: gl.getUniformLocation(prog, "u_maxBlur"),
        amount: gl.getUniformLocation(prog, "u_amount"),
        angle: gl.getUniformLocation(prog, "u_angle"),
        mode: gl.getUniformLocation(prog, "u_mode"),
        grain: gl.getUniformLocation(prog, "u_grain"),
        time: gl.getUniformLocation(prog, "u_time"),
      };

      // KT Flux for the poster plate — paints in a fallback sans if the file
      // is unreachable.
      try {
        const face = new FontFace(TEX_FONT, "url(/fonts/KT-Flux-2_Variable.ttf)");
        await face.load();
        document.fonts.add(face);
        // Detached canvases resolve newly added faces lazily — force resolution
        // at the spec we paint with, or measureText falls back mid-paint.
        await document.fonts.load(`600 100px "${TEX_FONT}"`);
      } catch {}
      if (disposed) return;

      const tex = gl.createTexture()!;
      const src = document.createElement("canvas");
      const srcCtx = src.getContext("2d")!;

      const retex = () => {
        const img = imageRef.current;
        const r = img
          ? paintImage(srcCtx, src.width, src.height, img)
          : paintPoster(srcCtx, src.width, src.height, RENDER_DPR);
        plateRef.current = { x: r.x / src.width, y: r.y / src.height, w: r.w / src.width, h: r.h / src.height };
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, tex);
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        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);
        dirtyRef.current = true;
      };

      const draw = () => {
        const p = paramsRef.current;
        const a = animRef.current;
        const dpr = RENDER_DPR;
        const minDim = Math.min(canvas.width, canvas.height);
        const plate = plateRef.current;
        const aspect = canvas.width / canvas.height;
        gl.uniform2f(locs.focus, plate.x + a.fx * plate.w, plate.y + a.fy * plate.h);
        gl.uniform1f(locs.maxDist, Math.hypot((plate.w / 2) * aspect, plate.h / 2));
        gl.uniform1f(locs.range, p.range);
        gl.uniform1f(locs.falloff, p.falloff);
        gl.uniform1f(locs.maxBlur, p.maxBlur * dpr);
        gl.uniform1f(locs.amount, p.amount * minDim * 0.18);
        gl.uniform1f(locs.angle, ((p.angle * Math.PI) / 180) + a.driftAngle);
        gl.uniform1f(locs.mode, p.mode === "linear" ? 0 : 1);
        gl.uniform1f(locs.grain, p.grain);
        gl.uniform1f(locs.time, performance.now() / 1000);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      };

      const resize = () => {
        const w = Math.max(1, Math.round(canvas.clientWidth * RENDER_DPR));
        const h = Math.max(1, Math.round(canvas.clientHeight * RENDER_DPR));
        if (canvas.width !== w || canvas.height !== h) {
          canvas.width = w;
          canvas.height = h;
        }
        src.width = w;
        src.height = h;
        gl.viewport(0, 0, w, h);
        gl.uniform2f(locs.res, w, h);
        retex();
      };

      drawRef.current = draw;
      retexRef.current = retex;
      setRenderMode("gl");
      resize();

      // Swap in the bundled plate once it resolves — unless an upload beat it.
      const preset = new Image();
      preset.onload = () => {
        if (!disposed && !imageRef.current) {
          imageRef.current = preset;
          retex();
        }
      };
      preset.src = PRESET_SRC;

      // Demand-driven: a frame renders only when a control changed, focus is
      // still easing, or drift is animating — otherwise the GPU stays idle.
      let last = performance.now();
      const loop = (now: number) => {
        const dt = Math.min((now - last) / 1000, 0.1);
        last = now;
        const p = paramsRef.current;
        const a = animRef.current;
        const focusMoving = Math.abs(a.tx - a.fx) > 0.0005 || Math.abs(a.ty - a.fy) > 0.0005;
        if (focusMoving) {
          a.fx += (a.tx - a.fx) * 0.08;
          a.fy += (a.ty - a.fy) * 0.08;
        }
        const drifting = p.drift && !a.dragging;
        if (drifting) a.driftAngle += dt * DRIFT_SPEED;
        if (dirtyRef.current || focusMoving || drifting || a.dragging) {
          dirtyRef.current = false;
          draw();
        }
        raf = requestAnimationFrame(loop);
      };
      raf = requestAnimationFrame(loop);

      const ro = new ResizeObserver(resize);
      ro.observe(canvas);

      cleanup = () => {
        ro.disconnect();
        drawRef.current = null;
        retexRef.current = null;
        gl.deleteTexture(tex);
        gl.deleteBuffer(vbo);
        gl.deleteProgram(prog);
        gl.deleteShader(vs);
        gl.deleteShader(fs);
      };
    })();

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      cleanup?.();
    };
  }, []);

  const applyDrag = useCallback((e: React.PointerEvent) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const r = canvas.getBoundingClientRect();
    const dx = e.clientX - r.left - r.width / 2;
    const dy = e.clientY - r.top - r.height / 2;
    setAngle(Math.round((Math.atan2(dy, dx) * 180) / Math.PI));
    setAmount(Math.min(1, Math.hypot(dx, dy) / (Math.min(r.width, r.height) * 0.42)));
  }, []);

  const onPointerDown = (e: React.PointerEvent) => {
    if (e.button !== 0 || renderMode !== "gl") return;
    if ((e.target as HTMLElement).closest("a, button, input, .lab-panel")) return;
    animRef.current.dragging = true;
    animRef.current.driftAngle = 0; // drag takes over the effective angle
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
    applyDrag(e);
  };

  const onPointerMove = (e: React.PointerEvent) => {
    if (animRef.current.dragging) applyDrag(e);
  };

  const onFocusPad = useCallback((x: number, y: number) => {
    setFocus({ x, y });
    animRef.current.tx = x;
    animRef.current.ty = y;
  }, []);

  const onPointerUp = () => {
    animRef.current.dragging = false;
  };

  const savePng = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    drawRef.current?.();
    canvas.toBlob((blob) => {
      if (!blob) return;
      const a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = "chromatic.png";
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 1000);
    });
  };

  return (
    <div
      className="fixed inset-0 overflow-hidden select-none bg-black"
      style={{ touchAction: "none" }}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onDragOver={(e) => {
        e.preventDefault();
        setDragOver(true);
      }}
      onDragLeave={() => setDragOver(false)}
      onDrop={(e) => {
        e.preventDefault();
        setDragOver(false);
        const file = e.dataTransfer.files[0];
        if (file) loadFile(file);
      }}
    >
      {renderMode !== "css" && <canvas ref={canvasRef} className="absolute inset-0 h-full w-full cursor-crosshair" />}
      {renderMode === "css" && <CSSFallback />}

      {dragOver && (
        <div className="absolute inset-4 z-10 pointer-events-none rounded-2xl border border-dashed border-white/40 flex items-center justify-center">
          <span className="text-[11px] uppercase tracking-[0.2em] text-white/70 font-[family-name:var(--font-flux)]">
            Drop image
          </span>
        </div>
      )}

      {glError && (
        <div className="absolute left-3 top-3 max-w-sm rounded border border-red-500/30 bg-black/80 px-3 py-2 z-10 font-[family-name:var(--font-geist-mono)]">
          <p className="text-[9px] uppercase tracking-widest text-red-400">WebGL shader failed (CSS fallback)</p>
          <p className="mt-1 text-[10px] leading-tight text-red-300/70">{glError}</p>
        </div>
      )}

      <input
        ref={fileRef}
        type="file"
        accept="image/*"
        className="hidden"
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (file) loadFile(file);
          e.target.value = "";
        }}
      />

      <div
        className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <p className="text-[12px] tracking-[0.08em] uppercase text-white/80">Chromatic</p>
        <p className="text-[12px] tracking-[0.08em] mt-1 text-white/50">WebGL · drag to split · drop an image</p>
      </div>

      <DraggablePanel
        isLight
        controls={[
          { label: "Amount", value: amount, set: setAmount, min: 0, max: 1, step: 0.01 },
          { label: "Angle", value: angle, set: (v) => setAngle(Math.round(v)), min: -180, max: 180, step: 1 },
          { label: "Max blur", value: maxBlur, set: (v) => setMaxBlur(Math.round(v)), min: 0, max: 80, step: 1 },
          { label: "Focus range", value: range, set: setRange, min: 0, max: 1, step: 0.01 },
          { label: "Aperture", value: falloff, set: setFalloff, min: 0.5, max: 4, step: 0.05 },
          { label: "Grain", value: grain, set: setGrain, min: 0, max: 0.4, step: 0.01 },
        ]}
      >
        <PanelFocusPad x={focus.x} y={focus.y} set={onFocusPad} />
        <PanelSelect label="Mode" value={mode} options={["linear", "radial"] as const} set={setMode} isLight />
        <PanelToggle label="Drift" value={drift} set={setDrift} isLight />
        <PanelButton label="Upload image" onClick={() => fileRef.current?.click()} />
        <PanelButton label="Save PNG" onClick={savePng} />
      </DraggablePanel>

      <div className="fixed top-8 left-8 z-10">
        <ScrambleLink
          from="NEMAWASHI LAB"
          to="← BACK"
          href="/lab"
          className="text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] text-white/70"
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe