NEMAWASHI LAB

Sunlight

WebGL

A full-screen WebGL shader that composites two elliptical light pools over Voronoi FBM caustics. The caustic cells are generated by a two-octave smooth Voronoi (3x3 or 5x5 neighbourhood depending on cell-shape slider), warped by simplex noise distortion and optional streak noise, and offset per-channel for chromatic aberration. Three presets -- ripple, sunlight, smoke -- snap density, distortion, spread, elongation, and speed to tuned values; a night toggle lerps the palette toward deep blue-black. Falls back to a radial-gradient CSS approximation when WebGL is unavailable.

Sunlight

WebGL · ripple

Controls
Intensity0.70
Speed66
Density20
Elongation0.50
Angle90
CA0.20
CA intensity1.00
Shape0.00
Blur0.30
Opacity1.00
Preset
Background
Light

Source

Sunlight.tsx558 lines
"use client";

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

const EASE = "cubic-bezier(0.455, 0.190, 0.000, 0.985)";

const PRESETS = {
  ripple:   { distortion: 0.05, streaks: 0.0, spread: 1.0, density: 20, ca: 0.2, caIntensity: 1.0, softness: 0.5, speed: 66, elongation: 0.5, angle: 90, cellShape: 0, blur: 0.3, opacity: 1.0, bgColor: "#dbd9db", lightColor: "#fffcf4" },
  sunlight: { distortion: 0.05, streaks: 0.0, spread: 1.0, density: 9, ca: 0.5, caIntensity: 1.0, softness: 0.5, speed: 54, elongation: 0.51, angle: 143, cellShape: 0, blur: 0.3, opacity: 1.0, bgColor: "#dbd9db", lightColor: "#fffcf4" },
  smoke:    { distortion: 1.0,  streaks: 1.0, spread: 0.0, density: 36, ca: 1.0, caIntensity: 0.7, softness: 0.7, speed: 3,  elongation: 0.5, angle: 226, cellShape: 0, blur: 0.3, opacity: 1.0, bgColor: "#dbd9db", lightColor: "#fffcf4" },
} as const;

type Mode = keyof typeof PRESETS;

const VERT = `
attribute vec2 aPosition;
void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}`;

const FRAG = `
precision highp float;

uniform float uTime;
uniform float uNight;
uniform float uWarmth;
uniform float uIntensity;
uniform float uSpeed;
uniform vec2  uResolution;
uniform float uDistortion;
uniform float uStreaks;
uniform float uSpread;
uniform float uCa;
uniform float uDensity;
uniform float uSoftness;
uniform float uElongation;
uniform float uAngle;
uniform float uCaIntensity;
uniform float uCellShape;
uniform float uBlur;
uniform float uOpacity;
uniform vec3  uBgColor;
uniform vec3  uLightColor;

vec3 mod289_3(vec3 x){ return x - floor(x*(1.0/289.0))*289.0; }
vec2 mod289_2(vec2 x){ return x - floor(x*(1.0/289.0))*289.0; }
vec3 permute(vec3 x){ return mod289_3(((x*34.0)+10.0)*x); }

float snoise(vec2 v){
  vec4 C = vec4(0.211324865405187, 0.366025403784439,
               -0.577350269189626, 0.024390243902439);
  vec2 i  = floor(v + dot(v, C.yy));
  vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1 = vec2(step(x0.y, x0.x), step(x0.x, x0.y));
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = mod289_2(i);
  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
  m = m*m;
  m = m*m;
  vec3 x = 2.0*fract(p*C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314*(a0*a0+h*h);
  vec3 g;
  g.x  = a0.x*x0.x  + h.x*x0.y;
  g.yz = a0.yz*x12.xz + h.yz*x12.yw;
  return 130.0 * dot(m, g);
}

vec2 vhash(vec2 p) {
  vec2 s = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
  return fract(sin(s) * 43758.5453);
}

vec2 voronoi3(vec2 st, float time, float soft) {
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);
  vec2 totalPt = vec2(0.0);
  float totalW = 0.0;
  float k = mix(10.0, 1.5, soft);
  for (int j = -1; j <= 1; j++) {
    for (int i = -1; i <= 1; i++) {
      vec2 nb = vec2(float(i), float(j));
      vec2 pt = vhash(i_st + nb);
      pt = 0.5 + 0.5 * sin(5.0 + time + 6.2831 * pt);
      vec2 df = nb + pt - f_st;
      float ds = dot(df, df);
      float w = exp(-k * ds);
      totalPt += pt * w;
      totalW += w;
    }
  }
  return totalPt / totalW;
}

vec2 voronoi5(vec2 st, float time, float soft) {
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);
  vec2 totalPt = vec2(0.0);
  float totalW = 0.0;
  float k = mix(10.0, 1.5, soft);
  float nDist = 10.0;
  float sDist = 10.0;
  vec2 nPt = vec2(0.0);
  for (int j = -2; j <= 2; j++) {
    for (int i = -2; i <= 2; i++) {
      vec2 nb = vec2(float(i), float(j));
      vec2 pt = vhash(i_st + nb);
      pt = 0.5 + 0.5 * sin(5.0 + time + 6.2831 * pt);
      vec2 df = nb + pt - f_st;
      float ds = dot(df, df);
      float w = exp(-k * ds);
      totalPt += pt * w;
      totalW += w;
      if (ds < nDist) {
        sDist = nDist;
        nDist = ds;
        nPt = pt;
      } else if (ds < sDist) {
        sDist = ds;
      }
    }
  }
  vec2 smoothR = totalPt / totalW;
  float edge = sqrt(sDist) - sqrt(nDist);
  float fade = smoothstep(0.0, soft * 0.5, edge);
  vec2 polyR = mix(vec2(0.5), nPt, fade);
  return mix(smoothR, polyR, uCellShape);
}

vec2 voronoi(vec2 st, float time, float soft) {
  return uCellShape < 0.01 ? voronoi3(st, time, soft) : voronoi5(st, time, soft);
}

vec2 voronoiFBM(vec2 st, float time, float soft) {
  vec2 val = vec2(0.0);
  vec2 shift = vec2(100.0);
  float sc = 1.41421;
  float rc = cos(0.5);
  float rs = sin(0.5);
  for (int o = 0; o < 2; o++) {
    val += voronoi(st, time, soft);
    vec2 nst = st * sc + shift;
    st = vec2(nst.x * rc - nst.y * rs, nst.x * rs + nst.y * rc);
  }
  return val * 0.5;
}

void main(){
  vec2 uv = gl_FragCoord.xy / uResolution;
  uv.y = 1.0 - uv.y;
  float t = uTime * uSpeed * 0.1;
  float aspect = uResolution.x / uResolution.y;

  vec2 wc = uv * 2.0 - 1.0;
  float wt = t * 0.25;
  float sf = mix(5.0, 1.5, uSpread);
  float wx = sin(wc.y * sf + wt * 1.05) * 0.22 * uDistortion;
  float wy = sin(wc.x * sf * 0.8 + wt * 0.85) * 0.22 * uDistortion;
  vec2 d = uv + vec2(wx, wy) * uIntensity;

  float nf = mix(1.0, 0.65, uSpread);
  float n1 = snoise(uv * 2.2 * nf + vec2(t * 0.06, t * 0.02));
  float n2 = snoise(uv * 2.2 * nf + vec2(t * 0.04, -t * 0.05) + 73.0);
  d += vec2(n1, n2) * 0.09 * uIntensity * uDistortion;

  float n3 = snoise(uv * 4.5 * nf + vec2(-t * 0.07, t * 0.03) + 137.0);
  float n4 = snoise(uv * 4.5 * nf + vec2(t * 0.05, t * 0.06) + 211.0);
  d += vec2(n3, n4) * 0.035 * uIntensity * uDistortion;

  float sca = cos(0.55);
  float ssa = sin(0.55);
  vec2 streakUV = vec2(uv.x * sca - uv.y * ssa, uv.x * ssa + uv.y * sca);
  streakUV = streakUV * vec2(2.5, 10.0);
  float streak = snoise(streakUV + vec2(t * 0.04, t * 0.01) + 300.0);
  d += vec2(streak, streak * 0.4) * 0.025 * uIntensity * uStreaks;

  float rca = cos(0.4);
  float rsa = sin(0.4);

  float iR1 = mix(0.12, 0.06, uSpread);
  float oR1 = mix(0.50, 0.75, uSpread);
  float iR2 = mix(0.10, 0.05, uSpread);
  float oR2 = mix(0.45, 0.70, uSpread);

  vec2 c1 = vec2(0.56, 0.38);
  vec2 diff1 = d - c1;
  diff1.x *= aspect;
  vec2 rd1 = vec2(diff1.x * rca - diff1.y * rsa, diff1.x * rsa + diff1.y * rca);
  rd1.x *= 1.4;
  float light1 = 1.0 - smoothstep(iR1, oR1, length(rd1));

  vec2 c2 = vec2(0.72, 0.28);
  vec2 diff2 = d - c2;
  diff2.x *= aspect;
  vec2 rd2 = vec2(diff2.x * rca - diff2.y * rsa, diff2.x * rsa + diff2.y * rca);
  rd2.x *= 1.2;
  float light2 = 1.0 - smoothstep(iR2, oR2, length(rd2));

  float lightG = min(max(light1, light2 * 0.85) + light1 * light2 * 0.15, 1.0);

  float caAngle = (0.4 + t * 0.05) * 6.28318;
  vec2 caDir = vec2(sin(caAngle), cos(caAngle));
  float caAmt = 0.022 * uIntensity * mix(1.0, 0.25, uNight);

  vec2 dR = d + caDir * caAmt;
  vec2 diffR1 = dR - c1; diffR1.x *= aspect;
  vec2 rdR1 = vec2(diffR1.x*rca - diffR1.y*rsa, diffR1.x*rsa + diffR1.y*rca);
  rdR1.x *= 1.4;
  float lR1 = 1.0 - smoothstep(iR1, oR1, length(rdR1));
  vec2 diffR2 = dR - c2; diffR2.x *= aspect;
  vec2 rdR2 = vec2(diffR2.x*rca - diffR2.y*rsa, diffR2.x*rsa + diffR2.y*rca);
  rdR2.x *= 1.2;
  float lR2 = 1.0 - smoothstep(iR2, oR2, length(rdR2));
  float lightR = min(max(lR1, lR2 * 0.85) + lR1 * lR2 * 0.15, 1.0);

  vec2 dB = d - caDir * caAmt;
  vec2 diffB1 = dB - c1; diffB1.x *= aspect;
  vec2 rdB1 = vec2(diffB1.x*rca - diffB1.y*rsa, diffB1.x*rsa + diffB1.y*rca);
  rdB1.x *= 1.4;
  float lB1 = 1.0 - smoothstep(iR1, oR1, length(rdB1));
  vec2 diffB2 = dB - c2; diffB2.x *= aspect;
  vec2 rdB2 = vec2(diffB2.x*rca - diffB2.y*rsa, diffB2.x*rsa + diffB2.y*rca);
  rdB2.x *= 1.2;
  float lB2 = 1.0 - smoothstep(iR2, oR2, length(rdB2));
  float lightB = min(max(lB1, lB2 * 0.85) + lB1 * lB2 * 0.15, 1.0);

  float vt = uTime * uSpeed * 0.1;

  vec2 vigC = vec2(0.70, 0.27);
  vec2 vigSkew = vec2(aspect * 0.54, 0.46);

  float aRad = uAngle * 0.01745329;
  float vrC = cos(aRad);
  float vrS = sin(aRad);
  float elong = uElongation;
  float warp = 0.5;

  vec2 caP = caDir * 0.04 * uCa;

  float ssEnd = mix(0.35, 0.80, uBlur);

  vec2 vstG = (uv - vigC) * vec2(aspect, 1.0) * uDensity;
  vstG = vec2(vstG.x * vrC - vstG.y * vrS, vstG.x * vrS + vstG.y * vrC);
  vstG *= vec2(1.0, elong);
  vec2 vOffG = voronoiFBM(vstG, vt, uSoftness);
  vOffG = vOffG * warp - warp * 0.5;
  vec2 duvG = uv + vOffG;
  float dayG = 1.0 - smoothstep(0.02, ssEnd, length((duvG - vigC) * vigSkew));

  vec2 uvR = uv + caP;
  vec2 vstR = (uvR - vigC) * vec2(aspect, 1.0) * uDensity;
  vstR = vec2(vstR.x * vrC - vstR.y * vrS, vstR.x * vrS + vstR.y * vrC);
  vstR *= vec2(1.0, elong);
  vec2 vOffR = voronoiFBM(vstR, vt, uSoftness);
  vOffR = vOffR * warp - warp * 0.5;
  vec2 duvR = uvR + vOffR;
  float dayR = 1.0 - smoothstep(0.02, ssEnd, length((duvR - vigC) * vigSkew));

  vec2 uvB = uv - caP;
  vec2 vstB = (uvB - vigC) * vec2(aspect, 1.0) * uDensity;
  vstB = vec2(vstB.x * vrC - vstB.y * vrS, vstB.x * vrS + vstB.y * vrC);
  vstB *= vec2(1.0, elong);
  vec2 vOffB = voronoiFBM(vstB, vt, uSoftness);
  vOffB = vOffB * warp - warp * 0.5;
  vec2 duvB = uvB + vOffB;
  float dayB = 1.0 - smoothstep(0.02, ssEnd, length((duvB - vigC) * vigSkew));

  lightG = mix(lightG, dayG, uSpread);
  lightR = mix(lightR, dayR, uSpread);
  lightB = mix(lightB, dayB, uSpread);

  lightR = mix(lightG, lightR, uCaIntensity);
  lightB = mix(lightG, lightB, uCaIntensity);

  vec3 litCol = mix(uLightColor, vec3(0.14, 0.18, 0.26), uNight);
  vec3 shadCol = mix(uBgColor, vec3(0.04, 0.05, 0.065), uNight);

  vec3 col;
  col.r = mix(shadCol.r, litCol.r, lightR);
  col.g = mix(shadCol.g, litCol.g, lightG);
  col.b = mix(shadCol.b, litCol.b, lightB);

  float vig = 1.0 - 0.04 * pow(length(uv - 0.5) * 1.3, 2.5);
  col *= vig;

  vec3 bg = mix(uBgColor, vec3(0.04, 0.05, 0.065), uNight);
  col = mix(bg, col, uOpacity);

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

const UNIFORM_NAMES = [
  "uTime", "uNight", "uWarmth", "uIntensity", "uSpeed", "uResolution",
  "uDistortion", "uStreaks", "uSpread", "uCa",
  "uDensity", "uSoftness", "uElongation", "uAngle", "uCaIntensity", "uCellShape",
  "uBlur", "uOpacity", "uBgColor", "uLightColor",
];

interface GLState {
  gl: WebGLRenderingContext;
  locs: Record<string, WebGLUniformLocation | null>;
}
type InitResult = { ok: true; state: GLState } | { ok: false; reason: string };

function initGL(canvas: HTMLCanvasElement): InitResult {
  const opts: WebGLContextAttributes = { antialias: false, alpha: false };
  const gl =
    (canvas.getContext("webgl", opts) as WebGLRenderingContext | null) ??
    (canvas.getContext("webgl2", opts) as WebGLRenderingContext | null) ??
    (canvas.getContext("experimental-webgl", opts) as WebGLRenderingContext | null);
  if (!gl) return { ok: false, reason: "No WebGL context available" };

  function compile(type: number, src: string, label: string) {
    const s = gl!.createShader(type);
    if (!s) return { shader: null as WebGLShader | null, error: `createShader(${label}) null` };
    gl!.shaderSource(s, src);
    gl!.compileShader(s);
    if (!gl!.getShaderParameter(s, gl!.COMPILE_STATUS)) {
      const log = gl!.getShaderInfoLog(s) || "(empty)";
      gl!.deleteShader(s);
      return { shader: null as WebGLShader | null, error: `${label}: ${log}` };
    }
    return { shader: s, error: null };
  }

  const vs = compile(gl.VERTEX_SHADER, VERT, "VERT");
  if (!vs.shader) return { ok: false, reason: vs.error! };
  const fs = compile(gl.FRAGMENT_SHADER, FRAG, "FRAG");
  if (!fs.shader) return { ok: false, reason: fs.error! };

  const prog = gl.createProgram()!;
  gl.attachShader(prog, vs.shader);
  gl.attachShader(prog, fs.shader);
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
    return { ok: false, reason: `LINK: ${gl.getProgramInfoLog(prog) || "(empty)"}` };
  }
  gl.useProgram(prog);

  const locs: Record<string, WebGLUniformLocation | null> = {};
  for (const n of UNIFORM_NAMES) locs[n] = gl.getUniformLocation(prog, n);

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

  return { ok: true, state: { gl, locs } };
}

function resizeCanvas(canvas: HTMLCanvasElement, gl: WebGLRenderingContext) {
  const dpr = Math.min(window.devicePixelRatio, 2) * 0.5;
  const w = Math.round(canvas.clientWidth * dpr);
  const h = Math.round(canvas.clientHeight * dpr);
  if (canvas.width !== w || canvas.height !== h) {
    canvas.width = w;
    canvas.height = h;
    gl.viewport(0, 0, w, h);
  }
}

function CSSFallback({ isNight, warmth, intensity }: { isNight: boolean; warmth: number; intensity: number }) {
  const wall = isNight ? "#0f131c" : "#fbfaf8";
  const warmHue = 30 + warmth * 20;
  const warmSat = 60 + warmth * 30;
  const alpha = intensity * (isNight ? 0.15 : 0.3);
  const glowColor = isNight ? `rgba(27,41,63,${alpha})` : `hsla(${warmHue},${warmSat}%,82%,${alpha})`;
  return (
    <div className="absolute inset-0" style={{ background: wall, transition: `background 1s ${EASE}` }}>
      <div className="absolute inset-0" style={{ background: `radial-gradient(ellipse 80% 130% at 55% 35%, ${glowColor}, transparent 70%)` }} />
      <div className="absolute inset-0" style={{ background: `radial-gradient(ellipse 100% 80% at 80% 10%, ${glowColor.replace(String(alpha), String(alpha * 0.4))}, transparent 60%)` }} />
      <div className="absolute inset-0" style={{ background: "radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.05) 100%)" }} />
    </div>
  );
}

function hexToRgb(hex: string): [number, number, number] {
  return [parseInt(hex.slice(1, 3), 16) / 255, parseInt(hex.slice(3, 5), 16) / 255, parseInt(hex.slice(5, 7), 16) / 255];
}

export default function Sunlight() {
  const [isNight, setIsNight] = useState(false);
  const [warmth] = useState(0.65);
  const [intensity, setIntensity] = useState(0.7);
  const [mode, setMode] = useState<Mode>("ripple");
  const [distortion, setDistortion] = useState<number>(PRESETS.ripple.distortion);
  const [streaks, setStreaks] = useState<number>(PRESETS.ripple.streaks);
  const [spread, setSpread] = useState<number>(PRESETS.ripple.spread);
  const [ca, setCa] = useState<number>(PRESETS.ripple.ca);
  const [caIntensity, setCaIntensity] = useState<number>(PRESETS.ripple.caIntensity);
  const [density, setDensity] = useState<number>(PRESETS.ripple.density);
  const [softness] = useState<number>(PRESETS.ripple.softness);
  const [speed, setSpeed] = useState<number>(PRESETS.ripple.speed);
  const [elongation, setElongation] = useState<number>(PRESETS.ripple.elongation);
  const [angle, setAngle] = useState<number>(PRESETS.ripple.angle);
  const [cellShape, setCellShape] = useState<number>(PRESETS.ripple.cellShape);
  const [blur, setBlur] = useState<number>(PRESETS.ripple.blur);
  const [opacity, setOpacity] = useState<number>(PRESETS.ripple.opacity);
  const [bgColor, setBgColor] = useState<string>(PRESETS.ripple.bgColor);
  const [lightColor, setLightColor] = useState<string>(PRESETS.ripple.lightColor);

  const [renderMode, setRenderMode] = useState<"gl" | "css" | null>(null);
  const [glError, setGlError] = useState<string | null>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const glRef = useRef<GLState | null>(null);
  const nightRef = useRef(0);
  const visibleRef = useRef(true);

  const params = useRef({ isNight, warmth, intensity, distortion, streaks, spread, ca, caIntensity, density, softness, speed, elongation, angle, cellShape, blur, opacity, bgColor, lightColor });
  // Keep the render loop's snapshot of the controls current (synced after each render).
  useEffect(() => {
    params.current = { isNight, warmth, intensity, distortion, streaks, spread, ca, caIntensity, density, softness, speed, elongation, angle, cellShape, blur, opacity, bgColor, lightColor };
  });

  const applyPreset = (m: Mode) => {
    setMode(m);
    const p = PRESETS[m];
    setDistortion(p.distortion); setStreaks(p.streaks); setSpread(p.spread); setCa(p.ca);
    setCaIntensity(p.caIntensity); setDensity(p.density); setSpeed(p.speed); setElongation(p.elongation);
    setAngle(p.angle); setCellShape(p.cellShape); setBlur(p.blur); setOpacity(p.opacity);
    setBgColor(p.bgColor); setLightColor(p.lightColor);
  };

  const render = useCallback((time: number) => {
    const state = glRef.current;
    if (!state) return;
    if (document.hidden || !visibleRef.current) return;
    const { gl, locs } = state;
    const canvas = canvasRef.current;
    if (!canvas) return; // ref may be nulled on unmount before the RAF loop is cancelled
    const p = params.current;

    const target = p.isNight ? 1 : 0;
    nightRef.current += (target - nightRef.current) * 0.035;
    if (Math.abs(nightRef.current - target) < 0.001) nightRef.current = target;

    gl.uniform1f(locs.uTime, time * 0.001);
    gl.uniform1f(locs.uNight, nightRef.current);
    gl.uniform1f(locs.uWarmth, p.warmth);
    gl.uniform1f(locs.uIntensity, p.intensity);
    gl.uniform1f(locs.uSpeed, p.speed);
    gl.uniform2f(locs.uResolution, canvas.width, canvas.height);
    gl.uniform1f(locs.uDistortion, p.distortion);
    gl.uniform1f(locs.uStreaks, p.streaks);
    gl.uniform1f(locs.uSpread, p.spread);
    gl.uniform1f(locs.uCa, p.ca);
    gl.uniform1f(locs.uDensity, p.density);
    gl.uniform1f(locs.uSoftness, p.softness);
    gl.uniform1f(locs.uElongation, p.elongation);
    gl.uniform1f(locs.uAngle, p.angle);
    gl.uniform1f(locs.uCaIntensity, p.caIntensity);
    gl.uniform1f(locs.uCellShape, p.cellShape);
    gl.uniform1f(locs.uBlur, p.blur);
    gl.uniform1f(locs.uOpacity, p.opacity);
    const bg = hexToRgb(p.bgColor);
    gl.uniform3f(locs.uBgColor, bg[0], bg[1], bg[2]);
    const lt = hexToRgb(p.lightColor);
    gl.uniform3f(locs.uLightColor, lt[0], lt[1], lt[2]);

    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const result = initGL(canvas);
    if (!result.ok) {
      setGlError(result.reason);
      setRenderMode("css");
      return;
    }
    glRef.current = result.state;
    setRenderMode("gl");

    const ro = new ResizeObserver(() => {
      if (glRef.current) resizeCanvas(canvas, glRef.current.gl);
    });
    ro.observe(canvas);
    resizeCanvas(canvas, result.state.gl);

    const io = new IntersectionObserver((entries) => { visibleRef.current = entries[0].isIntersecting; }, { threshold: 0 });
    io.observe(canvas);

    let raf = requestAnimationFrame(function loop(t) {
      render(t);
      raf = requestAnimationFrame(loop);
    });
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      glRef.current = null;
    };
  }, [render]);

  return (
    <div className="fixed inset-0 overflow-hidden select-none" style={{ background: "#000" }}>
      {renderMode !== "css" && <canvas ref={canvasRef} className="absolute inset-0 h-full w-full z-[1]" style={{ imageRendering: "auto" }} />}
      {renderMode === "css" && <CSSFallback isNight={isNight} warmth={warmth} intensity={intensity} />}

      {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: isNight ? "rgba(255,255,255,0.5)" : "rgba(23,23,23,0.5)" }}>Sunlight</p>
        <p className="text-[12px] tracking-[0.08em] mt-1" style={{ color: isNight ? "rgba(255,255,255,0.3)" : "rgba(23,23,23,0.3)" }}>WebGL · {mode}</p>
      </div>

      <DraggablePanel
        isLight={isNight}
        controls={[
          { label: "Intensity", value: intensity, set: setIntensity, min: 0, max: 1, step: 0.01 },
          { label: "Speed", value: speed, set: (v) => setSpeed(Math.round(v)), min: 1, max: 100, step: 1 },
          { label: "Density", value: density, set: (v) => setDensity(Math.round(v)), min: 5, max: 60, step: 1 },
          { label: "Elongation", value: elongation, set: setElongation, min: 0.1, max: 1, step: 0.01 },
          { label: "Angle", value: angle, set: (v) => setAngle(Math.round(v)), min: 0, max: 360, step: 1 },
          { label: "CA", value: ca, set: setCa, min: 0, max: 3, step: 0.05 },
          { label: "CA intensity", value: caIntensity, set: setCaIntensity, min: 0, max: 1, step: 0.01 },
          { label: "Shape", value: cellShape, set: setCellShape, min: 0, max: 1, step: 0.01 },
          { label: "Blur", value: blur, set: setBlur, min: 0, max: 1, step: 0.01 },
          { label: "Opacity", value: opacity, set: setOpacity, min: 0, max: 1, step: 0.01 },
        ]}
      >
        <PanelSelect label="Preset" value={mode} options={["ripple", "sunlight", "smoke"] as const} set={applyPreset} isLight={isNight} />
        <PanelToggle label="Night" value={isNight} set={setIsNight} isLight={isNight} />
        <PanelColor label="Background" value={bgColor} set={setBgColor} isLight={isNight} />
        <PanelColor label="Light" value={lightColor} set={setLightColor} isLight={isNight} />
      </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)] ${isNight ? "text-white/60" : "text-foreground/60"}`}
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe