236 lines
7.1 KiB
TypeScript
236 lines
7.1 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Iridescence — generative WebGL backdrop.
|
|
*
|
|
* A living interpretation of the brand wave: domain-warped fractal noise mapped
|
|
* onto the brand spectrum (violet -> blue -> rose -> emerald) glowing out of a
|
|
* near-black field, with a faint rainbow refraction band and animated film
|
|
* grain. Drifts on its own and leans toward the cursor. Falls back to a static
|
|
* dark 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.204, 0.827, 0.600); // #34d399 emerald
|
|
const vec3 BASE = vec3(0.031, 0.031, 0.047); // near-black #08080c
|
|
|
|
// -- 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);
|
|
|
|
// the brand spectrum, glowing ADDITIVELY out of a near-black base
|
|
vec3 glow = vec3(0.0);
|
|
glow += VIOLET * smoothstep(0.35, 0.95, f);
|
|
glow += BLUE * smoothstep(0.45, 1.0, r.x) * 0.9;
|
|
glow += ROSE * smoothstep(0.62, 1.05, q.y) * 0.5;
|
|
glow += MINT * smoothstep(0.66, 1.0, r.y) * 0.55;
|
|
|
|
// concentrate the light into a soft diagonal wave band (echoes the asset)
|
|
float band = smoothstep(0.62, 0.0, abs((uv.x - uv.y) * 1.25 - 0.05 + 0.06 * sin(t * 2.0)));
|
|
float field = smoothstep(0.2, 0.85, f);
|
|
float energy = (0.35 + 0.65 * band) * field;
|
|
|
|
vec3 col = BASE + glow * energy * (0.55 + 0.45 * uIntensity);
|
|
|
|
// faint rainbow refraction along the band
|
|
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.05 * (0.4 + uIntensity);
|
|
|
|
// darken edges so content stays the focus
|
|
float vig = smoothstep(1.3, 0.2, distance(uv, vec2(0.5)));
|
|
col *= 0.55 + 0.45 * vig;
|
|
|
|
// keep the base from washing out; clamp the glow softly
|
|
col = max(col, BASE * 0.6);
|
|
|
|
// animated grain to kill banding + add texture
|
|
float g = grain(uv, floor(uTime * 12.0) * 0.5);
|
|
col += (g - 0.5) * 0.03;
|
|
|
|
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.031, 0.031, 0.047, 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>
|
|
);
|
|
}
|