Fix hero
This commit is contained in:
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 |
@@ -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);
|
||||
---
|
||||
<section class="hero night" aria-labelledby="hero-title">
|
||||
<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
|
||||
src={background}
|
||||
src={poster}
|
||||
alt=""
|
||||
quality={70}
|
||||
formats={['avif']}
|
||||
@@ -24,6 +27,8 @@ const ui = t(lang);
|
||||
class="hero__img img-cover"
|
||||
pictureAttributes={{ class: 'hero__picture' }}
|
||||
/>
|
||||
<canvas class="hero__field" aria-hidden="true" data-hero-field></canvas>
|
||||
</div>
|
||||
<div class="hero__overlay"></div>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
</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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user