agency-web/app/components/FluidBackground.tsx

336 lines
11 KiB
TypeScript

"use client";
/**
* FLUID BACKGROUND — a live, cursor-reactive WebGL field (ogl) that sits BEHIND
* the content. A full-screen fragment shader paints a flowing iridescent
* metaball / fluid mesh in the brand palette (blue -> violet -> emerald). The
* field bends, glows and FLOWS toward the pointer: a spring-smoothed pointer
* position warps the domain-warped noise and injects a bright, additive glow
* blob that trails the cursor with momentum.
*
* How the cursor-reactivity works:
* - We track the raw pointer in 0..1 UV space and LERP a smoothed pointer
* toward it every frame (momentum => the glow trails, never snaps).
* - We also track pointer *velocity* (smoothed delta) and feed it to the
* shader as `uVel`, so fast flicks visibly push/stretch the fluid.
* - In the shader, the metaballs and the domain-warp offset are pulled toward
* `uPointer`; a soft radial glow blooms at the pointer; warp strength scales
* with `uVel` so the surface "reacts" to how you move.
*
* Variants:
* - "hero": subtle, recedes behind the headline.
* - "cta": bigger, brighter, faster — the closing dramatic moment.
*
* Performance & resilience:
* - Pauses rendering when the tab is hidden OR the canvas is offscreen (IO).
* - DPR capped at 1.6 so it never tanks fill-rate / LCP.
* - WebGL capability check: if no GL context, we render nothing and the CSS
* gradient fallback (always painted underneath) shows through.
* - prefers-reduced-motion: we never start the GL loop; the static CSS
* gradient fallback stands in.
* - Full cleanup on unmount (RAF, listeners, GL context lost).
*/
import { useEffect, useRef } from "react";
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
type Variant = "hero" | "cta";
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uPointer; // smoothed pointer, 0..1 (y up)
uniform float uVel; // smoothed pointer speed 0..~1
uniform float uIntensity; // variant intensity
uniform float uReveal; // 0..1 fade-in on mount
varying vec2 vUv;
// brand palette
const vec3 C_BLUE = vec3(0.231, 0.510, 0.965); // #3b82f6
const vec3 C_VIOLET = vec3(0.545, 0.361, 0.965); // #8b5cf6
const vec3 C_EMER = vec3(0.063, 0.725, 0.506); // #10b981
const vec3 C_BG = vec3(0.027, 0.027, 0.043); // #07070b
// hash / value noise
float hash(vec2 p){
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p){
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p){
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 5; i++){
v += a * noise(p);
p *= 2.0;
a *= 0.5;
}
return v;
}
// soft metaball field — sum of inverse-distance blobs that orbit + drift
float metaballs(vec2 p, vec2 ptr, float t, float speed){
float f = 0.0;
for (int i = 0; i < 5; i++){
float fi = float(i);
// each blob orbits on its own phase
vec2 c = vec2(
0.5 + 0.34 * sin(t * speed * (0.4 + fi * 0.12) + fi * 1.7),
0.5 + 0.30 * cos(t * speed * (0.5 + fi * 0.09) + fi * 2.3)
);
// pull every blob a little toward the pointer => the field "flows" to it
c = mix(c, ptr, 0.18 + 0.05 * fi);
float r = 0.16 + 0.05 * sin(t * 0.6 + fi);
f += r * r / (dot(p - c, p - c) + 0.0009);
}
return f;
}
void main(){
vec2 uv = vUv;
// correct for aspect so blobs stay round-ish
float aspect = uRes.x / max(uRes.y, 1.0);
vec2 p = uv;
p.x *= aspect;
vec2 ptr = uPointer;
ptr.x *= aspect;
float t = uTime;
// domain warp — distortion grows toward the pointer and with pointer speed
float dPtr = distance(p, ptr);
float pull = exp(-dPtr * 2.4); // 1 near cursor -> 0 far
float warpAmt = (0.18 + uVel * 0.9) * (0.4 + pull);
vec2 q = p + warpAmt * vec2(
fbm(p * 2.2 + vec2(t * 0.10, t * 0.07)),
fbm(p * 2.2 + vec2(-t * 0.08, t * 0.12) + 5.2)
);
// base flowing fbm field
float n = fbm(q * 1.7 + vec2(t * 0.05, -t * 0.04));
// metaballs flowing toward the pointer
float mb = metaballs(q, ptr, t, 0.7 + uVel * 1.2);
float field = smoothstep(0.7, 1.9, mb) * 0.9 + n * 0.6;
// iridescent color ramp across the field
vec3 col = mix(C_BLUE, C_VIOLET, smoothstep(0.15, 0.7, field + n * 0.3));
col = mix(col, C_EMER, smoothstep(0.55, 1.05, field + 0.18 * sin(t * 0.4 + uv.x * 3.0)));
// composite onto the dark canvas by the field strength
float lum = smoothstep(0.05, 1.2, field);
vec3 outc = mix(C_BG, col, clamp(lum, 0.0, 1.0) * uIntensity);
// pointer bloom — a bright additive glow that trails the cursor
float glow = exp(-dPtr * (4.5 - uVel * 1.5)) * (0.45 + uVel * 0.6);
vec3 glowCol = mix(C_VIOLET, C_EMER, 0.4 + 0.4 * sin(t * 0.7));
outc += glowCol * glow * uIntensity;
// subtle grain so it never bands on dark gradients
float g = hash(uv * uRes.xy * 0.5 + t) - 0.5;
outc += g * 0.018;
// soft vignette keeps edges grounded in the canvas
float vig = smoothstep(1.25, 0.25, length(uv - 0.5));
outc = mix(C_BG, outc, 0.35 + 0.65 * vig);
gl_FragColor = vec4(outc * uReveal, 1.0);
}
`;
const VERT = /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
export default function FluidBackground({
variant = "hero",
className,
}: {
variant?: Variant;
className?: string;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return; // CSS gradient fallback stands in
// --- WebGL capability check ---
let renderer: Renderer;
try {
renderer = new Renderer({
alpha: false,
antialias: false,
dpr: Math.min(window.devicePixelRatio || 1, 1.6),
powerPreference: "high-performance",
});
} catch {
return; // no GL -> CSS fallback shows
}
const gl = renderer.gl;
if (!gl) return;
const canvas = gl.canvas as HTMLCanvasElement;
canvas.className = "fluid__canvas";
canvas.setAttribute("aria-hidden", "true");
wrap.appendChild(canvas);
const isCta = variant === "cta";
// cta is bigger/faster but slightly toned so the centred headline stays
// legible against it (the readability backdrop does the rest)
const intensity = isCta ? 0.9 : 0.78;
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
uniforms: {
uTime: { value: 0 },
uRes: { value: new Vec2(1, 1) },
uPointer: { value: new Vec2(0.5, 0.5) },
uVel: { value: 0 },
uIntensity: { value: intensity },
uReveal: { value: 0 },
},
});
const mesh = new Mesh(gl, { geometry, program });
const resize = () => {
const w = wrap.clientWidth || window.innerWidth;
const h = wrap.clientHeight || window.innerHeight;
renderer.setSize(w, h);
program.uniforms.uRes.value.set(gl.drawingBufferWidth, gl.drawingBufferHeight);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(wrap);
// --- pointer tracking (raw -> smoothed, with velocity) ---
const rawPtr = { x: 0.5, y: 0.5 };
const smoothPtr = { x: 0.5, y: 0.5 };
let vel = 0;
let lastX = 0.5;
let lastY = 0.5;
const onPointerMove = (e: PointerEvent) => {
const r = wrap.getBoundingClientRect();
rawPtr.x = (e.clientX - r.left) / r.width;
// flip Y so shader-space y is up and matches screen intuition
rawPtr.y = 1 - (e.clientY - r.top) / r.height;
};
// listen on window so movement is tracked even over the content layer
window.addEventListener("pointermove", onPointerMove, { passive: true });
// --- visibility / offscreen pausing ---
let running = false;
let rafId = 0;
let inView = true;
let pageVisible = !document.hidden;
const startTime = performance.now();
let lastTick = startTime;
const frame = (now: number) => {
if (!running) return;
const dt = Math.min((now - lastTick) / 1000, 0.05);
lastTick = now;
// momentum: lerp smoothed pointer toward raw
const ease = 0.07;
smoothPtr.x += (rawPtr.x - smoothPtr.x) * ease;
smoothPtr.y += (rawPtr.y - smoothPtr.y) * ease;
// velocity from smoothed delta, decayed for a soft trailing reaction
const dx = smoothPtr.x - lastX;
const dy = smoothPtr.y - lastY;
lastX = smoothPtr.x;
lastY = smoothPtr.y;
const instVel = Math.min(Math.hypot(dx, dy) * 60, 1);
vel += (instVel - vel) * 0.12;
program.uniforms.uTime.value = (now - startTime) / 1000;
program.uniforms.uPointer.value.set(smoothPtr.x, smoothPtr.y);
program.uniforms.uVel.value = vel;
// ease the reveal in over ~1s
const rev = program.uniforms.uReveal.value as number;
if (rev < 1) program.uniforms.uReveal.value = Math.min(1, rev + dt * 1.4);
renderer.render({ scene: mesh });
rafId = requestAnimationFrame(frame);
};
const start = () => {
if (running || !inView || !pageVisible) return;
running = true;
lastTick = performance.now();
rafId = requestAnimationFrame(frame);
};
const stop = () => {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
};
const io = new IntersectionObserver(
([entry]) => {
inView = entry.isIntersecting;
if (inView) start();
else stop();
},
{ threshold: 0 }
);
io.observe(wrap);
const onVisibility = () => {
pageVisible = !document.hidden;
if (pageVisible) start();
else stop();
};
document.addEventListener("visibilitychange", onVisibility);
start();
return () => {
stop();
io.disconnect();
ro.disconnect();
window.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("visibilitychange", onVisibility);
const ext = gl.getExtension("WEBGL_lose_context");
ext?.loseContext();
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
};
}, [variant]);
return (
<div
ref={wrapRef}
className={`fluid fluid--${variant} ${className ?? ""}`}
aria-hidden="true"
>
{/* static gradient fallback — always painted underneath; the only thing
shown under reduced-motion / no-WebGL */}
<div className="fluid__fallback" />
</div>
);
}