Persistente 3D-Szene: Seitenwechsel als Kamerafahrt
Ein Canvas bleibt stehen, nur der Inhalt wechselt: Jeder Klick wird zur Kamerafahrt in den nächsten Raum.
- Ein einziger Three.js- oder WebGPU-Canvas liegt außerhalb des Bereichs, den der Router austauscht, und läuft über alle Seiten weiter.
- Klick auf einen Link: Der alte DOM-Inhalt geht (Router-Leave).
- Gleichzeitig fährt eine GSAP-Timeline die Kamera zur nächsten Station der Szene, etwa entlang der X-Achse.
- Gegen Ende der Fahrt steigt der neue DOM-Inhalt auf. Die Website wirkt wie ein zusammenhängender Raum statt wie eine Folge getrennter Seiten.
Navigation wird zur Ortsveränderung. Die Szene lädt nicht neu, es gibt keine Ladepause. Das Auge sieht den Weg zwischen zwei Seiten und behält so die Orientierung. Das wirkt wie ein Film mit Kamerafahrten statt wie eine Website mit Abschnitten. Es ist der stärkste Immersionseffekt dieser Kategorie.
Wann einsetzen
Studio- und Portfolio-Seiten, die als Ort gedacht sind. Marken- und Produktwelten, Kampagnen, Launches und Jahresberichte mit wenigen, inszenierten Hauptseiten oder Kapiteln.
Wann nicht
Seiten mit vielen Unterseiten, SEO-lastige Informationsseiten, Shops mit Conversion-Fokus, kleine Budgets. Nicht mit nativen Cross-document View Transitions kombinierbar: Beim echten Seitenwechsel stirbt der Canvas. Das Muster braucht einen Client-Router wie Barba, Highway oder eine SPA.
Varianten
- Kamerafahrt zwischen Objekten entlang einer Achse (Mael Ruffini; in der Demo „Fahrt“)
- Schwenk: Stationen im Kreis, die Kamera dreht sich um die Raummitte (in der Demo „Schwenk“)
- Szene bleibt, nur Parameter morphen: Farbe, Form, Objekt (in der Demo „Parameter“)
- Szenenwechsel per Offscreen-Rendering und eigenem Shader in WebGPU (Codrops 06/2026)
- Mehrere geschichtete Szenen (Aircord-Ansatz)
- Mehrszenige Erzählung: Szenen werden selektiv gerendert und per Übergangs-Shader gemischt (The Monolith Project, 13 Szenen)
- Scroll-gesteuerte Kapitel statt Klicks (Shader.se)
- Übergänge mit Frost, chromatischer Aberration und Displacement (Igloo Inc)
Umsetzung
Den Renderer einmal anlegen und außerhalb des ausgetauschten Bereichs mounten, also außerhalb von data-barba="container". Routen auf Kamera-Ziele abbilden, z. B. const stops = { '/': { x: 0 }, '/arbeiten/roestkontor': { x: 12 } }. Im Leave-Hook den DOM ausblenden und parallel gsap.to(camera.position, { ...stops[next], duration: 1.2, ease: 'power3.inOut' }) starten. Den neuen Inhalt einblenden, sobald beide Promises fertig sind oder kurz davor. In der Demo läuft ein Fortschritt p linear von 0 bis 1 und die Kurve wird beim Rechnen angewendet. So entstehen Kameraweg, Bogen (die Kamera zieht in der Mitte zurück) und Raumfarbe aus demselben Wert. Nach dem Wechsel nur DOM-Listener neu binden, den Canvas nie neu erzeugen.
Fallstricke: Szenen, die nicht entsorgt werden, verursachen Speicherlecks (dispose für Geometrie, Material, Textur). Ein Direkteinstieg auf eine Unterseite setzt die Kamera ohne Fahrt ans Ziel. Zurück-Navigation fährt in Gegenrichtung. Bei vielen Szenen hilft ein Szenen-Manager mit Lebenszyklus (init, enter, update, leave, dispose), der nur ein bis zwei Szenen gleichzeitig rendert. Assets kapitelweise nachladen, Shader im Hintergrund kompilieren. Für Shader-Übergänge zwischen zwei Render-Targets: mix(texA, texB, smoothstep(p - 0.1, p + 0.1, noise(uv))).
Robust umsetzen
Telefon. Szene vereinfachen: weniger Polygone, DPR 1 bis 1,5, kleinere Render-Targets. Auf schwachen Geräten ein statisches Bild oder Video statt der Szene. Im Hochformat die Station oben mittig zeigen, den Text darunter. Wischen als zusätzliche Navigation.
Ohne Bewegung. Die Kamera springt ohne Fahrt ans Ziel (duration 0), der DOM wechselt per kurzer Überblendung. Kein Displacement, kein Leerlauf, keine Parallaxe.
Barrierefrei. Canvas mit aria-hidden. Alle Inhalte stehen als HTML im DOM. Navigation über echte Links oder Buttons, Pfeiltasten als Abkürzung. Nach dem Wechsel Fokus auf die H1 und den Seitentitel per Live-Region ansagen. Ton nur nach Opt-in. Ohne WebGL ein einfacher Ersatz, hier ein Bildstreifen mit derselben Kurve.
Code
<!DOCTYPE html>
<!--
Persistente 3D-Szene: Seitenwechsel als Kamerafahrt
Fundus · Seitenübergänge · https://cecodesign.de/lab/fundus/seitenuebergaenge/seitenwechsel-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>Persistente 3D-Szene: Seitenwechsel als 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: '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: '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; }
:root {
--grund: #ebe6dc; --tinte: #1a1915; --leise: rgba(26, 25, 21, .58); --linie: rgba(26, 25, 21, .16);
--akzent: #b9532c;
--fahrt: cubic-bezier(0.76, 0, 0.24, 1); /* power3.inOut: Kamerafahrt */
--ein: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out: neuer Inhalt steigt auf */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; transition: background-color .6s linear; }
body { min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto; overflow-x: hidden; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Die Szene liegt außerhalb des ausgetauschten Bereichs und bleibt über alle „Seiten“ bestehen */
.szene { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; opacity: 0; transition: opacity .8s linear; }
.szene.bereit { opacity: 1; }
.kopf, .seite, .fuss { position: relative; z-index: 2; }
/* Kopf: Marke, gedachte Adresszeile, Raum-Navigation */
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; padding: clamp(18px, 3vw, 36px) clamp(20px, 4vw, 48px) 0; }
.marke { font-size: 17px; font-weight: 600; letter-spacing: -.02em; color: inherit; text-decoration: none; justify-self: start; }
.marke em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; font-size: 1.12em; letter-spacing: 0; }
.adresse { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 14px; border: 1px solid var(--linie); border-radius: 999px; text-transform: none; letter-spacing: .02em; white-space: nowrap; }
.adresse::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--akzent); }
.adresse b { font-weight: 500; color: var(--tinte); }
.nav { justify-self: end; display: flex; gap: clamp(14px, 2vw, 28px); }
.nav a { position: relative; color: var(--leise); text-decoration: none; padding: 6px 0; transition: color .15s linear; display: inline-flex; gap: 6px; }
.nav a span { opacity: .55; }
.nav a::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: currentColor; scale: 0 1; transform-origin: left; transition: scale .6s var(--ein); }
.nav a:hover, .nav a[aria-current='page'] { color: var(--tinte); }
.nav a[aria-current='page']::after { scale: 1 1; }
.nav a.gedrueckt { color: var(--akzent); }
.nav a:focus-visible, .knopf:focus-visible, .modi button:focus-visible, .marke:focus-visible { outline: 2px solid var(--akzent); outline-offset: 4px; border-radius: 2px; }
/* Ausgetauschter Bereich (wie data-barba="container") */
.seite { align-self: end; padding: 0 clamp(20px, 4vw, 48px) clamp(18px, 3vh, 32px); max-width: min(52vw, 700px); }
.seite .mono { margin-bottom: clamp(14px, 2vh, 22px); }
.titel { font-size: clamp(44px, 7.4vw, 118px); font-weight: 600; line-height: .9; letter-spacing: -.05em; outline: none; }
.titel em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.025em; color: var(--akzent); padding-right: .05em; }
.text { font-size: clamp(16px, 1.35vw, 19px); line-height: 1.45; color: var(--leise); max-width: 38ch; margin-top: clamp(16px, 2.4vh, 28px); }
.leistung { margin-top: 14px; color: var(--tinte); }
.knopf { margin-top: clamp(18px, 3vh, 32px); display: inline-flex; align-items: center; gap: 12px; height: 46px; padding: 0 8px 0 20px; border: 0; border-radius: 999px; background: var(--tinte); color: var(--grund); font: 500 15px/1 'Instrument Sans', system-ui, sans-serif; letter-spacing: -.01em; cursor: pointer; }
.knopf i { width: 32px; height: 32px; border-radius: 50%; background: var(--grund); color: var(--tinte); display: grid; place-items: center; font-style: normal; transition: translate .6s var(--ein); }
.knopf:hover i { translate: 4px 0; }
/* Maske je Zeile: Inhalt steigt aus der Kante auf und verschwindet nach oben */
.zeile { display: block; overflow: hidden; padding-block: .1em; margin-block: -.1em; }
.seite .text.zeile { margin-top: clamp(16px, 3vh, 32px); }
.seite .leistung.zeile { margin-top: 12px; }
.seite .mono.zeile:first-child { margin-bottom: clamp(12px, 2vh, 20px); }
.zeile > span { display: block; }
/* Fuß: Varianten und verwendete Werte */
.fuss { display: flex; justify-content: space-between; align-items: end; gap: 12px 24px; flex-wrap: wrap; padding: 0 clamp(20px, 4vw, 48px) clamp(18px, 3vw, 32px); border-top: 0; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; max-width: 62ch; }
.werte b { color: var(--tinte); font-weight: 500; }
.modi { display: flex; gap: 4px; }
.steuer { display: flex; gap: 4px; align-items: center; }
/* Zugang (WCAG 2.2.2): die Szene atmet im Leerlauf, also ein Knopf zum Anhalten */
.anhalten { width: 30px; height: 30px; flex: none; display: grid; place-items: center; padding: 0; color: var(--leise); background: rgba(255, 255, 255, .25); border: 1px solid var(--linie); border-radius: 999px; cursor: pointer; transition: color .15s, border-color .15s; }
.anhalten:hover { color: var(--tinte); border-color: rgba(26, 25, 21, .32); }
.anhalten:focus-visible { outline: 2px solid var(--akzent); outline-offset: 4px; }
.anhalten svg { width: 10px; height: 10px; fill: currentColor; }
.anhalten .abspielen, .anhalten[aria-pressed='true'] .pause { display: none; }
.anhalten[aria-pressed='true'] .abspielen { display: block; }
.reduziert .anhalten { display: none; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: rgba(255, 255, 255, .25); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s, background .15s, border-color .15s; }
.modi button:hover { color: var(--tinte); border-color: rgba(26, 25, 21, .32); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--grund); border-color: var(--tinte); }
/* Ohne WebGL: dieselbe Fahrt als Bildstreifen */
.ersatz { position: fixed; inset: 0; z-index: 1; overflow: hidden; display: none; }
.ohne-webgl .ersatz { display: block; }
.ersatz-streifen { display: flex; height: 100%; transition: transform 1.2s var(--fahrt) .1s; }
.ersatz-streifen div { flex: 0 0 100%; display: grid; place-items: center end; padding-right: clamp(20px, 9vw, 140px); }
.ersatz-streifen img { width: min(30vw, 340px); aspect-ratio: 4 / 5; object-fit: cover; border: 6px solid var(--tinte); box-shadow: 0 30px 60px -30px rgba(26, 25, 21, .5); }
.ersatz-streifen .bogen { width: min(24vw, 280px); aspect-ratio: 1; border-radius: 50% 50% 0 0; border: min(4vw, 48px) solid var(--akzent); border-bottom: 0; }
/* Vorführung: gedachter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 18px; height: 18px; pointer-events: none; display: none; transition: transform .75s var(--fahrt); }
.vorfuehrung .zeiger { display: block; }
/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .fuss { visibility: hidden; }
/* Telefon: Navigation unter die Marke, Szene oben, Text unten */
@media (max-width: 720px) {
.kopf { grid-template-columns: 1fr; gap: 12px; }
.adresse { display: none; }
.nav { justify-self: start; gap: 18px; }
.seite { max-width: none; }
.titel { font-size: clamp(44px, 13vw, 72px); }
.werte { display: none; }
.fuss { justify-content: flex-start; }
}
/* Zugang: 320 px ohne Querscrollen */
@media (max-width: 360px) { .nav { gap: 6px 14px; flex-wrap: wrap; } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
@media (hover: none) { .knopf:hover i { translate: none; } }
/* Reduzierte Bewegung: Kamera springt, Inhalt wechselt per kurzer Überblendung */
html.reduziert { transition: none; }
.reduziert .nav a::after, .reduziert .knopf i, .reduziert .ersatz-streifen { transition: none; }
</style>
</head>
<body>
<canvas class="szene" data-szene aria-hidden="true"></canvas>
<div class="ersatz" aria-hidden="true">
<div class="ersatz-streifen" data-streifen>
<div><span class="bogen"></span></div>
<div><img src="bilder/marke-roestkontor-hoch.jpg" alt=""></div>
<div><img src="bilder/marke-krume-hoch.jpg" alt=""></div>
<div><img src="bilder/flakon-hoch.jpg" alt=""></div>
</div>
</div>
<header class="kopf">
<a class="marke" href="#studio" data-ziel="0">Atelier <em>Hafenkante</em></a>
<p class="adresse mono" aria-hidden="true">hafenkante.de<b data-pfad>/</b></p>
<nav class="nav mono" aria-label="Räume">
<a href="#studio" data-ziel="0" aria-current="page"><span>00</span>Studio</a>
<a href="#kiosk" data-ziel="1"><span>01</span>Kiosk</a>
<a href="#krume" data-ziel="2"><span>02</span>Krume</a>
<a href="#ambra" data-ziel="3"><span>03</span>Ambra</a>
</nav>
</header>
<main class="seite" data-container></main>
<footer class="fuss">
<p class="werte mono" data-werte></p>
<div class="steuer">
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-variante="fahrt" aria-pressed="true">Fahrt</button>
<button type="button" data-variante="schwenk" aria-pressed="false">Schwenk</button>
<button type="button" data-variante="parameter" aria-pressed="false">Parameter</button>
</div>
<button class="anhalten" type="button" data-anhalten aria-pressed="false" aria-label="Bewegung anhalten" title="Bewegung anhalten"><svg class="pause" viewBox="0 0 10 10" aria-hidden="true"><rect x="1.5" y="1" width="2.4" height="8" rx=".6"/><rect x="6.1" y="1" width="2.4" height="8" rx=".6"/></svg><svg class="abspielen" viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 1.2v7.6L9 5z"/></svg></button>
</div>
</footer>
<p class="sr" aria-live="polite" data-ansage></p>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#1a1915" stroke="#ebe6dc" stroke-width="1.2" stroke-linejoin="round"/></svg>
<!-- Die vier „Seiten“. Im echten Einsatz liefert sie der Router (Barba, Highway, Astro) per fetch. -->
<template data-seite="0" data-pfad="/" data-titel="Studio">
<p class="mono zeile"><span>Büro für Marke, Raum und Web · Lübeck</span></p>
<h1 class="titel" tabindex="-1"><span class="zeile"><span>Wir bauen</span></span><span class="zeile"><span><em>Orte</em>, keine</span></span><span class="zeile"><span>Seiten.</span></span></h1>
<p class="text zeile"><span>Sieben Leute in einem alten Speicher an der Trave. Jedes Projekt bekommt hier einen eigenen Raum. Gehen Sie hinein.</span></p>
<button type="button" class="knopf" data-weiter>Rundgang beginnen <i aria-hidden="true">→</i></button>
</template>
<template data-seite="1" data-pfad="/arbeiten/roestkontor" data-titel="Röstkontor">
<p class="mono zeile"><span>01 / 03 · Kaffeerösterei · 2024</span></p>
<h1 class="titel" tabindex="-1"><span class="zeile"><span>Röst-</span></span><span class="zeile"><span><em>kontor</em></span></span></h1>
<p class="text zeile"><span>Marke, Verpackung und Website für eine Rösterei im alten Hafenkiosk. Eine Bohne als Signet, Ziegelrot wie die Speicher gegenüber.</span></p>
<p class="mono leistung zeile"><span>Marke · Verpackung · Website</span></p>
<button type="button" class="knopf" data-weiter>Nächster Raum <i aria-hidden="true">→</i></button>
</template>
<template data-seite="2" data-pfad="/arbeiten/krume" data-titel="Krume">
<p class="mono zeile"><span>02 / 03 · Backstube · 2025</span></p>
<h1 class="titel" tabindex="-1"><span class="zeile"><span>Krume,</span></span><span class="zeile"><span><em>frisch per Rad</em></span></span></h1>
<p class="text zeile"><span>Sauerteig um vier, Auslieferung um sechs. Wortmarke, Brottüten und eine Website, über die man das Lastenrad bis vor die Tür bestellt.</span></p>
<p class="mono leistung zeile"><span>Wortmarke · Verpackung · Website</span></p>
<button type="button" class="knopf" data-weiter>Nächster Raum <i aria-hidden="true">→</i></button>
</template>
<template data-seite="3" data-pfad="/arbeiten/ambra-nord" data-titel="Ambra Nord">
<p class="mono zeile"><span>03 / 03 · Kampagne · 2025</span></p>
<h1 class="titel" tabindex="-1"><span class="zeile"><span>Ein Duft,</span></span><span class="zeile"><span><em>ein Blau</em></span></span></h1>
<p class="text zeile"><span>Kampagne und Produktbilder für ein Parfum aus Lübeck. Der Flakon steht auf Sockeln wie ein Exponat, das Blau trägt vom Plakat bis zur Verpackung.</span></p>
<p class="mono leistung zeile"><span>Kampagne · Bildsprache · Verpackung</span></p>
<button type="button" class="knopf" data-weiter>Zurück ins Studio <i aria-hidden="true">→</i></button>
</template>
<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 container = $('[data-container]');
const seiten = $$('template[data-seite]');
const navLinks = $$('.nav a');
const N = seiten.length;
// Raumfarben: der Hintergrund wandert mit der Kamera von Raum zu Raum
const FARBEN = ['#ebe6dc', '#efe4d5', '#dfe6e1', '#e0e4ef'];
const TINTE = '#1a1915', TERRA = '#b9532c', SALBEI = '#8fa58f', TUERKIS = '#4fb3a3', BLAU = '#2443d6', PAPIER = '#f5f1ea';
// Drei Varianten, Werte aus den Messwerten des Musters (power3.inOut / expo.inOut, 1200–1800 ms)
const MODI = {
fahrt: { ease: 'power3.inOut', css: 'cubic-bezier(0.76, 0, 0.24, 1)', dauer: 1.2, weg: '12 Einheiten auf X, Bogen 2,8 zurück' },
schwenk: { ease: 'expo.inOut', css: 'cubic-bezier(0.87, 0, 0.13, 1)', dauer: 1.6, weg: '90° um die Raummitte, Radius 14' },
parameter: { ease: 'power3.inOut', css: 'cubic-bezier(0.76, 0, 0.24, 1)', dauer: 1.2, weg: 'Kamera fest, Objekt tauscht, Farbe morpht' },
};
const AUS = { dauer: 0.45, ease: 'power3.in', versatz: 0.035 }; // alter Inhalt geht
const EIN = { dauer: 0.9, ease: 'expo.out', versatz: 0.06 }; // neuer Inhalt kommt
const ABSTAND = 12, R = 14;
const MITTE = [0.1, 0.45, 0.5, 0.5]; // Bildmitte jeder Station (lokales x), damit jede Gruppe gleich sitzt
let variante = 'fahrt';
let aktiv = Math.max(0, navLinks.findIndex(a => a.getAttribute('href') === location.hash)); // Direkteinstieg: Kamera ohne Fahrt am Ziel
/* ---------- Seiten (simulierter Router) ---------- */
function seiteSetzen(i, versteckt) {
container.replaceChildren(seiten[i].content.cloneNode(true));
if (versteckt && !reduziert()) { gsap.set(teile(), { yPercent: 120 }); gsap.set(knopf(), { autoAlpha: 0, y: 18 }); }
container.querySelector('[data-weiter]').addEventListener('click', () => { stoppeVorfuehrung(); gehe((aktiv + 1) % N, true); });
}
const teile = () => container.querySelectorAll('.zeile > span');
const knopf = () => container.querySelector('.knopf');
function markieren(i) {
navLinks.forEach((a, j) => { if (j === i) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); });
$('[data-pfad]').textContent = seiten[i].dataset.pfad;
$('[data-streifen]').style.transform = `translateX(${-100 * i}%)`;
html.style.backgroundColor = FARBEN[i];
html.style.setProperty('--grund', FARBEN[i]);
}
function zeigeWerte() {
const m = MODI[variante];
$('[data-werte]').innerHTML = reduziert()
? '<span>Reduzierte Bewegung: <b>Kamera springt, Inhalt blendet 150 + 200 ms</b></span>'
: `<span>Kurve <b>${m.ease}</b></span><span>Kamera <b>${m.dauer * 1000} ms</b></span><span>Inhalt aus <b>450 ms</b> · ein <b>900 ms expo.out, +60 ms</b></span><span>Weg <b>${m.weg}</b></span>`;
}
/* ---------- Szene ---------- */
const canvas = $('[data-szene]');
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; }
}
if (!renderer || !renderer.getContext()) { document.body.classList.add('ohne-webgl'); renderer = null; }
const scene = new THREE.Scene();
const FOV = 30;
const camera = new THREE.PerspectiveCamera(FOV, innerWidth / innerHeight, 0.1, 120);
const hinter = new THREE.Color(FARBEN[aktiv]);
scene.background = hinter;
scene.fog = new THREE.Fog(hinter, 16, 44);
const hemi = new THREE.HemisphereLight('#fffaf2', '#cbbba6', 1.5);
const sonne = new THREE.DirectionalLight('#fff1df', 2.6);
sonne.castShadow = true;
sonne.shadow.mapSize.set(1024, 1024);
Object.assign(sonne.shadow.camera, { left: -6, right: 6, top: 6, bottom: -6, near: 1, far: 30 });
sonne.shadow.bias = -0.0005; sonne.shadow.normalBias = 0.03;
scene.add(hemi, sonne, sonne.target);
// Boden: nur Schatten, sonst unsichtbar – so geht er nahtlos in den Hintergrund über
const boden = new THREE.Mesh(new THREE.PlaneGeometry(240, 240), new THREE.ShadowMaterial({ opacity: 0.16 }));
boden.rotation.x = -Math.PI / 2; boden.receiveShadow = true;
scene.add(boden);
// Punktraster auf dem Boden: macht Tempo und Richtung der Fahrt lesbar
{
const p = [];
for (let x = -26; x <= 60; x += 1.2) for (let z = -26; z <= 18; z += 1.2) p.push(x, 0.005, z);
const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(p, 3));
const c = document.createElement('canvas'); c.width = c.height = 32;
const x = c.getContext('2d'); x.fillStyle = '#fff'; x.beginPath(); x.arc(16, 16, 14, 0, Math.PI * 2); x.fill();
scene.add(new THREE.Points(g, new THREE.PointsMaterial({ color: TINTE, size: 0.05, map: new THREE.CanvasTexture(c), alphaTest: 0.5, transparent: true, opacity: 0.3 })));
}
const mat = (c, o = {}) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.82, metalness: 0, ...o });
const wirft = m => { m.castShadow = true; m.receiveShadow = true; return m; };
const mesh = (geo, m, x = 0, y = 0, z = 0) => { const o = wirft(new THREE.Mesh(geo, m)); o.position.set(x, y, z); return o; };
const lader = new THREE.TextureLoader();
// weicher Kontaktschatten (Verlauf als Textur)
const blobTex = (() => {
const c = document.createElement('canvas'); c.width = c.height = 128;
const x = c.getContext('2d'), g = x.createRadialGradient(64, 64, 4, 64, 64, 64);
g.addColorStop(0, 'rgba(26,25,21,.55)'); g.addColorStop(1, 'rgba(26,25,21,0)');
x.fillStyle = g; x.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(c);
})();
function blob(w, d, x = 0, z = 0) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d), new THREE.MeshBasicMaterial({ map: blobTex, transparent: true, depthWrite: false, opacity: 0.6 }));
m.rotation.x = -Math.PI / 2; m.position.set(x, 0.01, z); return m;
}
function sockel(w, h, d, farbe = PAPIER, x = 0, z = 0) { return mesh(new THREE.BoxGeometry(w, h, d), mat(farbe), x, h / 2, z); }
function tafel(datei, y) {
const g = new THREE.Group();
g.add(mesh(new THREE.BoxGeometry(1.74, 2.16, 0.08), mat(TINTE, { roughness: 0.6 })));
const tex = lader.load('bilder/' + datei);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
const bild = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 2.0), new THREE.MeshBasicMaterial({ map: tex, color: '#f2f0ec' })); // ungeleuchtet: Arbeiten in echten Farben
bild.position.z = 0.041; g.add(bild);
g.position.set(-0.3, y + 1.08, -0.05); g.rotation.x = -0.07; g.rotation.y = 0.12;
return g;
}
function stab(a, b, r, m) {
const v = new THREE.Vector3().subVectors(b, a), o = wirft(new THREE.Mesh(new THREE.CylinderGeometry(r, r, v.length(), 10), m));
o.position.copy(a).addScaledVector(v, 0.5); o.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), v.normalize());
return o;
}
// Vier Stationen = vier Seiten. Jede bekommt ein Objekt, das sich im Leerlauf leise bewegt.
const stationen = [], leerlauf = [];
{ // 00 Studio: Bogen und Kugel auf runder Scheibe
const g = new THREE.Group();
g.add(mesh(new THREE.CylinderGeometry(2.05, 2.05, 0.1, 64), mat(PAPIER), 0, 0.05, 0));
g.add(mesh(new THREE.TorusGeometry(1.35, 0.3, 20, 72, Math.PI), mat(TERRA, { roughness: 0.7 }), 0.25, 0, -0.2));
const kugel = mesh(new THREE.SphereGeometry(0.52, 40, 28), mat(SALBEI), -1.05, 0.62, 0.95);
g.add(kugel, blob(5.2, 5.2));
leerlauf.push(t => { kugel.position.y = 0.62 + Math.sin(t * 1.1) * 0.05; });
stationen.push(g);
}
{ // 01 Röstkontor: Tafel auf Sockel, kleiner Hafenkiosk daneben
const g = new THREE.Group();
g.add(sockel(2.0, 0.62, 1.1, PAPIER, -0.3), tafel('marke-roestkontor-hoch.jpg', 0.62));
const pav = new THREE.Group();
pav.add(mesh(new THREE.CylinderGeometry(0.52, 0.56, 0.1, 8), mat(PAPIER), 0, 0.05, 0));
pav.add(mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.52, 8), mat('#f8f2e6'), 0, 0.36, 0));
pav.add(mesh(new THREE.ConeGeometry(0.58, 0.44, 8), mat('#d98b6b', { roughness: 0.7 }), 0, 0.84, 0));
pav.add(mesh(new THREE.SphereGeometry(0.05, 12, 8), mat(TINTE), 0, 1.1, 0));
pav.position.set(1.45, 0, 0.75); pav.traverse(o => { o.receiveShadow = false; });
g.add(pav, blob(4.6, 3.2, 0.2, 0.1));
leerlauf.push(t => { pav.rotation.y = t * 0.25; });
stationen.push(g);
}
{ // 02 Krume: dunkler Sockel, Lieferrad mit zwei Rädern
const g = new THREE.Group();
g.add(sockel(1.9, 0.9, 1.0, '#33251b', -0.3), tafel('marke-krume-hoch.jpg', 0.9));
const rad = new THREE.Group(), m = mat(TUERKIS, { roughness: 0.5 });
const ra = new THREE.TorusGeometry(0.42, 0.045, 12, 56);
const a = mesh(ra, m, -0.55, 0.45, 0), b = mesh(ra, m, 0.55, 0.45, 0);
const A = new THREE.Vector3(-0.55, 0.45, 0), B = new THREE.Vector3(0.55, 0.45, 0), T = new THREE.Vector3(0.05, 1.0, 0), S = new THREE.Vector3(-0.1, 1.04, 0);
rad.add(a, b, stab(A, T, 0.03, m), stab(B, T, 0.03, m), stab(A, new THREE.Vector3(0, 0.45, 0), 0.03, m), stab(new THREE.Vector3(0, 0.45, 0), T, 0.03, m), stab(T.clone().add(new THREE.Vector3(-0.15, 0, 0)), S.add(new THREE.Vector3(-0.15, 0.02, 0)), 0.04, mat(TINTE)));
rad.position.set(1.6, 0, 0.75); rad.rotation.y = -0.55;
g.add(rad, blob(4.8, 3.2, 0.3, 0.2));
leerlauf.push(t => { a.rotation.z = b.rotation.z = -t * 0.8; });
stationen.push(g);
}
{ // 03 Ambra Nord: Tafel und drei blaue Bausteine
const g = new THREE.Group();
g.add(sockel(2.0, 0.7, 1.1, PAPIER, -0.3), tafel('flakon-hoch.jpg', 0.7));
const m = mat(BLAU, { roughness: 0.55 });
const k1 = mesh(new THREE.BoxGeometry(0.62, 0.62, 0.62), m, 1.25, 0.31, 0.75);
const k2 = mesh(new THREE.BoxGeometry(0.62, 0.62, 0.62), m, 1.95, 0.31, 0.45); k2.rotation.y = 0.5;
const k3 = mesh(new THREE.BoxGeometry(0.46, 0.46, 0.46), m, 1.55, 0.86, 0.6); k3.rotation.y = 0.25;
g.add(k1, k2, k3, blob(5, 3.2, 0.4, 0.2));
leerlauf.push(t => { k3.rotation.y = 0.25 + Math.sin(t * 0.6) * 0.35; });
stationen.push(g);
}
stationen.forEach(s => scene.add(s));
function anordnen() {
stationen.forEach((g, i) => {
g.scale.setScalar(1); g.visible = true;
if (variante === 'fahrt') { g.position.set(i * ABSTAND, 0, 0); g.rotation.y = 0; }
else if (variante === 'schwenk') { const a = i * Math.PI / 2; g.position.set(Math.sin(a) * R, 0, Math.cos(a) * R); g.rotation.y = a + Math.PI; } // Stationen im Kreis, Blick nach außen
else { g.position.set(0, 0, 0); g.rotation.y = 0; }
});
}
/* ---------- Kamera ---------- */
const blick = { d: 10, shift: 0, camY: 2.4, tgtY: 1.3 };
function blickBerechnen() {
const asp = innerWidth / innerHeight, tan = 2 * Math.tan(THREE.MathUtils.degToRad(FOV / 2));
if (asp >= 1) { // quer: Objekt rechts, Text links
blick.d = Math.max(10.5, 10.4 / (asp * tan), 3.2 / (0.5 * tan));
blick.shift = blick.d * tan * asp * 0.2; // Station sitzt bei 70 % der Breite
blick.tgtY = 1.05;
} else { // hoch: Objekt oben, Text unten
blick.d = Math.max(11, 5.7 / (asp * tan));
blick.shift = 0;
blick.tgtY = 1.3 - blick.d * tan * 0.2;
}
blick.camY = blick.tgtY + blick.d * 0.13;
}
const cam = new THREE.Vector3(), tgt = new THREE.Vector3();
function rahmen(s, bogen, richtung, mitte) {
const { d, camY, tgtY } = blick, shift = blick.shift - mitte;
if (variante === 'fahrt') {
const x = s * ABSTAND;
cam.set(x - shift, camY + 0.45 * bogen, d + 2.8 * bogen);
tgt.set(x - shift + richtung * 1.4 * bogen, tgtY, 0); // Blick eilt in Fahrtrichtung voraus
} else if (variante === 'schwenk') {
// Kamera steht nahe der Raummitte und dreht sich zur nächsten Station (Schwenk)
const a = s * Math.PI / 2, si = Math.sin(a + Math.PI), co = Math.cos(a + Math.PI);
const px = Math.sin(a) * R, pz = Math.cos(a) * R;
const welt = (lx, y, lz, v) => v.set(px + lx * co + lz * si, y, pz - lx * si + lz * co);
welt(-shift, camY + 0.4 * bogen, d + 3 * bogen, cam);
welt(-shift, tgtY, 0, tgt);
} else {
cam.set(-shift, camY + 0.2 * bogen, d + 1.2 * bogen);
tgt.set(-shift, tgtY, 0);
}
}
// Laufender Übergang: p linear 0→1, Kurve wird beim Rechnen angewendet
let lauf = { a: aktiv, b: aktiv, p: 1 };
let kurve = gsap.parseEase(MODI.fahrt.ease);
const zeiger = { x: 0, y: 0, ix: 0, iy: 0 };
const fa = new THREE.Color(), fb = new THREE.Color();
const mod = i => ((Math.round(i) % N) + N) % N;
let uhrzeit = 0, letzte = performance.now(), rafId = 0, laeuft = false, erstesBild = true, angehalten = false;
function bild(jetzt) {
const dt = Math.min(0.1, (jetzt - letzte) / 1000); letzte = jetzt;
if (!reduziert() && !angehalten) uhrzeit += dt; /* angehalten: Atmen und Leerlauf stehen */
const e = kurve(lauf.p), s = lauf.a + (lauf.b - lauf.a) * e;
const bogen = lauf.p < 1 ? Math.sin(Math.PI * e) : 0, richtung = Math.sign(lauf.b - lauf.a);
const m = variante === 'parameter' ? MITTE[mod(lauf.a)] + (MITTE[mod(lauf.b)] - MITTE[mod(lauf.a)]) * e
: MITTE[mod(Math.floor(s))] + (MITTE[mod(Math.floor(s) + 1)] - MITTE[mod(Math.floor(s))]) * (s - Math.floor(s));
rahmen(variante === 'parameter' ? 0 : s, bogen, richtung, m);
// Leerlauf: leichtes Atmen und Maus-Parallaxe, damit die Szene als Ort lebt
if (!reduziert()) {
zeiger.ix += (zeiger.x - zeiger.ix) * Math.min(1, dt * 3);
zeiger.iy += (zeiger.y - zeiger.iy) * Math.min(1, dt * 3);
cam.x += zeiger.ix * 0.5 + Math.sin(uhrzeit * 0.35) * 0.06;
cam.y += zeiger.iy * 0.25 + Math.sin(uhrzeit * 0.5) * 0.04;
leerlauf.forEach(f => f(uhrzeit));
}
camera.position.copy(cam); camera.lookAt(tgt);
sonne.target.position.copy(tgt).setY(0);
sonne.position.copy(tgt).add(new THREE.Vector3(-4, 8, 6));
// Raumfarbe: zwischen den Stationen gemischt
if (variante === 'parameter') {
fa.set(FARBEN[mod(lauf.a)]); fb.set(FARBEN[mod(lauf.b)]); hinter.copy(fa).lerp(fb, e);
// alter Inhalt sinkt und schrumpft (power3.in), neuer wächst auf (expo.out)
stationen.forEach((g, i) => {
let k = i === mod(lauf.b) ? 1 : 0;
if (lauf.p < 1 && lauf.a !== lauf.b) {
const t = lauf.p;
if (i === mod(lauf.a)) { const q = Math.min(1, t / 0.45); k = 1 - q * q * q; }
else if (i === mod(lauf.b)) { const q = Math.max(0, (t - 0.35) / 0.65); k = q >= 1 ? 1 : 1 - Math.pow(2, -10 * q); }
}
g.visible = k > 0.002; g.scale.setScalar(Math.max(0.002, k)); g.position.y = -0.6 * (1 - k);
});
} else {
const lo = Math.floor(s), f = s - lo;
fa.set(FARBEN[mod(lo)]); fb.set(FARBEN[mod(lo + 1)]); hinter.copy(fa).lerp(fb, f);
}
scene.fog.color.copy(hinter);
renderer.render(scene, camera);
if (erstesBild) { erstesBild = false; canvas.classList.add('bereit'); }
if (laeuft) rafId = requestAnimationFrame(bild);
}
function starteBild() { if (!renderer || laeuft) return; laeuft = true; letzte = performance.now(); rafId = requestAnimationFrame(bild); }
function stoppeBild() { laeuft = false; cancelAnimationFrame(rafId); }
function groesse() {
if (!renderer) return;
renderer.setPixelRatio(Math.min(devicePixelRatio, beruehrung ? 1.5 : 2));
renderer.setSize(innerWidth, innerHeight, false);
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
blickBerechnen();
if (!laeuft) bild(performance.now());
}
if (renderer) {
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
addEventListener('resize', groesse);
}
/* ---------- Übergang: Inhalt aus · Kamera fährt · Inhalt ein ---------- */
let tl = null, ein = null;
function einblenden(fokus, verzoegerung = 0) {
const t = teile();
ein = gsap.timeline({ delay: verzoegerung, onComplete: () => fokus && container.querySelector('h1').focus({ preventScroll: true }) })
.to(t, { yPercent: 0, duration: EIN.dauer, ease: EIN.ease, stagger: EIN.versatz }, 0)
.to(knopf(), { autoAlpha: 1, y: 0, duration: EIN.dauer, ease: EIN.ease }, t.length * EIN.versatz);
}
function gehe(ziel, nutzer) {
if (ziel === aktiv && lauf.p >= 1) return;
if (tl) tl.progress(1).kill();
if (ein) ein.progress(1).kill();
const von = aktiv; aktiv = ziel;
markieren(ziel);
$('[data-ansage]').textContent = 'Seite: ' + seiten[ziel].dataset.titel;
if (reduziert() || !renderer) { // harter Kameraschnitt, Inhalt per Überblendung
lauf = { a: ziel, b: ziel, p: 1 };
tl = gsap.timeline()
.to(container, { opacity: 0, duration: 0.15, ease: 'none' })
.add(() => { seiteSetzen(ziel); })
.to(container, { opacity: 1, duration: 0.2, ease: 'none', onComplete: () => nutzer && container.querySelector('h1').focus({ preventScroll: true }) });
if (!laeuft) groesse();
return;
}
const M = MODI[variante];
let b = ziel;
if (variante === 'schwenk') { let dif = ((ziel - von) % N + N) % N; if (dif > N / 2) dif -= N; b = von + dif; } // kürzester Weg auf dem Kreis
lauf = { a: von, b, p: 0 };
tl = gsap.timeline({ onComplete() { lauf = { a: aktiv, b: aktiv, p: 1 }; } });
tl.to(teile(), { yPercent: -120, duration: AUS.dauer, ease: AUS.ease, stagger: AUS.versatz }, 0)
.to(knopf(), { autoAlpha: 0, duration: 0.3, ease: 'power3.in' }, 0)
.to(lauf, { p: 1, duration: M.dauer, ease: 'none' }, 0.1)
.add(() => seiteSetzen(ziel, true), 0.1 + AUS.dauer + 0.08)
.add(() => einblenden(nutzer), 0.1 + M.dauer * 0.68);
}
/* ---------- Bedienung ---------- */
document.addEventListener('click', e => {
const a = e.target.closest('[data-ziel]');
if (!a) return;
e.preventDefault();
stoppeVorfuehrung();
gehe(+a.dataset.ziel, true);
});
addEventListener('keydown', e => {
if (e.target.closest('button, a') && (e.key === 'Enter' || e.key === ' ')) return;
if (e.key === 'ArrowRight') { stoppeVorfuehrung(); gehe((aktiv + 1) % N, true); }
if (e.key === 'ArrowLeft') { stoppeVorfuehrung(); gehe((aktiv + N - 1) % N, true); }
});
if (!beruehrung) addEventListener('pointermove', e => { zeiger.x = e.clientX / innerWidth - 0.5; zeiger.y = 0.5 - e.clientY / innerHeight; });
// Touch: waagerecht wischen wechselt den Raum
let wisch = null;
addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') wisch = { x: e.clientX, y: e.clientY }; });
addEventListener('pointerup', e => {
if (!wisch) return;
const dx = e.clientX - wisch.x, dy = e.clientY - wisch.y; wisch = null;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.4) { stoppeVorfuehrung(); gehe((aktiv + (dx < 0 ? 1 : N - 1)) % N, true); }
});
$('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-variante]');
if (!b || b.dataset.variante === variante) return;
setzeVariante(b.dataset.variante);
});
function setzeVariante(v) {
if (tl) tl.progress(1).kill();
if (ein) ein.progress(1).kill();
variante = v;
kurve = gsap.parseEase(MODI[v].ease);
$$('.modi button').forEach(b => b.setAttribute('aria-pressed', b.dataset.variante === v));
lauf = { a: aktiv, b: aktiv, p: 1 };
anordnen(); zeigeWerte();
if (renderer && !laeuft) bild(performance.now());
}
/* ---------- Vorführung: gedachter Zeiger klickt sich durch die Räume ---------- */
const zeigerEl = $('[data-zeiger]');
let uhr = null, vorfuehrt = false;
function zeigerZu(el) {
const r = el.getBoundingClientRect();
zeigerEl.style.transform = `translate(${r.left + r.width * 0.55}px, ${r.top + r.height * 0.6}px)`;
}
function takt() {
const naechste = (aktiv + 1) % N, link = navLinks[naechste];
zeigerZu(link);
uhr = setTimeout(() => {
link.classList.add('gedrueckt'); setTimeout(() => link.classList.remove('gedrueckt'), 240);
gehe(naechste, false);
uhr = setTimeout(takt, MODI[variante].dauer * 1000 + 1700);
}, 800);
}
function stoppeVorfuehrung() { if (!vorfuehrt) return; vorfuehrt = false; clearTimeout(uhr); html.classList.remove('vorfuehrung'); }
function neustart() {
stoppeVorfuehrung();
if (tl) tl.kill(); if (ein) ein.kill();
aktiv = 0; lauf = { a: 0, b: 0, p: 1 };
markieren(0); seiteSetzen(0, true);
if (reduziert()) gsap.fromTo(container, { opacity: 0 }, { opacity: 1, duration: 0.2 });
else { gsap.set(container, { opacity: 1 }); einblenden(false, 0.2); }
starteBild();
}
function vorfuehren() {
clearTimeout(uhr);
vorfuehrt = true; html.classList.add('vorfuehrung');
starteBild();
const r = innerWidth * 0.62, h = innerHeight * 0.55;
zeigerEl.style.transition = 'none'; zeigerEl.style.transform = `translate(${r}px, ${h}px)`;
zeigerEl.getBoundingClientRect(); zeigerEl.style.transition = '';
uhr = setTimeout(takt, 250);
}
function pause() { clearTimeout(uhr); if (tl) tl.pause(); if (ein) ein.pause(); stoppeBild(); angehalten = true; anhaltenZeigen(); }
function weiter() { angehalten = false; anhaltenZeigen(); if (tl) tl.resume(); if (ein) ein.resume(); starteBild(); if (vorfuehrt) uhr = setTimeout(takt, 600); }
/* Knopf „Bewegung anhalten“ (WCAG 2.2.2): dieselbe Pause wie FUNDUS_DEMO.pause(); Fortsetzen ohne Vorführung */
function anhaltenZeigen() { const k = document.querySelector('[data-anhalten]'); if (k) k.setAttribute('aria-pressed', angehalten ? 'true' : 'false'); }
document.querySelector('[data-anhalten]').addEventListener('click', () => {
if (!angehalten) { pause(); return; }
angehalten = false; anhaltenZeigen(); if (tl) tl.resume(); if (ein) ein.resume(); starteBild();
});
/* Start */
markieren(aktiv); zeigeWerte(); anordnen();
seiteSetzen(aktiv, true);
gsap.set(container, { opacity: 1 });
if (!reduziert()) einblenden(false, 0.3);
if (renderer) { groesse(); starteBild(); }
</script>
</body>
</html>