Zum Inhalt
← 3D & WebGL

WebGL-Bildverzerrung per Shader (Hover, Linse, RGB-Shift)

Projektraster eines Zeichenateliers: Die Bilder geben unter dem Zeiger nach, als Quadrat-Linse mit RGB-Shift, als Wölbung oder als Strömung nach Mausgeschwindigkeit; beim Scrollen biegen sie sich.

Portfolio- oder Projektraster eines Studios, in dem der Zeiger Details der Arbeiten vergrößert.

Die HTML-Bilder bleiben die Layout-Quelle. Ein WebGL-Canvas liegt fixiert darüber und zeichnet für jedes Bild eine Plane, deren Größe und Position aus dem DOM kommen. Ein Fragment-Shader verschiebt die UVs abhängig von Zeigerposition und Geschwindigkeit: als Linse, als Wölbung oder als Strömung in Bewegungsrichtung. Beim RGB-Shift werden Rot, Grün und Blau mit leicht unterschiedlichem Versatz gelesen. Zeigerwerte werden pro Frame gelerpt, deshalb federt die Verzerrung nach und klingt ab.

Die Bilder reagieren wie Glas oder Flüssigkeit auf den Zeiger. Das macht Portfolios und Listen greifbar, ohne Inhalt zu verdecken. Kaum ein Effekt signalisiert so deutlich „Award-Seite“, und er wirkt auch auf einem schlichten Raster.

Wann einsetzen

Projektlisten, Galerien, Portfolio-Teaser und Produktkacheln in Mode, Musik, Kultur und bei Studios. Besonders günstig, wenn ohnehin ein WebGL-Canvas läuft.

Wann nicht

Bei Produktfotos, deren Details exakt erkennbar bleiben müssen. Bei einem einzelnen Bild ohne bestehende WebGL-Szene, weil Bundle und GPU-Last dann nicht lohnen. Bei sehr vielen Bildern ohne Instancing oder Texturatlas. Bei konservativen Business-Zielgruppen.

Varianten

  • Quadrat-Linse, die dem Zeiger folgt, mit Vergrößerung und RGB-Shift
  • Wölbung (Bulge) um den Zeiger, dazu leichte Hover-Skalierung
  • Strömung: UVs fließen in Zeigerrichtung, RGB-Split nach Mausgeschwindigkeit
  • Wasser: Wellengleichung im Ping-Pong, Wellen breiten sich vom Zeiger aus (Ripple Water)
  • Verzerrung nach Scroll-Geschwindigkeit (Biegung der Plane)
  • Sinus-Krümmung des ganzen Bildes
  • Flüssigkeitsspur: Der Zeiger malt in eine Geschwindigkeitstextur (Flowmap, FBO)
  • Hover-Wechsel zwischen zwei Bildern per Displacement-Textur (siehe shader-uebergang-dissolve)
  • Leichte Variante ohne WebGL: SVG-Filter mit feTurbulence und feDisplacementMap
  • Umsetzung mit WebGPU und TSL

Umsetzung

Ein Canvas für alle Bilder, position: fixed, pointer-events: none. Eine OrthographicCamera in CSS-Pixeln, pro img eine Plane, jedes Frame per getBoundingClientRect positioniert. Mit Lenis läuft der Scroll im selben Frame wie das Rendern, so kleben die Planes am DOM. Texturen im Shader mit cover-Seitenverhältnis lesen (Bildgröße und Plane-Größe als Uniforms). Bulge-Kern: vec2 d = vUv - uMouse; float f = smoothstep(uRadius, 0., length(d)) * uHover; vec2 uv = vUv - d * f * uStrength. uHover per Tween (power3.out, 400 ms ein, 600 ms aus), uMouse pro Frame mit 0.1 lerpen. Pixelratio auf 2 begrenzen, nur rendern, wenn sich etwas ändert, Texturen erst bei Sichtbarkeit laden. Das DOM-Bild bleibt als Fallback stehen, ohne WebGL reicht ein CSS-Hover-Scale.

Robust umsetzen

Telefon. Ohne Hover entfällt die Zeigerverzerrung. Stattdessen eine dezente Verzerrung nach Scroll-Geschwindigkeit, ein kurzer Impuls beim Tippen oder ganz ohne Canvas mit normalen Bildern.

Ohne Bewegung. Keine Verzerrung und kein Shader. Nur ein Wechsel von Helligkeit oder Deckkraft beim Hover.

Barrierefrei. Das Original-img mit Alt-Text bleibt im DOM (opacity 0 statt display: none) und ist die Quelle der Wahrheit. Der Canvas ist aria-hidden. Links bleiben fokussierbar mit sichtbarem Fokus, der Fokus löst dieselbe Hervorhebung aus wie der Hover.

Code

<!DOCTYPE html>
<!--
  WebGL-Bildverzerrung per Shader (Hover, Linse, RGB-Shift)
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/webgl-bildverzerrung/
  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>WebGL-Bildverzerrung per Shader · 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'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }

:root {
  --grund: #ebe6dc; --flaeche: #ddd7cb; --text: #171512; --leise: rgba(23, 21, 18, .64); --linie: rgba(23, 21, 18, .14);
  --zinnober: #d4442a;
  --kurve: cubic-bezier(0.25, 1, 0.5, 1);   /* power3.out, wie uHover */
  --rand: clamp(20px, 3.6vw, 56px);
  --spalt: clamp(14px, 1.6vw, 24px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
html.lenis, html.lenis body { height: auto; }
.lenis-smooth { scroll-behavior: auto !important; }
body { overflow-x: hidden; }
button { font: inherit; color: inherit; }
a { color: inherit; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ---------- Bühne ---------- */
.buehne { min-height: 100svh; display: grid; grid-template-rows: auto auto 1fr auto; }
.kopf { display: flex; justify-content: space-between; align-items: center; padding: clamp(18px, 2.4vw, 30px) var(--rand); }
.marke { font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
.marke span { font: italic 400 19px/1 'Instrument Serif', serif; color: var(--leise); margin-left: 6px; letter-spacing: 0; }
.kopf nav { display: flex; gap: 28px; }
.kopf nav a { color: var(--leise); text-decoration: none; transition: color .15s linear; }
.kopf nav a:hover, .kopf nav a:focus-visible, .kopf nav a[aria-current] { color: var(--text); }

.intro { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: var(--spalt) clamp(24px, 4vw, 64px); align-items: end; padding: clamp(4px, 1.4vw, 20px) var(--rand) clamp(22px, 2.6vw, 36px); }
.intro .mono { grid-column: 1 / -1; display: flex; gap: 14px; align-items: center; }
.intro .mono i { width: 32px; height: 1px; background: var(--linie); }
.intro .mono b { color: var(--text); font-weight: 500; }
h1 { font-family: 'Instrument Serif', serif; font-weight: 400; font-size: clamp(44px, 4.8vw, 78px); line-height: .94; letter-spacing: -.025em; text-wrap: balance; }
h1 em { font-style: italic; }
.intro p.lead { max-width: 36ch; font-size: 15px; line-height: 1.55; color: var(--leise); justify-self: end; }

/* Raster: DOM ist die Layout-Quelle, WebGL-Planes folgen den .bild-Rahmen */
.raster { list-style: none; padding: 0 var(--rand); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--spalt); align-content: start; }
.buehne .raster { align-content: center; }
.kachel a { display: grid; gap: 12px; text-decoration: none; outline: none; }
.bild { position: relative; aspect-ratio: 4 / 5; overflow: hidden; background: var(--flaeche); border-radius: 2px; }
.bild img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: transform .8s var(--kurve), filter .15s linear, opacity .4s linear; }
.gl-an .bild img { opacity: 0; transition: none; }          /* bleibt im DOM (Alt-Text), WebGL zeichnet darüber */
.kein-gl .kachel.ist-hover .bild img { transform: scale(1.03); }   /* Fallback ohne WebGL */
.reduziert .kachel.ist-hover .bild img { transform: none; filter: brightness(.9); }
.kachel a:focus-visible .bild { box-shadow: 0 0 0 1px var(--text); }
.unterschrift { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: baseline; }
.unterschrift b { font: 400 22px/1 'Instrument Serif', serif; letter-spacing: -.01em; color: var(--text); text-transform: none; }
.unterschrift span:first-child { font-variant-numeric: tabular-nums; }
.unterschrift .art { text-align: right; }
.unterschrift .art::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--zinnober); margin-right: 8px; transform: scale(0); transition: transform .4s var(--kurve); vertical-align: 1px; }
.kachel.ist-hover .unterschrift .art::before { transform: scale(1); }

.fuss { display: flex; justify-content: space-between; align-items: center; gap: 24px; padding: clamp(18px, 2.4vw, 30px) var(--rand); }
.modi { display: flex; gap: 6px; flex-wrap: wrap; }
.modi button { letter-spacing: inherit; text-transform: inherit; font: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: border-color .15s linear, color .15s linear, background-color .15s linear; }
.modi button:hover, .modi button:focus-visible { color: var(--text); border-color: var(--leise); }
.modi button:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button.drueckt { background: rgba(23, 21, 18, .7); }
.rechts { display: grid; gap: 6px; justify-items: end; text-align: right; }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; max-width: 720px; }
.werte b, .messung b { color: var(--text); font-weight: 500; font-variant-numeric: tabular-nums; }
.messung { display: flex; gap: 16px; }

/* ---------- Unter der Bühne ---------- */
.zweite { padding: clamp(40px, 6vw, 88px) 0 0; border-top: 1px solid var(--linie); margin-top: 8px; }
.zweite h2, .mehr h2 { font: italic 400 34px/1 'Instrument Serif', serif; letter-spacing: -.01em; }
.zweite header { display: flex; justify-content: space-between; align-items: baseline; padding: 0 var(--rand) 24px; }
.mehr { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(20px, 4vw, 56px); padding: clamp(56px, 8vw, 112px) var(--rand); }
.mehr h2 { margin: 10px 0 12px; }
.mehr p:not(.mono) { color: var(--leise); font-size: 15px; line-height: 1.55; max-width: 36ch; }

/* ---------- Canvas über allem, nimmt keine Zeigerereignisse ---------- */
.gl { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 2; display: block; }
.reduziert .gl, .kein-gl .gl { display: none; }

/* Gedachter Zeiger in der Vorführung */
.zeiger { position: fixed; z-index: 5; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border: 1.5px solid #fff; border-radius: 50%; mix-blend-mode: difference; pointer-events: none; display: none; transition: scale .15s linear, background-color .15s linear; }
.zeiger.drueckt { scale: .7; background: rgba(255, 255, 255, .5); }
.vorfuehrung .zeiger { display: block; }

/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .zweite, .vorschau .mehr, .vorschau .kopf nav, .vorschau .lead, .vorschau .werte, .vorschau .intro .mono { display: none; }
.vorschau .buehne { height: 100vh; min-height: 0; }
.vorschau .intro { padding-top: 6px; padding-bottom: 22px; }
.vorschau h1 { font-size: 50px; }
.vorschau .buehne .bild { height: 372px; }

:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

@media (min-width: 761px) { .buehne .bild { aspect-ratio: auto; height: clamp(300px, min(calc(100svh - 440px), 34vw), 640px); } }
  .reduziert .messung { display: none; }
@media (max-width: 1100px) { .werte span:nth-child(n+5) { display: none; } }
@media (max-width: 760px) {
  .kopf nav { display: none; }
  .intro { grid-template-columns: 1fr; padding-top: 8px; }
  .intro p.lead { justify-self: start; font-size: 14px; }
  h1 { font-size: clamp(42px, 12vw, 58px); }
  .raster { grid-template-columns: 1fr; gap: 34px; }
  .fuss { flex-direction: column; align-items: stretch; gap: 14px; padding-top: 30px; }
  .rechts { justify-items: start; text-align: left; }
  .werte { justify-content: flex-start; }
  .messung { display: none; }
  .mehr { grid-template-columns: 1fr; }
}
@media (max-height: 760px) and (min-width: 761px) { .intro p.lead { display: none; } }
@media (hover: hover) { .nur-touch { display: none; } }
@media (hover: none) {
  .nur-maus { display: none; }
  .messung { display: none; }
  .modi button { height: 36px; padding: 0 14px; }
}
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
  .kachel a:focus-visible { outline: 2px solid Highlight; outline-offset: 4px; }
}
</style>
</head>
<body>
<section class="buehne" aria-labelledby="titel">
  <header class="kopf">
    <p class="marke">Saalfeld<span>Atelier für Zeichen</span></p>
    <nav class="mono" aria-label="Hauptmenü"><a href="#arbeiten" aria-current="page">Arbeiten</a><a href="#atelier">Atelier</a><a href="#kontakt">Kontakt</a></nav>
  </header>

  <div class="intro">
    <p class="mono"><span>Arbeiten</span><i></i><b>2023–2026</b></p>
    <h1 id="titel">Zeichen, die man <em>anfassen</em> möchte.</h1>
    <p class="lead">Wir entwerfen Marken für Werkstätten, Cafés und kleine Verlage. <span class="nur-maus">Fahren Sie mit der Maus über die Arbeiten: Die Bilder geben nach wie Glas.</span><span class="nur-touch">Tippen Sie auf eine Arbeit oder scrollen Sie: Die Bilder geben nach wie Glas.</span></p>
  </div>

  <ul class="raster" id="arbeiten" data-raster>
    <li class="kachel"><a href="#arbeiten" aria-label="Röstkontor, Wortmarke, 2024">
      <div class="bild"><img src="bilder/marke-roestkontor-hoch.jpg" width="800" height="1000" alt="Wortmarke Röstkontor in Creme auf Ziegelrot, darüber eine Kaffeebohne als Signet"></div>
      <p class="unterschrift mono" aria-hidden="true"><span>01</span><b>Röstkontor</b><span class="art">Wortmarke</span></p></a></li>
    <li class="kachel"><a href="#arbeiten" aria-label="Eselsohr, Buchhandlung und Lesecafé, 2025">
      <div class="bild"><img src="bilder/marke-eselsohr-hoch.jpg" width="800" height="1000" alt="Marke Eselsohr: Buchseite mit umgeknickter rosa Ecke und Schriftzug in Serif-Kursive auf Anthrazit"></div>
      <p class="unterschrift mono" aria-hidden="true"><span>02</span><b>Eselsohr</b><span class="art">Bildmarke</span></p></a></li>
    <li class="kachel"><a href="#arbeiten" aria-label="Krume, Backstube, 2026">
      <div class="bild"><img src="bilder/marke-krume-hoch.jpg" width="800" height="1000" alt="Wortmarke krume in fetter dunkelbrauner Schrift auf Senfgelb, darüber ein Brotlaib als Zeichen"></div>
      <p class="unterschrift mono" aria-hidden="true"><span>03</span><b>Krume Backstube</b><span class="art">Wortmarke</span></p></a></li>
  </ul>

  <div class="fuss">
    <div class="modi mono" role="group" aria-label="Verzerrung wählen" data-modi>
      <button type="button" data-modus="linse" aria-pressed="true">Linse · RGB</button>
      <button type="button" data-modus="woelbung" aria-pressed="false">Wölbung</button>
      <button type="button" data-modus="stroemung" aria-pressed="false">Strömung · Tempo</button>
      <button type="button" data-modus="wasser" aria-pressed="false">Wasser</button>
    </div>
    <div class="rechts">
      <p class="werte mono" data-werte></p>
      <p class="messung mono" aria-hidden="true"><span>uHover <b data-uhover>0.00</b></span><span>|v| <b data-uvel>0.000</b></span><span>Scroll <b data-uscroll>0.00</b></span></p>
    </div>
  </div>
</section>

<section class="zweite" aria-labelledby="weitere">
  <header><h2 id="weitere">Weitere Arbeiten</h2><p class="mono">04 – 06</p></header>
  <ul class="raster" data-raster>
    <li class="kachel"><a href="#arbeiten" aria-label="Licht, Ausstellungsplakat, 2025">
      <div class="bild"><img src="bilder/typo-plakat-hoch.jpg" width="800" height="1000" loading="lazy" alt="Plakat Licht: schwarze Versalien und eine orange Scheibe auf Neongelb, dazu die Zeile „und was es übrig lässt“"></div>
      <p class="unterschrift mono" aria-hidden="true"><span>04</span><b>Licht, Halle 4</b><span class="art">Plakat</span></p></a></li>
    <li class="kachel"><a href="#arbeiten" aria-label="Gefüge, Magazin, 2025">
      <div class="bild"><img src="bilder/magazin-doppelseite-hoch.jpg" width="800" height="1000" loading="lazy" alt="Aufgeschlagenes Magazin Gefüge mit der Überschrift „Die Ruhe der Dinge“ in Serif und Fließtext in zwei Spalten"></div>
      <p class="unterschrift mono" aria-hidden="true"><span>05</span><b>Gefüge</b><span class="art">Editorial</span></p></a></li>
    <li class="kachel"><a href="#arbeiten" aria-label="Atelier Hain, Keramik, Bildsprache, 2026">
      <div class="bild"><img src="bilder/keramik-stillleben-hoch.jpg" width="800" height="1000" loading="lazy" alt="Stillleben mit heller Flaschenvase, schwarzer Kugelvase und kleinen Schalen auf Sockeln vor Terrakotta"></div>
      <p class="unterschrift mono" aria-hidden="true"><span>06</span><b>Atelier Hain</b><span class="art">Bildsprache</span></p></a></li>
  </ul>
</section>

<section class="mehr" id="atelier">
  <div><p class="mono">01 · Zeichen</p><h2>Wenig Striche</h2><p>Wir zeichnen so lange weg, bis nur bleibt, was auf einem Stempel, einer Tasse und in 16 Pixeln trägt.</p></div>
  <div><p class="mono">02 · Werkstatt</p><h2>Vor Ort</h2><p>Bevor wir skizzieren, stehen wir einen Tag hinter dem Tresen oder in der Werkstatt unserer Kunden.</p></div>
  <div id="kontakt"><p class="mono">03 · Anfragen</p><h2>Ein Gespräch</h2><p>Neue Projekte nehmen wir ab Februar an. Schreiben Sie uns zwei Sätze über Ihr Vorhaben.</p></div>
</section>

<canvas class="gl" data-gl aria-hidden="true"></canvas>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
<p class="nur-sr" aria-live="polite" data-ansage></p>

<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const $ = s => document.querySelector(s);
const canvas = $('[data-gl]'), zeiger = $('[data-zeiger]'), ansage = $('[data-ansage]'), werteEl = $('[data-werte]');
const anzeige = { h: $('[data-uhover]'), v: $('[data-uvel]'), s: $('[data-uscroll]') };
const knoepfe = [...document.querySelectorAll('[data-modus]')];
const kacheln = [...document.querySelectorAll('.kachel')].map(li => ({
  li, a: li.querySelector('a'), rahmen: li.querySelector('.bild'), img: li.querySelector('img'),
  ziel: 0, hover: 0, von: 0, start: 0,            // uHover: Tween power3.out
  maus: new THREE.Vector2(.5, .5), mausZiel: new THREE.Vector2(.5, .5), mausAlt: null,
  vel: new THREE.Vector2(), fokus: false, puls: 0,
}));

/* ---------- Werte (meta.json → messwerte): Lerp 0.08–0.12 pro Frame, Abklingen 400–800 ms, power3.out,
   Hover-Skalierung 3 % (Immersive Garden), UV-Versatz 0.15 und RGB-Shift 0.025 (Thibault), 9 Samples (Lusion),
   RGB-Offset 0.002–0.01 UV, Linse 150–300 px ---------- */
const W = {
  lerp: 0.1, ein: 400, aus: 600,
  linse: 240, linseAnteil: 0.6, zoom: 1.6, wulst: 0.78, rgbLinse: 0.006, rgbLinseTempo: 0.004, eckRadius: 6,
  radius: 0.45, staerke: 0.4, hoverSkala: 0.03, rgbWoelbung: 0.03,
  versatz: 0.15, rgbStroemung: 0.025, samples: 9, tempo: 30, radiusStroemung: 0.55,
  biegung: 24, rgbScroll: 0.012, scrollNorm: 30,
  // Wasser: Wellengleichung auf einem Gitter in ¼ Viewport-Auflösung, Ping-Pong (R = h, G = h vorher)
  wasserGitter: 4, daempfung: 0.985, tropfenRadius: 4, tropfen: 0.9, wasserBrechung: 0.05, glanz: 0.4, nachlauf: 4000,
};
const MODI = {
  linse: { nr: 0, name: 'Linse',
    werte: [['Linse', '240 px · max. 60 %'], ['Zoom', '1.6×'], ['RGB', '0.006 UV + Tempo 0.004'], ['Lerp', '0.1 / Frame'], ['Ein', '400 ms power3.out'], ['Aus', '600 ms']] },
  woelbung: { nr: 1, name: 'Wölbung',
    werte: [['Radius', '0.45'], ['Stärke', '0.4'], ['Hover-Skalierung', '3 %'], ['RGB', '0.03 × f'], ['Lerp', '0.1 / Frame'], ['Ein / Aus', '400 / 600 ms power3.out']] },
  stroemung: { nr: 2, name: 'Strömung',
    werte: [['UV-Versatz', '0.15 × Tempo'], ['RGB-Shift', '0.025'], ['Samples', '9'], ['Tempo', '|v| × 30'], ['Lerp v', '0.1 / Frame'], ['Abklingen', '≈ 470 ms']] },
  wasser: { nr: 3, name: 'Wasser',
    werte: [['Gitter', '¼ Viewport'], ['Welle', 'h = Σ4 / 2 − h₋₁'], ['Dämpfung', '0.985 / Schritt'], ['Tropfen', 'r 4 Zellen × Tempo'], ['Brechung', '0.05 UV'], ['Glanz', 'pow 40 · 0.4']] },
};
const SCROLL_WERTE = [['Scroll-Biegung', '24 px'], ['RGB y', '0.012']];
let modus = 'linse';
let schmutzig = true, vorfuehrt = false;
const power3Out = t => 1 - Math.pow(1 - t, 4);   // = cubic-bezier(0.25, 1, 0.5, 1)

/* ---------- Shader ---------- */
const vertex = /* glsl */`
uniform float uScroll, uBend;
uniform vec2 uPlane;
varying vec2 vUv;
void main() {
  vUv = uv;
  vec3 p = position;
  /* Scroll-Tempo biegt die Plane: Mitte bleibt zurück */
  p.y -= sin(uv.x * 3.14159265) * uScroll * uBend / uPlane.y;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`;

const fragment = /* glsl */`
precision highp float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uImg, uPlane, uMouse, uVel;
uniform float uHover, uMode, uScroll;
uniform float uLens, uZoom, uBulgeIn, uRgbLens, uRgbLensV, uCorner;
uniform float uRadius, uStrength, uHoverScale, uRgbBulge;
uniform float uFlow, uRgbFlow, uSpeedK, uFlowRadius, uRgbScroll;
uniform sampler2D uWasser; uniform vec2 uRes, uZelle; uniform float uBrechung, uGlanz;

vec2 cover(vec2 uv) {
  float rp = uPlane.x / uPlane.y, ri = uImg.x / uImg.y;
  vec2 s = rp < ri ? vec2(rp / ri, 1.0) : vec2(1.0, ri / rp);
  return (uv - 0.5) * s + 0.5;
}
vec3 bild(vec2 uv) { return texture2D(uTex, clamp(cover(uv), 0.001, 0.999)).rgb; }
vec3 kanaele(vec2 uv, vec2 o) { return vec3(bild(uv + o).r, bild(uv).g, bild(uv - o).b); }

void main() {
  vec2 uv = vUv;
  vec2 asp = vec2(uPlane.x / uPlane.y, 1.0);
  vec2 so = vec2(0.0, uScroll * uRgbScroll);
  vec3 col;

  if (uMode < 0.5) {
    /* Quadrat-Linse: vergrößert, wölbt innen, trennt Kanäle zum Rand hin */
    vec2 px = (uv - uMouse) * uPlane;
    float h = 0.5 * uLens * uHover;
    vec2 q = abs(px) - vec2(h) + uCorner;
    float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uCorner;
    float innen = 1.0 - smoothstep(-0.75, 0.75, sd);
    vec2 l = px / max(h, 1.0);
    float r2 = clamp(dot(l, l) * 0.5, 0.0, 1.0);
    vec2 luv = uMouse + (uv - uMouse) / uZoom * mix(uBulgeIn, 1.0, r2);
    vec2 vv = uVel * asp;
    vec2 o = (l * 0.7071) * uRgbLens * (0.35 + 0.65 * r2) / asp
           + (length(vv) > 1e-5 ? normalize(vv) / asp * min(length(vv) * uSpeedK, 1.0) * uRgbLensV : vec2(0.0));
    vec3 drin = kanaele(luv, o + so) * 1.04;
    vec3 aussen = kanaele(uv, so) * (1.0 - 0.12 * uHover);
    col = mix(aussen, drin, innen * step(0.5, h));
    float kante = 1.0 - smoothstep(0.0, 1.25, abs(sd));
    col = mix(col, vec3(1.0), kante * 0.7 * step(0.5, h));
  } else if (uMode < 1.5) {
    /* Wölbung um den Zeiger plus 3 % Hover-Skalierung */
    vec2 d = (uv - uMouse) * asp;
    float f = smoothstep(uRadius, 0.0, length(d)) * uHover;
    vec2 buv = uv - (d / asp) * f * uStrength;
    buv = (buv - 0.5) * (1.0 - uHoverScale * uHover) + 0.5;
    col = kanaele(buv, (d / asp) * f * uRgbBulge + so);
  } else if (uMode > 2.5) {
    /* Wasser: Höhenfeld in Bildschirmkoordinaten, Steigung bricht die UVs, Glanzlicht auf den Wellenkämmen */
    vec2 s = gl_FragCoord.xy / uRes;
    float hl = texture2D(uWasser, s - vec2(uZelle.x, 0.0)).r, hr = texture2D(uWasser, s + vec2(uZelle.x, 0.0)).r;
    float hu = texture2D(uWasser, s - vec2(0.0, uZelle.y)).r, ho = texture2D(uWasser, s + vec2(0.0, uZelle.y)).r;
    vec2 g = vec2(hr - hl, ho - hu);
    vec2 o = g * uBrechung;
    col = kanaele(uv + o, o * 0.25 + so);
    vec3 n = normalize(vec3(-g * 5.0, 1.0));
    float glanz = pow(clamp(dot(n, normalize(vec3(-0.45, 0.55, 0.7))), 0.0, 1.0), 40.0);
    col = col * (1.0 + texture2D(uWasser, s).r * 0.06) + vec3(glanz * uGlanz);
  } else {
    /* Strömung: UVs fließen in Zeigerrichtung, 9 Samples, RGB-Versatz nach Tempo */
    vec2 d = (uv - uMouse) * asp;
    float s = clamp(length(uVel * asp) * uSpeedK, 0.0, 1.0);
    float fall = smoothstep(uFlowRadius, 0.0, length(d)) * uHover * s;
    if (fall > 0.002) {
      vec2 dir = normalize(uVel * asp) / asp;
      vec2 off = dir * uFlow * fall;
      vec2 o = dir * uRgbFlow * fall + so;
      vec3 acc = vec3(0.0);
      for (int i = 0; i < 9; i++) {
        float t = float(i) / 8.0;
        acc += kanaele(uv - off * t, o * (0.5 + t));
      }
      col = acc / 9.0;
    } else {
      col = kanaele(uv, so);
    }
  }
  gl_FragColor = vec4(col, 1.0);
}`;

/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null, szene, kamera, geometrie;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
if (!renderer) html.classList.add('kein-gl');

/* ---------- Wasser: Höhenfeld per Ping-Pong, ein Durchlauf je Bild (Welle + Tropfen + Scroll-Ausgleich) ---------- */
const wasserRes = new THREE.Vector2(1, 1), wasserZelle = new THREE.Vector2(1, 1);
const WASSER_SIM = `precision highp float;
uniform sampler2D uQ; uniform vec2 uZelle, uVersatz, uSeite, uA, uB; uniform float uAmp, uRadius, uDaempf;
varying vec2 vUv;
void main() {
  vec2 p = vUv + uVersatz;                                  /* Feld wandert mit dem Scroll */
  vec4 c = texture2D(uQ, p);
  float n = texture2D(uQ, p + vec2(uZelle.x, 0.0)).r + texture2D(uQ, p - vec2(uZelle.x, 0.0)).r
          + texture2D(uQ, p + vec2(0.0, uZelle.y)).r + texture2D(uQ, p - vec2(0.0, uZelle.y)).r;
  float h = (n * 0.5 - c.g) * uDaempf;
  vec2 q = vUv * uSeite, a = uA * uSeite, ab = uB * uSeite - a;   /* Tropfen als Kapsel vom letzten zum neuen Punkt */
  float t = clamp(dot(q - a, ab) / max(dot(ab, ab), 1e-8), 0.0, 1.0);
  float d = length(q - a - ab * t) / uRadius;
  h -= uAmp * (0.5 + 0.5 * cos(3.14159265 * min(d, 1.0)));
  if (p.x < 0.0 || p.y < 0.0 || p.x > 1.0 || p.y > 1.0) h = 0.0;
  gl_FragColor = vec4(h, c.r, 0.0, 1.0);
}`;
let wasser = null;
function baueWasser() {
  const opt = { type: THREE.HalfFloatType, format: THREE.RGBAFormat, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, depthBuffer: false, stencilBuffer: false, wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping };
  const a = new THREE.WebGLRenderTarget(2, 2, opt), b = new THREE.WebGLRenderTarget(2, 2, opt);
  const mat = new THREE.ShaderMaterial({
    vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }', fragmentShader: WASSER_SIM,
    uniforms: { uQ: { value: null }, uZelle: { value: wasserZelle }, uVersatz: { value: new THREE.Vector2() }, uSeite: { value: new THREE.Vector2(1, 1) },
      uA: { value: new THREE.Vector2(-9, -9) }, uB: { value: new THREE.Vector2(-9, -9) }, uAmp: { value: 0 }, uRadius: { value: 0.01 }, uDaempf: { value: W.daempfung } },
    depthTest: false, depthWrite: false,
  });
  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat); quad.frustumCulled = false;
  const s = new THREE.Scene(); s.add(quad);
  const kam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const w = { a, b, mat, tropfen: null, alt: null, bis: 0,
    groesse() {
      const gx = Math.max(8, Math.ceil(innerWidth / W.wasserGitter)), gy = Math.max(8, Math.ceil(innerHeight / W.wasserGitter));
      a.setSize(gx, gy); b.setSize(gx, gy); wasserZelle.set(1 / gx, 1 / gy);
      const lang = Math.max(gx, gy); mat.uniforms.uSeite.value.set(gx / lang, gy / lang); mat.uniforms.uRadius.value = W.tropfenRadius / lang;
      w.leeren();
    },
    leeren() { const alt = renderer.getRenderTarget(); [a, b].forEach(r => { renderer.setRenderTarget(r); renderer.setClearColor(0x000000, 0); renderer.clear(); }); renderer.setRenderTarget(alt); },
    schritt(scrollPx) {
      const u = mat.uniforms, tr = w.tropfen;
      u.uQ.value = w.a.texture; u.uVersatz.value.set(0, -scrollPx / innerHeight);
      if (tr) { u.uA.value.copy(tr.a); u.uB.value.copy(tr.b); u.uAmp.value = tr.amp; } else u.uAmp.value = 0;
      w.tropfen = null;
      renderer.setRenderTarget(w.b); renderer.render(s, kam); renderer.setRenderTarget(null);
      [w.a, w.b] = [w.b, w.a];
    },
    // Tropfen in Bildschirm-px; amp 0…1
    tropf(x, y, amp, jetzt) {
      const p = new THREE.Vector2(x / innerWidth, 1 - y / innerHeight);
      w.tropfen = { a: w.alt && jetzt - w.alt.t < 80 ? w.alt.p : p, b: p, amp: amp * W.tropfen };
      w.alt = { p, t: jetzt }; w.bis = jetzt + W.nachlauf;
    },
  };
  return w;
}

if (renderer) {
  renderer.setClearColor(0x000000, 0);
  try { wasser = baueWasser(); } catch (e) { wasser = null; }
  szene = new THREE.Scene();
  kamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -10, 10);
  geometrie = new THREE.PlaneGeometry(1, 1, 32, 1);
  kacheln.forEach(k => {
    const tex = new THREE.Texture(k.img);
    tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false;
    k.material = new THREE.ShaderMaterial({
      vertexShader: vertex, fragmentShader: fragment, transparent: false, depthTest: false, depthWrite: false,
      uniforms: {
        uTex: { value: tex }, uImg: { value: new THREE.Vector2(800, 1000) }, uPlane: { value: new THREE.Vector2(1, 1) },
        uMouse: { value: k.maus }, uVel: { value: k.vel }, uHover: { value: 0 }, uMode: { value: 0 }, uScroll: { value: 0 }, uBend: { value: W.biegung },
        uLens: { value: W.linse }, uZoom: { value: W.zoom }, uBulgeIn: { value: W.wulst }, uRgbLens: { value: W.rgbLinse }, uRgbLensV: { value: W.rgbLinseTempo }, uCorner: { value: W.eckRadius },
        uRadius: { value: W.radius }, uStrength: { value: W.staerke }, uHoverScale: { value: W.hoverSkala }, uRgbBulge: { value: W.rgbWoelbung },
        uFlow: { value: W.versatz }, uRgbFlow: { value: W.rgbStroemung }, uSpeedK: { value: W.tempo }, uFlowRadius: { value: W.radiusStroemung }, uRgbScroll: { value: W.rgbScroll },
        uWasser: { value: wasser ? wasser.a.texture : null }, uRes: { value: wasserRes }, uZelle: { value: wasserZelle }, uBrechung: { value: W.wasserBrechung }, uGlanz: { value: W.glanz },
      },
    });
    k.mesh = new THREE.Mesh(geometrie, k.material);
    k.mesh.frustumCulled = false; k.mesh.visible = false;
    szene.add(k.mesh);
    const fertig = () => {
      tex.image = k.img; tex.needsUpdate = true;
      k.material.uniforms.uImg.value.set(k.img.naturalWidth, k.img.naturalHeight);
      k.bereit = true; schmutzig = true;
    };
    k.img.loading = 'eager';
    if (k.img.complete && k.img.naturalWidth) fertig(); else k.img.addEventListener('load', fertig, { once: true });
  });
  canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); renderer = null; html.classList.remove('gl-an'); html.classList.add('kein-gl'); });
}

let breite = 0, hoehe = 0;
function groesse() {
  if (!renderer) return;
  breite = innerWidth; hoehe = innerHeight;
  renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
  renderer.setSize(breite, hoehe, false);
  kamera.left = 0; kamera.right = breite; kamera.top = 0; kamera.bottom = -hoehe; kamera.updateProjectionMatrix();
  const db = renderer.getDrawingBufferSize(new THREE.Vector2()); wasserRes.set(db.x, db.y);
  if (wasser) wasser.groesse();
  schmutzig = true;
}
addEventListener('resize', groesse);
groesse();

/* ---------- Lenis: Scroll läuft im selben Frame wie das Rendern, Planes kleben am DOM ---------- */
let lenis = null;
function lenisPruefen() {
  const soll = !reduziert() && !beruehrung && !html.classList.contains('vorschau') && window.Lenis;
  if (soll && !lenis) lenis = new window.Lenis({ autoRaf: false, lerp: W.lerp });
  if (!soll && lenis) { lenis.destroy(); lenis = null; }
}

/* ---------- Zeiger: echte Maus oder gedachter Zeiger der Vorführung ---------- */
const zeigerPos = { x: -9999, y: -9999, da: false };
addEventListener('pointermove', e => {
  if (e.pointerType === 'touch') return;
  if (vorfuehrt) stopVorfuehrung();
  zeigerPos.x = e.clientX; zeigerPos.y = e.clientY; zeigerPos.da = true; schmutzig = true;
}, { passive: true });
document.addEventListener('pointerleave', () => { if (!vorfuehrt) { zeigerPos.da = false; schmutzig = true; } });

/* Touch: kein Hover. Tippen löst einen kurzen Impuls an der Tippstelle aus, Scrollen biegt die Bilder. */
kacheln.forEach(k => {
  k.a.addEventListener('click', e => e.preventDefault());
  k.a.addEventListener('pointerup', e => {
    if (e.pointerType !== 'touch') return;
    const r = k.rahmen.getBoundingClientRect();
    k.mausZiel.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
    k.maus.copy(k.mausZiel);
    if (modus === 'stroemung') { const w = Math.random() * Math.PI * 2; k.vel.set(Math.cos(w) * .05, Math.sin(w) * .05); }
    if (modus === 'wasser' && wasser) { wasser.alt = null; wasser.tropf(e.clientX, e.clientY, 1, performance.now()); }   // Tippen = ein Tropfen
    k.puls = performance.now() + W.ein;
    schmutzig = true;
  });
  k.a.addEventListener('focus', () => {
    k.fokus = true; k.mausZiel.set(.5, .5); schmutzig = true;
    if (modus === 'wasser' && wasser) { const r = k.rahmen.getBoundingClientRect(); wasser.alt = null; wasser.tropf(r.left + r.width / 2, r.top + r.height / 2, 0.8, performance.now()); }
  });
  k.a.addEventListener('blur', () => { k.fokus = false; schmutzig = true; });
});

/* ---------- Werte, Modus ---------- */
function werteZeigen() {
  const m = MODI[modus];
  werteEl.innerHTML = reduziert()
    ? '<span>Reduziert <b>keine Verzerrung · Helligkeit 0.9</b></span>'
    : [...m.werte, ...SCROLL_WERTE].map(([a, b]) => `<span>${a} <b>${b}</b></span>`).join('');
  knoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.modus === modus ? 'true' : 'false'));
}
function setzeModus(m) {
  if (m === 'wasser' && modus !== 'wasser' && wasser) { wasser.leeren(); wasser.bis = 0; }
  modus = m; werteZeigen(); schmutzig = true;
  kacheln.forEach(k => k.material && (k.material.uniforms.uMode.value = MODI[m].nr));
  ansage.textContent = `Verzerrung: ${MODI[m].name}`;
}
// Variante in der URL (?v=), andere Parameter bleiben erhalten
function urlSetzen(m) {
  const u = new URL(location.href);
  m === 'linse' ? u.searchParams.delete('v') : u.searchParams.set('v', m);
  try { history.replaceState(null, '', u); } catch (e) { /* ohne History */ }
}
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeModus(b.dataset.modus); urlSetzen(b.dataset.modus); }));
new MutationObserver(() => { werteZeigen(); lenisPruefen(); glPruefen(); schmutzig = true; }).observe(html, { attributes: true, attributeFilter: ['class'] });

function glPruefen() {
  const an = !!renderer && !reduziert() && kacheln.every(k => k.bereit);
  html.classList.toggle('gl-an', an);
  return an;
}

/* ---------- Schleife: synchronisieren, lerpen, nur bei Bedarf zeichnen ---------- */
let letzteZeit = performance.now(), scrollAlt = scrollY, scrollV = 0, aktiveKachel = kacheln[0], wasserZeiger = null;
const ruhe = 1e-4;
function bild(jetzt) {
  requestAnimationFrame(bild);
  const dt = Math.min(64, jetzt - letzteZeit) / 16.667; letzteZeit = jetzt;
  if (lenis) lenis.raf(jetzt);
  if (vorfuehrt) vorfuehrSchritt(jetzt);
  const a = 1 - Math.pow(1 - W.lerp, dt);   // Lerp 0.1 pro Frame bei 60 fps

  /* Scroll-Tempo, normiert auf ±1 bei 30 px/Frame */
  const sy = scrollY, sDelta = sy - scrollAlt, roh = sDelta / dt; scrollAlt = sy;
  scrollV += (Math.max(-1, Math.min(1, roh / W.scrollNorm)) - scrollV) * a;
  if (Math.abs(scrollV) < 1e-3) scrollV = 0;
  if (reduziert()) scrollV = 0;

  let bewegt = schmutzig || scrollV !== 0 || roh !== 0;
  const gl = glPruefen();

  for (const k of kacheln) {
    const r = k.rahmen.getBoundingClientRect();
    const drin = zeigerPos.da && zeigerPos.x >= r.left && zeigerPos.x <= r.right && zeigerPos.y >= r.top && zeigerPos.y <= r.bottom;
    const puls = k.puls > jetzt;
    const ziel = drin || k.fokus || puls ? 1 : 0;
    if (ziel !== k.ziel) { k.ziel = ziel; k.von = k.hover; k.start = jetzt; }
    if (drin) {
      k.mausZiel.set((zeigerPos.x - r.left) / r.width, 1 - (zeigerPos.y - r.top) / r.height);
      if (k.hover < 0.05) { k.maus.copy(k.mausZiel); k.mausAlt = null; }   // Eintritt: Linse springt an den Zeiger
      aktiveKachel = k;
    }
    k.li.classList.toggle('ist-hover', ziel === 1);

    /* uHover: Tween power3.out, ein 400 ms, aus 600 ms */
    const dauer = k.ziel ? W.ein : W.aus;
    const t = Math.min(1, (jetzt - k.start) / dauer);
    const neu = k.von + (k.ziel - k.von) * power3Out(t);
    if (Math.abs(neu - k.hover) > ruhe) bewegt = true;
    k.hover = neu;

    /* Geschwindigkeit in UV pro Frame, gelerpt: klingt nach dem Stopp in ≈ 470 ms ab */
    const vZiel = new THREE.Vector2();
    if (drin && k.mausAlt) vZiel.subVectors(k.mausZiel, k.mausAlt).divideScalar(dt);
    k.mausAlt = drin ? k.mausZiel.clone() : null;
    k.vel.lerp(vZiel, a);
    if (k.vel.lengthSq() < 1e-9) k.vel.set(0, 0); else bewegt = true;
    const md = k.maus.distanceToSquared(k.mausZiel);
    if (md > 1e-8) { k.maus.lerp(k.mausZiel, a); bewegt = true; }

    if (gl) {
      const sichtbar = r.bottom > -60 && r.top < hoehe + 60;
      k.mesh.visible = sichtbar;
      if (sichtbar) {
        k.mesh.position.set(r.left + r.width / 2, -(r.top + r.height / 2), 0);
        k.mesh.scale.set(r.width, r.height, 1);
        const u = k.material.uniforms;
        u.uPlane.value.set(r.width, r.height);
        u.uHover.value = reduziert() ? 0 : k.hover;
        u.uScroll.value = scrollV;
        u.uLens.value = Math.min(W.linse, r.width * W.linseAnteil);
      }
    }
  }

  /* Wasser: Zeiger über einem Bild zieht Tropfen nach, das Feld rechnet bis 4 s nach dem letzten Tropfen */
  if (gl && wasser && modus === 'wasser') {
    const ueber = zeigerPos.da && kacheln.some(k => k.li.classList.contains('ist-hover') && !k.fokus);
    if (ueber) {
      const d = wasserZeiger ? Math.hypot(zeigerPos.x - wasserZeiger.x, zeigerPos.y - wasserZeiger.y) : 0;
      if (d > 0.5) wasser.tropf(zeigerPos.x, zeigerPos.y, Math.min(1, d / 14), jetzt);
      wasserZeiger = { x: zeigerPos.x, y: zeigerPos.y };
    } else wasserZeiger = null;
    if (jetzt < wasser.bis) {
      // je 60-Hz-Bild ein Schritt, bei langsamen Geräten höchstens zwei (gleiche Wellengeschwindigkeit)
      const n = Math.min(2, Math.max(1, Math.round(dt)));
      for (let i = 0; i < n; i++) wasser.schritt(i ? 0 : sDelta);
      kacheln.forEach(k => { if (k.material) k.material.uniforms.uWasser.value = wasser.a.texture; });
      bewegt = true;
    } else if (wasser.bis) { wasser.leeren(); wasser.bis = 0; bewegt = true; }
  }

  if (anzeige.h && aktiveKachel) {
    anzeige.h.textContent = aktiveKachel.hover.toFixed(2);
    anzeige.v.textContent = aktiveKachel.vel.length().toFixed(3);
    anzeige.s.textContent = scrollV.toFixed(2);
  }
  if (gl && bewegt) renderer.render(szene, kamera);
  schmutzig = false;
}

/* ---------- Vorführung: gedachter Zeiger fährt durch die Kacheln, je Variante eine Kachel ---------- */
const FOLGE = ['linse', 'woelbung', 'stroemung'];
const SEGMENT = 5600;
let vStart = 0, vSegment = -1, vDrueck = 0;
function mitte(el) { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, r }; }
const glatt = t => t * t * (3 - 2 * t);
function bahn(i, t) {
  /* Lissajous-Bahn in Kachel i; Strömung fährt schneller, damit Tempo entsteht */
  const k = kacheln[i % kacheln.length], { x, y, r } = mitte(k.rahmen);
  const schnell = FOLGE[i] === 'stroemung';
  const w = t * Math.PI * 2 * (schnell ? 0.95 : 0.42);
  return { x: x + Math.sin(w) * r.width * (schnell ? 0.34 : 0.26), y: y + Math.sin(w * 2 + .6) * r.height * (schnell ? 0.3 : 0.2) };
}
let festV = null;   // mit ?v= bleibt die Vorführung in dieser Variante
function vorfuehrSchritt(jetzt) {
  const z = Math.max(0, jetzt - vStart);
  if (festV) {
    // eine Lissajous-Bahn quer über die drei oberen Kacheln; Wasser und Strömung fahren schneller
    const a = kacheln[0].rahmen.getBoundingClientRect(), c = kacheln[2].rahmen.getBoundingClientRect();
    const schnell = festV === 'stroemung' || festV === 'wasser', w = z / 1000 * Math.PI * 2 * (schnell ? 0.16 : 0.1);
    const x = (a.left + c.right) / 2 + Math.sin(w) * (c.right - a.left) * 0.42, y = (a.top + a.bottom) / 2 + Math.sin(w * 3 + .6) * a.height * 0.3;
    zeigerPos.x = x; zeigerPos.y = y; zeigerPos.da = true;
    zeiger.style.transform = `translate(${x}px, ${y}px)`;
    return;
  }
  const seg = Math.floor(z / SEGMENT) % FOLGE.length, s = (z % SEGMENT) / 1000;
  if (seg !== vSegment) { vSegment = seg; setzeModus(FOLGE[seg]); }
  const knopf = mitte(knoepfe[(seg + 1) % FOLGE.length]);
  let p;
  if (s < 0.8) {                       // vom Knopf in die Kachel
    const vonK = mitte(knoepfe[seg]), b = bahn(seg, 0), e = glatt(s / 0.8);
    p = { x: vonK.x + (b.x - vonK.x) * e, y: vonK.y + (b.y - vonK.y) * e };
  } else if (s < 4.6) {                // in der Kachel kreisen
    p = bahn(seg, s - 0.8);
  } else {                             // hinaus zum nächsten Knopf
    const b = bahn(seg, 3.8), e = glatt(Math.min(1, (s - 4.6) / 0.8));
    p = { x: b.x + (knopf.x - b.x) * e, y: b.y + (knopf.y - b.y) * e };
    if (s > 5.4 && vDrueck !== z - (z % SEGMENT)) {
      vDrueck = z - (z % SEGMENT);
      zeiger.classList.add('drueckt'); knoepfe[(seg + 1) % FOLGE.length].classList.add('drueckt');
      setTimeout(() => { zeiger.classList.remove('drueckt'); knoepfe.forEach(b => b.classList.remove('drueckt')); }, 160);
    }
  }
  zeigerPos.x = p.x; zeigerPos.y = p.y; zeigerPos.da = true;
  zeiger.style.transform = `translate(${p.x}px, ${p.y}px)`;
}
function stopVorfuehrung() {
  if (!vorfuehrt) return;
  vorfuehrt = false; html.classList.remove('vorfuehrung');
  zeigerPos.da = false; schmutzig = true;
}
function vorfuehren() {
  if (!html.classList.contains('vorschau')) scrollTo(0, 0);
  const v = new URLSearchParams(location.search).get('v');
  festV = MODI[v] ? v : null;
  if (festV) setzeModus(festV);
  vorfuehrt = true; vStart = performance.now(); vSegment = -1;
  html.classList.add('vorfuehrung');
}
function neustart() {
  stopVorfuehrung();
  kacheln.forEach(k => { k.ziel = 0; k.hover = 0; k.von = 0; k.vel.set(0, 0); k.puls = 0; });
  setzeModus(startModus());
  if (modus === 'wasser' && wasser) { const r = kacheln[0].rahmen.getBoundingClientRect(); wasser.alt = null; wasser.tropf(r.left + r.width * .42, r.top + r.height * .42, 1, performance.now()); }
  /* einmal abspielen: kurzer Impuls auf der ersten Kachel */
  const k = kacheln[0]; k.mausZiel.set(.42, .58); k.maus.copy(k.mausZiel); k.puls = performance.now() + 900;
  schmutzig = true;
}
function pause() { stopVorfuehrung(); }
function weiter() { vorfuehren(); }

function startModus() { const v = new URLSearchParams(location.search).get('v'); return MODI[v] ? v : 'linse'; }
setzeModus(startModus());
werteZeigen(); lenisPruefen();
requestAnimationFrame(bild);

</script>
</body>
</html>