Animationsstil „Shader linear“ (Zeit linear, Form im Material)
Showreel-Standbilder einer Ludwigsburger Kamerafrau: uProgress läuft linear, jeder Pixel blendet mit eigenem smoothstep-Fenster über, versetzt nach Helligkeit, Rauschen oder Abstand zum Klick; scrubbar und umkehrbar.
Ein Animationsstil für WebGL-Übergänge: Die globale Zeit (Uniform uProgress) läuft linear und eher lang, 1,2 bis 2,6 s. Die Kurve entsteht erst im Shader, pro Pixel per smoothstep mit räumlichem Versatz aus Helligkeit, Rauschen oder Abstand zum Klickpunkt. So bewegt sich das Bild wie Material und nicht wie ein Rechteck. DOM-Elemente drumherum folgen mit einer weichen Kurve wie sine.inOut.
Jede Bildregion hat ihren eigenen Ablauf, deshalb wirkt der Übergang organisch und kostbar. Weil die Grundzeit linear ist, lässt er sich steuern: scrubben, anhalten, umkehren.
Wann einsetzen
WebGL-Slider, Seitenübergänge mit Bildern und Hero-Wechsel in Award-orientierten Projekten.
Wann nicht
Ohne WebGL-Kompetenz im Team oder bei knappem Performance-Budget.
Varianten
- Helligkeitsbänder: helle Bildteile zuerst, BANDS 12 (Codrops Demo 20)
- Rauschmaske
- Radial ab Klickpunkt
- Scroll-gescrubbt: uProgress = Scrollfortschritt
Umsetzung
GLSL: float local = clamp((uProgress − offset · SPREAD) / FLIGHT, 0., 1.); local = smoothstep(0., 1., local); gl_FragColor = mix(alt, neu, local). Mit SPREAD + FLIGHT = 1 ist bei uProgress = 1 jeder Pixel fertig. Offset aus Helligkeit, Noise oder Distanz. GSAP: gsap.to(uniforms.uProgress, { value: 1, duration: 2.6, ease: 'none' }). Gerendert wird nur während des Übergangs. Texte wechseln bei 50 %.
Robust umsetzen
Telefon. Kürzere Dauer und niedrigere Render-Auflösung.
Ohne Bewegung. Einfache Überblendung ohne Versatz, keine automatische Diashow.
Barrierefrei. Canvas mit aria-hidden, echte Bilder mit alt im DOM. Diashow mit Pause-Knopf, Pfeiltasten am Bild, Bildwechsel per aria-live angesagt. Ohne WebGL eine einfache Überblendung mit Hinweis.
Code
<!DOCTYPE html>
<!--
Animationsstil „Shader linear“ (Zeit linear, Form im Material)
Fundus · Animationsstile · https://cecodesign.de/lab/fundus/animationsstile/token-filmisch-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>Animationsstil „Shader linear“ · 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: '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; }
@font-face { font-family: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
/* Rezept „Bild zuerst“, Film-Variante: neutrale dunkle Fläche, Text in einem Ton */
--grund: #0e0e0e; --text: #f2f2f2; --leise: #8a8a8a; --linie: rgba(242, 242, 242, .14); --platz: #1a1a1a;
--sinus: cubic-bezier(0.37, 0, 0.63, 1); /* sine.inOut: DOM um den Shader herum */
--rand: clamp(18px, 3.4vw, 48px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font: 400 15px/1.5 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow-x: hidden; display: flex; flex-direction: column; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 1px solid var(--text); outline-offset: 3px; }
.mono { font: 400 12px/1.4 'Fragment Mono', ui-monospace, monospace; letter-spacing: .02em; font-variant-numeric: tabular-nums; color: var(--leise); }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* ---------- Ecken: Name links, Navigation rechts ---------- */
.kopf { display: flex; justify-content: space-between; align-items: baseline; padding: clamp(16px, 2.2vw, 26px) var(--rand); }
.marke { font: 400 clamp(1.25rem, 1rem + .6vw, 1.5rem)/1 'Instrument Serif', serif; letter-spacing: -.01em; }
.marke span { font: 400 12px/1 'Fragment Mono', monospace; color: var(--leise); margin-left: 12px; letter-spacing: .02em; }
.kopf nav { display: flex; gap: 22px; }
.kopf nav a { color: var(--leise); transition: color 200ms var(--sinus); }
.kopf nav a:hover, .kopf nav a[aria-current] { color: var(--text); }
/* ---------- Bühne: Bild, Unterschrift, Zeitleiste ---------- */
.buehne { padding: 0 var(--rand); display: grid; gap: 14px; }
.rahmen { position: relative; width: 100%; aspect-ratio: 2.39 / 1; max-height: calc(100svh - 290px); min-height: 280px; background: var(--platz); overflow: hidden; cursor: pointer; touch-action: pan-y; outline: none; }
.rahmen:focus-visible { box-shadow: 0 0 0 1px var(--leise); }
.rahmen img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; }
.rahmen img.an { opacity: 1; }
.rahmen canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 500ms var(--sinus); }
.gl .rahmen canvas { opacity: 1; }
.gl .rahmen img { visibility: hidden; }
.hinweis-gl { position: absolute; left: 14px; bottom: 12px; padding: 5px 9px; background: rgba(14, 14, 14, .72); color: var(--text); display: none; }
.ohne-gl .hinweis-gl { display: block; }
/* Klickpunkt für die radiale Variante */
.ring { position: absolute; left: 0; top: 0; width: 44px; height: 44px; margin: -22px 0 0 -22px; border: 1px solid rgba(242, 242, 242, .9); border-radius: 50%; pointer-events: none; opacity: 0; }
.leiste { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 20px; }
.titel { font: 400 clamp(1.5rem, 3vw, 2.75rem)/1.1 'Instrument Serif', serif; letter-spacing: -.01em; }
.titel em { font-style: italic; }
.unterschrift { margin-top: 6px; }
.wechselt { transition: opacity 500ms var(--sinus); }
.wechselt.weg { opacity: 0; }
.blaettern { display: flex; align-items: center; gap: 18px; }
.zaehler b { color: var(--text); font-weight: 400; }
.pfeile { display: flex; gap: 6px; }
.pfeile button, .spiel { width: 38px; height: 38px; border: 1px solid var(--linie); border-radius: 50%; display: grid; place-items: center; transition: border-color 200ms var(--sinus); }
.pfeile button:hover, .spiel:hover { border-color: var(--leise); }
.pfeile svg, .spiel svg { width: 14px; height: 14px; }
.spiel { margin-left: 4px; }
.spiel[aria-pressed='true'] [data-icon-spiel], .spiel[aria-pressed='false'] [data-icon-pause] { display: none; }
/* Zeitleiste: globale Zeit linear, Kurve pro Pixel */
.zeit { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 18px; padding-top: 12px; border-top: 1px solid var(--linie); }
.zeit .wert { min-width: 15ch; }
.zeit .wert b { color: var(--text); font-weight: 400; }
.schieber { -webkit-appearance: none; appearance: none; width: 100%; height: 28px; background: transparent; cursor: ew-resize; margin: 0; --p: 0; }
.schieber::-webkit-slider-runnable-track { height: 1px; background: linear-gradient(var(--text), var(--text)) 0 0 / calc(var(--p) * 100%) 100% no-repeat, var(--linie); }
.schieber::-moz-range-track { height: 1px; background: var(--linie); }
.schieber::-moz-range-progress { height: 1px; background: var(--text); }
.schieber::-webkit-slider-thumb { -webkit-appearance: none; width: 11px; height: 11px; margin-top: -5px; border-radius: 50%; background: var(--text); border: 0; }
.schieber::-moz-range-thumb { width: 11px; height: 11px; border-radius: 50%; background: var(--text); border: 0; }
.schieber:focus-visible { outline-offset: 0; }
.kurven { display: flex; align-items: center; gap: 12px; }
.kurven svg { width: 132px; height: 62px; overflow: visible; }
.kurven .legende { display: grid; gap: 1px; }
.kurven .legende span::before { content: ''; display: inline-block; width: 12px; height: 1px; vertical-align: middle; margin-right: 6px; background: var(--f, currentColor); }
/* Bedienleiste der Demo (im Fluss, nicht fest) */
.pult { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 26px; padding: 18px var(--rand) 22px; margin-top: auto; }
.gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.gruppe > span { margin-right: 8px; }
.gruppe button { height: 28px; padding: 0 10px; border: 1px solid transparent; border-radius: 999px; transition: color 200ms var(--sinus), border-color 200ms var(--sinus); }
.gruppe button:hover { color: var(--text); }
.gruppe button[aria-pressed='true'] { color: var(--text); border-color: var(--linie); }
.pult .werte { margin-left: auto; text-align: right; max-width: 560px; }
.pult .werte b { color: var(--text); font-weight: 400; }
/* ---------- Vorschau 960 × 720: nur Bühne ---------- */
.vorschau body { overflow: hidden; height: 100vh; }
.vorschau .pult, .vorschau .kopf nav, .vorschau .pfeile, .vorschau .spiel, .vorschau .zeit .schieber { display: none; }
.vorschau .buehne { padding: 0 40px; gap: 16px; }
.vorschau .kopf { padding: 26px 40px 20px; }
.vorschau .rahmen { aspect-ratio: auto; height: 430px; max-height: none; }
.vorschau .titel { font-size: 46px; }
.vorschau .mono { font-size: 14px; }
.vorschau .zeit { grid-template-columns: auto auto; justify-content: space-between; }
.vorschau .kurven svg { width: 150px; height: 70px; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (max-width: 900px) {
.zeit { grid-template-columns: minmax(0, 1fr) auto; }
.zeit .schieber { grid-column: 1 / -1; grid-row: 2; }
.pult .werte { margin-left: 0; text-align: left; flex-basis: 100%; }
}
@media (max-width: 640px) {
.kopf nav a:not(:last-child) { display: none; }
.marke span { display: none; }
.rahmen { aspect-ratio: 1 / 1; max-height: none; }
.leiste { grid-template-columns: 1fr; gap: 14px; }
.blaettern { justify-content: flex-end; gap: 10px; }
.zaehler { margin-right: auto; }
.kurven .legende { display: none; }
.pult { gap: 10px; }
}
@media (hover: none) {
.pfeile button, .spiel { width: 44px; height: 44px; }
.gruppe button { height: 36px; }
.schieber { height: 40px; }
.schieber::-webkit-slider-thumb { width: 18px; height: 18px; margin-top: -9px; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#arbeiten">Nadja Wilke<span>Bildgestaltung · Ludwigsburg</span></a>
<nav class="mono" aria-label="Hauptmenü"><a href="#arbeiten" aria-current="page">Arbeiten</a><a href="#reel">Showreel</a><a href="#ueber">Über</a><a href="#kontakt">Kontakt</a></nav>
</header>
<main class="buehne" id="arbeiten" data-buehne>
<figure class="rahmen" data-rahmen tabindex="0" aria-roledescription="Diashow" aria-label="Standbilder aus Filmen. Klick, Tippen oder Pfeiltasten wechseln das Bild.">
<img data-bild alt="Blaue Stunde am Neckar: eine kleine Fähre mit warm erleuchteten Fenstern, am anderen Ufer die Lichter des Hangs.">
<img data-bild alt="Goldene Stunde im Weinberg: Rebzeilen laufen auf die tiefe Sonne zu, eine Person steht im Gegenlicht.">
<img data-bild alt="Nebelnacht in einer Lindenallee: Laternen in Natriumgelb spiegeln sich auf dem nassen Weg.">
<img data-bild alt="Nacht an einer Tankstelle: türkises Licht fällt aus dem Dach auf nassen Asphalt, rechts ein rotes Preisschild.">
<img data-bild alt="Morgen in einer Küche: Licht fällt schräg durch ein Sprossenfenster auf den Tisch, darauf eine Tasse.">
<canvas data-canvas aria-hidden="true"></canvas>
<span class="ring" data-ring aria-hidden="true"></span>
<p class="hinweis-gl mono">Ohne WebGL: einfache Überblendung</p>
</figure>
<div class="leiste">
<div>
<h1 class="titel wechselt" data-titel>Die Fähre von Hoheneck</h1>
<p class="unterschrift mono wechselt" data-unterschrift>Kurzfilm · 2025 · Regie Lukas Brändle · ALEXA 35, 40 mm</p>
</div>
<div class="blaettern">
<p class="zaehler mono" aria-live="polite"><b data-nr>01</b> / 05</p>
<div class="pfeile">
<button type="button" data-zurueck aria-label="Vorheriges Bild"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><path d="M13 8H3M7 4 3 8l4 4"/></svg></button>
<button type="button" data-vor aria-label="Nächstes Bild"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4"/></svg></button>
</div>
<button type="button" class="spiel" data-spiel aria-label="Diashow anhalten" aria-pressed="true"><svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-icon-pause><rect x="4" y="3" width="2.4" height="10"/><rect x="9.6" y="3" width="2.4" height="10"/></svg><svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-icon-spiel><path d="M4.5 3v10l8.5-5z"/></svg></button>
</div>
</div>
<div class="zeit">
<p class="wert mono">uProgress <b data-p>0.00</b> · linear</p>
<input class="schieber" data-schieber type="range" min="0" max="1000" value="0" aria-label="Fortschritt des Übergangs zum nächsten Bild (scrubben)">
<div class="kurven mono" aria-hidden="true">
<svg viewBox="0 0 132 62" data-kurven>
<path d="M0 61.5H132M.5 0V62" stroke="rgba(242,242,242,.14)" fill="none"/>
<path d="M0 62 132 0" stroke="rgba(242,242,242,.3)" stroke-dasharray="2 3" fill="none"/>
<path data-k="0" fill="none" stroke="#f2f2f2" stroke-width="1"/>
<path data-k="1" fill="none" stroke="#b4b4b4" stroke-width="1"/>
<path data-k="2" fill="none" stroke="#6e6e6e" stroke-width="1"/>
<line data-zeiger x1="0" x2="0" y1="0" y2="62" stroke="rgba(242,242,242,.35)"/>
<circle data-d="0" r="2.5" fill="#f2f2f2"/><circle data-d="1" r="2.5" fill="#b4b4b4"/><circle data-d="2" r="2.5" fill="#6e6e6e"/>
</svg>
<p class="legende" data-legende><span style="color:#f2f2f2">hell</span><span style="color:#b4b4b4">mittel</span><span style="color:#828282; --f:#6e6e6e">dunkel</span></p>
</div>
</div>
</main>
<div class="pult mono" role="group" aria-label="Einstellungen der Demo">
<div class="gruppe" role="group" aria-label="Versatz pro Pixel"><span>Versatz</span>
<button type="button" data-modus="baender" aria-pressed="true">Helligkeit</button>
<button type="button" data-modus="rauschen" aria-pressed="false">Rauschen</button>
<button type="button" data-modus="radial" aria-pressed="false">Radial</button>
</div>
<div class="gruppe" role="group" aria-label="Dauer"><span>Dauer</span>
<button type="button" data-dauer="1200" aria-pressed="false">1200</button>
<button type="button" data-dauer="1800" aria-pressed="false">1800</button>
<button type="button" data-dauer="2600" aria-pressed="true">2600</button>
</div>
<p class="werte" data-werte></p>
</div>
<p class="nur-sr" aria-live="polite" data-ansage></p>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.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), $$ = s => [...document.querySelectorAll(s)];
const rahmen = $('[data-rahmen]'), canvas = $('[data-canvas]'), ring = $('[data-ring]');
const imgs = $$('[data-bild]');
const titelEl = $('[data-titel]'), unterEl = $('[data-unterschrift]'), nrEl = $('[data-nr]');
const pEl = $('[data-p]'), schieber = $('[data-schieber]'), werteEl = $('[data-werte]'), ansage = $('[data-ansage]');
const spielKnopf = $('[data-spiel]');
/* ---------- Inhalte ---------- */
const FILME = [
{ titel: 'Die Fähre von Hoheneck', zeile: 'Kurzfilm · 2025 · Regie Lukas Brändle · ALEXA 35, 40 mm' },
{ titel: '<em>Trollinger</em>', zeile: 'Dokumentarfilm · 2024 · Regie Sevim Aksoy · Lese im Steilhang, 75 mm' },
{ titel: 'Allee im November', zeile: 'Musikvideo · 2025 · für Mondkälber · Nebelmaschine, 2 Laternen' },
{ titel: 'Letzte Ausfahrt <em>Pattonville</em>', zeile: 'Abschlussfilm · 2026 · Regie Jonas Ferber · anamorph, 50 mm' },
{ titel: 'Morgens bei Ilse', zeile: 'Kurzfilm · 2023 · Regie Merle Hauck · Tageslicht, ein Spiegel' },
];
/* ---------- Werte (meta.json → messwerte: progress linear, lokal smoothstep, SPREAD 0,6, FLIGHT 0,4, BANDS 12, Skala 1200/1800/2600) ---------- */
const W = { spread: 0.6, flight: 0.4, baender: 12, rauschSkala: 2.2, ringStreuung: 0.06, reduziertMs: 500, halteMs: 3200 };
const MODI = { baender: 0, rauschen: 1, radial: 2 };
const LEGENDE = { baender: ['hell', 'mittel', 'dunkel'], rauschen: ['Rauschen 0', '0,5', '1'], radial: ['nah', 'mittel', 'fern'] };
let modus = 'baender';
let dauer = beruehrung ? 1800 : 2600;
/* ---------- Eigene Standbilder: auf Canvas gemalt, keine fremden Fotos ---------- */
const BW = 1600, BH = 900;
function zufall(seed) { return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
function verlauf(c, x0, y0, x1, y1, stops) { const g = c.createLinearGradient(x0, y0, x1, y1); stops.forEach(([o, f]) => g.addColorStop(o, f)); return g; }
function glanz(c, x, y, r, farbe, a = 1) {
const g = c.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, farbe); g.addColorStop(1, 'rgba(0,0,0,0)');
c.save(); c.globalCompositeOperation = 'lighter'; c.globalAlpha = a; c.fillStyle = g; c.fillRect(x - r, y - r, 2 * r, 2 * r); c.restore();
}
function vignette(c, staerke) {
const g = c.createRadialGradient(BW / 2, BH / 2, BH * .35, BW / 2, BH / 2, BW * .62);
g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, `rgba(0,0,0,${staerke})`);
c.fillStyle = g; c.fillRect(0, 0, BW, BH);
}
function dither(c, r) {
const d = c.getImageData(0, 0, BW, BH), a = d.data;
for (let i = 0; i < a.length; i += 4) { const n = (r() - .5) * 3; a[i] += n; a[i + 1] += n; a[i + 2] += n; }
c.putImageData(d, 0, 0);
}
const MALER = [
/* 1 · Fähre, blaue Stunde */
(c, r) => {
const hz = BH * .56;
c.fillStyle = verlauf(c, 0, 0, 0, hz, [[0, '#08121d'], [.55, '#1a3048'], [.9, '#4b5c6c'], [1, '#86716a']]); c.fillRect(0, 0, BW, hz);
c.fillStyle = verlauf(c, 0, hz, 0, BH, [[0, '#3a4855'], [.25, '#141f29'], [1, '#05080c']]); c.fillRect(0, hz, BW, BH - hz);
glanz(c, BW * .62, hz, 700, 'rgba(160,120,105,.55)', .6);
c.fillStyle = '#0a1119'; c.beginPath(); c.moveTo(0, hz);
for (let x = 0; x <= BW; x += 20) c.lineTo(x, hz - 40 - 26 * Math.sin(x / 260) - 14 * Math.sin(x / 83 + 1) - (x > 900 ? (x - 900) * .06 : 0));
c.lineTo(BW, hz); c.fill();
for (let i = 0; i < 46; i++) {
const x = r() * BW, y = hz - 6 - r() * 46, w = 1 + r() * 2.2, f = r() < .8 ? '255,184,110' : '220,236,255';
glanz(c, x, y, 10 + r() * 16, `rgba(${f},.9)`, .55); c.fillStyle = `rgb(${f})`; c.fillRect(x - w / 2, y - w / 2, w, w);
c.save(); c.filter = 'blur(3px)'; c.globalCompositeOperation = 'lighter'; c.fillStyle = `rgba(${f},.22)`; c.fillRect(x - w, hz + 4, w * 2, 30 + r() * 120); c.restore();
}
c.save(); c.globalCompositeOperation = 'lighter';
for (let i = 0; i < 160; i++) { c.fillStyle = `rgba(150,170,190,${.02 + r() * .05})`; c.fillRect(r() * BW, hz + 8 + Math.pow(r(), 1.6) * (BH - hz), 40 + r() * 200, 1); }
c.restore();
const fx = BW * .36, fy = hz + 64;
c.save(); c.filter = 'blur(6px)'; c.globalCompositeOperation = 'lighter';
for (let k = 0; k < 7; k++) { c.fillStyle = 'rgba(255,196,120,.35)'; c.fillRect(fx - 52 + k * 17, fy + 16, 7, 90 + r() * 70); }
c.restore();
c.fillStyle = '#04070a';
c.beginPath(); c.moveTo(fx - 170, fy); c.lineTo(fx + 170, fy); c.lineTo(fx + 140, fy + 26); c.lineTo(fx - 150, fy + 26); c.fill();
c.fillRect(fx - 70, fy - 40, 150, 40); c.fillRect(fx - 4, fy - 92, 3, 52);
for (let k = 0; k < 7; k++) { const x = fx - 58 + k * 18; c.fillStyle = '#ffcf94'; c.fillRect(x, fy - 30, 10, 13); glanz(c, x + 5, fy - 24, 34, 'rgba(255,190,110,.8)', .5); }
glanz(c, fx - 2, fy - 94, 18, 'rgba(255,90,70,.9)', .9);
vignette(c, .55);
},
/* 2 · Weinberg, goldene Stunde */
(c, r) => {
const hz = BH * .47, sx = BW * .66;
c.fillStyle = verlauf(c, 0, 0, 0, hz, [[0, '#d98b52'], [.6, '#f3c27f'], [1, '#fde4b4']]); c.fillRect(0, 0, BW, hz);
glanz(c, sx, hz - 30, 520, 'rgba(255,206,150,1)', .8);
glanz(c, sx, hz - 30, 150, 'rgba(255,240,215,1)', .9);
glanz(c, sx, hz - 30, 46, 'rgba(255,252,240,1)', 1);
c.fillStyle = 'rgba(190,120,80,.55)'; c.beginPath(); c.moveTo(0, hz + 6);
for (let x = 0; x <= BW; x += 20) c.lineTo(x, hz - 22 - 18 * Math.sin(x / 300 + 2) - 8 * Math.sin(x / 90));
c.lineTo(BW, hz + 6); c.fill();
c.fillStyle = verlauf(c, 0, hz, 0, BH, [[0, '#8a5531'], [.4, '#4d2c18'], [1, '#22130b']]); c.fillRect(0, hz, BW, BH - hz);
const vx = sx, vy = hz + 4;
for (let i = 0; i < 26; i++) {
const bx = -BW * .9 + i * (BW * 2.8 / 26), w = 18 + Math.abs(bx - vx) * .025;
c.fillStyle = '#211c0d';
c.beginPath(); c.moveTo(vx - 1, vy); c.lineTo(vx + 1, vy); c.lineTo(bx + w, BH); c.lineTo(bx - w, BH); c.fill();
c.save(); c.globalCompositeOperation = 'lighter'; c.strokeStyle = 'rgba(255,170,90,.22)'; c.lineWidth = 2;
c.beginPath(); c.moveTo(vx, vy); c.lineTo(bx + (bx < vx ? w : -w), BH); c.stroke(); c.restore();
}
c.fillStyle = verlauf(c, 0, hz - 40, 0, hz + 260, [[0, 'rgba(253,228,180,.75)'], [1, 'rgba(253,228,180,0)']]); c.fillRect(0, hz - 40, BW, 300);
const px = BW * .45, py = BH * .74;
c.fillStyle = '#160d07';
/* Person im Gegenlicht: Kopf, Schultern, Arme, zwei Beine */
c.beginPath(); c.ellipse(px, py - 136, 9.5, 11.5, 0, 0, 7); c.fill();
c.fillRect(px - 4, py - 126, 8, 8);
c.beginPath();
c.moveTo(px - 20, py - 116); c.quadraticCurveTo(px, py - 122, px + 20, py - 116);
c.lineTo(px + 24, py - 64); c.lineTo(px + 18, py - 62); c.lineTo(px + 15, py - 96);
c.lineTo(px + 14, py - 60); c.lineTo(px + 10, py); c.lineTo(px + 3, py); c.lineTo(px + 1, py - 52);
c.lineTo(px - 1, py - 52); c.lineTo(px - 3, py); c.lineTo(px - 10, py); c.lineTo(px - 14, py - 60);
c.lineTo(px - 15, py - 96); c.lineTo(px - 18, py - 62); c.lineTo(px - 24, py - 64); c.closePath(); c.fill();
c.save(); c.globalCompositeOperation = 'lighter'; c.strokeStyle = 'rgba(255,200,130,.45)'; c.lineWidth = 1.4;
c.beginPath(); c.ellipse(px, py - 136, 9.5, 11.5, 0, -1.3, .5); c.moveTo(px + 20, py - 115); c.lineTo(px + 24, py - 64); c.stroke(); c.restore();
c.fillStyle = 'rgba(22,13,7,.35)'; c.beginPath(); c.moveTo(px - 10, py); c.lineTo(px + 10, py); c.lineTo(px - 140, py + 46); c.lineTo(px - 168, py + 40); c.fill();
vignette(c, .45);
},
/* 3 · Allee, Nebelnacht */
(c, r) => {
const vx = BW * .5, vy = BH * .5;
c.fillStyle = verlauf(c, 0, 0, 0, BH, [[0, '#040708'], [.42, '#121c19'], [.52, '#24302c'], [.6, '#111816'], [1, '#050807']]); c.fillRect(0, 0, BW, BH);
c.fillStyle = verlauf(c, 0, vy, 0, BH, [[0, '#2a3430'], [1, '#141a18']]);
c.beginPath(); c.moveTo(vx - 8, vy); c.lineTo(vx + 8, vy); c.lineTo(BW * .78, BH); c.lineTo(BW * .22, BH); c.fill();
for (let z = 12; z >= 1; z--) {
const s = 1 / z, nebel = Math.min(1, z / 10);
for (const seite of [-1, 1]) {
const x = vx + seite * BW * .46 * s, top = vy - BH * .5 * s, fuss = vy + BH * .55 * s;
c.save(); c.filter = `blur(${2 + 10 * nebel}px)`; c.fillStyle = `rgba(4,7,6,${.95 - .6 * nebel})`;
c.beginPath(); c.ellipse(x + seite * 60 * s, top - 60 * s, 260 * s, 300 * s, 0, 0, 7); c.fill();
c.fillRect(x + seite * 40 * s - 10 * s, top, 20 * s, fuss - top); c.restore();
const lx = vx + seite * BW * .3 * s, ly = vy - BH * .16 * s;
c.fillStyle = `rgba(10,12,11,${1 - nebel * .7})`; c.fillRect(lx - 3 * s, ly, 6 * s, (fuss - ly) * .98);
glanz(c, lx, ly, 260 * s + 30, 'rgba(255,170,80,.9)', .55 - .3 * nebel);
glanz(c, lx, ly, 26 * s + 4, 'rgba(255,230,180,1)', 1 - .6 * nebel);
c.save(); c.filter = 'blur(8px)'; c.globalCompositeOperation = 'lighter'; c.fillStyle = `rgba(255,170,80,${.3 - .2 * nebel})`;
c.fillRect(lx - 14 * s, fuss + 4, 28 * s, 180 * s); c.restore();
}
}
c.fillStyle = verlauf(c, 0, vy - 120, 0, vy + 160, [[0, 'rgba(60,72,68,0)'], [.5, 'rgba(70,82,77,.35)'], [1, 'rgba(60,72,68,0)']]); c.fillRect(0, vy - 120, BW, 280);
vignette(c, .7);
},
/* 4 · Tankstelle, Nacht */
(c, r) => {
const hz = BH * .6;
c.fillStyle = verlauf(c, 0, 0, 0, hz, [[0, '#03070b'], [1, '#0d1d26']]); c.fillRect(0, 0, BW, hz);
c.fillStyle = verlauf(c, 0, hz, 0, BH, [[0, '#0a1114'], [1, '#030506']]); c.fillRect(0, hz, BW, BH - hz);
c.fillStyle = '#020405'; c.beginPath(); c.moveTo(0, hz);
for (let x = 0; x <= BW; x += 14) c.lineTo(x, hz - 30 - 40 * Math.abs(Math.sin(x / 70)) * (.4 + .6 * Math.abs(Math.sin(x / 400))));
c.lineTo(BW, hz); c.fill();
const x0 = BW * .3, x1 = BW * .76, y0 = BH * .3, y1 = BH * .36;
c.save(); c.globalCompositeOperation = 'lighter'; c.filter = 'blur(18px)';
c.fillStyle = verlauf(c, 0, y1, 0, BH * .78, [[0, 'rgba(120,230,210,.34)'], [1, 'rgba(120,230,210,0)']]);
c.beginPath(); c.moveTo(x0 + 20, y1); c.lineTo(x1 - 20, y1); c.lineTo(x1 + 120, BH * .8); c.lineTo(x0 - 120, BH * .8); c.fill(); c.restore();
c.fillStyle = '#0b1a1e'; c.fillRect(x0, y0, x1 - x0, y1 - y0);
c.fillStyle = '#dffff8'; c.fillRect(x0 + 16, y1 - 6, x1 - x0 - 32, 5);
glanz(c, (x0 + x1) / 2, y1, 520, 'rgba(110,230,205,.8)', .5);
c.fillStyle = '#071012'; c.fillRect(x0 + 110, y1, 18, BH * .7 - y1); c.fillRect(x1 - 128, y1, 18, BH * .7 - y1);
for (const px of [x0 + 220, x1 - 250]) {
c.fillStyle = '#0c1416'; c.fillRect(px, BH * .55, 60, BH * .15);
c.fillStyle = '#9ff5e3'; c.fillRect(px + 14, BH * .57, 32, 14); glanz(c, px + 30, BH * .575, 40, 'rgba(140,250,230,.8)', .5);
}
c.save(); c.globalCompositeOperation = 'lighter'; c.filter = 'blur(14px)';
c.fillStyle = 'rgba(120,230,210,.2)'; c.fillRect(x0 + 40, BH * .72, x1 - x0 - 80, 200);
c.fillStyle = 'rgba(255,60,60,.25)'; c.fillRect(BW * .86 - 10, BH * .72, 40, 190); c.restore();
c.fillStyle = '#0a0d0e'; c.fillRect(BW * .86 + 6, BH * .32, 6, BH * .38);
c.fillStyle = '#ff4646'; c.fillRect(BW * .86 - 26, BH * .24, 70, 54); glanz(c, BW * .86 + 9, BH * .27, 150, 'rgba(255,70,70,.9)', .55);
c.fillStyle = '#2a0606'; for (let k = 0; k < 3; k++) c.fillRect(BW * .86 - 18, BH * .25 + k * 15 + 4, 54, 3);
c.save(); c.globalCompositeOperation = 'lighter'; c.strokeStyle = 'rgba(190,255,240,.09)'; c.lineWidth = 1;
for (let i = 0; i < 260; i++) { const x = x0 - 80 + r() * (x1 - x0 + 160), y = y1 + r() * (BH * .45); c.beginPath(); c.moveTo(x, y); c.lineTo(x - 6, y + 26); c.stroke(); }
c.restore();
vignette(c, .6);
},
/* 5 · Küche am Morgen */
(c, r) => {
c.fillStyle = verlauf(c, 0, 0, BW, 0, [[0, '#3a281c'], [.5, '#2c1e15'], [1, '#160f0a']]); c.fillRect(0, 0, BW, BH);
const wx = BW * .27, wy = BH * .14, ww = BW * .2, wh = BH * .5;
glanz(c, wx + ww / 2, wy + wh / 2, 620, 'rgba(255,214,160,.8)', .45);
c.save(); c.globalCompositeOperation = 'lighter'; c.filter = 'blur(26px)';
c.fillStyle = 'rgba(255,214,160,.16)';
c.beginPath(); c.moveTo(wx + ww, wy); c.lineTo(wx + ww, wy + wh); c.lineTo(BW * .92, BH * .9); c.lineTo(BW * .98, BH * .42); c.fill(); c.restore();
c.fillStyle = '#fff3dc'; c.fillRect(wx, wy, ww, wh);
c.fillStyle = verlauf(c, wx, wy, wx + ww, wy + wh, [[0, 'rgba(210,225,220,.0)'], [1, 'rgba(255,200,140,.35)']]); c.fillRect(wx, wy, ww, wh);
c.fillStyle = '#2a1c13'; c.fillRect(wx + ww / 2 - 7, wy, 14, wh); c.fillRect(wx, wy + wh * .42 - 7, ww, 14);
c.lineWidth = 18; c.strokeStyle = '#2a1c13'; c.strokeRect(wx, wy, ww, wh);
const ty = BH * .72;
c.fillStyle = verlauf(c, 0, ty, 0, BH, [[0, '#5a3a26'], [1, '#24170f']]); c.fillRect(0, ty, BW, BH - ty);
c.fillStyle = '#1a110b'; c.fillRect(0, ty - 4, BW, 6);
c.save(); c.filter = 'blur(10px)'; c.globalCompositeOperation = 'lighter';
c.fillStyle = 'rgba(255,196,130,.55)';
c.beginPath(); c.moveTo(BW * .5, ty + 6); c.lineTo(BW * .72, ty + 6); c.lineTo(BW * .9, BH * .95); c.lineTo(BW * .62, BH * .95); c.fill();
c.fillStyle = 'rgba(0,0,0,.6)'; c.globalCompositeOperation = 'source-over'; c.fillRect(BW * .6, ty + 6, 12, BH * .25); c.fillRect(BW * .52, BH * .82, BW * .3, 12);
c.restore();
const tx = BW * .63;
c.fillStyle = '#ece3d4'; c.beginPath(); c.moveTo(tx - 42, ty - 74); c.lineTo(tx + 42, ty - 74); c.lineTo(tx + 36, ty - 2); c.lineTo(tx - 36, ty - 2); c.fill();
c.fillStyle = verlauf(c, tx - 42, 0, tx + 42, 0, [[0, 'rgba(255,240,215,.0)'], [.4, 'rgba(0,0,0,0)'], [1, 'rgba(40,25,15,.75)']]); c.fill();
c.lineWidth = 9; c.strokeStyle = '#d8ccba'; c.beginPath(); c.arc(tx + 46, ty - 44, 18, -1.3, 1.3); c.stroke();
c.save(); c.globalCompositeOperation = 'lighter';
for (let i = 0; i < 220; i++) { const t = r(), x = wx + ww + t * (BW * .7 - wx - ww), y = wy + t * BH * .35 + r() * (wh + t * 120); c.fillStyle = `rgba(255,230,190,${.15 + r() * .35})`; c.fillRect(x, y, 1.6, 1.6); }
c.restore();
vignette(c, .55);
},
];
function standbild(i) {
const cv = document.createElement('canvas'); cv.width = BW; cv.height = BH;
const c = cv.getContext('2d'), r = zufall(17 + i * 101);
MALER[i](c, r); dither(c, r);
return cv;
}
const bilder = MALER.map((_, i) => standbild(i));
imgs.forEach((img, i) => { img.src = bilder[i].toDataURL('image/jpeg', .9); });
/* ---------- Shader: globale Zeit linear, Kurve pro Pixel ---------- */
const vertex = /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const fragment = /* glsl */`
precision highp float;
varying vec2 vUv;
uniform sampler2D uFrom, uTo;
uniform vec2 uRes, uImg, uPunkt;
uniform float uProgress, uMode, uSpread, uFlight, uBands, uScale, uSeed, uStreu;
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), 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 + vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 4; i++) { v += a * noise(p); p = p * 2.03 + 17.1; a *= 0.5; } return v; }
vec2 cover(vec2 uv) {
float rs = uRes.x / uRes.y, ri = uImg.x / uImg.y;
vec2 s = rs < ri ? vec2(rs / ri, 1.0) : vec2(1.0, ri / rs);
return (uv - 0.5) * s + 0.5;
}
void main() {
vec2 uv = cover(vUv);
vec3 von = texture2D(uFrom, uv).rgb, nach = texture2D(uTo, uv).rgb;
vec2 asp = vec2(uRes.x / uRes.y, 1.0);
float offset = 0.0;
if (uMode < 0.5) {
/* Helligkeitsbänder: helle Bildteile zuerst, in uBands Stufen */
/* Helligkeit leicht gemittelt, damit die Bandkanten nicht flimmern */
vec2 px = 3.0 / uRes;
vec3 m = von * 0.4 + 0.15 * (texture2D(uFrom, cover(vUv + vec2(px.x, 0.0))).rgb + texture2D(uFrom, cover(vUv - vec2(px.x, 0.0))).rgb
+ texture2D(uFrom, cover(vUv + vec2(0.0, px.y))).rgb + texture2D(uFrom, cover(vUv - vec2(0.0, px.y))).rgb);
float l = dot(m, vec3(0.2126, 0.7152, 0.0722));
offset = 1.0 - floor(clamp(l * 1.25, 0.0, 0.999) * uBands) / (uBands - 1.0);
} else if (uMode < 1.5) {
/* Rauschmaske */
offset = smoothstep(0.15, 0.85, fbm((vUv - 0.5) * asp * uScale + uSeed));
} else if (uMode < 2.5) {
/* Radial ab Klickpunkt, Rand leicht unruhig */
vec2 d = (vUv - uPunkt) * asp;
vec2 ecke = max(uPunkt, 1.0 - uPunkt) * asp;
offset = clamp(length(d) / length(ecke) + (fbm(vUv * asp * 6.0 + uSeed) - 0.5) * uStreu, 0.0, 1.0);
}
/* Kern aus dem Codrops-Muster: local = clamp((p − offset · SPREAD) / FLIGHT), dann smoothstep */
float local = clamp((uProgress - offset * uSpread) / uFlight, 0.0, 1.0);
local = smoothstep(0.0, 1.0, local);
gl_FragColor = vec4(mix(von, nach, local), 1.0);
}`;
/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null, szene, kamera, material;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false }); } catch (e) { renderer = null; }
}
const texturen = bilder.map(b => { const t = new THREE.CanvasTexture(b); t.minFilter = THREE.LinearFilter; t.generateMipmaps = false; t.colorSpace = THREE.NoColorSpace; return t; });
if (renderer) {
renderer.setClearColor(0x1a1a1a, 1);
szene = new THREE.Scene(); kamera = new THREE.Camera();
material = new THREE.ShaderMaterial({
vertexShader: vertex, fragmentShader: fragment, depthTest: false, depthWrite: false,
uniforms: {
uFrom: { value: texturen[0] }, uTo: { value: texturen[1] },
uRes: { value: new THREE.Vector2(1, 1) }, uImg: { value: new THREE.Vector2(BW, BH) }, uPunkt: { value: new THREE.Vector2(.5, .5) },
uProgress: { value: 0 }, uMode: { value: 0 }, uSpread: { value: W.spread }, uFlight: { value: W.flight },
uBands: { value: W.baender }, uScale: { value: W.rauschSkala }, uSeed: { value: 0 }, uStreu: { value: W.ringStreuung },
},
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material); quad.frustumCulled = false; szene.add(quad);
canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); renderer = null; html.classList.remove('gl'); html.classList.add('ohne-gl'); malen(); });
html.classList.add('gl');
} else {
html.classList.add('ohne-gl');
}
const U = () => material.uniforms;
function groesse() {
if (!renderer) return;
/* Touch: niedrigere Render-Auflösung */
renderer.setPixelRatio(beruehrung ? Math.max(1, Math.min(devicePixelRatio || 1, 2) * .6) : Math.min(devicePixelRatio || 1, 2));
renderer.setSize(rahmen.clientWidth, rahmen.clientHeight, false);
const v = new THREE.Vector2(); renderer.getDrawingBufferSize(v); U().uRes.value.copy(v);
malen();
}
new ResizeObserver(groesse).observe(rahmen);
/* ---------- Zustand: von → nach, p linear ---------- */
let von = 0, nach = 1, richtung = 1;
const z = { p: 0 };
let tween = null, halte = null, diashow = !reduziert(), angezeigt = 0; /* reduziert: keine automatische Diashow */
function malen() {
const p = z.p;
if (renderer) {
const u = U();
u.uFrom.value = texturen[von]; u.uTo.value = texturen[nach]; u.uProgress.value = p;
renderer.render(szene, kamera);
} else {
/* Ersatz ohne WebGL: einfache Überblendung, gleiche Zeit */
imgs.forEach((im, i) => { im.classList.toggle('an', i === von); im.style.opacity = i === nach ? p : (i === von ? 1 : 0); });
}
pEl.textContent = p.toFixed(2);
schieber.value = Math.round(p * 1000); schieber.style.setProperty('--p', p.toFixed(3));
kurvenPunkte(p);
/* Titel und Zähler folgen der Bildmitte: ab p = 0,5 gilt das neue Bild */
const soll = p >= .5 ? nach : von;
if (soll !== angezeigt) texteWechseln(soll);
}
/* ---------- Kurvenschreiber: global linear (gestrichelt), lokal smoothstep für drei Pixel ---------- */
const KW = 132, KH = 62;
const lokal = (p, off, sp = W.spread, fl = W.flight) => { const t = Math.min(1, Math.max(0, (p - off * sp) / fl)); return t * t * (3 - 2 * t); };
const kPfade = $$('[data-k]'), kPunkte = $$('[data-d]'), kZeiger = $('[data-zeiger]');
function kurvenZeichnen() {
const sp = reduziert() ? 0 : W.spread, fl = reduziert() ? 1 : W.flight;
kPfade.forEach((pf, i) => {
let d = '';
for (let k = 0; k <= 66; k++) { const p = k / 66; d += (k ? 'L' : 'M') + (p * KW).toFixed(1) + ' ' + (KH - lokal(p, i / 2, sp, fl) * KH).toFixed(1); }
pf.setAttribute('d', d);
});
kurvenPunkte(z.p);
}
function kurvenPunkte(p) {
const sp = reduziert() ? 0 : W.spread, fl = reduziert() ? 1 : W.flight;
kPunkte.forEach((c, i) => { c.setAttribute('cx', (p * KW).toFixed(1)); c.setAttribute('cy', (KH - lokal(p, i / 2, sp, fl) * KH).toFixed(1)); });
kZeiger.setAttribute('x1', (p * KW).toFixed(1)); kZeiger.setAttribute('x2', (p * KW).toFixed(1));
}
/* ---------- Texte: Überblendung 500 ms sine.inOut, nur Opazität ---------- */
let textUhr = null;
function texteWechseln(i) {
angezeigt = i;
const f = FILME[i];
ansage.textContent = `Bild ${i + 1} von ${FILME.length}: ${f.titel.replace(/<[^>]+>/g, '')}`;
clearTimeout(textUhr);
const setzen = () => { titelEl.innerHTML = f.titel; unterEl.textContent = f.zeile; nrEl.textContent = String(i + 1).padStart(2, '0'); };
if (reduziert()) { setzen(); return; }
titelEl.classList.add('weg'); unterEl.classList.add('weg');
textUhr = setTimeout(() => { setzen(); titelEl.classList.remove('weg'); unterEl.classList.remove('weg'); }, 500);
}
/* ---------- Übergang: GSAP treibt uProgress linear (ease 'none') ---------- */
function ziel(dir) { return (von + dir + FILME.length) % FILME.length; }
function vorbereiten(dir, punkt) {
richtung = dir; nach = ziel(dir);
if (renderer) {
const u = U();
u.uMode.value = reduziert() ? 3 : MODI[modus];
u.uSpread.value = reduziert() ? 0 : W.spread; u.uFlight.value = reduziert() ? 1 : W.flight;
u.uSeed.value = Math.random() * 40;
if (punkt) u.uPunkt.value.set(punkt.x, punkt.y); else u.uPunkt.value.set(.5, .5);
}
}
function abschliessen() {
von = nach; z.p = 0; tween = null;
vorbereiten(richtung); malen();
if (diashow) halteDann();
}
function spielen(bis = 1) {
if (tween) tween.kill();
const ms = reduziert() ? W.reduziertMs : dauer;
const rest = Math.abs(bis - z.p);
if (rest < .001) { if (bis === 1) abschliessen(); return; }
tween = gsap.to(z, { p: bis, duration: rest * ms / 1000, ease: 'none', onUpdate: malen, onComplete: () => { tween = null; if (bis === 1) abschliessen(); else if (diashow) halteDann(); } });
}
function wechsel(dir, punkt) {
clearTimeout(halte);
if (tween && z.p > 0) {
/* Läuft schon: zu Ende spielen, dann weiter */
tween.eventCallback('onComplete', () => { tween = null; abschliessen(); wechsel(dir, punkt); });
return;
}
if (dir !== richtung || z.p === 0) vorbereiten(dir, punkt);
if (punkt && renderer) U().uPunkt.value.set(punkt.x, punkt.y);
spielen(1);
}
function halteDann() {
clearTimeout(halte);
/* In der Vorführung kürzer halten und jede Runde die Versatz-Art wechseln */
halte = html.classList.contains('vorfuehrung') ? setTimeout(vorfuehrSchritt, 600) : setTimeout(() => wechsel(1), W.halteMs);
}
/* ---------- Bedienung ---------- */
function anhalten() { diashow = false; clearTimeout(halte); spielKnopfSetzen(); }
function spielKnopfSetzen() {
spielKnopf.setAttribute('aria-pressed', diashow ? 'true' : 'false');
spielKnopf.setAttribute('aria-label', diashow ? 'Diashow anhalten' : 'Diashow starten');
}
spielKnopf.addEventListener('click', () => { stopVorfuehrung(); diashow = !diashow; spielKnopfSetzen(); if (diashow) halteDann(); else clearTimeout(halte); });
$('[data-vor]').addEventListener('click', () => { stopVorfuehrung(); anhalten(); wechsel(1); });
$('[data-zurueck]').addEventListener('click', () => { stopVorfuehrung(); anhalten(); wechsel(-1); });
function punktAus(e) { const r = rahmen.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: 1 - (e.clientY - r.top) / r.height, px: e.clientX - r.left, py: e.clientY - r.top }; }
function ringZeigen(pk) {
if (reduziert()) return;
ring.style.transform = `translate(${pk.px}px, ${pk.py}px)`;
ring.animate([{ opacity: .9, scale: .4 }, { opacity: 0, scale: 1.6 }], { duration: 700, easing: 'cubic-bezier(0.37, 0, 0.63, 1)' });
}
let druck = null;
rahmen.addEventListener('pointerdown', e => { druck = { x: e.clientX, y: e.clientY }; });
rahmen.addEventListener('pointerup', e => {
if (!druck) return;
const dx = e.clientX - druck.x, dy = e.clientY - druck.y; druck = null;
if (Math.abs(dy) > 40 && Math.abs(dy) > Math.abs(dx)) return;
stopVorfuehrung(); anhalten();
const pk = punktAus(e); ringZeigen(pk);
if (dx > 40) wechsel(-1, pk); else wechsel(1, pk);
});
rahmen.addEventListener('pointercancel', () => { druck = null; });
rahmen.addEventListener('keydown', e => {
if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); stopVorfuehrung(); anhalten(); wechsel(1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); stopVorfuehrung(); anhalten(); wechsel(-1); }
});
/* Scrubben: die lineare Zeit lässt sich direkt setzen und umkehren */
schieber.addEventListener('input', () => {
stopVorfuehrung(); anhalten();
if (tween) { tween.kill(); tween = null; }
if (z.p === 0) vorbereiten(1);
z.p = schieber.value / 1000; malen();
});
schieber.addEventListener('change', () => { if (z.p >= .5) spielen(1); else spielen(0); });
/* Varianten und Dauer */
const modusKnoepfe = $$('[data-modus]'), dauerKnoepfe = $$('[data-dauer]');
function werteZeigen() {
modusKnoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.modus === modus ? 'true' : 'false'));
dauerKnoepfe.forEach(b => b.setAttribute('aria-pressed', +b.dataset.dauer === dauer ? 'true' : 'false'));
const versatz = { baender: `Helligkeit, BANDS <b>${W.baender}</b>`, rauschen: `fbm × <b>${W.rauschSkala}</b>`, radial: `Abstand zum Klickpunkt ± <b>${W.ringStreuung}</b>` }[modus];
werteEl.innerHTML = reduziert()
? `Reduziert: Überblendung <b>${W.reduziertMs} ms</b>, ohne Versatz`
: `progress <b>${dauer} ms</b> linear · SPREAD <b>${W.spread}</b> · FLIGHT <b>${W.flight}</b> · lokal smoothstep · Versatz ${versatz} · Text 500 ms sine.inOut`;
const l = LEGENDE[modus];
$$('[data-legende] span').forEach((s, i) => { s.textContent = l[i]; });
kurvenZeichnen();
}
modusKnoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); modus = b.dataset.modus; werteZeigen(); anhalten(); if (!tween) { vorbereiten(1); wechsel(1); } }));
dauerKnoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); dauer = +b.dataset.dauer; werteZeigen(); }));
let warReduziert = reduziert();
new MutationObserver(() => { if (reduziert() === warReduziert) return; warReduziert = reduziert(); werteZeigen(); if (!tween) { vorbereiten(richtung); malen(); } }).observe(html, { attributes: true, attributeFilter: ['class'] });
/* ---------- Vorführung: wechselt selbst, je Bild eine andere Versatz-Art, Radial mit gedachtem Klick ---------- */
let runde = 0;
function stopVorfuehrung() { if (html.classList.contains('vorfuehrung')) { html.classList.remove('vorfuehrung'); } }
function vorfuehrSchritt() {
const folge = ['baender', 'rauschen', 'radial'];
modus = folge[runde++ % 3]; werteZeigen();
vorbereiten(1);
if (modus === 'radial') {
const r = rahmen.getBoundingClientRect(), x = .25 + Math.random() * .5, y = .3 + Math.random() * .4;
const pk = { x, y, px: x * r.width, py: (1 - y) * r.height };
ringZeigen(pk); wechsel(1, pk);
} else wechsel(1);
}
function vorfuehren() {
pause(); html.classList.add('vorfuehrung'); diashow = true; spielKnopfSetzen();
halte = setTimeout(vorfuehrSchritt, 500);
}
function pause() { clearTimeout(halte); if (tween) tween.pause(); }
function weiter() { if (tween && tween.paused()) tween.resume(); else if (diashow) halteDann(); }
function neustart() {
pause(); if (tween) { tween.kill(); tween = null; }
html.classList.remove('vorfuehrung');
von = 0; z.p = 0; vorbereiten(1); angezeigt = -1; malen(); diashow = !reduziert(); spielKnopfSetzen(); if (diashow) halteDann();
}
werteZeigen(); groesse(); angezeigt = -1; malen(); spielKnopfSetzen(); if (diashow) halteDann();
</script>
</body>
</html>