agency-web/app/components/Iridescence.tsx

235 lines
7.1 KiB
TypeScript

"use client";
/**
* Iridescence — generative WebGL backdrop.
*
* A living interpretation of the brand cloudscape: domain-warped fractal noise
* mapped onto an iridescent pastel palette (lavender -> blue -> rose -> mint),
* with a faint rainbow refraction band and animated film grain. Drifts on its
* own and leans toward the cursor. Falls back to a static CSS gradient when
* WebGL is unavailable or the user prefers reduced motion.
*
* Renders behind everything (fixed, -z) and is purely decorative (aria-hidden).
*/
import { useEffect, useRef } from "react";
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
const VERT = /* glsl */ `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uMouse; // 0..1, smoothed
uniform float uIntensity; // global motion amount (0 for reduced motion)
// Brand palette anchors
const vec3 VIOLET = vec3(0.545, 0.361, 0.965); // #8b5cf6
const vec3 BLUE = vec3(0.231, 0.510, 0.965); // #3b82f6
const vec3 ROSE = vec3(0.957, 0.475, 0.851); // soft pink
const vec3 MINT = vec3(0.063, 0.725, 0.506); // #10b981 softened
const vec3 HAZE = vec3(0.945, 0.949, 0.992); // near-white lavender
// -- hash / value noise --------------------------------------------------
vec2 hash22(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = dot(hash22(i + vec2(0.0, 0.0)) - 0.5, f - vec2(0.0, 0.0));
float b = dot(hash22(i + vec2(1.0, 0.0)) - 0.5, f - vec2(1.0, 0.0));
float c = dot(hash22(i + vec2(0.0, 1.0)) - 0.5, f - vec2(0.0, 1.0));
float d = dot(hash22(i + vec2(1.0, 1.0)) - 0.5, f - vec2(1.0, 1.0));
return 0.5 + mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float amp = 0.55;
mat2 rot = mat2(0.8, -0.6, 0.6, 0.8);
for (int i = 0; i < 5; i++) {
v += amp * noise(p);
p = rot * p * 2.0;
amp *= 0.5;
}
return v;
}
// film grain
float grain(vec2 uv, float t) {
return fract(sin(dot(uv * (t + 1.0), vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = gl_FragCoord.xy / uResolution.xy;
float aspect = uResolution.x / uResolution.y;
vec2 p = uv;
p.x *= aspect;
float t = uTime * 0.045 * (0.25 + uIntensity);
// mouse parallax (gentle)
vec2 m = (uMouse - 0.5);
p += m * 0.12 * uIntensity;
// domain warping for that liquid cloud feel
vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t)));
vec2 r = vec2(
fbm(p + 1.7 * q + vec2(8.3, 2.8) + 0.15 * t),
fbm(p + 1.7 * q + vec2(1.2, 6.5) - 0.12 * t)
);
float f = fbm(p + 2.0 * r);
// build the iridescent gradient from the field
vec3 col = HAZE;
col = mix(col, VIOLET, smoothstep(0.15, 0.85, f));
col = mix(col, BLUE, smoothstep(0.30, 0.95, r.x));
col = mix(col, ROSE, smoothstep(0.55, 1.05, q.y) * 0.65);
col = mix(col, MINT, smoothstep(0.62, 1.0, r.y) * 0.30);
// central radiant bloom (echoes the cloudscape light source up high)
vec2 center = vec2(0.5 * aspect, 0.86);
float d = distance(p, center);
float bloom = smoothstep(0.9, 0.0, d);
col = mix(col, HAZE, bloom * 0.55);
// faint rainbow refraction band, lower-left like the asset
float band = smoothstep(0.5, 0.0, abs((uv.x - uv.y) * 1.3 - 0.15 + 0.05 * sin(t * 2.0)));
vec3 spectrum = 0.5 + 0.5 * cos(6.2831 * (vec3(0.0, 0.33, 0.67) + (uv.x + uv.y) * 0.7));
col += spectrum * band * 0.06 * (0.4 + uIntensity);
// lift overall to keep it airy / pastel and protect text legibility
col = mix(col, vec3(1.0), 0.18);
// soft vignette to anchor content readability
float vig = smoothstep(1.25, 0.25, distance(uv, vec2(0.5)));
col *= 0.82 + 0.18 * vig;
// animated grain to kill banding + add texture
float g = grain(uv, floor(uTime * 12.0) * 0.5);
col += (g - 0.5) * 0.035;
gl_FragColor = vec4(col, 1.0);
}
`;
export default function Iridescence() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let renderer: Renderer;
try {
renderer = new Renderer({
canvas,
dpr: Math.min(window.devicePixelRatio, 1.75),
alpha: false,
antialias: false,
});
} catch {
// WebGL unavailable — CSS fallback (set on the host) remains visible.
canvas.style.display = "none";
return;
}
const gl = renderer.gl;
gl.clearColor(0.95, 0.95, 0.99, 1);
const uniforms = {
uTime: { value: 0 },
uResolution: { value: new Vec2(1, 1) },
uMouse: { value: new Vec2(0.5, 0.5) },
uIntensity: { value: reduce ? 0 : 1 },
};
const program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
uniforms,
});
const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });
const resize = () => {
const w = window.innerWidth;
const h = window.innerHeight;
renderer.setSize(w, h);
uniforms.uResolution.value.set(gl.drawingBufferWidth, gl.drawingBufferHeight);
};
resize();
window.addEventListener("resize", resize);
// smoothed pointer
const target = { x: 0.5, y: 0.5 };
const onMove = (e: PointerEvent) => {
target.x = e.clientX / window.innerWidth;
target.y = 1 - e.clientY / window.innerHeight;
};
window.addEventListener("pointermove", onMove, { passive: true });
let raf = 0;
let running = true;
const start = performance.now();
const loop = (now: number) => {
if (!running) return;
raf = requestAnimationFrame(loop);
uniforms.uTime.value = (now - start) / 1000;
// ease pointer
const m = uniforms.uMouse.value;
m.x += (target.x - m.x) * 0.04;
m.y += (target.y - m.y) * 0.04;
renderer.render({ scene: mesh });
};
if (reduce) {
// render a single frame, then idle
uniforms.uTime.value = 18;
renderer.render({ scene: mesh });
} else {
raf = requestAnimationFrame(loop);
}
// pause when tab hidden (perf + battery)
const onVis = () => {
if (document.hidden) {
running = false;
cancelAnimationFrame(raf);
} else if (!reduce) {
running = true;
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
return () => {
running = false;
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onMove);
document.removeEventListener("visibilitychange", onVis);
const ext = gl.getExtension("WEBGL_lose_context");
ext?.loseContext();
};
}, []);
return (
<div className="iridescence" aria-hidden="true">
<canvas ref={ref} className="iridescence__canvas" />
</div>
);
}