"use client"; import { useEffect, useRef } from "react"; /** * Ein Farbfeld aus einem Fragment-Shader hinter der Bühne. * * ── WARUM ROHES WEBGL UND KEIN THREE.JS ───────────────────────────────────── * * Three.js ist eine 3D-Bibliothek: Szenengraph, Kamera, Licht, Material, * Lader. Hier wird nichts davon gebraucht — es gibt genau ein Rechteck, das * den Ausschnitt füllt, und einen Shader, der jedem Bildpunkt eine Farbe * gibt. Das sind zwei Dreiecke. Three.js dafür zu laden hieße, 600 kB für * einen Aufruf mitzuschleppen, den man in achtzig Zeilen selbst schreibt. * * ── DIE FARBEN KOMMEN AUS DEN TOKENS ──────────────────────────────────────── * * Kein Farbwert steht in diesem Shader. Sie werden zur Laufzeit aus * `--a2-accent` und `--a2-bg-page` gelesen und als Uniform übergeben. Damit * wechselt das Feld mit dem Schema mit, ohne dass es eine zweite Farbtabelle * gäbe — genau die Doppelung, die `tokens.css` vermeiden soll. * * ── WAS PASSIERT, WENN ES NICHT GEHT ──────────────────────────────────────── * * Nichts Sichtbares. Ohne WebGL, ohne JavaScript oder bei einem verlorenen * Kontext bleibt der CSS-Verlauf der Bühne stehen — er liegt darunter und * wird nie entfernt. Das Feld ist eine Zugabe, keine Bedingung: Der Inhalt * liegt darüber und ist von alldem unberührt. * * ── UND WANN ES ANHÄLT ────────────────────────────────────────────────────── * * Außer Sicht, bei verborgenem Reiter und bei `prefers-reduced-motion`. Ein * Shader, der unter dem Fußbereich weiterrechnet, während niemand hinsieht, * ist ein Akku, der leer wird. Bei Bewegungsreduktion wird **ein** Bild * gezeichnet und dann angehalten — nicht abgeschaltet: Das Feld ist Fläche, * und eine Fläche darf stehen bleiben. */ const SCHEITEL = `#version 300 es in vec2 lage; void main() { gl_Position = vec4(lage, 0.0, 1.0); } `; /* * Wertrauschen mit Domänenverzerrung. * * Zwei Lagen Rauschen, wobei die zweite die Koordinaten der ersten verbiegt. * Das ist der Unterschied zwischen „wabert" und „fließt": Ohne die * Verzerrung sieht Rauschen aus wie ein Wattebausch, mit ihr wie eine * Strömung. Drei Oktaven genügen — mehr sieht man bei dieser Weichheit nicht, * kostet aber Rechenzeit auf jedem Bildpunkt. */ const SPLITTER = `#version 300 es precision highp float; uniform vec2 u_flaeche; uniform float u_zeit; uniform vec3 u_akzent; uniform vec3 u_zweit; uniform float u_staerke; out vec4 farbe; 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 rauschen(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 summe = 0.0; float gewicht = 0.5; for (int i = 0; i < 3; i++) { summe += gewicht * rauschen(p); p *= 2.02; gewicht *= 0.5; } return summe; } void main() { vec2 uv = gl_FragCoord.xy / u_flaeche; vec2 p = uv * vec2(u_flaeche.x / u_flaeche.y, 1.0); float t = u_zeit * 0.035; // Die Verzerrung: Wohin geschaut wird, entscheidet ein zweites Rauschen. vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, 1.3 - t))); vec2 r = vec2(fbm(p + 3.0 * q + vec2(1.7, 9.2) + 0.15 * t), fbm(p + 3.0 * q + vec2(8.3, 2.8) - 0.12 * t)); float f = fbm(p + 2.4 * r); vec3 c = mix(u_akzent, u_zweit, clamp(length(q) * 1.1, 0.0, 1.0)); c = mix(c, u_akzent, clamp(r.x * r.x * 1.4, 0.0, 1.0)); // Nach unten und zu den Rändern ausblenden: Das Feld ist Raum hinter dem // Text, keine Fläche mit Kante. Eine sichtbare Kante machte daraus ein // Bild, und ein Bild müsste etwas bedeuten. float rand = smoothstep(1.05, 0.2, length((uv - vec2(0.5, 0.36)) * vec2(1.1, 1.35))); // pow(f, 1.4) statt f * f: Quadrieren drueckt alles unter der Mitte fast // auf null — nachgemessen kam das Feld dabei auf 6 % mittlere Deckung, also // praktisch unsichtbar, mit ein paar hellen Inseln. Der flachere Exponent // hebt die Mitte an und laesst die Flaeche zusammenhaengen, ohne die // Spitzen zu ueberzeichnen. // // Keine Backticks in diesem Kommentar: Er steht in einem // Template-Literal, und ein Backtick darin beendet die Zeichenkette. float staerke = clamp(pow(f, 1.4) * 3.1, 0.0, 1.0) * rand * u_staerke; farbe = vec4(c * staerke, staerke); } `; /** `#0a0a0a` oder `rgb(10 10 10)` → `[0.04, 0.04, 0.04]`. */ function alsRgb(wert: string): [number, number, number] { const s = wert.trim(); if (s.startsWith("#")) { const h = s.length === 4 ? [...s.slice(1)].map((z) => z + z).join("") : s.slice(1, 7); const n = parseInt(h, 16); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; } const zahlen = s.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0]; return [(zahlen[0] ?? 0) / 255, (zahlen[1] ?? 0) / 255, (zahlen[2] ?? 0) / 255]; } function baue(gl: WebGL2RenderingContext) { const uebersetze = (art: number, quelle: string) => { const s = gl.createShader(art)!; gl.shaderSource(s, quelle); gl.compileShader(s); return s; }; const programm = gl.createProgram()!; gl.attachShader(programm, uebersetze(gl.VERTEX_SHADER, SCHEITEL)); gl.attachShader(programm, uebersetze(gl.FRAGMENT_SHADER, SPLITTER)); gl.linkProgram(programm); if (!gl.getProgramParameter(programm, gl.LINK_STATUS)) return null; // Zwei Dreiecke, die den ganzen Ausschnitt decken. Mehr Geometrie gibt es // nicht — alles Weitere entscheidet der Shader je Bildpunkt. const puffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, puffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const lage = gl.getAttribLocation(programm, "lage"); gl.enableVertexAttribArray(lage); gl.vertexAttribPointer(lage, 2, gl.FLOAT, false, 0, 0); return programm; } export function Schimmer({ className, staerke, }: { className?: string; /** * Überschreibt die aus der Grundhelligkeit abgeleitete Stärke. * * Für Seiten, auf denen das Feld leiser sein soll als auf der Bühne — * Registerdaten brauchen keinen Auftritt. Ohne Angabe entscheidet die * Helligkeit des Grundes. */ staerke?: number; }) { const tuch = useRef(null); useEffect(() => { const leinwand = tuch.current; if (!leinwand) return; const gl = leinwand.getContext("webgl2", { alpha: true, antialias: false, // Kein Tiefenpuffer und kein Erhalt: Wir zeichnen eine Fläche, kein // Gebäude, und lesen nie zurück. Beides spart Speicher auf dem Gerät. depth: false, stencil: false, preserveDrawingBuffer: false, powerPreference: "low-power", }); if (!gl) return; const programm = baue(gl); if (!programm) return; gl.useProgram(programm); const wo = (name: string) => gl.getUniformLocation(programm, name); const uFlaeche = wo("u_flaeche"); const uZeit = wo("u_zeit"); const uAkzent = wo("u_akzent"); const uZweit = wo("u_zweit"); const uStaerke = wo("u_staerke"); /* * Farben und Stärke aus den Tokens — und zwar bei **jedem** Wechsel neu. * * Einmal beim Montieren zu lesen sah richtig aus und war es nicht: Die * Seite hat einen Umschalter für die Darstellung, und nach einem Wechsel * lief das Feld in den Farben des alten Schemas weiter. * * Die Stärke hängt an der Helligkeit des Grundes. Auf Schwarz trägt ein * Farbfeld viel; auf Weiß wird dasselbe Feld zur Trübung, die unter dem * Text liegt — nachgemessen 19 % mittlere Deckung in Hell gegenüber 33 % * in Dunkel, und in Hell sah es nach Schmutz aus statt nach Licht. */ const liesFarben = () => { const stil = getComputedStyle(document.documentElement); const hole = (name: string) => stil.getPropertyValue(name).trim(); gl.uniform3fv(uAkzent, alsRgb(hole("--a2-accent") || "#0070f3")); gl.uniform3fv(uZweit, alsRgb(hole("--a2-agent") || hole("--a2-accent") || "#7c5cff")); const grund = alsRgb(hole("--a2-bg-page") || hole("--a2-bg") || "#000"); const helligkeit = 0.2126 * grund[0] + 0.7152 * grund[1] + 0.0722 * grund[2]; gl.uniform1f(uStaerke, staerke ?? (helligkeit > 0.5 ? 0.3 : 0.85)); }; liesFarben(); const ruhig = window.matchMedia("(prefers-reduced-motion: reduce)"); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); let laufend = 0; let sichtbar = true; const messen = () => { // Auf zwei begrenzt: Ein Retina-Schirm mit drei wäre die neunfache // Zahl an Bildpunkten für ein weiches Farbfeld, dem man den // Unterschied nicht ansieht. const dichte = Math.min(window.devicePixelRatio || 1, 2); const b = Math.floor(leinwand.clientWidth * dichte); const h = Math.floor(leinwand.clientHeight * dichte); if (leinwand.width !== b || leinwand.height !== h) { leinwand.width = b; leinwand.height = h; gl.viewport(0, 0, b, h); } gl.uniform2f(uFlaeche, leinwand.width, leinwand.height); }; const zeichne = (zeit: number) => { messen(); gl.uniform1f(uZeit, zeit / 1000); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const schleife = (zeit: number) => { zeichne(zeit); laufend = requestAnimationFrame(schleife); }; const starte = () => { if (laufend || !sichtbar || ruhig.matches) return; laufend = requestAnimationFrame(schleife); }; const halte = () => { if (!laufend) return; cancelAnimationFrame(laufend); laufend = 0; }; // Ein Bild in jedem Fall — auch bei Bewegungsreduktion und auch, bevor // die Schleife anläuft. Sonst bliebe die Fläche einen Wimpernschlag leer. zeichne(0); const beobachter = new IntersectionObserver(([eintrag]) => { sichtbar = eintrag.isIntersecting; if (sichtbar) starte(); else halte(); }); beobachter.observe(leinwand); const beiReiter = () => (document.hidden ? halte() : starte()); document.addEventListener("visibilitychange", beiReiter); // Der Umschalter setzt `data-theme` an der Wurzel. Ohne diesen Beobachter // liefe das Feld nach einem Wechsel in den alten Farben weiter. const schemaWaechter = new MutationObserver(() => { liesFarben(); // Ein Bild sofort, damit der Wechsel auch dann greift, wenn die // Schleife gerade angehalten ist — außer Sicht oder bei // Bewegungsreduktion. zeichne(performance.now()); }); schemaWaechter.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); ruhig.addEventListener("change", () => (ruhig.matches ? halte() : starte())); // Ein verlorener Kontext — Grafiktreiber schlafen, Reiter im Hintergrund // zu lange — würde sonst als schwarze Fläche stehen bleiben. const beiVerlust = (e: Event) => { e.preventDefault(); halte(); leinwand.style.opacity = "0"; }; leinwand.addEventListener("webglcontextlost", beiVerlust); starte(); return () => { halte(); beobachter.disconnect(); schemaWaechter.disconnect(); document.removeEventListener("visibilitychange", beiReiter); leinwand.removeEventListener("webglcontextlost", beiVerlust); /* * **Hier wird der Kontext bewusst nicht weggeworfen.** * * `loseContext()` sah nach sauberem Aufräumen aus und war der Grund, * warum in der Entwicklung nie etwas zu sehen war: React ruft Effekte * im strengen Modus doppelt auf — montieren, aufräumen, montieren. Nach * dem Aufräumen liefert `getContext` **denselben**, nun toten Kontext * zurück, und der zweite Durchgang zeichnet ins Nichts. Genau so * verhält es sich auch bei jedem schnellen Seitenwechsel. * * Nötig ist es ohnehin nicht: Die Leinwand verlässt mit dem Bauteil den * Baum, und der Browser gibt den Kontext mit ihr frei. `loseContext` * ist für Leinwände gedacht, die stehen bleiben. */ }; }, [staerke]); return ( ); }