Scroll-gesteuerte 3D-Szene / 3D-Modell (Kamerafahrt)
Ein Architekturmodell am Weinberg: Der Scroll fährt die Kamera auf einer CatmullRom-Kurve durch vier Kapitel, geglättet mit damp, synchron zu HTML-Texten und Beschriftungen am Modell.
Eine fixierte Canvas liegt hinter normal scrollendem HTML. Der Scrollfortschritt wird zu einem einzigen Wert. Dieser Wert steuert die Kamera entlang einer Kurve (CatmullRomCurve3) oder das Modell selbst: Drehung, Explosionsansicht, Material, Licht. Jedes Kapitel hat einen Keyframe. Der Wert wird geglättet (damp oder lerp), damit Scroll-Rucke nicht durchschlagen. HTML-Texte und Beschriftungen laufen synchron und zeigen auf Teile des Modells.
Der Nutzer steuert die Erzählung selbst. Das Produkt wird von allen Seiten erfahrbar, ohne Video. Die Seite wird zur Markenwelt. Award-Seiten der Developer-Kategorie leben davon.
Wann einsetzen
Produktlaunches, Hardware, Fahrzeuge, Architekturmodelle, Kampagnen und Studio-Portfolios. Auch für konzeptuelle Produkte, die ein Alltagsobjekt groß inszenieren.
Wann nicht
Wenn Inhalte schnell gefunden werden müssen, etwa auf Informationsseiten und in Shops. Ohne optimiertes Modell (Draco oder Meshopt, KTX2), ohne 3D-Kompetenz und bei schwachen Zielgeräten. Nie ohne HTML-Inhalt als Grundlage.
Varianten
- Kamerafahrt durch die Szene entlang einer CatmullRom-Kurve.
- Explosionsansicht: Das Objekt zerlegt sich Kapitel für Kapitel.
- Drehung am Platz mit Beschriftungen zu einzelnen Merkmalen.
- Selektives WebGL: 3D nur an Schlüsselmomenten (Lusion, Atlas Motion).
- Flug über ein 3D-Terrain (Ridgeline).
- WebGL-Flächen, die DOM-Positionen folgen: Bilder im Canvas laufen mit dem Layout.
- Scroll steuert Shader-Uniforms, etwa Dissolve oder Noise.
- Die ganze Seite als durchgehende Szene, Kapitel für Kapitel mit HTML synchronisiert.
Umsetzung
Den Fortschritt zentral halten: pro Kapitel ein Wert, die Szene liest nur diesen Wert und nie das DOM. Mit ScrollTrigger: state.target = progress. Im Render-Loop: state.current = damp(state.current, state.target, 6, dt), dann camera.position.copy(curve.getPoint(state.current)). Die Canvas ist position: fixed mit pointer-events: none. Modelle mit gltf-transform komprimieren, Texturen als KTX2, schwere Kapitel erst bei Bedarf laden. Shader beim Laden vorkompilieren (renderer.compile). Pixelratio auf 1,5 bis 2 begrenzen. Rendern pausieren, wenn sich nichts bewegt oder die Canvas nicht sichtbar ist. Fallstricke: Ladegewicht, Speichergrenzen auf iOS, Lesbarkeit von Text über bewegtem Bild und die Adressleiste auf iOS, die die Viewporthöhe ändert (100lvh oder svh verwenden).
Robust umsetzen
Telefon. Leichteres Modell (LOD), Pixelratio höchstens 1,5 bis 2, kleinere Shadow-Map. Bildausschnitt und Textlage an das Hochformat anpassen. Bei schwachen Geräten eine Bildsequenz oder ein vorgerendertes Video statt Echtzeit.
Ohne Bewegung. Keine Fahrt: Die Kamera springt pro Kapitel zur Schlüsselpose. Alternativ ein statisches Rendering je Kapitel.
Barrierefrei. Alle Informationen stehen im HTML, nicht nur im Canvas. Die Canvas ist aria-hidden. Den Scroll nicht kapern, Tastatur-Scroll muss genauso funktionieren. Kapitel-Anker in der Navigation bieten einen Tastaturpfad.
Code
<!DOCTYPE html>
<!--
Scroll-gesteuerte 3D-Szene / 3D-Modell (Kamerafahrt)
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/scroll-3d-kamerafahrt/
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>Scroll-gesteuerte 3D-Kamerafahrt · 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: #151412; --text: #f2efe8; --leise: rgba(242, 239, 232, .58); --linie: rgba(242, 239, 232, .16);
--holz: #d9ad7c;
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out für Ein- und Umblenden */
--rand: clamp(20px, 4vw, 56px);
}
* { 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 { overflow-x: hidden; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
em { font-family: 'Instrument Serif', serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; color: var(--holz); }
/* ---------- Fixierte Bühne hinter dem HTML ---------- */
.leinwand { position: fixed; inset: 0 0 auto; width: 100%; height: 100vh; height: 100lvh; z-index: 0; display: block; pointer-events: none; opacity: 0; transition: opacity 1.4s var(--kurve); }
.gl-bereit .leinwand { opacity: 1; }
.schleier { position: fixed; inset: 0; z-index: 1; pointer-events: none;
background: linear-gradient(90deg, rgba(21, 20, 18, .82) 0%, rgba(21, 20, 18, .45) 30%, rgba(21, 20, 18, 0) 52%),
linear-gradient(0deg, rgba(21, 20, 18, .7) 0%, rgba(21, 20, 18, 0) 22%); }
.poster { position: fixed; z-index: 0; right: 6vw; top: 50%; translate: 0 -50%; width: min(56vw, 760px); display: none; }
.ohne-webgl .poster { display: block; }
/* Beschriftungen, die an Punkten im Modell hängen */
.marken { position: fixed; inset: 0; z-index: 2; pointer-events: none; }
.marke { position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: 10px; white-space: nowrap; opacity: 0; transition: opacity .6s var(--kurve); will-change: transform; }
.marke::before { content: ''; width: 9px; height: 9px; border-radius: 50%; background: var(--text); box-shadow: 0 0 0 4px rgba(242, 239, 232, .18); flex: none; }
.marke i { display: block; width: 36px; height: 1px; background: var(--text); opacity: .6; }
.marke span { color: var(--text); background: rgba(21, 20, 18, .55); padding: 6px 9px; border: 1px solid var(--linie); border-radius: 2px; }
.marke.an { opacity: 1; }
.marke.links { flex-direction: row-reverse; }
/* ---------- Kopf, Kapitelnavigation, Werte ---------- */
.kopf { position: fixed; z-index: 5; inset: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: clamp(16px, 2.6vw, 30px) var(--rand); }
/* Kopf deckt sauber ab: deckend bis unter Logo und Navigation, erst darunter 48 px Verlauf */
.kopf::before { content: ''; position: absolute; inset: 0 0 -48px; z-index: -1; pointer-events: none; background: linear-gradient(180deg, var(--grund) 0%, var(--grund) calc(100% - 48px), rgba(21, 20, 18, .6) calc(100% - 24px), rgba(21, 20, 18, 0) 100%); transition: opacity .15s linear; }
.am-ende .kopf::before { opacity: 0; }
.marken { transition: opacity .6s var(--kurve); }
.vor-ende .marken { opacity: 0; }
.am-ende .buero, .am-ende .kapitelnav a, .am-ende .werte, .am-ende .werte b { color: #1b1916; }
.am-ende .buero span { color: rgba(27, 25, 22, .66); }
.buero { font-size: 16px; font-weight: 600; letter-spacing: -.01em; }
.buero span { font: italic 400 18px/1 'Instrument Serif', serif; color: var(--leise); margin-left: 4px; letter-spacing: 0; }
.modi { display: flex; gap: 6px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: rgba(21, 20, 18, .5); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: border-color .15s linear, color .15s linear; }
.modi button:hover { color: var(--text); border-color: var(--leise); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.kapitelnav { position: fixed; z-index: 5; left: 50%; top: clamp(16px, 2.6vw, 30px); translate: -50% 0; height: 30px; display: flex; align-items: center; gap: 22px; }
.kapitelnav a { display: flex; align-items: center; gap: 8px; color: var(--leise); text-decoration: none; transition: color .15s linear; }
.kapitelnav a::before { content: ''; height: 1px; background: currentColor; width: 0; transition: width .6s var(--kurve); }
.kapitelnav a.aktiv { color: var(--text); }
.kapitelnav a.aktiv::before { width: 18px; }
.kapitelnav a:hover, .kapitelnav a:focus-visible { color: var(--text); }
.werte { position: fixed; z-index: 5; right: var(--rand); bottom: clamp(16px, 2.6vw, 30px); display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; max-width: 560px; }
.werte b { color: var(--text); font-weight: 500; font-variant-numeric: tabular-nums; }
.leiste { position: fixed; z-index: 5; left: 0; top: 0; height: 2px; width: 100%; background: var(--holz); transform-origin: 0 50%; scale: 0 1; }
/* ---------- Kapitel: normales HTML, scrollt über die Szene ---------- */
main { position: relative; z-index: 3; }
.kapitel { min-height: 150vh; display: grid; align-items: center; padding: 0 var(--rand); }
.kapitel:first-child { min-height: 130vh; align-items: start; padding-top: 30vh; }
.karte { max-width: 430px; }
.karte .mono { margin-bottom: 18px; display: flex; gap: 12px; }
.karte .mono b { color: var(--holz); font-weight: 500; }
h1 { font-size: clamp(48px, 6.4vw, 104px); font-weight: 600; line-height: .94; letter-spacing: -.045em; max-width: 9.5ch; }
h1 em { font-size: 1.08em; letter-spacing: -.02em; }
h2 { font-size: clamp(34px, 3.8vw, 58px); font-weight: 600; line-height: 1; letter-spacing: -.035em; margin-bottom: 18px; }
.karte p:not(.mono) { font-size: 16px; line-height: 1.6; color: rgba(242, 239, 232, .76); max-width: 38ch; }
.kapitel:first-child .karte { max-width: 640px; }
.kapitel:first-child p:not(.mono) { margin-top: 26px; }
.fakten { display: flex; gap: 28px; margin-top: 28px; }
.fakten b { display: block; color: var(--text); font: 500 15px/1.3 'Instrument Sans', sans-serif; letter-spacing: 0; text-transform: none; margin-top: 4px; }
.ende { position: relative; z-index: 3; background: #ece7dd; color: #1b1916; padding: clamp(64px, 10vw, 140px) var(--rand); display: grid; grid-template-columns: 1.2fr 1fr; gap: clamp(28px, 5vw, 80px); align-items: end; }
.ende .mono { color: rgba(27, 25, 22, .66); }
.ende h2 { margin: 14px 0 0; font-size: clamp(40px, 5vw, 76px); }
.ende h2 em { color: #9a6a3a; }
.ende p:not(.mono) { font-size: 16px; line-height: 1.6; color: rgba(27, 25, 22, .72); max-width: 42ch; }
.ende a { display: inline-flex; gap: 10px; margin-top: 18px; color: #1b1916; font-size: 15px; text-decoration: none; border-bottom: 1px solid rgba(27, 25, 22, .3); padding-bottom: 4px; transition: border-color .15s linear; }
.ende a:hover, .ende a:focus-visible { border-color: #1b1916; }
/* ---------- Vorschau: nur Bühne mit Kapiteltext ---------- */
.vorschau body { overflow: hidden; }
.vorschau .ende, .vorschau .karte p:not(.mono), .vorschau .fakten { display: none; }
.vorschau h1 { font-size: 52px; } /* Platz für die Beschriftung am Riegel, die nach links aufklappt */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
.reduziert .marke { transition: opacity .2s linear !important; }
@media (max-width: 1180px) { .kapitelnav { display: none; } }
@media (max-width: 760px) {
.kopf::before { bottom: -72px; background: linear-gradient(180deg, var(--grund) 0%, var(--grund) calc(100% - 72px), rgba(21, 20, 18, .85) calc(100% - 40px), rgba(21, 20, 18, 0) 100%); }
.schleier { background: linear-gradient(0deg, rgba(21, 20, 18, .9) 0%, rgba(21, 20, 18, .6) 34%, rgba(21, 20, 18, 0) 58%), linear-gradient(180deg, rgba(21, 20, 18, .6) 0%, rgba(21, 20, 18, 0) 18%); }
.kopf { flex-wrap: wrap; row-gap: 10px; }
.modi button { padding: 0 10px; }
.werte { left: var(--rand); right: var(--rand); top: 104px; bottom: auto; justify-content: flex-start; max-width: none; gap: 2px 12px; }
.werte .lang { display: none; }
.kapitel { min-height: 150svh; align-items: end; padding-bottom: 14svh; }
.kapitel:first-child { min-height: 120svh; align-items: end; padding-top: 0; padding-bottom: 20svh; }
h1 { font-size: 13vw; }
h2 { font-size: 36px; }
.karte p:not(.mono) { font-size: 15px; }
.ende { grid-template-columns: 1fr; }
.marke i { width: 18px; }
.poster { right: 5vw; top: 32%; width: 90vw; }
}
@media (hover: none) { .modi button:hover { color: var(--leise); border-color: var(--linie); } .modi button[aria-pressed='true']:hover { color: var(--grund); border-color: var(--text); } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<canvas class="leinwand" data-canvas aria-hidden="true"></canvas>
<svg class="poster" viewBox="0 0 600 380" aria-hidden="true" fill="none" stroke="rgba(242,239,232,.7)" stroke-width="1.2">
<path d="M30 250 300 360 570 250 300 140Z" stroke-opacity=".35"/>
<path d="M60 238 300 334 540 238M100 222 300 302 500 222M140 206 300 270 460 206" stroke-opacity=".25"/>
<path d="M250 196 330 228 330 262 250 230Z M330 228 420 192 420 226 330 262" fill="rgba(242,239,232,.08)"/>
<path d="M270 150 380 194 380 226 270 182Z" fill="rgba(217,173,124,.35)" stroke="#d9ad7c"/>
<path d="M380 194 420 178 420 210 380 226Z" fill="rgba(217,173,124,.2)" stroke="#d9ad7c"/>
<path d="M270 150 310 134 420 178 380 194Z" stroke="#d9ad7c"/>
</svg>
<div class="schleier" aria-hidden="true"></div>
<div class="leiste" data-leiste aria-hidden="true"></div>
<header class="kopf">
<p class="buero">Brandt & Okafor<span>Architekten</span></p>
<div class="modi mono" role="group" aria-label="Variante wählen">
<button type="button" data-modus="fahrt" aria-pressed="true">Kamerafahrt</button>
<button type="button" data-modus="explosion" aria-pressed="false">Explosion</button>
<button type="button" data-modus="drehung" aria-pressed="false">Drehung</button>
</div>
</header>
<nav class="kapitelnav mono" aria-label="Kapitel">
<a href="#k1" data-nav>Ankunft</a>
<a href="#k2" data-nav>Auskragung</a>
<a href="#k3" data-nav>Lichthof</a>
<a href="#k4" data-nav>Talblick</a>
</nav>
<div class="marken" aria-hidden="true">
<p class="marke mono" data-marke="0"><i></i><span data-kurz="Riegel · Lärche">Riegel · Brettsperrholz, Lärche</span></p>
<p class="marke mono" data-marke="1"><i></i><span>Auskragung 6,40 m</span></p>
<p class="marke mono" data-marke="2"><i></i><span data-kurz="Lichthof">Lichthof · Felsenbirne</span></p>
<p class="marke mono" data-marke="3"><i></i><span data-kurz="Fenster 7,20 m">Panoramafenster 7,20 m</span></p>
</div>
<main>
<section class="kapitel" id="k1">
<div class="karte">
<p class="mono"><b>01</b><span>Weinberghaus · Radebeul</span></p>
<h1>Ein Haus, das <em>über den Hang</em> hinausragt.</h1>
<p>Wohnhaus für eine Familie am Südhang über der Elbe. Ein Holzriegel liegt auf einem Sockel aus Stampfbeton und schiebt sich weit über die Reben.</p>
<div class="fakten mono"><span>Wohnfläche<b>214 m²</b></span><span>Hangneigung<b>32 %</b></span><span>Fertig<b>Frühjahr 2026</b></span></div>
</div>
</section>
<section class="kapitel" id="k2">
<div class="karte">
<p class="mono"><b>02</b><span>Auskragung</span></p>
<h2>6,40 m frei über den Reben.</h2>
<p>Der Riegel liegt hinten auf dem Hang auf und kragt vorne frei aus. Zwei geschosshohe Wandscheiben aus Brettsperrholz tragen ihn wie einen Brückenträger. Unter dem Haus wird der Weinberg weiter bewirtschaftet.</p>
</div>
</section>
<section class="kapitel" id="k3">
<div class="karte">
<p class="mono"><b>03</b><span>Lichthof</span></p>
<h2>Ein Hof <em>mitten</em> im Haus.</h2>
<p>Zwischen Eingang und Wohnraum öffnet sich ein Lichthof, 2,60 auf 8 Meter. Eine Felsenbirne wächst darin über das Dach hinaus. Ein gläserner Steg verbindet beide Hälften, jeder Raum bekommt Licht von zwei Seiten.</p>
</div>
</section>
<section class="kapitel" id="k4">
<div class="karte">
<p class="mono"><b>04</b><span>Talblick</span></p>
<h2>Am Ende: das ganze Elbtal.</h2>
<p>Die Stirnseite ist 7,20 Meter breit und rahmenlos verglast. An Sommerabenden steht die Sonne genau in der Achse des Riegels.</p>
</div>
</section>
</main>
<section class="ende" id="besuch">
<div><p class="mono">Tag der Architektur · 14. November</p><h2>Das Haus <em>von innen</em> sehen.</h2></div>
<div>
<p>Die Bauherren öffnen das Weinberghaus für zwei Führungen, um 11 und um 14 Uhr. Die Plätze sind begrenzt, wir bitten um Anmeldung im Büro in der Dresdner Neustadt.</p>
<a href="#k1">Zur Anmeldung <span aria-hidden="true">→</span></a>
</div>
</section>
<p class="werte mono" data-werte aria-live="polite"></p>
<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 canvas = document.querySelector('[data-canvas]');
const kapitel = [...document.querySelectorAll('.kapitel')];
const navLinks = [...document.querySelectorAll('[data-nav]')];
const marken = [...document.querySelectorAll('[data-marke]')];
const werteEl = document.querySelector('[data-werte]');
const leiste = document.querySelector('[data-leiste]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];
/* ---------- Werte (meta.json → messwerte: damp lambda 4–8, 100–300vh je Keyframe, pixelRatio ≤ 2) ---------- */
const W = {
lambda: 6, // damp(aktuell, ziel, 6, dt) wie maath/easing
kapitelVh: 150, // Scrollstrecke je Kamera-Keyframe
dprMax: beruehrung ? 1.5 : 2,
schatten: beruehrung || html.classList.contains('vorschau') || location.search.includes('vorschau') ? 1024 : 2048,
};
const GRAD = Math.PI / 180;
const v = (x, y, z) => new THREE.Vector3(x, y, z);
/* Keyframes je Kapitel. Kamerakurven haben 7 Punkte: Kapitel i liegt auf Punkt 2·i, dazwischen Stützpunkte. */
const VARIANTEN = {
fahrt: {
pos: [v(46, 30, 54), v(38, 15, 46), v(17, 3.6, 26), v(20, 26, 20), v(-15, 37, 9), v(-24, 22, 28), v(11, 11.6, 31)],
ziel: [v(0, 7, 0), v(1, 8, 3), v(2, 9.6, 6), v(2, 9.6, 1), v(2, 9.6, -5.7), v(0, 9.5, 3), v(2, 10.9, 8)],
fov: [30, 36, 42, 38, 34, 36, 38],
explosion: [0, 0, 0, 0], drehung: [0, 0, 0, 0],
werte: `<span>damp <b>λ 6</b></span><span>pfad <b>CatmullRom · 7 Punkte</b></span><span class="lang">fov <b>30 / 42 / 34 / 38</b></span><span>kapitel <b>150 vh</b></span>`,
},
explosion: {
pos: [v(44, 28, 50), v(43, 27, 46), v(40, 24, 42), v(37, 27, 38), v(34, 29, 36), v(40, 28, 44), v(46, 27, 50)],
ziel: [v(0, 7, 0), v(0, 8, 0), v(1, 11, 0), v(1, 12.5, -0.5), v(1, 14, -1), v(0.5, 11, 0), v(0, 8, 0)],
fov: [30, 31, 32, 33, 34, 32, 30],
explosion: [0, 0.55, 1, 0], drehung: [0, 0, 0, 0],
werte: `<span>damp <b>λ 6</b></span><span>explosion <b>0 / 0.55 / 1 / 0</b></span><span class="lang">hub <b>Sockel 2 · Riegel 6 · Dach 10 m</b></span><span>kapitel <b>150 vh</b></span>`,
},
drehung: {
pos: Array(7).fill(v(40, 25, 46)), ziel: Array(7).fill(v(0, 7.5, 0)), fov: Array(7).fill(32),
explosion: [0, 0, 0, 0], drehung: [0, -43 * GRAD, -120 * GRAD, -195 * GRAD],
werte: `<span>damp <b>λ 6</b></span><span>drehung <b>0° / −43° / −120° / −195°</b></span><span class="lang">kamera <b>fest · fov 32</b></span><span>kapitel <b>150 vh</b></span>`,
},
};
/* Licht je Kapitel: ein Tageslauf von Mittag bis Abendsonne */
const LICHT = {
hoehe: [52, 40, 66, 13].map(g => g * GRAD),
richtung: [-38, -12, 18, 8].map(g => g * GRAD),
farbe: ['#fff2e2', '#ffefdd', '#fff6ec', '#ffb57a'].map(c => new THREE.Color(c)),
staerke: [2.6, 2.4, 2.8, 2.3],
fenster: [0.06, 0.08, 0.06, 0.28], // warmes Licht hinter dem Glas, abends heller
grund: ['#161513', '#151412', '#171614', '#1b1612'].map(c => new THREE.Color(c)),
};
let modus = 'fahrt';
function zeigeWerte() {
werteEl.innerHTML = (reduziert() ? `<span>bewegung <b>aus · Sprung je Kapitel</b></span>` : VARIANTEN[modus].werte) + `<span>t <b data-t>0.00</b></span>`;
}
function knopfZustand() { knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === modus))); }
/* ---------- Scrollfortschritt: ein Wert t von 0 bis 3, Kapitel i liegt genau bei t = i ---------- */
let anker = [0, 1, 2, 3];
function messeAnker() {
anker = kapitel.map((k, i) => i === 0 ? 0 : k.offsetTop + k.offsetHeight / 2 - innerHeight / 2);
}
const glatt = f => f * f * (3 - 2 * f); // Verweilen am Keyframe, damit Text in Ruhe lesbar ist
const endeEl = document.querySelector('.ende');
const pruefeEnde = () => { const o = endeEl.getBoundingClientRect().top; html.classList.toggle('am-ende', o < 70); html.classList.toggle('vor-ende', o < innerHeight * 0.75); };
addEventListener('scroll', pruefeEnde, { passive: true });
/* Kapiteltexte rechtzeitig ausblenden, bevor sie unter Kopf und Navigation laufen:
eine Maske, im Viewport verankert: zeilengenau 80 px Verlauf, ganz weg an der Unterkante des Kopfs */
const kopfEl = document.querySelector('.kopf');
const kartenTeile = [...document.querySelectorAll('.karte > *')];
const AUSBLEND_PX = 80;
function blendeKarten() {
let kante = kopfEl.getBoundingClientRect().bottom + 8;
const w = werteEl.getBoundingClientRect();
if (w.height && w.top < innerHeight / 2) kante = Math.max(kante, w.bottom + 8); // Telefon: Werte stehen unter dem Kopf
for (const el of kartenTeile) {
const d = Math.round(kante - el.getBoundingClientRect().top);
const wert = d + AUSBLEND_PX <= 0 ? '' : `linear-gradient(180deg, transparent ${d}px, #000 ${d + AUSBLEND_PX}px)`;
if (el.dataset.maske !== wert) { el.dataset.maske = wert; el.style.webkitMaskImage = el.style.maskImage = wert; }
}
}
addEventListener('scroll', blendeKarten, { passive: true });
addEventListener('resize', blendeKarten);
blendeKarten();
let virtY = null; // Vorführung: simulierte Scrollposition statt echtem Scroll
function zielAusScroll() {
const y = virtY ?? scrollY;
if (y <= anker[0]) return 0;
for (let i = 0; i < anker.length - 1; i++) {
if (y < anker[i + 1]) return i + glatt((y - anker[i]) / (anker[i + 1] - anker[i]));
}
return anker.length - 1;
}
const stufe = (arr, t) => { const i = Math.min(arr.length - 2, Math.floor(t)), f = t - i; return arr[i] + (arr[i + 1] - arr[i]) * f; };
/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
let neustart = () => {}, vorfuehren = () => {}, pause = () => {}, weiter = () => {};
let anstossen = () => {};
function setzeModus(m) { modus = m; knopfZustand(); zeigeWerte(); anstossen(); }
knoepfe.forEach(k => k.addEventListener('click', () => setzeModus(k.dataset.modus)));
messeAnker();
if (!renderer) {
html.classList.add('ohne-webgl');
setzeModus('fahrt');
const scrollen = () => {
const t = zielAusScroll(), aktiv = Math.round(t);
navLinks.forEach((a, i) => a.classList.toggle('aktiv', i === aktiv));
leiste.style.scale = `${t / 3} 1`;
};
addEventListener('scroll', scrollen, { passive: true }); scrollen();
neustart = () => scrollTo(0, 0);
} else {
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
const szene = new THREE.Scene();
szene.background = LICHT.grund[0].clone();
szene.fog = new THREE.Fog(szene.background, 80, 170);
const kamera = new THREE.PerspectiveCamera(30, 1, 0.5, 400);
/* Weiche Umgebung für Glasreflexe (eigene Mini-Szene über PMREM, keine HDRI-Datei) */
const pmrem = new THREE.PMREMGenerator(renderer);
const studio = new THREE.Scene();
studio.add(new THREE.Mesh(new THREE.SphereGeometry(20, 16, 8), new THREE.MeshBasicMaterial({ color: 0x2a2724, side: THREE.BackSide })));
const box = (w, h, farbe, pos) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(farbe), side: THREE.DoubleSide })); m.position.set(...pos); m.lookAt(0, 0, 0); studio.add(m); };
box(16, 4, '#f5e9d8', [0, 12, 6]); box(4, 10, '#ffd9b0', [-12, 3, 6]); box(10, 3, '#c9d4e6', [10, 2, -8]);
szene.environment = pmrem.fromScene(studio, 0.04).texture;
szene.environmentIntensity = 0.55;
const sonne = new THREE.DirectionalLight('#fff2e2', 2.6);
sonne.castShadow = true;
sonne.shadow.mapSize.set(W.schatten, W.schatten);
Object.assign(sonne.shadow.camera, { left: -36, right: 36, top: 36, bottom: -36, near: 1, far: 160 });
sonne.shadow.radius = 3; sonne.shadow.bias = -0.0004; sonne.shadow.normalBias = 0.03;
szene.add(sonne, sonne.target);
const himmel = new THREE.HemisphereLight('#e8e4dc', '#3a3029', 0.9);
szene.add(himmel);
/* ---------- Das Architekturmodell: Sockelplatte, Hang in Schichten, Reben, Haus ---------- */
const modell = new THREE.Group(); szene.add(modell);
const mat = (farbe, extra = {}) => new THREE.MeshStandardMaterial({ color: farbe, roughness: 0.86, metalness: 0, ...extra });
const kanteMat = new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.22 });
function quader(w, h, t, material, x, y, z, eltern = modell, kanten = false) {
const g = new THREE.BoxGeometry(w, h, t);
const m = new THREE.Mesh(g, material); m.position.set(x, y, z); m.castShadow = m.receiveShadow = true; eltern.add(m);
if (kanten) m.add(new THREE.LineSegments(new THREE.EdgesGeometry(g), kanteMat));
return m;
}
// Grundplatte wie ein Modellbaubrett
quader(60, 1, 48, mat('#6e5843', { roughness: 0.7 }), 0, -2, 0);
// Hang: fünf Schichten aus Karton, je 3,20 m (eine Geschosshöhe) tiefer Richtung Tal (+z)
const STUFE = 3.2, TIEFE = 8.8;
const schichtFarben = ['#e7e1d5', '#ddd6c8', '#e7e1d5', '#ddd6c8', '#e7e1d5'];
const oben = i => (4 - i) * STUFE; // Oberkante Schicht i
const zVon = i => -22 + i * TIEFE;
for (let i = 0; i < 5; i++) {
const h = oben(i) + 1.5;
quader(56, h, TIEFE, mat(schichtFarben[i]), 0, -1.5 + h / 2, zVon(i) + TIEFE / 2);
}
// Reben als InstancedMesh: Zeilen laufen hangabwärts
const rebenMat = mat('#b9bf9f', { roughness: 0.95 });
const reben = [];
for (let i = 1; i < 5; i++) for (let x = -26; x <= 26; x += 1.7) {
if (i === 1 && x > -19 && x < 19) continue; // Garten hinter dem Haus
if (i === 2 && x > -10.5 && x < 6.5) continue; // Sockelgeschoss
reben.push([x, oben(i) + 0.35, zVon(i) + TIEFE / 2]);
}
const rebenGeo = new THREE.BoxGeometry(0.3, 0.7, TIEFE - 1.6);
const rebenMesh = new THREE.InstancedMesh(rebenGeo, rebenMat, reben.length);
const hilf = new THREE.Object3D();
reben.forEach(([x, y, z], n) => { hilf.position.set(x, y, z); hilf.updateMatrix(); rebenMesh.setMatrixAt(n, hilf.matrix); });
rebenMesh.castShadow = rebenMesh.receiveShadow = true; modell.add(rebenMesh);
// Bäume: flach schattierte Kronen
const kroneMat = mat('#8f9a78', { flatShading: true, roughness: 0.95 }), stammMat = mat('#5a4a3b');
const kroneGeo = new THREE.IcosahedronGeometry(1, 1), stammGeo = new THREE.CylinderGeometry(0.16, 0.22, 1, 6);
function baum(x, y, z, r, h, eltern = modell) {
const g = new THREE.Group(); g.position.set(x, y, z);
const s = new THREE.Mesh(stammGeo, stammMat); s.scale.y = h; s.position.y = h / 2; s.castShadow = true;
const k = new THREE.Mesh(kroneGeo, kroneMat); k.scale.set(r, r * 1.15, r); k.position.y = h + r * 0.8; k.castShadow = k.receiveShadow = true;
g.add(s, k); eltern.add(g); return g;
}
[[-22, -18, 2.4, 2.2], [-14, -19.5, 2, 2], [-25, -14, 1.7, 1.6], [13, -18, 2.3, 2.4], [21, -19, 2, 1.8], [24, -15, 1.6, 1.5]].forEach(([x, z, r, h]) => baum(x, oben(0), z, r, h));
[[-15, -9, 1.6, 1.6], [15, -8.5, 1.8, 1.7], [-9, -10.5, 1.3, 1.4]].forEach(([x, z, r, h]) => baum(x, oben(1), z, r, h));
// Texturen: Stampfbeton (waagerechte Lagen) und Lärchenschalung (senkrechte Bretter)
function streifen(waagerecht, grund, toene, anzahl) {
const c = document.createElement('canvas'); c.width = c.height = 256; const g = c.getContext('2d');
g.fillStyle = grund; g.fillRect(0, 0, 256, 256);
let s = 11; const z = () => (s = (s * 16807) % 2147483647) / 2147483647;
for (let i = 0; i < anzahl; i++) {
const a = i / anzahl * 256, b = 256 / anzahl;
g.fillStyle = toene[Math.floor(z() * toene.length)]; g.globalAlpha = 0.35 + z() * 0.4;
waagerecht ? g.fillRect(0, a, 256, b) : g.fillRect(a, 0, b, 256);
g.globalAlpha = 0.5; g.fillStyle = 'rgba(0,0,0,.35)';
waagerecht ? g.fillRect(0, a, 256, 1) : g.fillRect(a, 0, 1.2, 256);
}
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4; return t;
}
const betonMat = mat('#ffffff', { map: streifen(true, '#c9c3b8', ['#d3cdc2', '#bdb6aa', '#c6bfb3'], 9) });
const holzMat = mat('#ffffff', { map: streifen(false, '#c79a6b', ['#d4a878', '#b98a5c', '#caa073', '#a97d52'], 26), roughness: 0.72 });
const dachMat = mat('#ebe7de');
const glasMat = new THREE.MeshStandardMaterial({ color: '#33404a', roughness: 0.05, metalness: 0.5, envMapIntensity: 2.2, emissive: '#6b4523', emissiveIntensity: 0.35 }); // Glas mit warmem Licht dahinter
const stegMat = new THREE.MeshStandardMaterial({ color: '#33403f', roughness: 0.1, metalness: 0.2, transparent: true, opacity: 0.55 });
// Haus in drei Teilen, damit die Explosionsansicht sie anheben kann
const sockel = new THREE.Group(), riegel = new THREE.Group(), dach = new THREE.Group();
modell.add(sockel, riegel, dach);
const y2 = oben(2), y1 = oben(1); // 6,4 und 9,6
quader(14, STUFE, 7.4, betonMat, -2, y2 + STUFE / 2, -0.7, sockel, true);
quader(12, 2.2, 0.06, glasMat, -2.6, y2 + 1.5, 2.94, sockel);
// Riegel: hinterer Teil auf dem Hang, vorderer Teil auf dem Sockel und 6,40 m frei auskragend
const RH = 3.0, RX = 2;
quader(8, RH, 5, holzMat, RX, y1 + RH / 2, -9.5, riegel, true);
quader(8, RH, 13.8, holzMat, RX, y1 + RH / 2, 2.5, riegel, true);
quader(7.2, 2.4, 0.06, glasMat, RX, y1 + 1.5, 9.43, riegel); // Panoramafenster Stirnseite
quader(0.06, 1.5, 10, glasMat, RX + 4.03, y1 + 1.7, 3.2, riegel); // Fensterband Ostseite
quader(0.06, 1.5, 3.2, glasMat, RX - 4.03, y1 + 1.7, -9.5, riegel);
quader(2.2, 2.6, 2.6, stegMat, RX + 2.9, y1 + 1.3, -5.7, riegel); // gläserner Steg über den Hof
baum(RX - 1, y1, -5.7, 1.05, 3.8, riegel); // Felsenbirne im Lichthof
quader(8.4, 0.35, 5.4, dachMat, RX, y1 + RH + 0.18, -9.5, dach, true);
quader(8.4, 0.35, 14.2, dachMat, RX, y1 + RH + 0.18, 2.5, dach, true);
// Maßstabsleiste 10 m auf der Grundplatte, wie im Modellbau
quader(0.12, 0.05, 10, mat('#2a2622'), -27, -1.47, 19.5);
// Ankerpunkte der Beschriftungen hängen an den Bauteilen
const anker3d = [
[riegel, v(RX, y1 + RH, -2)],
[riegel, v(RX, y1, 9.4)],
[riegel, v(RX - 1, y1 + 5.6, -5.7)],
[riegel, v(RX, y1 + 1.5, 9.45)],
].map(([eltern, p]) => { const o = new THREE.Object3D(); o.position.copy(p); eltern.add(o); return o; });
// Boden um das Modell, fängt den Schatten der Platte
const boden = new THREE.Mesh(new THREE.PlaneGeometry(500, 500), new THREE.MeshStandardMaterial({ color: '#1d1b18', roughness: 1 }));
boden.rotation.x = -Math.PI / 2; boden.position.y = -2.5; boden.receiveShadow = true; szene.add(boden);
/* ---------- Kurven je Variante ---------- */
const kurven = {};
for (const [name, d] of Object.entries(VARIANTEN)) {
kurven[name] = { pos: new THREE.CatmullRomCurve3(d.pos, false, 'centripetal'), ziel: new THREE.CatmullRomCurve3(d.ziel, false, 'centripetal') };
}
/* ---------- Zustand und Glättung ---------- */
const Z = { ziel: 0, jetzt: 0, mix: { fahrt: 1, explosion: 0, drehung: 0 } };
const damp = (a, b, lambda, dt) => THREE.MathUtils.lerp(a, b, 1 - Math.exp(-lambda * dt));
let B = 1, H = 1, schmal = false;
const P = v(0, 0, 0), Q = v(0, 0, 0), tmpP = v(0, 0, 0), tmpQ = v(0, 0, 0), proj = v(0, 0, 0);
function groesse() {
B = innerWidth; H = canvas.clientHeight || innerHeight; schmal = B < 761;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, W.dprMax));
renderer.setSize(B, H, false);
kamera.aspect = B / H;
// Bildausschnitt verschieben: Desktop Modell nach rechts (Text links), Telefon nach oben (Text unten)
if (schmal) kamera.setViewOffset(B, H, 0, H * 0.13, B, H); else kamera.setViewOffset(B, H, -B * (B < 1300 ? 0.19 : 0.14), 0, B, H);
// Telefon: kurze Beschriftungen
marken.forEach(m => { const sp = m.querySelector('span'); sp.dataset.lang ??= sp.textContent; sp.textContent = schmal && sp.dataset.kurz ? sp.dataset.kurz : sp.dataset.lang; });
messeAnker(); Z.ziel = zielAusScroll(); anstossen();
}
function stelle(t) {
t = THREE.MathUtils.clamp(t, 0, 3);
// Varianten werden weich überblendet, wenn man umschaltet
P.set(0, 0, 0); Q.set(0, 0, 0); let fov = 0, expl = 0, dreh = 0;
for (const [name, d] of Object.entries(VARIANTEN)) {
const w = Z.mix[name]; if (w < 0.0005) continue;
kurven[name].pos.getPoint(t / 3, tmpP); kurven[name].ziel.getPoint(t / 3, tmpQ);
P.addScaledVector(tmpP, w); Q.addScaledVector(tmpQ, w);
fov += stufe(d.fov, t * 2) * w; expl += stufe(d.explosion, t) * w; dreh += stufe(d.drehung, t) * w;
}
if (schmal) { fov *= 1.32; P.sub(Q).multiplyScalar(1.12).add(Q); } // hochkant: weiter und etwas zurück
kamera.fov = fov; kamera.position.copy(P); kamera.lookAt(Q); kamera.updateProjectionMatrix();
modell.rotation.y = dreh;
sockel.position.y = expl * 2; riegel.position.y = expl * 6; dach.position.y = expl * 10;
// Licht: Tageslauf
const h = stufe(LICHT.hoehe, t), r = stufe(LICHT.richtung, t), i = Math.min(2, Math.floor(t)), f = t - i;
sonne.position.set(Math.sin(r) * Math.cos(h), Math.sin(h), Math.cos(r) * Math.cos(h)).multiplyScalar(70).add(v(2, 0, 0));
sonne.target.position.set(2, 4, 0);
sonne.color.lerpColors(LICHT.farbe[i], LICHT.farbe[i + 1], f);
sonne.intensity = stufe(LICHT.staerke, t);
glasMat.emissiveIntensity = stufe(LICHT.fenster, t);
szene.background.lerpColors(LICHT.grund[i], LICHT.grund[i + 1], f); szene.fog.color.copy(szene.background);
modell.updateMatrixWorld(true);
}
function beschriften(t) {
const aktiv = Math.round(t);
marken.forEach((m, n) => {
anker3d[n].getWorldPosition(proj); proj.project(kamera);
const x = (proj.x + 1) / 2 * B, y = (1 - proj.y) / 2 * H;
const sichtbar = n === aktiv && Math.abs(t - n) < 0.32 && proj.z < 1;
m.classList.toggle('an', sichtbar);
const w = m.offsetWidth, links = x + w > B - 12 && x > B / 2; // am rechten Rand nach links aufklappen
m.classList.toggle('links', links);
const lx = links ? Math.max(12, x - w + 4.5) : Math.min(x - 4.5, B - 12 - w);
m.style.transform = `translate(${lx.toFixed(1)}px, ${(y - m.offsetHeight / 2).toFixed(1)}px)`;
});
navLinks.forEach((a, n) => a.classList.toggle('aktiv', n === aktiv));
leiste.style.scale = `${(t / 3).toFixed(4)} 1`;
const tEl = werteEl.querySelector('[data-t]'); if (tEl) tEl.textContent = t.toFixed(2);
}
/* ---------- Schleife: rendert nur, solange sich etwas bewegt ---------- */
let laeuft = false, letzte = 0, vorf = null;
function bild() {
const jetzt = performance.now(); // eigene Uhr statt RAF-Zeitstempel
const dt = THREE.MathUtils.clamp((jetzt - letzte) / 1000, 0, 0.5); letzte = Math.max(letzte, jetzt);
if (vorf) vorf.schritt(jetzt);
Z.ziel = zielAusScroll();
let ruhig = true;
if (reduziert()) Z.jetzt = Math.round(Z.ziel); // springt zur Schlüsselpose
else { Z.jetzt = damp(Z.jetzt, Z.ziel, W.lambda, dt); if (Math.abs(Z.jetzt - Z.ziel) > 0.0004) ruhig = false; else Z.jetzt = Z.ziel; }
for (const name in Z.mix) {
const soll = name === modus ? 1 : 0;
Z.mix[name] = reduziert() ? soll : damp(Z.mix[name], soll, 4, dt);
if (Math.abs(Z.mix[name] - soll) > 0.001) ruhig = false; else Z.mix[name] = soll;
}
stelle(Z.jetzt); renderer.render(szene, kamera); beschriften(Z.jetzt);
if (virtY != null) blendeKarten();
if (ruhig && !vorf && !document.hidden) { laeuft = false; return; }
requestAnimationFrame(bild);
}
anstossen = () => { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } };
addEventListener('scroll', anstossen, { passive: true });
let breiteVorher = innerWidth;
addEventListener('resize', () => { if (!schmal || innerWidth !== breiteVorher) { breiteVorher = innerWidth; groesse(); } else { messeAnker(); anstossen(); } });
document.addEventListener('visibilitychange', () => { if (!document.hidden) anstossen(); });
/* ---------- Vorführung: simulierter Scroll durch alle Kapitel und zurück (Inhalt wird verschoben, die Seite selbst bleibt stehen) ---------- */
const RUNDE = 22000;
const inhalt = [document.querySelector('main'), document.querySelector('.ende')];
function setzeVirt(y) { virtY = y; inhalt.forEach(el => { el.style.transform = y == null ? '' : `translate3d(0, ${(-y).toFixed(1)}px, 0)`; }); }
function stoppeVorfuehrung() {
if (virtY != null) { const y = virtY; setzeVirt(null); scrollTo(0, y); }
vorf = null; vPausiert = false; html.classList.remove('vorfuehrung'); anstossen();
}
['wheel', 'touchstart', 'keydown', 'pointerdown'].forEach(e => addEventListener(e, ev => { if ((vorf || vPausiert) && !ev.target.closest?.('button')) stoppeVorfuehrung(); }, { passive: true }));
let vZeit = 0, vPausiert = false;
vorfuehren = () => {
if (virtY == null) { scrollTo(0, 0); messeAnker(); }
html.classList.add('vorfuehrung'); vPausiert = false;
const ende = anker[anker.length - 1], start = performance.now() - vZeit;
vZeit = 0;
vorf = reduziert()
// Sprünge statt Fahrt: alle 2,6 s ein Kapitel weiter
? { schritt(j) { this.zeit = j - start; setzeVirt(anker[Math.floor(this.zeit / 2600) % 4]); } }
: { schritt(j) { this.zeit = j - start; setzeVirt(ende * (0.5 - 0.5 * Math.cos((this.zeit % RUNDE) / RUNDE * Math.PI * 2))); } };
anstossen();
};
pause = () => { if (vorf) { vZeit = vorf.zeit || 0; vPausiert = true; vorf = null; } };
weiter = () => { if (vPausiert) vorfuehren(); else anstossen(); };
neustart = () => { stoppeVorfuehrung(); vZeit = 0; scrollTo(0, 0); setzeVirt(null); Z.ziel = Z.jetzt = 0; anstossen(); };
// Shader vorkompilieren, dann einblenden
setzeModus('fahrt');
groesse(); stelle(Z.jetzt = Z.ziel);
renderer.compile(szene, kamera);
renderer.render(szene, kamera); beschriften(Z.jetzt);
requestAnimationFrame(() => html.classList.add('gl-bereit'));
}
</script>
</body>
</html>