"use client" import { useEffect, useRef, useState } from "react" import { cn } from "@/lib/utils" import { onIntroEnd } from "@/modules/shared/lib/intro" const VERTEX = ` attribute vec2 a_position; void main() { gl_Position = vec4(a_position, 0.0, 1.0); } ` // Monochrome liquid chrome: domain-warped fbm noise turned into sharp // specular bands. The pointer acts as a lens that pushes the liquid away. const FRAGMENT = ` #ifdef GL_FRAGMENT_PRECISION_HIGH precision highp float; #else precision mediump float; #endif uniform vec2 u_res; uniform float u_time; uniform vec2 u_mouse; uniform float u_force; uniform float u_reveal; 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); return mix( mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y ); } float fbm(vec2 p) { float value = 0.0; float amplitude = 0.5; mat2 rotate = mat2(1.6, 1.2, -1.2, 1.6); for (int i = 0; i < 5; i++) { value += amplitude * noise(p); p = rotate * p; amplitude *= 0.5; } return value; } void main() { vec2 p = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y; vec2 m = (u_mouse - 0.5 * u_res) / u_res.y; vec2 dm = p - m; float lens = exp(-dot(dm, dm) * 7.0) * u_force; p -= dm * lens * 0.6; float t = u_time * 0.045; vec2 q = vec2(fbm(p * 1.3 + vec2(0.0, t)), fbm(p * 1.3 + vec2(5.2, -t))); vec2 r = vec2( fbm(p * 1.3 + 2.8 * q + vec2(1.7, 9.2) + 0.9 * t), fbm(p * 1.3 + 2.8 * q + vec2(8.3, 2.8) - 0.7 * t) ); float f = fbm(p * 1.3 + 3.0 * r); float bands = 0.5 + 0.5 * cos(6.28318 * (f * 2.4 + r.y * 0.8 + lens * 0.9)); float chrome = pow(bands, 6.0) * 1.15 + pow(f, 2.5) * 0.4; // Darker towards the bottom left, where the headline sits vec2 uv = gl_FragCoord.xy / u_res; float shade = smoothstep(-0.15, 1.1, uv.x * 0.5 + uv.y * 0.85); float c = chrome * mix(0.18, 1.0, shade); c *= smoothstep(1.7, 0.2, length(p * vec2(0.7, 1.0))); c *= u_reveal; c += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.045; gl_FragColor = vec4(vec3(clamp(c, 0.0, 1.0)), 1.0); } ` // Rendering below device resolution keeps the GPU cost low; the image is soft anyway const RENDER_SCALE = 0.5 const REVEAL_MS = 1800 type Renderer = { draw: (uniforms: { width: number height: number time: number mouseX: number mouseY: number force: number reveal: number }) => void dispose: () => void } function compile(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type) if (!shader) return null gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.warn("[HeroShader]", gl.getShaderInfoLog(shader)) gl.deleteShader(shader) return null } return shader } /** Sets up program and geometry. Returns null if WebGL is not usable. */ function createRenderer(gl: WebGLRenderingContext): Renderer | null { if (gl.isContextLost()) return null const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX) const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT) const program = gl.createProgram() if (!vertex || !fragment || !program) return null gl.attachShader(program, vertex) gl.attachShader(program, fragment) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.warn("[HeroShader]", gl.getProgramInfoLog(program)) return null } gl.useProgram(program) // One triangle that covers the whole viewport const buffer = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, buffer) gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW) const position = gl.getAttribLocation(program, "a_position") gl.enableVertexAttribArray(position) gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0) const uRes = gl.getUniformLocation(program, "u_res") const uTime = gl.getUniformLocation(program, "u_time") const uMouse = gl.getUniformLocation(program, "u_mouse") const uForce = gl.getUniformLocation(program, "u_force") const uReveal = gl.getUniformLocation(program, "u_reveal") return { draw({ width, height, time, mouseX, mouseY, force, reveal }) { gl.viewport(0, 0, width, height) gl.uniform2f(uRes, width, height) gl.uniform1f(uTime, time) gl.uniform2f(uMouse, mouseX, mouseY) gl.uniform1f(uForce, force) gl.uniform1f(uReveal, reveal) gl.drawArrays(gl.TRIANGLES, 0, 3) }, // Frees GPU resources but keeps the context alive. Losing the context on // purpose would break React Strict Mode, which mounts effects twice. dispose() { if (gl.isContextLost()) return gl.deleteBuffer(buffer) gl.deleteProgram(program) gl.deleteShader(vertex) gl.deleteShader(fragment) }, } } export function HeroShader({ className }: { className?: string }) { const canvasRef = useRef(null) // The CSS fallback below stays visible until the first frame is drawn const [ready, setReady] = useState(false) useEffect(() => { const canvas = canvasRef.current if (!canvas) return const gl = canvas.getContext("webgl", { antialias: false, alpha: false, depth: false, powerPreference: "high-performance", }) if (!gl) return const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches const scale = Math.min(window.devicePixelRatio, 2) * RENDER_SCALE let renderer = createRenderer(gl) let width = 1 let height = 1 let mouseX = 0 let mouseY = 0 let targetX = 0 let targetY = 0 let force = 0 let targetForce = 0 let revealStart = -1 let visible = true let frame = 0 let shown = false const start = performance.now() const resize = () => { width = Math.max(1, Math.round(canvas.clientWidth * scale)) height = Math.max(1, Math.round(canvas.clientHeight * scale)) canvas.width = width canvas.height = height if (!targetX) { targetX = mouseX = width * 0.7 targetY = mouseY = height * 0.6 } } const draw = (now: number) => { if (!renderer) return mouseX += (targetX - mouseX) * 0.06 mouseY += (targetY - mouseY) * 0.06 force += (targetForce - force) * 0.04 targetForce *= 0.985 const progress = revealStart < 0 ? 0 : Math.min((now - revealStart) / REVEAL_MS, 1) renderer.draw({ width, height, time: (now - start) / 1000, mouseX, mouseY, force, reveal: reduced ? 1 : 1 - Math.pow(1 - progress, 3), }) if (!shown) { shown = true setReady(true) } } const loop = (now: number) => { if (!visible || document.hidden || !renderer) { frame = 0 return } draw(now) frame = requestAnimationFrame(loop) } const play = () => { if (reduced) draw(performance.now()) else if (!frame) frame = requestAnimationFrame(loop) } const onPointerMove = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect() targetX = (event.clientX - rect.left) * scale // WebGL's origin is bottom left targetY = (rect.bottom - event.clientY) * scale targetForce = 1 } // The GPU may drop the context (driver reset, too many tabs): show the // fallback meanwhile and rebuild once the browser restores it. const onContextLost = (event: Event) => { event.preventDefault() cancelAnimationFrame(frame) frame = 0 renderer = null shown = false setReady(false) } const onContextRestored = () => { renderer = createRenderer(gl) resize() play() } const observer = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting if (visible) play() }) const resizeObserver = new ResizeObserver(() => { resize() if (reduced) draw(performance.now()) }) const onVisibility = () => { if (!document.hidden) play() } resize() observer.observe(canvas) resizeObserver.observe(canvas) canvas.addEventListener("webglcontextlost", onContextLost) canvas.addEventListener("webglcontextrestored", onContextRestored) document.addEventListener("visibilitychange", onVisibility) window.addEventListener("pointermove", onPointerMove, { passive: true }) const cleanupIntro = onIntroEnd(() => { revealStart = performance.now() }) play() return () => { cleanupIntro() cancelAnimationFrame(frame) observer.disconnect() resizeObserver.disconnect() canvas.removeEventListener("webglcontextlost", onContextLost) canvas.removeEventListener("webglcontextrestored", onContextRestored) document.removeEventListener("visibilitychange", onVisibility) window.removeEventListener("pointermove", onPointerMove) renderer?.dispose() } }, []) return (