since3-website/modules/landingPage/ui/components/hero/hero-shader.tsx
2026-09-25 00:45:33 +02:00

330 lines
9.7 KiB
TypeScript

"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<HTMLCanvasElement>(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 (
<div aria-hidden="true" className={cn("pointer-events-none overflow-hidden bg-black", className)}>
{/* Static fallback when WebGL is unavailable (e.g. hardware acceleration off) */}
<div className="absolute inset-0 bg-[radial-gradient(60%_50%_at_75%_25%,rgb(255_255_255/0.28),transparent_70%),radial-gradient(40%_35%_at_30%_10%,rgb(255_255_255/0.14),transparent_70%),radial-gradient(50%_40%_at_90%_70%,rgb(255_255_255/0.1),transparent_70%)]" />
<canvas
ref={canvasRef}
className={cn(
"absolute inset-0 size-full transition-opacity duration-700",
ready ? "opacity-100" : "opacity-0"
)}
/>
</div>
)
}