diff --git a/src/assets/images/hero-field.png b/src/assets/images/hero-field.png new file mode 100644 index 0000000..ea8268d Binary files /dev/null and b/src/assets/images/hero-field.png differ diff --git a/src/components/home/Hero.astro b/src/components/home/Hero.astro index e309a0b..580a9e0 100644 --- a/src/components/home/Hero.astro +++ b/src/components/home/Hero.astro @@ -1,6 +1,6 @@ --- import { Picture } from 'astro:assets'; -import background from '../../assets/images/background.png'; +import poster from '../../assets/images/hero-field.png'; import { t, type Lang } from '../../i18n/ui'; interface Props { lang: Lang } @@ -9,9 +9,12 @@ const ui = t(lang); ---
- + +
+ +
@@ -55,9 +60,23 @@ const ui = t(lang); height: 100%; object-fit: cover; object-position: 50% 50%; + } + .hero__layers { + position: absolute; + inset: 0; transform-origin: 50% 60%; animation: hero-settle var(--dur-hero) var(--ease) both; } + .hero__field { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity var(--dur-slow) var(--ease); + } + /* Set by the first drawn frame, so the poster never cuts to black. */ + .hero__field[data-ready] { opacity: 1; } .hero__overlay { position: absolute; inset: 0; @@ -89,7 +108,17 @@ const ui = t(lang); } @media (prefers-reduced-motion: reduce) { - .hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; } + .hero__layers, .hero__line { animation: none; opacity: 1; transform: none; } + .hero__field { display: none; } } + diff --git a/src/scripts/noise-field.ts b/src/scripts/noise-field.ts new file mode 100644 index 0000000..0e18c05 --- /dev/null +++ b/src/scripts/noise-field.ts @@ -0,0 +1,196 @@ +/* The home hero's noise field: a domain-warped noise gradient in the logo's blues on night, + with film grain, drifting slowly and bending towards the pointer. + + Kept cheap on purpose: it renders at half the CSS resolution (a soft gradient loses nothing), + caps at 30 fps, and only runs while the hero is on screen and the tab is visible. The caller + decides whether to start it at all (reduced motion, no WebGL); the poster image underneath + is a frame of this same shader, so the hand-over is invisible. */ + +const VERTEX = ` +attribute vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +`; + +const FRAGMENT = ` +precision mediump float; +uniform vec2 uRes; +uniform float uTime; +uniform vec2 uPointer; + +// Logo palette, deepest to lightest, on the site's night. +const vec3 NIGHT = vec3(0.055, 0.063, 0.059); +const vec3 DEEP = vec3(0.157, 0.282, 0.533); // #284888 +const vec3 MID = vec3(0.220, 0.408, 0.596); // #386898 +const vec3 TEAL = vec3(0.282, 0.659, 0.722); // #48A8B8 +const vec3 CYAN = vec3(0.345, 0.784, 0.847); // #58C8D8 + +// 2D simplex noise (Ashima Arts / Stefan Gustavson, MIT). +vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); } +float snoise(vec2 v) { + const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439); + vec2 i = floor(v + dot(v, C.yy)); + vec2 x0 = v - i + dot(i, C.xx); + vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = x0.xyxy + C.xxzz; + x12.xy -= i1; + i = mod(i, 289.0); + vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m = m * m; m = m * m; + vec3 x = 2.0 * fract(p * C.www) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + vec3 g; + g.x = a0.x * x0.x + h.x * x0.y; + g.yz = a0.yz * x12.xz + h.yz * x12.yw; + return 130.0 * dot(m, g); +} + +float fbm(vec2 p) { + float v = 0.0, a = 0.5; + for (int i = 0; i < 3; i++) { v += a * snoise(p); p = p * 2.02 + 17.0; a *= 0.5; } + return v; +} + +float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } + +void main() { + vec2 uv = gl_FragCoord.xy / uRes; + float aspect = uRes.x / uRes.y; + vec2 p = (uv - 0.5) * vec2(aspect, 1.0); + vec2 m = (uPointer - 0.5) * vec2(aspect, 1.0); + float t = uTime * 0.035; + + // The pointer bends the field towards itself, strongest close by, and lifts it a little. + vec2 d = m - p; + float pull = exp(-dot(d, d) * 2.4); + p += d * pull * 0.28; + + // Low-frequency warping: broad, slow folds rather than texture. + vec2 q = vec2(fbm(p * 0.55 + vec2(0.0, t)), fbm(p * 0.55 + vec2(5.2, 1.3) - t)); + float n = fbm(p * 0.8 + q * 1.2 + vec2(1.7, 9.2) + 0.5 * t); + + // A diagonal gradient, dark lower left (where the headline sits) to light upper right, + // whose edges the noise folds. + vec2 s = p / vec2(aspect, 1.0) + 0.5; + float g = s.x * 0.55 + s.y * 0.75 - 0.38 + n * 0.38 + pull * 0.14; + + vec3 col = mix(NIGHT, DEEP, smoothstep(0.04, 0.46, g)); + col = mix(col, MID, smoothstep(0.32, 0.72, g)); + vec3 high = mix(TEAL, CYAN, smoothstep(-0.25, 0.55, q.x)); + col = mix(col, high, smoothstep(0.58, 1.02, g) * 0.85); + + // Static film grain, so the drift reads as light moving under paper rather than TV noise. + col += (hash(gl_FragCoord.xy) - 0.5) * 0.06; + + gl_FragColor = vec4(col, 1.0); +} +`; + +const SCALE = 0.5; +const MAX_WIDTH = 1280; +const FRAME_MS = 1000 / 30; +const EASE = 0.06; + +export function startNoiseField(canvas: HTMLCanvasElement, area: HTMLElement): (() => void) | undefined { + const gl = canvas.getContext('webgl', { antialias: false, alpha: false, depth: false, powerPreference: 'low-power' }); + if (!gl) return undefined; + + const shader = (type: number, source: string) => { + const s = gl.createShader(type)!; + gl.shaderSource(s, source); + gl.compileShader(s); + return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null; + }; + const vs = shader(gl.VERTEX_SHADER, VERTEX); + const fs = shader(gl.FRAGMENT_SHADER, FRAGMENT); + if (!vs || !fs) return undefined; + const program = gl.createProgram()!; + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return undefined; + gl.useProgram(program); + + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); + const aPos = gl.getAttribLocation(program, 'aPos'); + gl.enableVertexAttribArray(aPos); + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); + const uRes = gl.getUniformLocation(program, 'uRes'); + const uTime = gl.getUniformLocation(program, 'uTime'); + const uPointer = gl.getUniformLocation(program, 'uPointer'); + + // Pointer in hero coordinates, 0..1 with y up; eased every frame so it never jumps. + const target = { x: 0.62, y: 0.58 }; + const pointer = { ...target }; + const onPointer = (e: PointerEvent) => { + const r = area.getBoundingClientRect(); + target.x = Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1); + target.y = Math.min(Math.max(1 - (e.clientY - r.top) / r.height, 0), 1); + }; + + const resize = () => { + const w = Math.min(Math.round(canvas.clientWidth * SCALE), MAX_WIDTH); + const h = Math.round(w * (canvas.clientHeight / Math.max(canvas.clientWidth, 1))); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + gl.viewport(0, 0, w, h); + } + }; + const resizer = new ResizeObserver(resize); + resizer.observe(canvas); + resize(); + + let frame = 0; + let last = 0; + let elapsed = 0; + let visible = true; + const draw = (now: number) => { + frame = requestAnimationFrame(draw); + if (now - last < FRAME_MS) return; + elapsed += last ? Math.min(now - last, 100) : 0; + last = now; + pointer.x += (target.x - pointer.x) * EASE; + pointer.y += (target.y - pointer.y) * EASE; + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform1f(uTime, elapsed / 1000); + gl.uniform2f(uPointer, pointer.x, pointer.y); + gl.drawArrays(gl.TRIANGLES, 0, 3); + if (!canvas.hasAttribute('data-ready')) canvas.setAttribute('data-ready', ''); + }; + const run = () => { + if (frame || !visible || document.hidden) return; + last = 0; + frame = requestAnimationFrame(draw); + }; + const pause = () => { + cancelAnimationFrame(frame); + frame = 0; + }; + + const onScreen = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting; + if (visible) run(); else pause(); + }); + onScreen.observe(area); + const onVisibility = () => (document.hidden ? pause() : run()); + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('pointermove', onPointer, { passive: true }); + + const stop = () => { + pause(); + onScreen.disconnect(); + resizer.disconnect(); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('pointermove', onPointer); + canvas.removeAttribute('data-ready'); + }; + // A lost context (GPU reset, too many tabs) hands back to the poster. + canvas.addEventListener('webglcontextlost', stop, { once: true }); + run(); + return stop; +}