This commit is contained in:
Felix Klement committed 2026-10-03 12:23:33 +02:00
1 parent 755d91d0f4
commit c8740fb1d3
3 files changed
+229 -4

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+33 -4
View File
@@ -1,6 +1,6 @@
--- ---
import { Picture } from 'astro:assets'; 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'; import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
@@ -9,9 +9,12 @@ const ui = t(lang);
--- ---
<section class="hero night" aria-labelledby="hero-title"> <section class="hero night" aria-labelledby="hero-title">
<div class="hero__media"> <div class="hero__media">
<!-- Abstract and decorative, so it carries no alt text. --> <!-- Abstract and decorative, so neither layer carries alt text. The poster is a frame of
the noise field, so it carries the hero until the canvas is drawing, and stays for
anyone who never gets one: reduced motion, no WebGL, no script. -->
<div class="hero__layers">
<Picture <Picture
src={background} src={poster}
alt="" alt=""
quality={70} quality={70}
formats={['avif']} formats={['avif']}
@@ -24,6 +27,8 @@ const ui = t(lang);
class="hero__img img-cover" class="hero__img img-cover"
pictureAttributes={{ class: 'hero__picture' }} pictureAttributes={{ class: 'hero__picture' }}
/> />
<canvas class="hero__field" aria-hidden="true" data-hero-field></canvas>
</div>
<div class="hero__overlay"></div> <div class="hero__overlay"></div>
</div> </div>
@@ -55,9 +60,23 @@ const ui = t(lang);
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: 50% 50%; object-position: 50% 50%;
}
.hero__layers {
position: absolute;
inset: 0;
transform-origin: 50% 60%; transform-origin: 50% 60%;
animation: hero-settle var(--dur-hero) var(--ease) both; 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 { .hero__overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -89,7 +108,17 @@ const ui = t(lang);
} }
@media (prefers-reduced-motion: reduce) { @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; }
} }
</style> </style>
<script>
import { startNoiseField } from '../../scripts/noise-field';
const canvas = document.querySelector<HTMLCanvasElement>('[data-hero-field]');
const hero = canvas?.closest<HTMLElement>('.hero');
if (canvas && hero && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
startNoiseField(canvas, hero);
}
</script>
+196
View File
@@ -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;
}