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