mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 19:51:16 +00:00
330 lines
9.7 KiB
TypeScript
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>
|
|
)
|
|
}
|