Bildsequenz-/Video-Scrub
Ein Architekturmodell dreht sich Frame für Frame mit dem Scroll, das Dach hebt ab: 120 vorgeladene WebP-Bilder im gepinnten Canvas.
Eine Section wird gepinnt, darin liegt ein Canvas in Vollbild. Eine vorgerenderte Bildfolge wird vorgeladen, typisch 60 bis 200 Frames: ein Produkt dreht sich, zerlegt sich oder die Kamera fährt. Der Scroll-Fortschritt von 0 bis 1 bestimmt den Frame-Index, der passende Frame wird ins Canvas gezeichnet. Begleitende Texte blenden an festen Fortschrittspunkten ein. Alternativ koppelt man die currentTime eines Videos an den Scroll.
Der Nutzer steuert einen Film mit dem Scrollrad. Das Ergebnis hat die Qualität eines Renderings, fühlt sich aber direkt an. Das Produkt wirkt greifbar, ohne dass im Browser 3D in Echtzeit läuft.
Wann einsetzen
Produktseiten für Hardware, Möbel oder Mode, Architekturvisualisierung, Launch-Seiten.
Wann nicht
Wenn die Zielgruppe oft mobil ohne WLAN unterwegs ist. Wenn kein hochwertiges Ausgangsmaterial da ist. Auf Seiten mit SEO-Fokus, wenn der Inhalt nur im Bild steckt und begleitender Text fehlt.
Varianten
- Canvas mit Bildsequenz (WebP oder AVIF), wie auf Apple-Produktseiten
- Video-Scrub mit dichtem Keyframe-Encoding
- Start- und Endframe, dazwischen eine kurze Animation, die ab einer Schwelle abspielt
- Sprite-Sheet für kurze Sequenzen: eine Datei, wenige Frames
Umsetzung
Frames als nummerierte AVIF- oder WebP-Dateien vorladen, je etwa 30 bis 80 kB. Die ersten 10 sofort laden, den Rest progressiv. Muster: const obj = { f: 0 }; gsap.to(obj, { f: N - 1, snap: 'f', ease: 'none', scrollTrigger: { trigger: section, pin: true, scrub: 0.5, end: '+=300%' }, onUpdate: () => draw(images[obj.f]) }). draw() zeichnet mit drawImage im object-fit-cover-Verfahren und berücksichtigt devicePixelRatio, höchstens 2. Fallstricke: Viele 4K-Frames kosten viel Speicher, mobil daher halbe Auflösung und nur jeden 2. Frame. Video-Scrub ruckelt mit normalem H.264, weil die Keyframes zu weit auseinanderliegen; mit -g 1 oder kurzer Keyframe-Distanz encodieren. Safari decodiert Sprünge in currentTime langsamer.
Robust umsetzen
Telefon. Weniger Frames und kleinere Auflösung, im Hochformat eine eigene Sequenz. Bei Save-Data oder langsamer Verbindung (navigator.connection) ein Standbild oder ein kurzes Loop-Video zeigen.
Ohne Bewegung. Schlüsselframes als statische Bildfolge, mit Text untereinander.
Barrierefrei. Canvas mit role="img" und aria-label oder einer versteckten Textbeschreibung. Alle begleitenden Texte stehen im DOM, nicht im Bild.
Code
<!DOCTYPE html>
<!--
Bildsequenz-/Video-Scrub
Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/bildsequenz-video-scrub/
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>Bildsequenz-/Video-Scrub · 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-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
:root {
--grund: #12110f; --text: #f1ece2; --leise: rgba(241, 236, 226, .58); --linie: rgba(241, 236, 226, .16);
--akzent: #d9b98c;
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, nur für Texteinblendungen */
}
* { 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; }
body { background: var(--grund); overflow-x: hidden; }
.mono { font: 500 11px/1.45 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
/* Bühne: gepinnt, Canvas in Vollbild */
.buehne { position: relative; height: 100svh; overflow: hidden; background: var(--grund); }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.buehne::after { content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(to top, rgba(18,17,15,.92) 0%, rgba(18,17,15,.55) 22%, rgba(18,17,15,0) 46%),
linear-gradient(to bottom, rgba(18,17,15,.6) 0%, rgba(18,17,15,0) 16%); }
.schicht { position: absolute; inset: 0; z-index: 1; display: grid; grid-template-rows: auto 1fr auto; padding: clamp(18px, 3vw, 40px); pointer-events: none; }
.oben { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.oben b { color: var(--text); font-weight: 500; }
.steuer { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; justify-content: flex-end; pointer-events: auto; }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: rgba(18,17,15,.35); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: background-color 150ms linear, color 150ms linear, border-color 150ms linear; backdrop-filter: blur(6px); }
.modi button:hover { color: var(--text); border-color: rgba(241,236,226,.4); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.lader { font-variant-numeric: tabular-nums; }
.lader i { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--akzent); margin-right: 8px; vertical-align: 1px; }
.lader.fertig i { background: #9fc39a; }
.unten { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 32px; align-items: end; }
.texte { display: grid; }
.texte > * { grid-area: 1 / 1; }
.intro h1 { font-size: clamp(42px, 6.6vw, 108px); font-weight: 600; letter-spacing: -.045em; line-height: .92; max-width: 9.5ch; }
.intro h1 em { letter-spacing: -.02em; color: var(--akzent); }
.intro p { margin-top: 22px; }
.kapitel { opacity: 0; translate: 0 18px; transition: opacity .8s var(--kurve), translate .8s var(--kurve); }
.kapitel.an { opacity: 1; translate: 0 0; }
.kapitel h2 { font-size: clamp(30px, 3.6vw, 56px); font-weight: 600; letter-spacing: -.035em; line-height: 1; margin: 12px 0 14px; }
.kapitel h2 em { color: var(--akzent); }
.kapitel p:last-child { font-size: clamp(15px, 1.15vw, 17px); line-height: 1.5; color: var(--leise); max-width: 36ch; }
.werte { border-top: 1px solid var(--linie); padding-top: 14px; display: grid; gap: 12px; }
.zaehler { display: flex; justify-content: space-between; align-items: baseline; }
.zaehler strong { font: 400 30px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: -.02em; color: var(--text); font-variant-numeric: tabular-nums; text-transform: none; }
.zaehler strong span { color: var(--leise); font-size: 14px; letter-spacing: 0; }
.balken { height: 2px; background: var(--linie); position: relative; overflow: hidden; }
.balken span { position: absolute; inset: 0; background: var(--akzent); transform-origin: 0 50%; transform: scaleX(0); }
.werte dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; }
.werte dt { color: rgba(241,236,226,.38); }
.werte dd { color: var(--text); text-align: right; }
/* Abspann */
.abspann { padding: clamp(72px, 12vh, 140px) clamp(20px, 4vw, 48px) clamp(64px, 10vh, 120px); display: grid; gap: clamp(48px, 8vh, 88px); }
.abspann h2 { font-size: clamp(32px, 4.6vw, 72px); font-weight: 600; letter-spacing: -.04em; line-height: .98; max-width: 15ch; }
.abspann h2 em { color: var(--akzent); }
.fakten { letter-spacing: .14em; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; border-top: 1px solid var(--linie); padding-top: 22px; }
.fakten strong { display: block; font-family: 'Instrument Sans', system-ui, sans-serif; text-transform: none; color: var(--text); font-size: clamp(26px, 2.6vw, 40px); font-weight: 500; letter-spacing: -.03em; margin-top: 10px; }
.schluessel { display: none; gap: 40px; }
.schluessel figure { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 32px; align-items: end; }
.schluessel img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; display: block; background: #1c1a17; border-radius: 6px; outline: 1px solid var(--linie); outline-offset: -1px; }
.schluessel h3 { font-size: clamp(26px, 2.8vw, 40px); font-weight: 600; letter-spacing: -.03em; margin: 10px 0 12px; }
.schluessel p:last-child { color: var(--leise); line-height: 1.5; max-width: 36ch; }
/* Reduzierte Bewegung: kein Pin, kein Scrub. Erstes Bild steht, Schlüsselframes folgen untereinander. */
.reduziert .buehne { height: 82svh; }
.reduziert .kapitel, .reduziert .werte, .reduziert .modi { display: none; }
.reduziert .schluessel { display: grid; }
.reduziert .schluessel figure { opacity: 0; transition: opacity .25s linear; }
.reduziert .schluessel figure.sichtbar { opacity: 1; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
@media (max-width: 760px) {
.unten { grid-template-columns: minmax(0, 1fr); gap: 18px; }
.werte dl { display: none; }
.zaehler strong { font-size: 22px; }
.modi button { height: 28px; padding: 0 10px; }
.oben { gap: 10px; }
.steuer { justify-content: flex-start; }
.fakten { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 28px; }
.schluessel figure { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.schluessel img { aspect-ratio: 4 / 5; }
}
@media (hover: none) { .modi button:hover { color: var(--leise); border-color: var(--linie); } .modi button[aria-pressed='true'] { color: var(--grund); } }
</style>
</head>
<body>
<section class="buehne" data-buehne>
<canvas data-leinwand role="img" aria-label="Architekturmodell des Pavillons Seewinkel. Beim Scrollen dreht sich das Modell, das Dach hebt ab und gibt den Innenraum frei."></canvas>
<div class="schicht">
<div class="oben mono">
<p><b>Atelier Nordufer</b> · Pavillon Seewinkel</p>
<div class="steuer">
<p class="lader" data-lader aria-live="polite"><i></i>Frames 000 / 120</p>
<div class="modi" role="group" aria-label="Variante">
<button type="button" data-variante="sequenz" aria-pressed="true">Bildsequenz</button>
<button type="button" data-variante="sprite" aria-pressed="false">Sprite-Sheet</button>
<button type="button" data-variante="startende" aria-pressed="false">Start/Ende</button>
</div>
</div>
</div>
<div></div>
<div class="unten">
<div class="texte">
<div class="intro" data-intro>
<h1>Ein Haus am See, <em>Bild für Bild.</em></h1>
<p class="mono">Scrollen dreht das Modell ↓</p>
</div>
<article class="kapitel" data-von="0.1" data-bis="0.36">
<p class="mono">01 / 03 · Baukörper</p>
<h2>Sockel, Glas, <em>Lärche.</em></h2>
<p>96 m² auf einem Sockel aus Sichtbeton. Zum Wasser hin ist der Raum ganz verglast, die Rückwand schützt vor dem Westwind.</p>
</article>
<article class="kapitel" data-von="0.36" data-bis="0.68">
<p class="mono">02 / 03 · Dach</p>
<h2>Das Dach <em>schwebt.</em></h2>
<p>Eine 35 cm starke Platte aus Brettschichtholz, getragen von schlanken Stützen. Für den Blick hinein heben wir sie ab.</p>
</article>
<article class="kapitel" data-von="0.68" data-bis="1.01">
<p class="mono">03 / 03 · Innenraum</p>
<h2>Ein Tisch <em>für zwölf.</em></h2>
<p>Küche an der Rückwand, Bänke zu beiden Seiten, der Steg führt direkt aufs Wasser. Mehr braucht ein Sommerabend nicht.</p>
</article>
</div>
<div class="werte mono" aria-hidden="true">
<div class="zaehler"><span>Frame</span><strong data-frame>001 <span>/ 120</span></strong></div>
<div class="balken"><span data-balken></span></div>
<dl data-werte></dl>
</div>
</div>
</div>
</section>
<section class="abspann">
<div class="schluessel" data-schluessel>
<figure><img alt="Modell von vorn: Sockel, Glasfront und Rückwand" data-schluesselbild="0"><figcaption><p class="mono">01 / 03 · Baukörper</p><h3>Sockel, Glas, Lärche.</h3><p>96 m² auf einem Sockel aus Sichtbeton. Zum Wasser hin ist der Raum ganz verglast.</p></figcaption></figure>
<figure><img alt="Modell mit abgehobenem Dach" data-schluesselbild="1"><figcaption><p class="mono">02 / 03 · Dach</p><h3>Das Dach schwebt.</h3><p>Eine 35 cm starke Platte aus Brettschichtholz, getragen von schlanken Stützen.</p></figcaption></figure>
<figure><img alt="Blick von oben in den Innenraum mit langem Tisch" data-schluesselbild="2"><figcaption><p class="mono">03 / 03 · Innenraum</p><h3>Ein Tisch für zwölf.</h3><p>Küche an der Rückwand, Bänke zu beiden Seiten, der Steg führt direkt aufs Wasser.</p></figcaption></figure>
</div>
<h2>Ein Raum, der sich erst <em>beim Herumgehen</em> erklärt.</h2>
<div class="fakten mono">
<p>Nutzfläche<strong>96 m²</strong></p>
<p>Material<strong>Lärche, Glas</strong></p>
<p>Plätze<strong>12</strong></p>
<p>Fertig<strong>Mai 2027</strong></p>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const buehne = $('[data-buehne]'), leinwand = $('[data-leinwand]'), lc = leinwand.getContext('2d');
const hoch = innerWidth / innerHeight < 0.9; // Hochformat: eigene, leichtere Sequenz
const sparen = !!(navigator.connection && navigator.connection.saveData);
const N = sparen ? 24 : hoch ? 60 : 120; // Mobil jeder 2. Frame
const FW = hoch ? 600 : 1440, FH = hoch ? 1200 : 900; // Frame-Auflösung
const SPRITE = { n: 24, spalten: 6, zeilen: 4, w: hoch ? 240 : 480, h: hoch ? 480 : 300 };
const SCRUB = 0.5, PIN = '+=300%', CLIP_S = 1.2, DPR = Math.min(devicePixelRatio || 1, 2);
/* ---------- 1. Das „Rendering“: ein Architekturmodell, Frame für Frame vorgerechnet ---------- */
// Stellvertretend für eine echte, vorgerenderte Sequenz (z. B. aus Blender): Die Frames entstehen
// einmal beim Laden als WebP-Dateien und werden danach nur noch gezeichnet, nie neu berechnet.
const L = (() => { const v = [-0.5, 0.82, 0.36], l = Math.hypot(...v); return v.map(x => x / l); })();
const ton = { brett: '#2a2723', ton: '#ebe5d9', beton: '#b3ac9f', holz: '#c39c72', dunkel: '#6f685e', glas: 'glas' };
const B = (x0, y0, z0, x1, y1, z1, m, opt = {}) => ({ a: [x0, y0, z0], b: [x1, y1, z1], m, ...opt });
const modell = [
B(-11, -0.9, -7.5, 11, 0, 7.5, 'brett', { ebene: 0, schatten: false }),
B(-6, 0, -4, 6, 0.42, 4.2, 'beton', { ebene: 1, schatten: false }),
B(-1.6, 0, 4.2, 1.6, 0.21, 4.9, 'beton', { ebene: 1, schatten: false }),
B(-0.75, 0.12, 4.9, 0.75, 0.26, 7.5, 'holz', { ebene: 1, schatten: false }),
B(-5, 0.42, -3.2, 5, 0.52, 3.2, 'holz', { ebene: 1, schatten: false }),
B(-5, 0.52, -3.2, 1.2, 3.3, -2.9, 'ton', { sockel: 1 }), // Rückwand
B(4.7, 0.52, -3.2, 5, 3.3, 0.4, 'ton', { sockel: 1, weg: [1.4, 0, 0] }), // Seitenwand
B(-4.4, 0.52, -2.88, -1.4, 1.42, -2.25, 'ton', { sockel: 1 }), // Küche
B(-2.4, 1.2, -0.55, 2.4, 1.3, 0.55, 'holz', { sockel: 1 }), // Tisch
B(-2.0, 0.52, -0.12, -1.8, 1.2, 0.12, 'dunkel', { sockel: 1 }),
B(1.8, 0.52, -0.12, 2.0, 1.2, 0.12, 'dunkel', { sockel: 1 }),
B(-2.2, 0.92, -1.3, 2.2, 0.99, -0.92, 'holz', { sockel: 1 }), // Bänke
B(-2.2, 0.92, 0.92, 2.2, 0.99, 1.3, 'holz', { sockel: 1 }),
B(-5, 0.52, 3.04, 5, 3.3, 3.14, 'glas', { sockel: 1, weg: [0, 0, 1.1] }), // Glasfront
B(-5, 0.52, -2.9, -4.9, 3.3, 3.04, 'glas', { sockel: 1, weg: [-1.1, 0, 0] }), // Glas links
...[-5, -1.75, 1.6, 4.82].map(x => B(x, 0.52, 3.0, x + 0.18, 3.3, 3.18, 'ton', { sockel: 1, weg: [0, 0, 1.1] })),
B(-6.3, 3.3, -4.3, 6.3, 3.65, 4.4, 'ton', { sockel: 1, dach: 1 }), // Dachplatte
B(-0.1, 0.26, 6.32, 0.16, 1.66, 6.52, 'dunkel'), // Maßstabsfiguren
B(-4.26, 0.52, 3.58, -4.02, 1.86, 3.78, 'dunkel', { sockel: 1 }),
B(-8.25, 0, -3.15, -7.95, 1.6, -2.85, 'dunkel'), B(7.35, 0, -4.65, 7.6, 1.3, -4.4, 'dunkel'), B(5.4, 0, -6.3, 5.6, 1.0, -6.1, 'dunkel'),
];
const kugeln = [{ c: [0.03, 1.84, 6.42], r: 0.17, dunkel: 1 }, { c: [-4.14, 2.04, 3.68], r: 0.17, dunkel: 1 }, { c: [-8.1, 3.0, -3], r: 1.75 }, { c: [7.5, 2.35, -4.5], r: 1.35 }, { c: [5.5, 1.75, -6.2], r: 0.95 }, { c: [-9, 1.6, 2.6], r: 0.9 }];
const flaechen = [[[0, 2, 1], 0, -1], [[0, 2, 1], 1, 1], [[1, 0, 2], 0, -1], [[1, 0, 2], 1, 1], [[2, 1, 0], 0, -1], [[2, 1, 0], 1, 1]];
const glatt = (a, b, t) => { const x = Math.min(1, Math.max(0, (t - a) / (b - a))); return x * x * (3 - 2 * x); };
const lerp = (a, b, t) => a + (b - a) * t;
const farbe = (hex, k) => { const n = parseInt(hex.slice(1), 16); return `rgb(${Math.min(255, (n >> 16) * k) | 0},${Math.min(255, (n >> 8 & 255) * k) | 0},${Math.min(255, (n & 255) * k) | 0})`; };
function huelle(p) {
p = p.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const x = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const u = [], o = [];
for (const q of p) { while (u.length > 1 && x(u[u.length - 2], u[u.length - 1], q) <= 0) u.pop(); u.push(q); }
for (const q of p.reverse()) { while (o.length > 1 && x(o[o.length - 2], o[o.length - 1], q) <= 0) o.pop(); o.push(q); }
return u.slice(0, -1).concat(o.slice(0, -1));
}
const schattenEbene = document.createElement('canvas'), weich = document.createElement('canvas');
function rendere(c, W, H, t) {
const yaw = lerp(-34, 46, t) * Math.PI / 180, pitch = lerp(17, 40, glatt(0, 1, t)) * Math.PI / 180;
const dach = glatt(0.3, 0.62, t) * 3.2, weg = glatt(0.55, 0.85, t);
const D = 42, F = (hoch ? W * 2.05 : Math.min(W * 1.45, H * 2.15)) * lerp(1, 1.06, glatt(0.6, 1, t));
const cx = W * (hoch ? 0.5 : 0.56), cy = H * (hoch ? 0.4 : 0.47), ziel = [0, 1.4 + dach * 0.5, 0.4];
const cy_ = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
const dreh = v => { const x = v[0], y = v[1], z = v[2]; const x1 = x * cy_ - z * sy, z1 = x * sy + z * cy_; return [x1, y * cp - z1 * sp, y * sp + z1 * cp]; };
const proj = v => { const r = dreh([v[0] - ziel[0], v[1] - ziel[1], v[2] - ziel[2]]); const d = D - r[2]; return [cx + F * r[0] / d, cy - F * r[1] / d, d, r]; };
const lage = k => { const o = [0, k.dach ? dach : 0, 0]; if (k.weg) for (let i = 0; i < 3; i++) o[i] += k.weg[i] * weg; return [k.a.map((v, i) => v + o[i]), k.b.map((v, i) => v + o[i])]; };
c.fillStyle = '#12110f'; c.fillRect(0, 0, W, H);
const licht = c.createRadialGradient(cx, cy * 0.9, 0, cx, cy, Math.max(W, H) * 0.7);
licht.addColorStop(0, 'rgba(217,185,140,.10)'); licht.addColorStop(1, 'rgba(18,17,15,0)');
c.fillStyle = licht; c.fillRect(0, 0, W, H);
const box = (k, alpha) => {
const [a, b] = lage(k), ab = [a, b];
for (const [ach, seite, n] of flaechen) {
const [i, j, l] = ach, ecken = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([u, w]) => { const v = [0, 0, 0]; v[i] = ab[seite][i]; v[j] = ab[u][j]; v[l] = ab[w][l]; return v; });
const nv = [0, 0, 0]; nv[i] = n;
const mitte = ecken.reduce((s, v) => s.map((x, q) => x + v[q] / 4), [0, 0, 0]);
const rn = dreh(nv), rm = dreh(mitte.map((x, q) => x - ziel[q]));
if (rn[0] * -rm[0] + rn[1] * -rm[1] + rn[2] * (D - rm[2]) <= 0) continue; // Rückseite
const p = ecken.map(proj);
c.beginPath(); p.forEach((q, z) => z ? c.lineTo(q[0], q[1]) : c.moveTo(q[0], q[1])); c.closePath();
const hell = 0.46 + 0.62 * Math.max(0, nv[0] * L[0] + nv[1] * L[1] + nv[2] * L[2]);
if (k.m === 'glas') { c.fillStyle = `rgba(178,206,210,${0.13 * alpha})`; c.fill(); c.strokeStyle = `rgba(235,244,244,${0.42 * alpha})`; c.lineWidth = 1; c.stroke(); continue; }
c.fillStyle = farbe(ton[k.m], hell); c.fill();
c.strokeStyle = k.m === 'brett' ? 'rgba(255,255,255,.05)' : 'rgba(30,26,22,.22)'; c.lineWidth = 1; c.stroke();
}
};
const kugel = s => {
const p = proj(s.c), r = F * s.r / p[2];
const g = c.createRadialGradient(p[0] - r * 0.4, p[1] - r * 0.45, r * 0.1, p[0], p[1], r);
if (s.dunkel) { g.addColorStop(0, '#8f877b'); g.addColorStop(1, '#4b463f'); }
else { g.addColorStop(0, '#f2ede3'); g.addColorStop(0.7, '#cfc8ba'); g.addColorStop(1, '#8d867a'); }
c.fillStyle = g; c.beginPath(); c.arc(p[0], p[1], r, 0, Math.PI * 2); c.fill();
};
// Grundplatte, Wasser, Raster
box(modell[0], 1);
const quad = (pts, fill) => { const p = pts.map(proj); c.beginPath(); p.forEach((q, z) => z ? c.lineTo(q[0], q[1]) : c.moveTo(q[0], q[1])); c.closePath(); c.fillStyle = fill; c.fill(); };
quad([[-11, 0.002, 5.4], [11, 0.002, 5.4], [11, 0.002, 7.5], [-11, 0.002, 7.5]], '#1d2629');
c.strokeStyle = 'rgba(241,236,226,.06)'; c.lineWidth = 1; c.beginPath();
for (let x = -10; x <= 10; x++) { const a = proj([x, 0.004, -7.5]), b = proj([x, 0.004, 5.4]); c.moveTo(a[0], a[1]); c.lineTo(b[0], b[1]); }
for (let z = -7; z <= 5; z++) { const a = proj([-11, 0.004, z]), b = proj([11, 0.004, z]); c.moveTo(a[0], a[1]); c.lineTo(b[0], b[1]); }
c.stroke();
c.strokeStyle = 'rgba(178,206,210,.10)'; c.beginPath();
for (let z = 5.8; z < 7.5; z += 0.45) { const a = proj([-11, 0.004, z]), b = proj([11, 0.004, z]); c.moveTo(a[0], a[1]); c.lineTo(b[0], b[1]); }
c.stroke();
// Sockel, Steg, Boden
modell.filter(k => k.ebene === 1).forEach(k => box(k, 1));
// Weicher Schlagschatten (Projektion entlang des Lichts, gesammelt, dann weichgezeichnet)
const SK = 0.25; schattenEbene.width = Math.ceil(W * SK); schattenEbene.height = Math.ceil(H * SK);
const s = schattenEbene.getContext('2d'); s.setTransform(SK, 0, 0, SK, 0, 0); s.fillStyle = '#000';
const wirf = (pts, h) => { const q = huelle(pts.map(v => { const k = (v[1] - h) / L[1]; return proj([v[0] - L[0] * k, h, v[2] - L[2] * k]).slice(0, 2); })); s.beginPath(); q.forEach((p, z) => z ? s.lineTo(p[0], p[1]) : s.moveTo(p[0], p[1])); s.closePath(); s.fill(); };
modell.filter(k => k.schatten !== false && k.m !== 'glas').forEach(k => { const [a, b] = lage(k); const e = []; for (const x of [a[0], b[0]]) for (const y of [a[1], b[1]]) for (const z of [a[2], b[2]]) e.push([x, y, z]); wirf(e, k.sockel ? 0.52 : 0); });
kugeln.forEach(k => { const e = []; for (let i = 0; i < 16; i++) { const w = i / 16 * Math.PI * 2; e.push([k.c[0] + Math.cos(w) * k.r, k.c[1], k.c[2] + Math.sin(w) * k.r], [k.c[0] + Math.cos(w) * k.r * 0.6, k.c[1] + k.r * 0.8, k.c[2] + Math.sin(w) * k.r * 0.6]); } wirf(e, 0); });
weich.width = schattenEbene.width; weich.height = schattenEbene.height;
const w2 = weich.getContext('2d'); w2.filter = `blur(${Math.max(1, Math.round(W * SK / 260))}px)`; w2.drawImage(schattenEbene, 0, 0);
c.save(); c.globalAlpha = 0.42; c.imageSmoothingQuality = 'high'; c.drawImage(weich, 0, 0, W, H); c.restore();
// Rest nach Tiefe sortiert (Maler-Algorithmus)
const liste = modell.filter(k => k.ebene === undefined).map(k => { const [a, b] = lage(k); return { k, d: proj(a.map((v, i) => (v + b[i]) / 2))[2] }; })
.concat(kugeln.map(k => ({ s: k, d: proj(k.c)[2] })));
liste.sort((x, y) => y.d - x.d).forEach(e => e.s ? kugel(e.s) : box(e.k, 1));
}
/* ---------- 2. Frames vorladen: erst die ersten 10, dann grob nach fein ---------- */
const frames = new Array(N).fill(null);
let geladen = 0, sprite = null;
const lader = $('[data-lader]');
const drei = n => String(n).padStart(3, '0');
function reihenfolge(n) {
const r = [], drin = new Set();
const nimm = i => { if (i < n && !drin.has(i)) { drin.add(i); r.push(i); } };
for (let i = 0; i < 10; i++) nimm(i);
for (const schritt of [8, 4, 2, 1]) for (let i = 0; i < n; i += schritt) nimm(i);
nimm(n - 1);
return r;
}
const arbeit = document.createElement('canvas');
const alsBild = (cv, typ = 'image/webp') => new Promise(res => cv.toBlob(b => { const img = new Image(); img.onload = () => res(img); img.src = URL.createObjectURL(b); }, typ, 0.84));
async function frame(i, w = FW, h = FH) {
arbeit.width = w; arbeit.height = h;
rendere(arbeit.getContext('2d'), w, h, N > 1 ? i / (N - 1) : 0);
return alsBild(arbeit);
}
async function ladeSequenz() {
for (const i of reihenfolge(N)) {
frames[i] = await frame(i);
geladen++;
lader.lastChild.textContent = `Frames ${drei(geladen)} / ${N}`;
if (geladen === N) lader.classList.add('fertig');
malen();
await new Promise(r => setTimeout(r, 0));
}
}
async function ladeSprite() {
if (sprite) return sprite;
const { n, spalten, zeilen, w, h } = SPRITE, blatt = document.createElement('canvas');
blatt.width = w * spalten; blatt.height = h * zeilen;
const b = blatt.getContext('2d');
for (let i = 0; i < n; i++) {
arbeit.width = w; arbeit.height = h; rendere(arbeit.getContext('2d'), w, h, i / (n - 1));
b.drawImage(arbeit, (i % spalten) * w, Math.floor(i / spalten) * h);
}
sprite = await alsBild(blatt);
return sprite;
}
const naechster = f => { for (let d = 0; d < N; d++) { if (frames[f - d]) return frames[f - d]; if (frames[f + d]) return frames[f + d]; } return null; };
/* ---------- 3. Zeichnen: object-fit cover, DPR max. 2 ---------- */
function groesse() {
const w = Math.round(leinwand.clientWidth * DPR), h = Math.round(leinwand.clientHeight * DPR);
if (leinwand.width !== w || leinwand.height !== h) { leinwand.width = w; leinwand.height = h; }
}
function cover(img, sx, sy, sw, sh) {
const W = leinwand.width, H = leinwand.height, k = Math.max(W / sw, H / sh), dw = sw * k, dh = sh * k;
lc.drawImage(img, sx, sy, sw, sh, (W - dw) / 2, (H - dh) / 2, dw, dh);
}
const zustand = { p: 0 }, clip = { f: 0 };
let variante = 'sequenz', clipZiel = 0, clipTween = null, letzterFrame = -1;
const anzeige = { frame: $('[data-frame]'), balken: $('[data-balken]'), werte: $('[data-werte]') };
const kapitel = [...document.querySelectorAll('.kapitel')], intro = $('[data-intro]');
const WERTE = {
sequenz: [['Frames', `${N} · WebP`], ['Größe', `${FW} × ${FH}`], ['Scrub', `${SCRUB} s`], ['Ease', 'none'], ['Pin', '300 %'], ['DPR', '≤ 2']],
sprite: [['Datei', '1 Sprite-Sheet'], ['Raster', `${SPRITE.spalten} × ${SPRITE.zeilen}`], ['Kachel', `${SPRITE.w} × ${SPRITE.h}`], ['Scrub', `${SCRUB} s`], ['Ease', 'none'], ['Pin', '300 %']],
startende: [['Frames', `Start + Ende`], ['Schwelle', '50 %'], ['Clip', `${CLIP_S} s`], ['Ease', 'none'], ['Pin', '300 %'], ['DPR', '≤ 2']],
};
const gesamt = () => variante === 'sprite' ? SPRITE.n : N;
function malen() {
groesse();
const p = zustand.p;
let f;
if (variante === 'sprite' && sprite) {
f = Math.round(p * (SPRITE.n - 1));
const { spalten, w, h } = SPRITE;
lc.fillStyle = '#12110f'; lc.fillRect(0, 0, leinwand.width, leinwand.height);
cover(sprite, (f % spalten) * w, Math.floor(f / spalten) * h, w, h);
} else {
f = variante === 'startende' ? Math.round(clip.f) : Math.round(p * (N - 1));
const img = naechster(f);
if (img) cover(img, 0, 0, img.naturalWidth, img.naturalHeight);
}
if (f !== letzterFrame) { letzterFrame = f; anzeige.frame.innerHTML = `${drei(f + 1)} <span>/ ${gesamt()}</span>`; }
anzeige.balken.style.transform = `scaleX(${p})`;
intro.style.opacity = Math.max(0, 1 - p / 0.07);
intro.style.translate = `0 ${-p * 300}px`;
kapitel.forEach(k => k.classList.toggle('an', p >= +k.dataset.von && p < +k.dataset.bis));
}
function fortschritt() {
if (variante === 'startende') {
const z = zustand.p >= 0.5 ? N - 1 : 0;
if (z !== clipZiel) {
clipZiel = z;
if (clipTween) clipTween.kill();
const rest = Math.abs(z - clip.f) / (N - 1);
clipTween = gsap.to(clip, { f: z, duration: CLIP_S * rest, ease: 'none', onUpdate: malen });
}
}
malen();
}
function setzeVariante(v) {
variante = v;
letzterFrame = -1;
document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
anzeige.werte.innerHTML = WERTE[v].map(([a, b]) => `<dt>${a}</dt><dd>${b}</dd>`).join('');
if (clipTween) clipTween.kill();
clipZiel = zustand.p >= 0.5 ? N - 1 : 0; clip.f = clipZiel;
if (v === 'sprite') ladeSprite().then(malen);
malen();
}
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.variante)));
/* ---------- 4. Scroll: Pin über 300 %, Fortschritt 0–1 auf den Frame-Index ---------- */
let st = null;
function start() {
if (!reduziert() && window.gsap && window.ScrollTrigger) {
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.config({ ignoreMobileResize: true });
st = gsap.to(zustand, { p: 1, ease: 'none', onUpdate: fortschritt,
scrollTrigger: { trigger: buehne, pin: true, start: 'top top', end: PIN, scrub: SCRUB } }).scrollTrigger;
setzeVariante('sequenz');
ladeSequenz();
} else {
// Reduziert: Erstes Bild steht, drei Schlüsselframes als Bildfolge darunter
lader.lastChild.textContent = '3 Schlüsselframes';
intro.querySelector('p').textContent = 'Drei Schlüsselbilder statt Scroll-Film ↓';
lader.classList.add('fertig');
(async () => {
frames[0] = await frame(0); malen();
const keys = [0.18, 0.55, 1];
const bilder = document.querySelectorAll('[data-schluesselbild]');
for (let i = 0; i < 3; i++) { arbeit.width = FW; arbeit.height = FH; rendere(arbeit.getContext('2d'), FW, FH, keys[i]); bilder[i].src = (await alsBild(arbeit)).src; }
})();
const io = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { e.target.classList.add('sichtbar'); io.unobserve(e.target); } }), { threshold: 0.2 });
document.querySelectorAll('.schluessel figure').forEach(f => io.observe(f));
setzeVariante('sequenz');
}
}
// erst nach dem Rahmen-Skript starten (es setzt .reduziert aus ?reduziert)
document.addEventListener('DOMContentLoaded', start);
addEventListener('resize', () => malen());
/* ---------- 5. Vorführung: die Seite scrollt selbst durch die Pin-Strecke, hin und zurück ---------- */
let uhr = null, t0 = 0;
const RUNDE = 11000;
function vorfuehren() {
pause();
if (!st) { scrollTo(0, 0); return; }
setzeVariante(variante);
t0 = performance.now();
const schritt = t => {
const r = ((t - t0) % RUNDE) / RUNDE;
// 0–8 %: halten, 8–50 %: vor, 50–58 %: halten, 58–100 %: zurück
const q = r < 0.08 ? 0 : r < 0.5 ? (r - 0.08) / 0.42 : r < 0.58 ? 1 : 1 - (r - 0.58) / 0.42;
const e = 0.5 - 0.5 * Math.cos(q * Math.PI);
scrollTo(0, st.start + (st.end - st.start) * e);
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function pause() { cancelAnimationFrame(uhr); uhr = null; }
function neustart() { pause(); scrollTo(0, 0); setzeVariante(variante); }
})();
</script>
</body>
</html>