206 lines
6.6 KiB
TypeScript
206 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
|
|
|
|
/**
|
|
* Custom WebGL signature: a flowing "revenue field" — animated contour
|
|
* lines that rise and surge like a growth chart / topographic gain map,
|
|
* rendered as ink hairlines on paper with an acid-green crest threading
|
|
* through. Reacts gently to the pointer.
|
|
*
|
|
* Constraints honored:
|
|
* - "use client", all GL created in useEffect, full cleanup
|
|
* - no window/document access during SSR/render
|
|
* - pauses RAF when the tab is hidden or canvas is offscreen
|
|
* - respects prefers-reduced-motion (renders one static frame, no loop)
|
|
* - decorative only (aria-hidden); never the sole carrier of meaning
|
|
*/
|
|
export default function RevenueField() {
|
|
const wrap = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const host = wrap.current;
|
|
if (!host) return;
|
|
|
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
|
|
const renderer = new Renderer({
|
|
alpha: true,
|
|
antialias: true,
|
|
dpr: Math.min(window.devicePixelRatio || 1, 2),
|
|
});
|
|
const gl = renderer.gl;
|
|
gl.clearColor(0, 0, 0, 0);
|
|
host.appendChild(gl.canvas);
|
|
gl.canvas.style.width = "100%";
|
|
gl.canvas.style.height = "100%";
|
|
gl.canvas.style.display = "block";
|
|
|
|
const geometry = new Triangle(gl);
|
|
|
|
const program = new Program(gl, {
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
uRes: { value: new Vec2(1, 1) },
|
|
uMouse: { value: new Vec2(0.5, 0.5) },
|
|
// ink + emerald crest (the hero sits on light paper, so the crest
|
|
// uses the deeper "gain" green to stay perceptible, not invisible lime)
|
|
uInk: { value: [0.078, 0.075, 0.059] },
|
|
uAcid: { value: [0.039, 0.431, 0.278] },
|
|
},
|
|
vertex: /* glsl */ `
|
|
attribute vec2 uv;
|
|
attribute vec2 position;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = vec4(position, 0.0, 1.0);
|
|
}
|
|
`,
|
|
fragment: /* glsl */ `
|
|
precision highp float;
|
|
varying vec2 vUv;
|
|
uniform float uTime;
|
|
uniform vec2 uRes;
|
|
uniform vec2 uMouse;
|
|
uniform vec3 uInk;
|
|
uniform vec3 uAcid;
|
|
|
|
// cheap value noise
|
|
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
|
float noise(vec2 p){
|
|
vec2 i = floor(p); vec2 f = fract(p);
|
|
vec2 u = f*f*(3.0-2.0*f);
|
|
return mix(mix(hash(i), hash(i+vec2(1,0)), u.x),
|
|
mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), 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;
|
|
}
|
|
|
|
void main() {
|
|
vec2 uv = vUv;
|
|
float aspect = uRes.x / max(uRes.y, 1.0);
|
|
vec2 p = uv;
|
|
p.x *= aspect;
|
|
|
|
// mouse influence — a gentle lift around the pointer
|
|
vec2 m = uMouse; m.x *= aspect;
|
|
float md = distance(p, m);
|
|
float lift = smoothstep(0.6, 0.0, md) * 0.18;
|
|
|
|
float t = uTime * 0.06;
|
|
|
|
// a rising surface: base height climbs left->right (growth)
|
|
float climb = uv.x * 0.55;
|
|
float field = fbm(p * 2.4 + vec2(t, t * 0.4)) + climb + lift;
|
|
field += fbm(p * 5.0 - vec2(t * 0.7, 0.0)) * 0.25;
|
|
|
|
// contour lines from the height field
|
|
float lines = abs(fract(field * 9.0) - 0.5);
|
|
float w = fwidth(field * 9.0) * 1.2;
|
|
float contour = 1.0 - smoothstep(0.0, w, lines);
|
|
|
|
// crest highlight: the topmost band glows acid (the "gain")
|
|
float crest = smoothstep(0.62, 0.86, field) * smoothstep(0.96, 0.7, field);
|
|
|
|
vec3 col = mix(uInk, uAcid, crest * 0.9);
|
|
float alpha = contour * (0.20 + crest * 0.85);
|
|
|
|
// soft right/top fade so type stays readable, lines vignette off edges
|
|
alpha *= smoothstep(0.0, 0.18, uv.x);
|
|
alpha *= smoothstep(0.0, 0.14, uv.y) * smoothstep(1.0, 0.82, uv.y);
|
|
|
|
gl_FragColor = vec4(col, alpha);
|
|
}
|
|
`,
|
|
});
|
|
|
|
const mesh = new Mesh(gl, { geometry, program });
|
|
|
|
const resize = () => {
|
|
const w = host.clientWidth;
|
|
const h = host.clientHeight;
|
|
renderer.setSize(w, h);
|
|
program.uniforms.uRes.value.set(w, h);
|
|
};
|
|
resize();
|
|
const ro = new ResizeObserver(resize);
|
|
ro.observe(host);
|
|
|
|
// pointer (eased)
|
|
const target = new Vec2(0.5, 0.5);
|
|
const onPointer = (e: PointerEvent) => {
|
|
const r = host.getBoundingClientRect();
|
|
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
|
|
};
|
|
window.addEventListener("pointermove", onPointer);
|
|
|
|
let raf = 0;
|
|
let running = true;
|
|
const start = performance.now();
|
|
|
|
const frame = (now: number) => {
|
|
if (!running) return;
|
|
const u = program.uniforms;
|
|
u.uTime.value = (now - start) / 1000;
|
|
(u.uMouse.value as Vec2).x += (target.x - (u.uMouse.value as Vec2).x) * 0.05;
|
|
(u.uMouse.value as Vec2).y += (target.y - (u.uMouse.value as Vec2).y) * 0.05;
|
|
renderer.render({ scene: mesh });
|
|
raf = requestAnimationFrame(frame);
|
|
};
|
|
|
|
// visibility: pause GL when tab hidden or canvas scrolled offscreen
|
|
const onVisibility = () => {
|
|
if (document.hidden) {
|
|
running = false;
|
|
cancelAnimationFrame(raf);
|
|
} else if (!reduce) {
|
|
running = true;
|
|
raf = requestAnimationFrame(frame);
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
|
|
const io = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting && !document.hidden && !reduce) {
|
|
if (!running) {
|
|
running = true;
|
|
raf = requestAnimationFrame(frame);
|
|
}
|
|
} else {
|
|
running = false;
|
|
cancelAnimationFrame(raf);
|
|
}
|
|
},
|
|
{ threshold: 0 }
|
|
);
|
|
io.observe(host);
|
|
|
|
if (reduce) {
|
|
// single static frame, no animation loop
|
|
program.uniforms.uTime.value = 8;
|
|
renderer.render({ scene: mesh });
|
|
} else {
|
|
raf = requestAnimationFrame(frame);
|
|
}
|
|
|
|
return () => {
|
|
running = false;
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener("pointermove", onPointer);
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
io.disconnect();
|
|
ro.disconnect();
|
|
const ext = gl.getExtension("WEBGL_lose_context");
|
|
if (ext) ext.loseContext();
|
|
if (gl.canvas.parentNode) gl.canvas.parentNode.removeChild(gl.canvas);
|
|
};
|
|
}, []);
|
|
|
|
return <div ref={wrap} className="revfield" aria-hidden="true" />;
|
|
}
|