Animierter Mesh-Verlauf
Warmer Mesh-Verlauf aus vier Markenfarben, der langsam atmet und einer Glaskarte etwas zum Brechen gibt, wahlweise als schräges Band oder als CSS-Fallback.
Ein Canvas liegt vollflächig hinter dem Hero. Ein Fragment-Shader mischt drei bis fünf Farben. Jede Farbe hat ein Zentrum, das langsam wandert. Die Gewichtung folgt der inversen Distanz. Vorher verschiebt Noise die Koordinaten (distortion), eine leichte Drehung kommt dazu (swirl). Korn im selben Shader verhindert Banding. Die Stripe-Variante legt die Fläche schräg (skewY -12deg) und zeichnet fließende Farbwellen.
Die Fläche trägt Markenfarben, ganz ohne Bildmaterial. Die Bewegung ist so langsam, dass sie lebendig wirkt und trotzdem Hintergrund bleibt.
Wann einsetzen
Für Hero- und Abschnittshintergründe von Tech-, Finanz- und Kreativmarken. Hinter Glas-Elementen, damit das Glas etwas zum Brechen hat.
Wann nicht
Wenn starke Fotografie schon die Bühne trägt. Nicht in generischem Lila-Blau-Pink: Das gilt 2026 als KI-SaaS-Klischee. Farben immer aus der Marke ableiten.
Varianten
- Statisch: mehrere radial-gradients übereinander, reines CSS
- Animiert per WebGL-Shader (wie Paper MeshGradient)
- Schräges Band im Stripe-Stil, Text mit color-burn
- Mit Maus-Einfluss: ein Farbzentrum folgt dem Zeiger
- Körnig (grain gradient)
Umsetzung
Ein Fullscreen-Triangle und ein Fragment-Shader. Uniforms für Farben, Zeit und Auflösung. Die Farben kommen aus CSS-Variablen (getComputedStyle). Die Zeit läuft linear: zeit += dt · speed. DPR auf 1,5 begrenzen oder in halber Auflösung rendern, Verläufe vertragen das. Per IntersectionObserver außerhalb des Viewports pausieren. Fallback ohne WebGL: gestapelte radial-gradients, deren Positionen über @property animiert werden.
Robust umsetzen
Telefon. In halber Auflösung und mit 30 fps rendern oder ein statisches Standbild zeigen.
Ohne Bewegung. Zeit einfrieren und ein einziges Standbild rendern.
Barrierefrei. Rein dekorativ: Canvas mit aria-hidden. Den Textkontrast an der hellsten und an der dunkelsten Stelle des Verlaufs prüfen.
Code
<!DOCTYPE html>
<!--
Animierter Mesh-Verlauf
Fundus · Glas & Material · https://cecodesign.de/lab/fundus/glas-material/mesh-gradient-shader/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animierter Mesh-Verlauf · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }
/* Markenfarben: Der Shader liest genau diese vier Variablen (getComputedStyle) */
:root {
--verlauf-1: #ede5d8; /* Leinen */
--verlauf-2: #f0a571; /* Aprikose */
--verlauf-3: #d4623a; /* Ziegel */
--verlauf-4: #9cb8a0; /* Salbei */
--papier: #f5f1e9; --tinte: #1b1915; --tinte-leise: rgba(27, 25, 21, .62); --linie: rgba(27, 25, 21, .16);
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, nur für UI-Wechsel */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--tinte-leise); }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
/* ---------- Bühne ---------- */
.buehne { position: relative; min-height: 100svh; min-height: max(100svh, 640px); overflow: hidden; isolation: isolate;
display: grid; grid-template-rows: auto 1fr auto; padding: clamp(18px, 3vw, 40px); }
/* Fläche: trägt Canvas (WebGL) und CSS-Verlauf. Dekorativ. */
.flaeche { position: absolute; z-index: -1; left: 0; right: 0; top: 0; height: 100%;
transition: top .9s var(--kurve), height .9s var(--kurve), transform .9s var(--kurve); transform-origin: 0 100%; }
.flaeche canvas, .flaeche .css-mesh { position: absolute; inset: 0; width: 100%; height: 100%; display: block; transition: opacity .6s linear; }
.flaeche .css-mesh { opacity: 0; }
/* Variante Band: schräg gestellt wie die Stripe-Analyse (--section-skew-Y -12deg) */
.v-band .flaeche { top: -38%; height: 108%; transform: skewY(-12deg); }
.v-band .titel { mix-blend-mode: color-burn; }
/* Variante CSS: gestapelte radial-gradients, Positionen per @property animiert */
.v-css .flaeche canvas { opacity: 0; }
.v-css .flaeche .css-mesh { opacity: 1; }
@property --m1x { syntax: '<percentage>'; inherits: false; initial-value: 18%; }
@property --m1y { syntax: '<percentage>'; inherits: false; initial-value: 22%; }
@property --m2x { syntax: '<percentage>'; inherits: false; initial-value: 82%; }
@property --m2y { syntax: '<percentage>'; inherits: false; initial-value: 30%; }
@property --m3x { syntax: '<percentage>'; inherits: false; initial-value: 46%; }
@property --m3y { syntax: '<percentage>'; inherits: false; initial-value: 88%; }
.css-mesh {
background:
radial-gradient(52% 62% at var(--m1x) var(--m1y), var(--verlauf-3) 0%, transparent 72%),
radial-gradient(48% 58% at var(--m2x) var(--m2y), var(--verlauf-4) 0%, transparent 70%),
radial-gradient(62% 56% at var(--m3x) var(--m3y), var(--verlauf-2) 0%, transparent 74%),
var(--verlauf-1);
animation: m1 42s linear infinite, m2 52s linear infinite, m3 60s linear infinite;
}
.css-mesh::after { content: ''; position: absolute; inset: 0; opacity: .35; mix-blend-mode: soft-light;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='k'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23k)'/%3E%3C/svg%3E"); }
@keyframes m1 { 0%, 100% { --m1x: 18%; --m1y: 22%; } 33% { --m1x: 58%; --m1y: 14%; } 66% { --m1x: 30%; --m1y: 64%; } }
@keyframes m2 { 0%, 100% { --m2x: 82%; --m2y: 30%; } 50% { --m2x: 64%; --m2y: 78%; } }
@keyframes m3 { 0%, 100% { --m3x: 46%; --m3y: 88%; } 40% { --m3x: 86%; --m3y: 70%; } 75% { --m3x: 12%; --m3y: 80%; } }
/* ---------- Kopfzeile ---------- */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.marke { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 19px; letter-spacing: -.02em; color: var(--tinte); text-decoration: none; }
.marke svg { width: 22px; height: 22px; }
.nav { display: flex; gap: clamp(16px, 2.4vw, 34px); list-style: none; padding: 0; font-size: 14px; }
.nav a { color: var(--tinte); text-decoration: none; opacity: .78; transition: opacity 150ms linear; }
.nav a:hover { opacity: 1; }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 18px; border-radius: 999px; font: 500 14px/1 'Instrument Sans', sans-serif; text-decoration: none; transition: background 150ms linear, color 150ms linear; }
.knopf--voll { background: var(--tinte); color: var(--papier); }
.knopf--voll:hover { background: #3a342c; }
.knopf--rand { color: var(--tinte); border: 1px solid rgba(27, 25, 21, .32); }
.knopf--rand:hover { background: rgba(27, 25, 21, .06); }
/* ---------- Hero ---------- */
.held { align-self: end; display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: clamp(24px, 4vw, 64px); align-items: end; padding: clamp(28px, 6vh, 72px) 0 clamp(28px, 5vh, 56px); }
.held__ober { margin-bottom: 22px; display: flex; gap: 10px; align-items: center; }
.held__ober::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--tinte); }
.titel { font-size: clamp(48px, 8.4vw, 136px); line-height: .9; letter-spacing: -.05em; font-weight: 600; max-width: 9.5ch; }
.titel em { letter-spacing: -.03em; }
.held__text { margin-top: 26px; max-width: 46ch; font-size: clamp(16px, 1.3vw, 19px); line-height: 1.5; color: rgba(27, 25, 21, .82); }
.held__knoepfe { margin-top: 28px; display: flex; gap: 10px; flex-wrap: wrap; }
/* Glas-Karte: zeigt, wofür der Verlauf da ist – er gibt dem Glas etwas zum Brechen */
.karte { justify-self: end; width: min(100%, 360px); padding: 22px; border-radius: 22px;
background: rgba(255, 252, 246, .34); border: 1px solid rgba(255, 255, 255, .55);
backdrop-filter: blur(22px) saturate(1.35); -webkit-backdrop-filter: blur(22px) saturate(1.35);
box-shadow: 0 1px 0 rgba(255, 255, 255, .6) inset, 0 24px 60px -30px rgba(60, 30, 10, .35); }
.karte__kopf { display: flex; justify-content: space-between; }
.karte__summe { margin: 14px 0 18px; font-size: 40px; font-weight: 600; letter-spacing: -.04em; font-variant-numeric: tabular-nums; }
.karte__summe small { font-size: 15px; font-weight: 500; letter-spacing: 0; color: var(--tinte-leise); margin-left: 6px; }
.karte ul { list-style: none; padding: 0; display: grid; }
.karte li { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-top: 1px solid var(--linie); font-size: 14px; }
.karte li span:last-child { font-variant-numeric: tabular-nums; white-space: nowrap; }
.karte li .plus { color: #2d5a3c; }
.karte li .fertig { display: inline-flex; gap: 6px; align-items: center; }
.karte li .fertig::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #2d5a3c; }
/* ---------- Leiste: Umschalter + Werte ---------- */
.leiste { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; padding-top: 16px; border-top: 1px solid var(--linie); }
.modi { display: flex; gap: 4px; flex-wrap: wrap; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--tinte-leise); background: rgba(255, 252, 246, .3); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: background 150ms linear, color 150ms linear; }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
.modi button:disabled { opacity: .35; cursor: not-allowed; }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; justify-content: flex-end; }
.werte b { color: var(--tinte); font-weight: 500; }
.werte span { white-space: nowrap; }
/* Reduzierte Bewegung: Zeit steht, ein Standbild; CSS-Positionen fest */
.reduziert .css-mesh { animation: none; }
.reduziert .flaeche, .reduziert .flaeche canvas, .reduziert .flaeche .css-mesh { transition: opacity .25s linear; }
/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .modi, .vorschau .nav { display: none; }
@media (max-width: 820px) {
.nav { display: none; }
.held { grid-template-columns: 1fr; }
.karte { justify-self: start; }
}
@media (max-width: 640px) {
.buehne { padding: 18px; }
.kopf .knopf--voll { height: 36px; padding: 0 14px; font-size: 13px; }
.titel { font-size: clamp(48px, 15vw, 64px); }
.held { padding-top: 28px; gap: 22px; }
.held__text { margin-top: 18px; font-size: 16px; }
.karte { padding: 18px; border-radius: 18px; }
.karte__summe { font-size: 32px; margin: 10px 0 12px; }
.karte li:nth-child(n+3) { display: none; }
.werte { justify-content: flex-start; }
.v-band .flaeche { top: -30%; height: 92%; }
}
@media (hover: none) { .nav a, .knopf, .modi button { transition: none; } }
</style>
</head>
<body>
<section class="buehne" data-buehne>
<div class="flaeche" aria-hidden="true">
<canvas data-canvas></canvas>
<div class="css-mesh"></div>
</div>
<header class="kopf">
<a class="marke" href="#" aria-label="Saldo, Startseite">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="10.5" stroke="currentColor" stroke-width="1.6"/><path d="M6.5 13.5h11M6.5 9.5h7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
Saldo
</a>
<ul class="nav">
<li><a href="#">Funktionen</a></li>
<li><a href="#">Für Werkstätten</a></li>
<li><a href="#">Preise</a></li>
<li><a href="#">Anmelden</a></li>
</ul>
<a class="knopf knopf--voll" href="#">14 Tage testen</a>
</header>
<div class="held">
<div>
<p class="mono held__ober">Buchhaltung für Werkstätten · aus Leipzig</p>
<h1 class="titel">Belege rein, <em>Ruhe</em> raus.</h1>
<p class="held__text">Saldo liest Rechnungen, sortiert Belege und bereitet die Umsatzsteuer vor. Für Tischlereien, Ateliers und kleine Studios, die lieber an ihrer Sache arbeiten.</p>
<div class="held__knoepfe">
<a class="knopf knopf--voll" href="#">Kostenlos starten</a>
<a class="knopf knopf--rand" href="#">So funktioniert’s</a>
</div>
</div>
<aside class="karte" aria-label="Beispiel: Monatsübersicht">
<div class="karte__kopf"><span class="mono">Oktober 2026</span><span class="mono">Saldo</span></div>
<p class="karte__summe">18.420,00 €<small>netto</small></p>
<ul>
<li><span>Rechnung 2026-114 bezahlt</span><span class="plus">+ 3.900,00</span></li>
<li><span>Holzhandel Brandt</span><span>− 1.284,50</span></li>
<li><span>USt.-Voranmeldung</span><span class="fertig">bereit</span></li>
</ul>
</aside>
</div>
<footer class="leiste">
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-v="mesh" aria-pressed="true">Mesh · WebGL</button>
<button type="button" data-v="band" aria-pressed="false">Schräges Band</button>
<button type="button" data-v="css" aria-pressed="false">CSS-Fallback</button>
</div>
<p class="werte mono" data-werte aria-live="polite"></p>
</footer>
</section>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const touch = matchMedia('(hover: none)').matches;
const canvas = document.querySelector('[data-canvas]');
const buehne = document.querySelector('[data-buehne]');
const werteEl = document.querySelector('[data-werte]');
// Gemessene Werte (Paper-Produktion, Stripe-/Plaid-Analyse)
const P = { speed: 0.75, distortion: 0.85, swirl: 0.6, scale: 1.27, grainMixer: 0.35, skew: -12, dprMax: 1.5 };
const WERTE = {
mesh: [['speed', '0.75'], ['distortion', '0.85'], ['swirl', '0.6'], ['scale', '1.27'], ['grainMixer', '0.35'], ['Farben', '4'], ['Zyklus', '≈ 42 s linear']],
band: [['skewY', '−12deg'], ['Wellen', '3 Layer'], ['speed', '0.75'], ['Text', 'color-burn'], ['Zyklus', '≈ 42 s linear']],
css: [['radial-gradient', '3 + Grund'], ['@property', 'Positionen'], ['Zyklen', '42 / 52 / 60 s linear'], ['Grain', 'SVG .35']],
};
/* ---------- WebGL: ein Fullscreen-Triangle, ein Fragment-Shader ---------- */
const VS = `attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }`;
const FS = `
precision highp float;
uniform vec2 u_res; uniform float u_t; uniform vec3 u_c[4];
uniform float u_distortion, u_swirl, u_scale, u_grain, u_mode, u_ptrMix; uniform vec2 u_ptr;
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 noise(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + 1.0), u.x), u.y); }
float fbm(vec2 p){ return noise(p) * 0.65 + noise(p * 2.03 + 11.7) * 0.35; }
vec3 mesh(vec2 uv, float t){
float asp = u_res.x / u_res.y;
vec2 p = (uv - 0.5) * vec2(asp, 1.0) / u_scale;
// distortion: Koordinaten per Noise und Zeit verschieben
vec2 n = vec2(fbm(p * 1.4 + vec2(t * 0.06, 0.0)), fbm(p * 1.4 + vec2(5.2, -t * 0.05)));
p += (n - 0.5) * u_distortion * 0.9;
// swirl: Drehung, die zur Mitte hin zunimmt
float a = u_swirl * 1.6 * (1.0 - smoothstep(0.0, 1.1, length(p))) * sin(t * 0.07 + 1.0);
p = mat2(cos(a), -sin(a), sin(a), cos(a)) * p;
vec3 col = vec3(0.0); float ws = 0.0;
float g = (hash(gl_FragCoord.xy * 0.73 + 3.1) - 0.5) * u_grain;
for (int i = 0; i < 4; i++) {
float fi = float(i);
// wanderndes Zentrum je Farbe, Perioden 42 / 35 / 30 / 26 s bei speed 0.75
vec2 c = vec2(asp * 0.42, 0.4) * vec2(sin(t * (0.2 + 0.04 * fi) + fi * 1.7), cos(t * (0.2 + 0.04 * fi) * 0.83 + fi * 2.3)) / u_scale;
if (i == 2) c = mix(c, u_ptr * vec2(asp, 1.0) * 0.5 / u_scale, u_ptrMix);
float d = length(p - c) + g * 0.5; // grainMixer: Korn in der Gewichtung
float w = 1.0 / (pow(max(d, 0.0), 3.2) + 0.006); // inverse Distanz
col += u_c[i] * w; ws += w;
}
return col / ws;
}
vec3 band(vec2 uv, float t){
float asp = u_res.x / u_res.y;
vec2 q = vec2(uv.x * asp, uv.y);
vec3 col = u_c[0];
for (int i = 1; i < 4; i++) {
float fi = float(i);
float welle = fbm(vec2(q.x * (0.9 + 0.25 * fi) + t * (0.14 + 0.03 * fi), fi * 3.7 + t * 0.05));
float kante = 0.14 + fi * 0.21 + (welle - 0.5) * 0.55;
float m = smoothstep(kante - 0.16, kante + 0.16, 1.0 - q.y + (fbm(q * 2.2 - t * 0.04) - 0.5) * 0.12);
col = mix(col, u_c[i], m * 0.92);
}
return col;
}
void main(){
vec2 uv = gl_FragCoord.xy / u_res;
vec3 col = u_mode < 0.5 ? mesh(uv, u_t) : band(uv, u_t);
col += (hash(gl_FragCoord.xy + 17.0) - 0.5) / 255.0 * 2.0; // Dither gegen Banding
gl_FragColor = vec4(col, 1.0);
}`;
let gl = null, prog = null, U = {};
try {
gl = canvas.getContext('webgl', { antialias: false, alpha: false, preserveDrawingBuffer: false, powerPreference: 'low-power' });
if (gl) {
const sh = (typ, src) => { const s = gl.createShader(typ); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; };
prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog); gl.useProgram(prog);
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'a'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
['u_res', 'u_t', 'u_c', 'u_distortion', 'u_swirl', 'u_scale', 'u_grain', 'u_mode', 'u_ptr', 'u_ptrMix'].forEach(n => U[n] = gl.getUniformLocation(prog, n));
}
} catch (e) { gl = null; }
// Farben aus den CSS-Variablen
function farbenLesen() {
const cs = getComputedStyle(html), arr = [];
for (let i = 1; i <= 4; i++) {
const hex = cs.getPropertyValue('--verlauf-' + i).trim().replace('#', '');
arr.push(parseInt(hex.slice(0, 2), 16) / 255, parseInt(hex.slice(2, 4), 16) / 255, parseInt(hex.slice(4, 6), 16) / 255);
}
return new Float32Array(arr);
}
// Auflösung: DPR max. 1.5; Touch rendert halb so fein (Verläufe vertragen das)
function groesse() {
if (!gl) return;
const r = canvas.getBoundingClientRect();
const f = Math.min(devicePixelRatio || 1, P.dprMax) * (touch ? 0.5 : 1);
const w = Math.max(1, Math.round(r.width * f)), h = Math.max(1, Math.round(r.height * f));
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); }
}
let variante = 'mesh', zeit = 11.0, letzte = 0, rafId = 0, sichtbar = true, laeuft = true;
const ptr = { x: 0, y: 0, zx: 0, zy: 0, mix: 0, zmix: 0 };
function zeichnen() {
if (!gl || variante === 'css') return;
groesse();
gl.uniform2f(U.u_res, canvas.width, canvas.height);
gl.uniform1f(U.u_t, zeit);
gl.uniform3fv(U.u_c, farbenLesen());
gl.uniform1f(U.u_distortion, P.distortion); gl.uniform1f(U.u_swirl, P.swirl);
gl.uniform1f(U.u_scale, P.scale); gl.uniform1f(U.u_grain, P.grainMixer);
gl.uniform1f(U.u_mode, variante === 'band' ? 1 : 0);
gl.uniform2f(U.u_ptr, ptr.x, ptr.y); gl.uniform1f(U.u_ptrMix, ptr.mix);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function schritt(t) {
rafId = 0;
if (!laeuft || !sichtbar || reduziert()) return;
const dt = Math.min(0.05, letzte ? (t - letzte) / 1000 : 0);
if (touch && t - letzte < 1000 / 30 - 2) { rafId = requestAnimationFrame(schritt); return; } // Touch: 30 fps
letzte = t;
zeit += dt * P.speed; // linear, zeitbasiert
ptr.x += (ptr.zx - ptr.x) * 0.04; ptr.y += (ptr.zy - ptr.y) * 0.04; ptr.mix += (ptr.zmix - ptr.mix) * 0.03;
zeichnen();
rafId = requestAnimationFrame(schritt);
}
function starten() { letzte = 0; if (!rafId) rafId = requestAnimationFrame(schritt); }
function anhalten() { cancelAnimationFrame(rafId); rafId = 0; }
// Außerhalb des Viewports pausieren
new IntersectionObserver(e => { sichtbar = e[0].isIntersecting; sichtbar ? starten() : anhalten(); }).observe(buehne);
document.addEventListener('visibilitychange', () => { document.hidden ? anhalten() : starten(); });
new ResizeObserver(() => { if (reduziert() || !rafId) zeichnen(); }).observe(canvas);
// Maus-Einfluss: das Ziegel-Zentrum folgt dem Zeiger (nur mit Hover)
if (!touch) {
buehne.addEventListener('pointermove', e => {
if (vorfuehrung) return;
const r = buehne.getBoundingClientRect();
ptr.zx = ((e.clientX - r.left) / r.width) * 2 - 1; ptr.zy = 1 - ((e.clientY - r.top) / r.height) * 2; ptr.zmix = 0.55;
});
buehne.addEventListener('pointerleave', () => { if (!vorfuehrung) ptr.zmix = 0; });
}
/* ---------- Varianten ---------- */
const knoepfe = [...document.querySelectorAll('[data-v]')];
function setzen(v) {
variante = gl ? v : 'css';
['mesh', 'band', 'css'].forEach(n => buehne.classList.toggle('v-' + n, n === variante));
knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === variante)));
werteEl.innerHTML = WERTE[variante].map(([k, w]) => `<span>${k} <b>${w}</b></span>`).join('');
zeichnen();
}
knoepfe.forEach(b => {
if (!gl && b.dataset.v !== 'css') { b.disabled = true; b.title = 'WebGL ist hier nicht verfügbar'; }
b.addEventListener('click', () => { pause(); setzen(b.dataset.v); weiterLaufen(); });
});
/* ---------- Vorführung: gedachter Zeiger kreist, Varianten wechseln ---------- */
let vorfuehrung = false, folge = null, zeigerUhr = 0;
const FOLGE = [['mesh', 9000], ['band', 7000], ['css', 6000]];
function zeigerKreisen(t0) {
const s = tt => {
const a = (tt - t0) / 1000;
ptr.zx = Math.sin(a * 0.9) * 0.7; ptr.zy = Math.cos(a * 0.6) * 0.5; ptr.zmix = 0.6;
zeigerUhr = requestAnimationFrame(s);
};
zeigerUhr = requestAnimationFrame(s);
}
function vorfuehren() {
pause(); vorfuehrung = true; html.classList.add('vorfuehrung');
let i = 0;
const naechste = () => {
const [v, dauer] = FOLGE[i % FOLGE.length]; i++;
setzen(gl ? v : 'css');
folge = setTimeout(naechste, dauer);
};
naechste(); zeigerKreisen(performance.now()); weiterLaufen();
}
function pause() {
clearTimeout(folge); cancelAnimationFrame(zeigerUhr); vorfuehrung = false; html.classList.remove('vorfuehrung');
ptr.zmix = 0; laeuft = false; anhalten();
}
function weiterLaufen() { laeuft = true; if (reduziert()) zeichnen(); else starten(); }
function neustart() { pause(); zeit = 11.0; ptr.x = ptr.y = ptr.mix = 0; setzen('mesh'); weiterLaufen(); }
setzen('mesh');
weiterLaufen();
// Reduzierte Bewegung kann nachträglich per ?reduziert gesetzt werden: dann Standbild
new MutationObserver(() => { if (reduziert()) { anhalten(); zeichnen(); } else if (laeuft) starten(); }).observe(html, { attributes: true, attributeFilter: ['class'] });
})();
</script>
</body>
</html>