1import { useEffect, useRef } from "preact/hooks";23/**4 * Decorative full-viewport backdrop: domain-warped fbm smoke drifting up,5 * a molten glow rising from the bottom edge, and sparse embers. Hand-rolled6 * WebGL, no dependencies. Honors prefers-reduced-motion (one static frame),7 * pauses while the tab is hidden, caps at ~30fps, and quietly does nothing8 * when WebGL is unavailable (the stylesheet gradient stays behind it).9 */1011const VERTEX_SOURCE = `12attribute vec2 a_position;13void main() {14 gl_Position = vec4(a_position, 0.0, 1.0);15}16`;1718const FRAGMENT_SOURCE = `19precision mediump float;2021uniform vec2 u_resolution;22uniform float u_time;2324float hash(vec2 p) {25 return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);26}2728float noise(vec2 p) {29 vec2 i = floor(p);30 vec2 f = fract(p);31 vec2 u = f * f * (3.0 - 2.0 * f);32 float a = hash(i);33 float b = hash(i + vec2(1.0, 0.0));34 float c = hash(i + vec2(0.0, 1.0));35 float d = hash(i + vec2(1.0, 1.0));36 return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);37}3839float fbm(vec2 p) {40 float value = 0.0;41 float amplitude = 0.5;42 for (int i = 0; i < 5; i++) {43 value += amplitude * noise(p);44 p = p * 2.03 + vec2(11.7, 5.3);45 amplitude *= 0.55;46 }47 return value;48}4950float embers(vec2 uv, float t, float scale, float speed, float threshold) {51 vec2 grid = vec2(scale, scale * 0.6);52 vec2 p = uv * grid;53 p.y -= t * speed;54 vec2 cell = floor(p);55 float seed = hash(cell);56 if (seed < threshold) return 0.0;57 vec2 center = fract(p) - 0.5;58 center.x += (hash(cell + 7.0) - 0.5) * 0.6 + sin(t * 1.7 + seed * 40.0) * 0.12;59 float flicker = 0.55 + 0.45 * sin(t * (2.0 + seed * 4.0) + seed * 90.0);60 float glow = smoothstep(0.16, 0.0, length(center)) * flicker;61 return glow * smoothstep(0.0, 0.25, uv.y) * smoothstep(1.05, 0.55, uv.y);62}6364void main() {65 vec2 uv = gl_FragCoord.xy / u_resolution;66 vec2 aspect = vec2(u_resolution.x / u_resolution.y, 1.0);67 vec2 p = uv * aspect;68 float t = u_time;6970 vec3 charcoal = vec3(0.047, 0.035, 0.031);71 vec3 crimson = vec3(0.70, 0.07, 0.18);72 vec3 magma = vec3(1.0, 0.24, 0.0);73 vec3 emberTone = vec3(1.0, 0.62, 0.27);74 vec3 smokeTone = vec3(0.36, 0.31, 0.28);7576 // Domain-warped smoke, advected upward.77 vec2 drift = vec2(0.02 * sin(t * 0.11), -t * 0.045);78 vec2 warp = vec2(79 fbm(p * 2.1 + drift),80 fbm(p * 2.1 + drift + vec2(4.2, 9.7))81 );82 float smoke = fbm(p * 2.6 + warp * 1.4 + vec2(0.0, -t * 0.09));83 smoke = smoothstep(0.35, 0.95, smoke);8485 // Molten glow breathing along the bottom edge.86 float ground = pow(1.0 - uv.y, 3.2);87 float shimmer = 0.55 + 0.45 * fbm(vec2(p.x * 3.4, t * 0.22));88 float heat = ground * shimmer;8990 vec3 color = charcoal;91 color += smokeTone * smoke * (0.16 + 0.3 * heat);92 color += mix(crimson, magma, shimmer) * heat * 0.55;93 color += emberTone * pow(heat, 2.4) * 0.5;9495 float sparks = embers(uv, t, 26.0, 0.05, 0.965) +96 embers(uv, t, 14.0, 0.028, 0.975) * 1.4;97 color += emberTone * sparks;9899 // Vignette so content edges stay quiet.100 float vignette = smoothstep(1.25, 0.45, length(uv - vec2(0.5, 0.42)));101 color *= 0.72 + 0.28 * vignette;102103 gl_FragColor = vec4(color, 1.0);104}105`;106107const FRAME_INTERVAL_MS = 1000 / 30;108const MAX_PIXEL_RATIO = 1.5;109110function compile(gl: WebGLRenderingContext, kind: number, source: string): WebGLShader | null {111 const shader = gl.createShader(kind);112 if (!shader) return null;113 gl.shaderSource(shader, source);114 gl.compileShader(shader);115 if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {116 gl.deleteShader(shader);117 return null;118 }119 return shader;120}121122function start(canvas: HTMLCanvasElement): () => void {123 const gl = canvas.getContext("webgl", {124 alpha: false,125 antialias: false,126 depth: false,127 stencil: false,128 powerPreference: "low-power",129 });130 if (!gl) return () => {};131132 const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SOURCE);133 const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SOURCE);134 const program = gl.createProgram();135 if (!vertex || !fragment || !program) return () => {};136 gl.attachShader(program, vertex);137 gl.attachShader(program, fragment);138 gl.linkProgram(program);139 if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return () => {};140 gl.useProgram(program);141142 const buffer = gl.createBuffer();143 gl.bindBuffer(gl.ARRAY_BUFFER, buffer);144 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);145 const position = gl.getAttribLocation(program, "a_position");146 gl.enableVertexAttribArray(position);147 gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);148149 const resolutionUniform = gl.getUniformLocation(program, "u_resolution");150 const timeUniform = gl.getUniformLocation(program, "u_time");151152 const resize = () => {153 const ratio = Math.min(globalThis.devicePixelRatio || 1, MAX_PIXEL_RATIO);154 const width = Math.max(1, Math.floor(canvas.clientWidth * ratio));155 const height = Math.max(1, Math.floor(canvas.clientHeight * ratio));156 if (canvas.width !== width || canvas.height !== height) {157 canvas.width = width;158 canvas.height = height;159 gl.viewport(0, 0, width, height);160 }161 };162163 const render = (seconds: number) => {164 resize();165 gl.uniform2f(resolutionUniform, canvas.width, canvas.height);166 gl.uniform1f(timeUniform, seconds);167 gl.drawArrays(gl.TRIANGLES, 0, 3);168 };169170 const reducedMotion = globalThis.matchMedia("(prefers-reduced-motion: reduce)");171 let frame = 0;172 let lastFrameAt = 0;173 let running = false;174175 const loop = (now: number) => {176 frame = requestAnimationFrame(loop);177 if (now - lastFrameAt < FRAME_INTERVAL_MS) return;178 lastFrameAt = now;179 render(now / 1000);180 };181182 const stopLoop = () => {183 running = false;184 cancelAnimationFrame(frame);185 };186187 const startLoop = () => {188 if (running || reducedMotion.matches || document.hidden) return;189 running = true;190 frame = requestAnimationFrame(loop);191 };192193 const applyMotionPreference = () => {194 stopLoop();195 // A developed, mid-drift frame reads as intentional art when static.196 if (reducedMotion.matches) render(8);197 else startLoop();198 };199200 const onVisibility = () => {201 if (document.hidden) stopLoop();202 else startLoop();203 };204205 applyMotionPreference();206 reducedMotion.addEventListener("change", applyMotionPreference);207 document.addEventListener("visibilitychange", onVisibility);208 globalThis.addEventListener("resize", resize);209210 return () => {211 stopLoop();212 reducedMotion.removeEventListener("change", applyMotionPreference);213 document.removeEventListener("visibilitychange", onVisibility);214 globalThis.removeEventListener("resize", resize);215 };216}217218export default function MagmaBackdrop() {219 const canvasRef = useRef<HTMLCanvasElement>(null);220221 useEffect(() => {222 const canvas = canvasRef.current;223 if (!canvas) return;224 return start(canvas);225 }, []);226227 return <canvas ref={canvasRef} class="magma-backdrop" aria-hidden="true" />;228}