NEMAWASHI LAB

Ripple

WebGL

A WebGL shockwave page transition: clicking sends a noise-warped Gaussian wavefront outward from the pointer, displacing UVs with an RGB split (chromatic aberration on the wave ridge) and a glow burn, revealing the alternate texture behind the front. The two textures are GIRAGIRA colourways (ink-on-paper and paper-on-red) painted to offscreen canvases with registration-mark grids that give the displacement something to bite on. FBM noise warps the wavefront so it looks organic, not circular. Falls back to a CSS clip-path circle sweep when WebGL is unavailable.

Ripple

WebGL · click anywhere

Controls
Duration1.40
Wave width0.15
Density5
Displacement0.14
RGB split0.02
Glow0.73
Noise warp1.00
Ease

Source

Ripple.tsx544 lines
"use client";

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

// Click-triggered shockwave transition (after ripple-gl): a noise-warped
// Gaussian wavefront expands from the pointer, pushes the UVs outward with an
// RGB split and a glow ridge, and reveals the other texture behind it. Here
// the two textures are painted GIRAGIRA colourways instead of photos.
const PAPER = "#f4f1ec";
const INK = "#4c4549";
const RED = "#ED1E26";
// FontFace-registered copy of KT Flux so the 2D texture canvas can use it.
const TEX_FONT = "KT Flux Ripple";

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_texA;
uniform sampler2D u_texB;
uniform vec2 u_resolution;
uniform vec2 u_center;
uniform float u_progress;
uniform float u_sigma;
uniform float u_waveFreq;
uniform float u_pushAmt;
uniform float u_caStrength;
uniform float u_glow;
uniform float u_noiseWarp;
uniform float u_swap;
uniform float u_pinch;

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);
}

float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  float a = hash21(i);
  float b = hash21(i + vec2(1.0, 0.0));
  float c = hash21(i + vec2(0.0, 1.0));
  float d = hash21(i + vec2(1.0, 1.0));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

float fbm(vec2 p, int octaves) {
  float val = 0.0;
  float amp = 0.5;
  float freq = 1.0;
  for (int i = 0; i < 8; i++) {
    if (i >= octaves) break;
    val += amp * vnoise(p * freq);
    freq *= 2.0;
    amp *= 0.5;
  }
  return val;
}

void main() {
  vec2 uv = v_uv;
  vec2 size = u_resolution;
  vec2 center = u_center;

  vec2 p = uv - center;
  float aspect = size.x / size.y;
  p.x *= aspect;

  float dist = length(p);
  float maxDist = length(vec2(0.5 * aspect, 0.5));
  float normDist = clamp(dist / maxDist, 0.0, 1.0);

  float noiseLarge = fbm(p * 4.0 + vec2(u_progress * 1.0, u_progress * 0.5), 4);
  float noiseSmall = fbm(p * 12.0 + vec2(u_progress * 2.0, -u_progress * 1.5), 3);

  float coverage = 1.0 + 0.5 * u_noiseWarp + 0.1;
  float waveFront = u_progress * coverage;

  float warpScale = smoothstep(0.0, 0.05, u_progress);
  float warpedDist = normDist
    + (noiseLarge - 0.5) * u_noiseWarp * warpScale
    + (noiseSmall - 0.5) * (u_noiseWarp * 0.9) * warpScale;

  float delta = warpedDist - waveFront;
  float baseEnvelope = exp(-delta * delta / (2.0 * u_sigma * u_sigma));
  float ripples = max(0.0, cos(delta * u_waveFreq));
  float envelope = baseEnvelope * ripples;

  float gate = smoothstep(0.0, 0.05, u_progress)
             * (1.0 - smoothstep(0.85, 1.0, u_progress));
  envelope *= gate;

  vec2 dir = (dist > 0.001) ? normalize(p) : vec2(0.0);
  float pushAmt = envelope * u_pushAmt;

  float pinchSigma = 0.10;
  float pinchG = exp(-dist * dist / (2.0 * pinchSigma * pinchSigma));
  float pinchDisp = (dist / (pinchSigma * pinchSigma)) * pinchG * 0.01 * u_pinch;

  vec2 toEdge = min(uv, 1.0 - uv);
  float edgeFade = smoothstep(0.0, 0.14, min(toEdge.x, toEdge.y));
  pinchDisp *= edgeFade;

  vec2 uvOffset = dir * (pushAmt - pinchDisp);
  uvOffset.x /= aspect;

  float caStrength = envelope * u_caStrength;
  vec2 caOffset = dir * caStrength;
  caOffset.x /= aspect;

  vec2 uvR = uv - uvOffset - caOffset;
  vec2 uvG = uv - uvOffset;
  vec2 uvB = uv - uvOffset + caOffset;

  vec4 colorA = vec4(
    texture2D(u_texA, uvR).r,
    texture2D(u_texA, uvG).g,
    texture2D(u_texA, uvB).b,
    1.0
  );
  vec4 colorB = vec4(
    texture2D(u_texB, uvR).r,
    texture2D(u_texB, uvG).g,
    texture2D(u_texB, uvB).b,
    1.0
  );

  float feather = 0.04 + 0.05 * noiseLarge;
  float reveal = smoothstep(waveFront + feather, waveFront - feather, warpedDist);
  reveal *= smoothstep(0.0, 0.05, u_progress);

  vec4 base = mix(colorA, colorB, u_swap);
  vec4 target = mix(colorB, colorA, u_swap);
  vec4 color = mix(base, target, reveal);

  float glow = envelope * u_glow;
  color.rgb = clamp(color.rgb / max(1.0 - glow, 0.01), 0.0, 1.0);

  color.rgb *= 1.0 - 0.16 * pinchG * edgeFade * u_pinch;
  color.rgb = clamp(color.rgb, 0.0, 1.0);

  gl_FragColor = vec4(color.rgb, 1.0);
}`;

const EASES = {
  inout: "power2.inOut",
  out: "power3.out",
  expo: "expo.out",
  back: "back.out(1.4)",
} as const;
type EaseKey = keyof typeof EASES;

const DEFAULTS = { sigma: 0.15, waveFreq: 5, pushAmt: 0.145, ca: 0.02, glow: 0.73, noiseWarp: 1, duration: 1.4 };

// Paint one colourway poster: flat ground, registration-mark grid (gives the
// displacement wave something to bite on), fitted wordmark + caption.
function paintSide(ctx: CanvasRenderingContext2D, w: number, h: number, dpr: number, side: 0 | 1) {
  const bg = side === 0 ? PAPER : RED;
  const fg = side === 0 ? INK : PAPER;
  ctx.fillStyle = bg;
  ctx.fillRect(0, 0, w, h);

  const step = 64 * dpr;
  const arm = 4.5 * dpr;
  ctx.strokeStyle = fg;
  ctx.lineWidth = Math.max(1, dpr * 0.9);
  ctx.globalAlpha = side === 0 ? 0.14 : 0.22;
  for (let y = step * 0.5; y < h; y += step) {
    for (let x = step * 0.5; x < w; 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 = (w * 0.88 * 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 > w * 0.88) {
    size *= (w * 0.88) / atSize;
    ctx.font = `600 ${size}px "${TEX_FONT}", sans-serif`;
  }
  ctx.fillStyle = fg;
  ctx.fillText(word, w / 2, h * 0.5 - size * 0.06);

  const spaced = ctx as CanvasRenderingContext2D & { letterSpacing: string };
  if ("letterSpacing" in ctx) spaced.letterSpacing = `${3 * dpr}px`;
  ctx.font = `500 ${11 * dpr}px "${TEX_FONT}", sans-serif`;
  ctx.fillText(side === 0 ? "COLOURWAY 01 — INK ON PAPER" : "COLOURWAY 02 — PAPER ON RED", w / 2, h * 0.5 + size * 0.5);
  if ("letterSpacing" in ctx) spaced.letterSpacing = "0px";
}

function FallbackFace({ side }: { side: 0 | 1 }) {
  return (
    <div className="absolute inset-0 flex items-center justify-center" style={{ background: side === 0 ? PAPER : RED }}>
      <span
        className="font-[family-name:var(--font-flux)] text-[12vw] leading-none"
        style={{ fontVariationSettings: "'wght' 600, 'SRIF' 200", color: side === 0 ? INK : PAPER }}
      >
        GIRAGIRA
      </span>
    </div>
  );
}

// No-WebGL fallback: same colourway swap as a clip-path circle sweep.
function CSSFallback({ side, onSwap }: { side: 0 | 1; onSwap: () => void }) {
  const [sweep, setSweep] = useState<{ x: number; y: number; on: boolean } | null>(null);
  const busy = useRef(false);
  const onClick = (e: React.MouseEvent) => {
    if (busy.current) return;
    busy.current = true;
    const x = (e.clientX / window.innerWidth) * 100;
    const y = (e.clientY / window.innerHeight) * 100;
    setSweep({ x, y, on: false });
    requestAnimationFrame(() => requestAnimationFrame(() => setSweep((s) => (s ? { ...s, on: true } : s))));
    setTimeout(() => {
      onSwap();
      setSweep(null);
      busy.current = false;
    }, 1250);
  };
  return (
    <div className="absolute inset-0" onClick={onClick}>
      <FallbackFace side={side} />
      {sweep && (
        <div
          className="absolute inset-0"
          style={{
            clipPath: `circle(${sweep.on ? "150%" : "0%"} at ${sweep.x}% ${sweep.y}%)`,
            transition: "clip-path 1.1s cubic-bezier(0.5, 0, 0.2, 1)",
          }}
        >
          <FallbackFace side={side === 0 ? 1 : 0} />
        </div>
      )}
    </div>
  );
}

export default function Ripple() {
  const [sigma, setSigma] = useState(DEFAULTS.sigma);
  const [waveFreq, setWaveFreq] = useState(DEFAULTS.waveFreq);
  const [pushAmt, setPushAmt] = useState(DEFAULTS.pushAmt);
  const [ca, setCa] = useState(DEFAULTS.ca);
  const [glow, setGlow] = useState(DEFAULTS.glow);
  const [noiseWarp, setNoiseWarp] = useState(DEFAULTS.noiseWarp);
  const [duration, setDuration] = useState(DEFAULTS.duration);
  const [ease, setEase] = useState<EaseKey>("inout");
  const [pinch, setPinch] = useState(true);
  const [side, setSide] = useState<0 | 1>(0);

  const [renderMode, setRenderMode] = useState<"gl" | "css" | null>(null);
  const [glError, setGlError] = useState<string | null>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const drawRef = useRef<(() => void) | null>(null);
  // Wave state lives outside React — gsap mutates it and redraws imperatively.
  const animRef = useRef({ progress: 0, cx: 0.5, cy: 0.5, swap: 0, pinch: 0, busy: false });

  const paramsRef = useRef({ sigma, waveFreq, pushAmt, ca, glow, noiseWarp, duration, ease, pinch });
  // Keep the imperative draw's snapshot of the controls current (synced after each render).
  useEffect(() => {
    paramsRef.current = { sigma, waveFreq, pushAmt, ca, glow, noiseWarp, duration, ease, pinch };
    drawRef.current?.();
  });

  const trigger = useCallback((cx: number, cy: number) => {
    const a = animRef.current;
    const draw = () => drawRef.current?.();
    if (a.busy || !drawRef.current) return;
    a.cx = cx;
    a.cy = cy;
    gsap.killTweensOf(a);
    a.progress = 0;
    a.pinch = 0;
    a.busy = true;
    const p = paramsRef.current;
    if (p.pinch) {
      gsap.to(a, {
        keyframes: [
          { pinch: 0.3, duration: 0.1, ease: "power3.out" },
          { pinch: 0, duration: 0.4, ease: "power2.in" },
        ],
        onUpdate: draw,
      });
    }
    gsap.to(a, {
      progress: 1,
      duration: p.duration,
      ease: EASES[p.ease],
      onUpdate: draw,
      onComplete: () => {
        a.swap = a.swap > 0.5 ? 0 : 1;
        a.progress = 0;
        a.busy = false;
        setSide(a.swap as 0 | 1);
        draw();
      },
    });
  }, []);

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

    (async () => {
      const gl = canvas.getContext("webgl", { antialias: false, premultipliedAlpha: false }) 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_texA"), 0);
      gl.uniform1i(gl.getUniformLocation(prog, "u_texB"), 1);
      const locs = {
        res: gl.getUniformLocation(prog, "u_resolution"),
        center: gl.getUniformLocation(prog, "u_center"),
        progress: gl.getUniformLocation(prog, "u_progress"),
        sigma: gl.getUniformLocation(prog, "u_sigma"),
        waveFreq: gl.getUniformLocation(prog, "u_waveFreq"),
        pushAmt: gl.getUniformLocation(prog, "u_pushAmt"),
        caStrength: gl.getUniformLocation(prog, "u_caStrength"),
        glow: gl.getUniformLocation(prog, "u_glow"),
        noiseWarp: gl.getUniformLocation(prog, "u_noiseWarp"),
        swap: gl.getUniformLocation(prog, "u_swap"),
        pinch: gl.getUniformLocation(prog, "u_pinch"),
      };

      // KT Flux for the texture canvas — poster still 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 texA = gl.createTexture()!;
      const texB = gl.createTexture()!;
      const src = document.createElement("canvas");
      const srcCtx = src.getContext("2d")!;

      const uploadSide = (unit: number, tex: WebGLTexture, sideIdx: 0 | 1, dpr: number) => {
        paintSide(srcCtx, src.width, src.height, dpr, sideIdx);
        gl.activeTexture(gl.TEXTURE0 + unit);
        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);
      };

      const draw = () => {
        const p = paramsRef.current;
        const a = animRef.current;
        gl.uniform2f(locs.center, a.cx, a.cy);
        gl.uniform1f(locs.progress, a.progress);
        gl.uniform1f(locs.sigma, p.sigma);
        gl.uniform1f(locs.waveFreq, p.waveFreq);
        gl.uniform1f(locs.pushAmt, p.pushAmt);
        gl.uniform1f(locs.caStrength, p.ca);
        gl.uniform1f(locs.glow, p.glow);
        gl.uniform1f(locs.noiseWarp, p.noiseWarp);
        gl.uniform1f(locs.swap, a.swap);
        gl.uniform1f(locs.pinch, a.pinch);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      };

      const resize = () => {
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
        const h = Math.max(1, Math.round(canvas.clientHeight * 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);
        uploadSide(0, texA, 0, dpr);
        uploadSide(1, texB, 1, dpr);
        draw();
      };

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

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

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

    const anim = animRef.current;
    return () => {
      disposed = true;
      gsap.killTweensOf(anim);
      cleanup?.();
    };
  }, []);

  const onPointerDown = (e: React.PointerEvent) => {
    if (e.button !== 0 || renderMode !== "gl") return;
    if ((e.target as HTMLElement).closest("a, button, input, .lab-panel")) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const r = canvas.getBoundingClientRect();
    trigger((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height);
  };

  const light = side === 1; // red ground → light chrome

  return (
    <div className="fixed inset-0 overflow-hidden select-none" style={{ background: PAPER, touchAction: "none" }} onPointerDown={onPointerDown}>
      {renderMode !== "css" && <canvas ref={canvasRef} className="absolute inset-0 h-full w-full cursor-crosshair" />}
      {renderMode === "css" && <CSSFallback side={side} onSwap={() => setSide((s) => (s === 0 ? 1 : 0))} />}

      {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>
      )}

      <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" style={{ color: light ? "rgba(255,252,244,0.8)" : "rgba(23,23,23,0.5)" }}>Ripple</p>
        <p className="text-[12px] tracking-[0.08em] mt-1" style={{ color: light ? "rgba(255,252,244,0.55)" : "rgba(23,23,23,0.3)" }}>WebGL · click anywhere</p>
      </div>

      <DraggablePanel
        isLight={light}
        controls={[
          { label: "Duration", value: duration, set: setDuration, min: 0.4, max: 3, step: 0.05 },
          { label: "Wave width", value: sigma, set: setSigma, min: 0.05, max: 0.5, step: 0.01 },
          { label: "Density", value: waveFreq, set: (v) => setWaveFreq(Math.round(v)), min: 5, max: 100, step: 1 },
          { label: "Displacement", value: pushAmt, set: setPushAmt, min: 0, max: 0.5, step: 0.005 },
          { label: "RGB split", value: ca, set: setCa, min: 0, max: 0.05, step: 0.005 },
          { label: "Glow", value: glow, set: setGlow, min: 0, max: 1, step: 0.01 },
          { label: "Noise warp", value: noiseWarp, set: setNoiseWarp, min: 0, max: 1, step: 0.01 },
        ]}
      >
        <PanelSelect label="Ease" value={ease} options={["inout", "out", "expo", "back"] as const} set={setEase} isLight={light} />
        <PanelToggle label="Pinch" value={pinch} set={setPinch} isLight={light} />
      </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)] ${light ? "text-white/70" : "text-foreground/60"}`}
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe