Zum Inhalt
← Footer

Footer als 3D-/Shader-Bühne

Bleilettern aus three.js als Schlussbühne eines Mainzer Studios: eine Letter folgt dem Zeiger, die ganze Wortmarke steht spiegelverkehrt im Satz oder zerfällt in Partikel.

Der Footer wird zur Bühne einer WebGL-Szene: ein 3D-Objekt wie das Logo, die ganze Wortmarke aus Körpern, Partikel, Flüssigkeit oder Kristall. Die Szene reagiert auf Zeiger und Scroll, sie neigt sich, weicht aus oder wirft Wellen. Typografie und Links bleiben HTML und liegen darüber. Oft ist sie als Endszene inszeniert, die den Erzählbogen der Seite schließt.

Ein bildstarker Schlusspunkt. Besonders stark, wenn die Seite schon mit einer 3D-Szene beginnt: Anfang und Ende bilden eine Klammer.

Wann einsetzen

Immersive Marken- und Kampagnenseiten. Studios, die WebGL beherrschen und das zeigen wollen.

Wann nicht

Wenn die übrige Seite kein WebGL nutzt, denn dann entsteht die ganze Last nur für den Footer. Bei schwachen Zielgeräten.

Varianten

  • Logo-Objekt, das dem Zeiger folgt
  • Endszene: die ganze Wortmarke als 3D-Körper
  • Partikel-Wortmarke, die dem Zeiger ausweicht
  • Flüssigkeits- oder Kristall-Shader als Fläche

Umsetzung

Canvas absolut hinter dem Footer-Inhalt, Texte und Links als HTML darüber. Den Renderer erst anlegen, wenn der Footer nahe am Viewport ist (IntersectionObserver, rootMargin 50 %), und die Render-Schleife außerhalb anhalten. Hat die Seite schon eine WebGL-Szene, denselben Renderer weiterverwenden (ein GL-Kontext). Pixel-Ratio auf 2 deckeln, mobil auf 1 bis 1,5. Objekte an leeren Platzhalter-Elementen im Layout ausrichten, so bleibt die Szene responsiv und überdeckt keinen Text. Shader vorab mit renderer.compile übersetzen, sonst ruckelt der erste Variantenwechsel. Ohne WebGL ein Standbild zeigen.

Robust umsetzen

Telefon. Vereinfachte Szene mit weniger Partikeln und Pixel-Ratio 1 bis 1,5, oder ein Standbild bzw. Video. Kein Zeiger-Folgen, höchstens eine Reaktion auf Antippen.

Ohne Bewegung. Standbild der Szene oder Szene ohne Eigenbewegung. Nur bei Eingaben neu rendern.

Barrierefrei. Canvas mit aria-hidden. Den Kontrast der Texte über allen Szenenzuständen sichern, am einfachsten mit einem eigenen Bereich für die Szene. Was die Szene zeigt (etwa die Wortmarke), zusätzlich als Text anbieten.

Code

<!DOCTYPE html>
<!--
  Footer als 3D-/Shader-Bühne
  Fundus · Footer · https://cecodesign.de/lab/fundus/footer/footer-3d-buehne/
  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>Footer als 3D-/Shader-Bühne · 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: 'TikTok Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/tiktok-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 300 900; font-stretch: 75% 150%; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  --schwarz: #0b0b0b; --hell: #f2f2f2; --weiss: #ffffff; --meta: #8a8a8a; --linie: rgba(255, 255, 255, .14);
  --signal: #ff5a1f;
  --kurve: cubic-bezier(0.16, 1, 0.3, 1);   /* eine Kurve für alles Sichtbare */
  --rand: clamp(18px, 3vw, 44px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--schwarz); color: var(--weiss); font-family: 'TikTok Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
a { color: inherit; text-decoration: none; }
.mono { font: 500 .75rem/1.35 'DM Mono', ui-monospace, monospace; letter-spacing: .04em; text-transform: uppercase; }
.weg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ---------- Demo-Pult (nicht Teil der erfundenen Seite) ---------- */
.pult { position: fixed; top: 14px; right: var(--rand); z-index: 20; display: flex; align-items: center; gap: 6px 14px; flex-wrap: wrap; justify-content: flex-end; max-width: calc(100vw - 2 * var(--rand));
  padding: 5px 5px 5px 14px; border: 1px solid var(--linie); border-radius: 18px; background: rgba(11, 11, 11, .78); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); color: var(--meta); }
.pult .werte { white-space: nowrap; }
.pult .werte b { color: var(--weiss); font-weight: 500; }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--meta); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 11px; cursor: pointer; transition: color .2s var(--kurve), background-color .2s var(--kurve), border-color .2s var(--kurve); }
.modi button:hover { color: var(--weiss); border-color: rgba(255, 255, 255, .34); }
.modi button[aria-pressed='true'] { background: var(--weiss); color: var(--schwarz); border-color: var(--weiss); }
:focus-visible { outline: 2px solid var(--signal); outline-offset: 3px; }

/* ---------- Footer ---------- */
.fuss { position: relative; min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; isolation: isolate; overflow: hidden; }
.buehne { position: relative; display: grid; grid-template-rows: auto minmax(260px, 1fr); }
.kopfzeile { display: flex; gap: 8px 28px; flex-wrap: wrap; padding: 22px var(--rand) 0; color: var(--meta); min-height: 64px; align-items: start; }
.kopfzeile .ort time { color: var(--weiss); }

.haupt { position: relative; z-index: 2; display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr); gap: 32px 4vw; padding: clamp(8px, 2.4vh, 32px) var(--rand) 0; }
.platz { pointer-events: none; }
.ansprache { font-weight: 800; font-stretch: 75%; font-size: clamp(3.5rem, 9vw, 11rem); line-height: .9; letter-spacing: -.03em; }
.ansprache > span { display: block; }
.ansprache .rot { color: var(--signal); }
.anfrage { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 22px; margin-top: clamp(18px, 3vh, 32px); }
.mail { font-size: clamp(1.125rem, 1.6vw, 1.5rem); font-weight: 500; letter-spacing: -.01em; background: linear-gradient(currentColor, currentColor) 0 100% / 100% 1px no-repeat; padding-bottom: 3px; transition: background-size .4s var(--kurve); }
.mail:hover { background-size: 0 1px; background-position: 100% 100%; }

/* Text-Roll im Knopf (Signatur des Rezepts) */
.knopf { position: relative; display: inline-flex; height: 52px; padding: 0 24px; align-items: center; border-radius: 999px; background: var(--signal); color: var(--schwarz); font-weight: 600; font-size: 1rem; overflow: hidden; }
.knopf .rolle { display: grid; overflow: hidden; height: 1.3em; line-height: 1.3; }
.knopf .rolle span { transition: transform .4s var(--kurve); }
.knopf:hover .rolle span { transform: translateY(-100%); }

.unten { position: relative; z-index: 2; padding: 0 var(--rand); }
.spalten { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px clamp(16px, 2vw, 32px); font-size: 1rem; line-height: 1.45; padding: 18px 0 22px; border-top: 1px solid var(--linie); }
.spalten .satz { color: var(--meta); max-width: 30ch; }
.spalten h3 { color: var(--meta); margin-bottom: 10px; font-weight: 500; }
.spalten ul { list-style: none; }
.spalten address { font-style: normal; }
.spalten a { transition: color .2s var(--kurve); }
.spalten li a { display: inline-block; padding-block: 2px; margin-block: -2px; }   /* Zielhöhe ≥ 24 px ohne Layoutänderung */
.spalten a:hover { color: var(--signal); }

/* Bühne: das Band, in dem die Szene steht. Canvas ragt oben und unten über, damit gekippte Lettern nicht abgeschnitten werden. */
.band { position: relative; z-index: 1; }
.leinwand { position: absolute; left: 0; right: 0; top: -40px; bottom: -40px; z-index: 0; pointer-events: none; }
.leinwand canvas { display: block; width: 100%; height: 100%; opacity: 0; transition: opacity 1s var(--kurve); }
.bereit .leinwand canvas { opacity: 1; }
.bildnotiz { position: absolute; left: var(--rand); bottom: 16px; z-index: 2; max-width: 80ch; color: var(--meta); }
.bildnotiz b { color: var(--weiss); font-weight: 500; }
/* Pause (WCAG 2.2.2): die Lettern bewegen sich sonst endlos */
.ruhe { position: absolute; right: var(--rand); bottom: 10px; z-index: 2; display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 12px; border: 1px solid var(--linie); border-radius: 999px; background: none; color: var(--meta); cursor: pointer; transition: color .2s var(--kurve), border-color .2s var(--kurve); }
.ruhe:hover { color: var(--weiss); border-color: rgba(255, 255, 255, .4); }
.ruhe svg { width: 9px; height: 9px; }
.ruhe .zeichen-spiel, .ruhe[aria-pressed='true'] .zeichen-pause { display: none; }
.ruhe[aria-pressed='true'] .zeichen-spiel { display: block; }
.ruhe[aria-pressed='true'] { color: var(--weiss); border-color: var(--weiss); }
.reduziert .ruhe, .vorschau .ruhe, .ohne-webgl .ruhe { display: none; }
@media (max-width: 640px) {
  .ruhe { width: 32px; height: 32px; padding: 0; justify-content: center; }
  .ruhe span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }   /* nur Zeichen, Name bleibt */
  .bildnotiz { padding-right: 48px; }
}

/* Ohne WebGL: Standbild als SVG mit Hinweis */
.standbild { display: none; position: absolute; inset: 0; z-index: 0; place-items: center; }
.standbild svg { height: 86%; width: auto; max-width: 92%; }
.ohne-webgl .standbild { display: grid; }
.ohne-webgl .leinwand { display: none; }
.hinweis { display: none; }
.ohne-webgl .hinweis { display: block; color: var(--signal); margin-top: 6px; }

.recht { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px 24px; color: var(--meta); padding: 14px 0 18px; border-top: 1px solid var(--linie); }
.recht nav { display: flex; gap: 22px; }
.recht a { transition: color .2s var(--kurve); }
.recht a:hover { color: var(--weiss); }

/* Gedachter Zeiger der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 30; width: 22px; height: 22px; margin: -11px 0 0 -11px; border: 1.5px solid var(--weiss); border-radius: 50%; background: rgba(255, 255, 255, .12); pointer-events: none; opacity: 0; transition: opacity .35s var(--kurve); }
.zeiger.an { opacity: 1; }

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

@media (min-width: 1081px) { .kopfzeile { padding-right: calc(var(--rand) + 680px); } }
@media (max-width: 1080px) { .kopfzeile .lang { display: none; } .haupt { grid-template-columns: 1fr; } .platz { display: none; } .spalten { grid-template-columns: repeat(3, minmax(0, 1fr)); } .spalten .satz { display: none; } }
@media (max-width: 640px) {
  .kopfzeile { min-height: 0; padding-top: 18px; padding-right: 150px; }
  .kopfzeile .lang { display: none; }
  .ansprache { font-size: clamp(2.75rem, 13.6vw, 4.5rem); }
  .knopf { width: 100%; justify-content: center; }
  .spalten { grid-template-columns: 1fr 1fr; font-size: .9375rem; }
  .spalten > :last-child { grid-column: 1 / -1; }
  .buehne { grid-template-rows: auto minmax(320px, 46svh); }
  .bildnotiz { bottom: 12px; }
}
@media (hover: none) { .knopf:hover .rolle span { transform: none; } }

/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .unten { display: none; }
</style>
</head>
<body>
<div class="pult mono">
  <p class="werte" data-werte aria-live="polite"></p>
  <div class="modi" role="group" aria-label="Variante">
    <button type="button" data-modus="objekt" aria-pressed="true">Logo-Objekt</button>
    <button type="button" data-modus="endszene" aria-pressed="false">Endszene</button>
    <button type="button" data-modus="partikel" aria-pressed="false">Partikel</button>
  </div>
</div>

<footer class="fuss" data-fuss>
  <div class="kopfzeile mono">
    <span>Winkel · Studio für digitale Produkte</span>
    <span class="lang">Seit 2014 am Rheinufer</span>
    <span class="ort">Mainz <time data-uhr>--:--</time></span>
  </div>

  <div class="buehne" data-buehne>
  <div class="leinwand" aria-hidden="true"><canvas data-canvas></canvas></div>
  <div class="haupt">
    <div>
      <h2 class="ansprache"><span>Was setzen wir</span><span>als <span class="rot">Nächstes?</span></span></h2>
      <div class="anfrage">
        <a class="mail" href="mailto:hallo@winkel-mainz.de">hallo@winkel-mainz.de</a>
        <a class="knopf" href="mailto:hallo@winkel-mainz.de?subject=Projektanfrage"><span class="rolle"><span>Projekt anfragen</span><span aria-hidden="true">Projekt anfragen</span></span></a>
      </div>
    </div>
    <div class="platz" data-platz aria-hidden="true"></div>
  </div>

  <div class="band" data-band>
    <div class="standbild" aria-hidden="true">
      <!-- Standbild: Bleiletter W, schräg von vorn -->
      <svg viewBox="0 0 420 360" xmlns="http://www.w3.org/2000/svg">
        <polygon points="120,90 300,60 360,110 180,140" fill="#4a4c50"/>
        <polygon points="180,140 360,110 360,300 180,330" fill="#2a2b2e"/>
        <polygon points="120,90 180,140 180,330 120,280" fill="#7c7f84"/>
        <polygon points="132,112 152,108 163,170 172,134 182,132 191,166 201,104 221,100 204,196 186,199 178,162 170,202 152,205" fill="#d9dcdf" transform="translate(-6,8)"/>
      </svg>
    </div>
    <p class="bildnotiz mono" data-notiz></p>
    <button type="button" class="ruhe mono" aria-pressed="false" data-ruhe><svg class="zeichen-pause" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1h3v10H2zM7 1h3v10H7z" fill="currentColor"/></svg><svg class="zeichen-spiel" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1l9 5-9 5z" fill="currentColor"/></svg><span>Bewegung anhalten</span></button>
  </div>

  </div>

  <div class="unten">
    <div class="spalten">
      <p class="satz">Der Winkelhaken ist das Werkzeug, in dem in Mainz Zeile für Zeile gesetzt wurde. Wir bauen digitale Produkte. Gesetzt wird immer noch Zeile für Zeile.</p>
      <div>
        <h3 class="mono">Studio</h3>
        <address>Winkel GmbH<br>Rheinallee 41<br>55118 Mainz</address>
      </div>
      <div>
        <h3 class="mono">Direkt</h3>
        <ul><li><a href="tel:+4961314987200">06131 498 720</a></li><li><a href="#">Jobs (1 offen)</a></li><li><a href="#">Presse</a></li></ul>
      </div>
      <nav aria-label="Soziale Netzwerke">
        <h3 class="mono">Folgen</h3>
        <ul><li><a href="#">Instagram</a></li><li><a href="#">LinkedIn</a></li><li><a href="#">Vimeo</a></li></ul>
      </nav>
    </div>
  <div class="recht mono">
    <span>© 2026 Winkel GmbH<span class="hinweis">WebGL ist hier nicht verfügbar: Standbild der Szene.</span></span>
    <nav aria-label="Rechtliches"><a href="#">Impressum</a><a href="#">Datenschutz</a><a href="#" data-hoch>Nach oben ↑</a></nav>
  </div>
  </div>
</footer>
<div class="zeiger" data-zeiger aria-hidden="true"></div>

<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 $ = s => document.querySelector(s);
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const fuss = $('[data-fuss]'), band = $('[data-band]'), platz = $('[data-platz]'), canvas = $('[data-canvas]'), leinwand = canvas.parentElement;
const werteEl = $('[data-werte]'), notizEl = $('[data-notiz]'), zeigerEl = $('[data-zeiger]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];

/* ---------- Werte (meta.json → messwerte: Lerp 0.05–0.1 für die Maus, DPR-Deckel 2 / Mobil 1–1.5) ---------- */
const W = {
  lerp: 0.08,                       // Maus-Reaktion je Bild bei 60 Bildern/s, auf echte Bildrate umgerechnet
  dpr: beruehrung ? 1.5 : 2,        // Pixel-Ratio-Deckel
  rootMargin: '50% 0px',            // Renderer erst nahe am Viewport anlegen
  kurve: [0.16, 1, 0.3, 1],         // expo.out, wie im CSS
  auftritt: 1200, versatz: 80,      // Auftritt der Lettern: 1200 ms, 80 ms versetzt
  partikelAuftritt: 1600, partikelVersatz: 600,
  neigungY: 0.55, neigungX: 0.3,    // maximale Neigung zum Zeiger in rad
  partikel: beruehrung ? 1800 : 4200,
  abstossRadius: 0.9,               // Partikel: Radius um den Zeiger in Letter-Höhen
};
const NOTIZ = {
  objekt: '<b>W</b>, Hartblei, 12 Cicero. Folgt dem Zeiger.',
  endszene: '<b>Winkel</b>, gesetzt in Blei. Spiegelverkehrt, wie es sich für eine Druckform gehört.',
  partikel: '<b>4 200 Punkte</b> bilden die Wortmarke und weichen dem Zeiger aus.',
};
let modus = 'objekt';

function zeigeWerte() {
  if (reduziert()) { werteEl.innerHTML = 'Bewegung <b>aus</b> · Standbild der Szene'; return; }
  const basis = `lerp <b>${W.lerp}</b> · dpr <b>≤ ${W.dpr}</b>`;
  werteEl.innerHTML = modus === 'partikel'
    ? `${basis} · <b>${W.partikelAuftritt} ms</b> expo.out`
    : `${basis} · <b>${W.auftritt} / ${W.versatz} ms</b> expo.out`;
}

/* Uhr in Mainz */
const uhr = $('[data-uhr]');
const stelle = () => { uhr.textContent = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' }).format(new Date()); };
stelle(); setInterval(stelle, 20000);
$('[data-hoch]').addEventListener('click', e => { e.preventDefault(); scrollTo({ top: 0, behavior: reduziert() ? 'auto' : 'smooth' }); });

/* ---------- Kurve als JS-Funktion (cubic-bezier) ---------- */
function bezier(x1, y1, x2, y2) {
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx, cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
  const fx = t => ((ax * t + bx) * t + cx) * t, fy = t => ((ay * t + by) * t + cy) * t, dx = t => (3 * ax * t + 2 * bx) * t + cx;
  return x => { if (x <= 0) return 0; if (x >= 1) return 1; let t = x; for (let i = 0; i < 8; i++) { const d = dx(t); if (Math.abs(d) < 1e-6) break; t -= (fx(t) - x) / d; } return fy(Math.min(1, Math.max(0, t))); };
}
const aus = bezier(...W.kurve);
const nachLerp = (dt, l = W.lerp) => 1 - Math.pow(1 - l, dt * 60);

/* ---------- 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, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}

let api = { neustart() {}, vorfuehren() {}, pause() {}, weiter() {} };
function setzeKnoepfe() { knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === modus))); notizEl.innerHTML = NOTIZ[modus]; zeigeWerte(); }

if (!renderer) {
  html.classList.add('ohne-webgl');
  setzeKnoepfe();
  knoepfe.forEach(k => k.addEventListener('click', () => { modus = k.dataset.modus; setzeKnoepfe(); }));
} else {
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.05;
  renderer.setClearColor(0x000000, 0);

  const szene = new THREE.Scene();
  const FOV = 28, KAMERA_Z = 16;
  const kamera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 80);
  kamera.position.set(0, 0, KAMERA_Z);

  /* Studio-Umgebung statt HDRI-Datei: dunkler Raum mit sieben Softboxen, über PMREM vorgefiltert */
  const pmrem = new THREE.PMREMGenerator(renderer);
  const studio = new THREE.Scene();
  studio.add(new THREE.Mesh(new THREE.SphereGeometry(20, 24, 12), new THREE.MeshBasicMaterial({ color: 0x2c2c30, side: THREE.BackSide })));
  const softbox = (w, h, staerke, pos) => {
    const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffffff).multiplyScalar(staerke), side: THREE.DoubleSide }));
    m.position.set(...pos); m.lookAt(0, 0, 0); studio.add(m);
  };
  softbox(16, 3, 6, [0, 9, 4]);         // Deckenstreifen
  softbox(3, 12, 4.5, [-10, 1, 6]);     // Kante links
  softbox(2.4, 10, 3.2, [10, 0, 2]);    // Streiflicht rechts
  softbox(9, 6, 2.6, [2, 3, 12]);       // Fläche hinter der Kamera
  softbox(7, 14, 2.2, [-13, -1, 2]);    // zwei hohe Wände links und rechts: das polierte Bild spiegelt sie, wenn die Letter sich neigt
  softbox(7, 14, 1.8, [13, -1, 2]);
  softbox(12, 2, 1.2, [0, -7, 8]);      // Aufhellung von unten
  szene.environment = pmrem.fromScene(studio, 0.02).texture;
  const spitze = new THREE.DirectionalLight(0xffffff, 2.2); spitze.position.set(-4, 6, 8); szene.add(spitze);

  /* ---------- Bleiletter: Kegel (Körper) + erhabenes, spiegelverkehrtes Bild ---------- */
  // Glyphen als Polygone (Versalhöhe 1, Strich ≈ 0,25), schmal und fett wie TikTok Sans wdth 75 / 800
  const GLYPHE = {
    W: { b: 1.0, p: [[0, 1], [0.23, 1], [0.3, 0.4], [0.4, 0.94], [0.6, 0.94], [0.7, 0.4], [0.77, 1], [1, 1], [0.86, 0], [0.63, 0], [0.5, 0.5], [0.37, 0], [0.14, 0]] },
    I: { b: 0.27, p: [[0, 0], [0.27, 0], [0.27, 1], [0, 1]] },
    N: { b: 0.72, p: [[0, 0], [0.25, 0], [0.25, 0.56], [0.47, 0], [0.72, 0], [0.72, 1], [0.47, 1], [0.47, 0.44], [0.25, 1], [0, 1]] },
    K: { b: 0.7, p: [[0, 0], [0.26, 0], [0.26, 0.3], [0.34, 0.41], [0.44, 0], [0.7, 0], [0.52, 0.58], [0.68, 1], [0.41, 1], [0.26, 0.64], [0.26, 1], [0, 1]] },
    E: { b: 0.58, p: [[0, 0], [0.58, 0], [0.58, 0.2], [0.26, 0.2], [0.26, 0.41], [0.53, 0.41], [0.53, 0.6], [0.26, 0.6], [0.26, 0.8], [0.58, 0.8], [0.58, 1], [0, 1]] },
    L: { b: 0.56, p: [[0, 0], [0.56, 0], [0.56, 0.2], [0.26, 0.2], [0.26, 1], [0, 1]] },
  };
  const KEGEL = 1.5, TIEFE = 2.3, FLEISCH = 0.15;   // Kegelhöhe, Schrifthöhe (Tiefe), Fleisch links/rechts
  const mKoerper = new THREE.MeshStandardMaterial({ color: 0x7d8086, metalness: 1, roughness: 0.48 });   // Hartblei, matt gebürstet
  const mBild = new THREE.MeshStandardMaterial({ color: 0xf4f5f7, metalness: 1, roughness: 0.26 });      // poliertes Bild
  const mSignatur = new THREE.MeshStandardMaterial({ color: 0x1c1d1f, metalness: 0.5, roughness: 0.7 });
  const rundRechteck = (b, h, r) => {
    const s = new THREE.Shape(), x = -b / 2, y = -h / 2;
    s.moveTo(x + r, y); s.lineTo(x + b - r, y); s.quadraticCurveTo(x + b, y, x + b, y + r); s.lineTo(x + b, y + h - r); s.quadraticCurveTo(x + b, y + h, x + b - r, y + h);
    s.lineTo(x + r, y + h); s.quadraticCurveTo(x, y + h, x, y + h - r); s.lineTo(x, y + r); s.quadraticCurveTo(x, y, x + r, y); return s;
  };
  function letter(z) {
    const g = GLYPHE[z], b = g.b + 2 * FLEISCH;
    const gruppe = new THREE.Group();
    const kGeo = new THREE.ExtrudeGeometry(rundRechteck(b, KEGEL, 0.05), { depth: TIEFE, bevelEnabled: true, bevelThickness: 0.03, bevelSize: 0.03, bevelSegments: 2, curveSegments: 3 });
    kGeo.translate(0, 0, -TIEFE / 2 - 0.03);
    gruppe.add(new THREE.Mesh(kGeo, mKoerper));
    // Signatur: Rille an der Unterseite, als dunkles Band über die ganze Länge
    const sig = new THREE.Mesh(new THREE.BoxGeometry(b + 0.07, 0.05, TIEFE * 0.97), mSignatur);
    sig.position.set(0, -KEGEL * 0.24, -0.02); gruppe.add(sig);
    // Bild: spiegelverkehrt (x → b − x, Reihenfolge umgedreht), etwas über der Mitte
    const pts = g.p.map(([x, y]) => new THREE.Vector2(g.b - x - g.b / 2, y - 0.5 + 0.04)).reverse();
    const bGeo = new THREE.ExtrudeGeometry(new THREE.Shape(pts), { depth: 0.12, bevelEnabled: true, bevelThickness: 0.025, bevelSize: 0.016, bevelSegments: 2 });
    bGeo.translate(0, 0, TIEFE / 2);
    gruppe.add(new THREE.Mesh(bGeo, mBild));
    gruppe.userData.breite = b;
    return gruppe;
  }

  /* ---------- Variante A: Logo-Objekt ---------- */
  const objekt = new THREE.Group();
  const objektLetter = letter('W'); objekt.add(objektLetter);

  /* ---------- Variante B: Endszene, die ganze Zeile im Winkelhaken ---------- */
  const zeile = new THREE.Group();
  const zeilenLettern = [];
  {
    const folge = ['L', 'E', 'K', 'N', 'I', 'W'];   // gespiegelt: die Druckform liest sich von rechts nach links
    const breiten = folge.map(z => GLYPHE[z].b + 2 * FLEISCH), fuge = 0.03;
    const gesamt = breiten.reduce((a, b) => a + b, 0) + fuge * (folge.length - 1);
    let x = -gesamt / 2;
    folge.forEach((z, i) => {
      const l = letter(z), halter = new THREE.Group();
      halter.position.x = x + breiten[i] / 2; x += breiten[i] + fuge;
      halter.add(l); zeile.add(halter);
      zeilenLettern.push({ halter, l, rx: 0, ry: 0, hub: 0 });
    });
    zeile.userData.breite = gesamt;
  }

  /* ---------- Variante C: Partikel-Wortmarke ---------- */
  const partikel = new THREE.Group();
  let punkte = null, heim = null, lage = null, start = null, hitze = null, verzug = null, wortBreite = 1;
  const pMat = new THREE.ShaderMaterial({
    transparent: true, depthWrite: false,
    uniforms: { uGroesse: { value: 1 }, uFarbe: { value: new THREE.Color(0xf2f2f2) }, uSignal: { value: new THREE.Color(0xff5a1f) } },
    vertexShader: `attribute float aHitze; attribute float aGroesse; varying float vHitze; uniform float uGroesse;
      void main(){ vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv; gl_PointSize = aGroesse * uGroesse * (16.0 / -mv.z); vHitze = aHitze; }`,
    fragmentShader: `varying float vHitze; uniform vec3 uFarbe; uniform vec3 uSignal;
      void main(){ float d = length(gl_PointCoord - 0.5); if (d > 0.5) discard; gl_FragColor = vec4(mix(uFarbe, uSignal, vHitze), smoothstep(0.5, 0.32, d));
        #include <colorspace_fragment>
      }`,
  });
  function baueWortmarke() {
    // Wortmarke auf 2D-Canvas setzen (TikTok Sans 800, wdth 75) und abtasten
    const c = document.createElement('canvas'), B = 1400, H = 420; c.width = B; c.height = H;
    const g = c.getContext('2d');
    g.fillStyle = '#fff'; g.textBaseline = 'alphabetic'; g.textAlign = 'center';
    try { g.fontStretch = 'condensed'; } catch (e) {}
    g.font = `800 360px "TikTok Sans", sans-serif`;
    g.fillText('winkel', B / 2, H * 0.82);
    const daten = g.getImageData(0, 0, B, H).data, kandidaten = [];
    for (let y = 0; y < H; y += 3) for (let x = 0; x < B; x += 3) if (daten[(y * B + x) * 4 + 3] > 128) kandidaten.push([x, y]);
    let minX = B, maxX = 0, minY = H, maxY = 0;
    kandidaten.forEach(([x, y]) => { minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); });
    const n = Math.min(W.partikel, kandidaten.length), skala = KEGEL * 1.25 / (maxY - minY);
    wortBreite = (maxX - minX) * skala;
    heim = new Float32Array(n * 3); lage = new Float32Array(n * 3); start = new Float32Array(n * 3); verzug = new Float32Array(n);
    hitze = new Float32Array(n); const groesse = new Float32Array(n);
    for (let i = 0; i < n; i++) {
      const [x, y] = kandidaten[Math.floor(Math.random() * kandidaten.length)];
      heim[i * 3] = (x + Math.random() * 3 - (minX + maxX) / 2) * skala;
      heim[i * 3 + 1] = -(y + Math.random() * 3 - (minY + maxY) / 2) * skala;
      heim[i * 3 + 2] = (Math.random() - 0.5) * 0.35;
      const w = Math.random() * Math.PI * 2, r = 4 + Math.random() * 5;
      start[i * 3] = Math.cos(w) * r * 1.6; start[i * 3 + 1] = Math.sin(w) * r * 0.7; start[i * 3 + 2] = (Math.random() - 0.5) * 6;
      verzug[i] = (heim[i * 3] / wortBreite + 0.5) * W.partikelVersatz;   // links zuerst
      groesse[i] = 0.7 + Math.random() * 0.8;
    }
    lage.set(start);
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(lage, 3));
    geo.setAttribute('aHitze', new THREE.BufferAttribute(hitze, 1));
    geo.setAttribute('aGroesse', new THREE.BufferAttribute(groesse, 1));
    if (punkte) { partikel.remove(punkte); punkte.geometry.dispose(); }
    punkte = new THREE.Points(geo, pMat);
    partikel.add(punkte);
  }

  szene.add(objekt, zeile, partikel);

  /* ---------- Maß: Szene auf das Band einpassen ---------- */
  let pxJeEinheit = 1, bandB = 1, bandH = 1, leinH = 1;
  function einpassen() {
    const r = leinwand.getBoundingClientRect(), br = band.getBoundingClientRect();
    renderer.setPixelRatio(Math.min(devicePixelRatio || 1, W.dpr));
    renderer.setSize(r.width, r.height, false);
    kamera.aspect = r.width / r.height; kamera.updateProjectionMatrix();
    leinH = r.height; bandB = br.width; bandH = br.height;
    pxJeEinheit = r.height / (2 * KAMERA_Z * Math.tan(FOV / 2 * Math.PI / 180));
    const u = 1 / pxJeEinheit;
    // Bildschirm-Rechteck → Weltmitte (Ebene z = 0)
    const welt = (x, y) => [(x - r.left - r.width / 2) * u, -(y - r.top - r.height / 2) * u];
    const pr = platz.getBoundingClientRect(), mitPlatz = pr.width > 40;
    // A: eine Letter. Desktop: rechte Spalte über Haupt und Band, sonst mitten im Band
    const fA = mitPlatz ? { l: pr.left, r: pr.right, o: pr.top, u: br.bottom } : { l: br.left, r: br.right, o: br.top, u: br.bottom };
    const sA = (mitPlatz ? Math.min(0.46 * (fA.u - fA.o), 0.5 * (fA.r - fA.l)) : Math.min(0.4 * (fA.u - fA.o), 0.36 * (fA.r - fA.l))) * u / KEGEL;
    objekt.scale.setScalar(sA);
    const [ax, ay] = welt((fA.l + fA.r) / 2, (fA.o + fA.u) / 2);
    objekt.userData.basisX = ax; objekt.userData.basisY = ay;
    // B: ganze Zeile, 80 % der Bandbreite, höchstens 52 % der Bandhöhe (unten bleibt Platz für die Bildnotiz)
    const sB = Math.min(0.8 * bandB * u / zeile.userData.breite, 0.52 * bandH * u / KEGEL);
    zeile.scale.setScalar(sB);
    const [bx, by] = welt(br.left + br.width / 2, br.top + br.height * 0.44);
    zeile.position.set(bx, by, 0);
    // C: Wortmarke, 88 % der Bandbreite, höchstens 62 % der Bandhöhe
    const sC = Math.min(0.88 * bandB * u / wortBreite, 0.62 * bandH * u / (KEGEL * 1.25));
    partikel.scale.setScalar(sC);
    partikel.position.set(bx, by, 0);
  }

  /* ---------- Zeiger ---------- */
  const zeiger = { x: 0, y: 0, zx: 0, zy: 0, aktiv: false, wx: 99, wy: 99 };   // normiert −1…1 auf dem Footer, Weltkoordinaten
  function zeigerAus(clientX, clientY) {
    const f = fuss.getBoundingClientRect(), l = leinwand.getBoundingClientRect();
    zeiger.zx = ((clientX - f.left) / f.width) * 2 - 1;
    zeiger.zy = ((clientY - l.top) / l.height) * 2 - 1;
    zeiger.wx = (clientX - l.left - l.width / 2) / pxJeEinheit;
    zeiger.wy = -(clientY - l.top - l.height / 2) / pxJeEinheit;
    zeiger.aktiv = true;
  }
  let vorf = false;
  fuss.addEventListener('pointermove', e => { if (!vorf && e.pointerType === 'mouse') zeigerAus(e.clientX, e.clientY); });
  fuss.addEventListener('pointerleave', () => { if (!vorf) { zeiger.zx = 0; zeiger.zy = 0; zeiger.aktiv = false; zeiger.wx = zeiger.wy = 99; } });
  // Touch: Antippen der Bühne lenkt die Szene kurz dorthin, danach zurück in die Ruhe
  let tippUhr = null;
  band.addEventListener('pointerdown', e => {
    if (vorf || e.pointerType === 'mouse') return;
    zeigerAus(e.clientX, e.clientY); clearTimeout(tippUhr);
    tippUhr = setTimeout(() => { zeiger.zx = 0; zeiger.zy = 0; zeiger.aktiv = false; zeiger.wx = zeiger.wy = 99; }, 1400);
  });

  /* ---------- Auftritt ---------- */
  let auftrittT0 = 0;
  function auftritt() { auftrittT0 = performance.now(); if (modus === 'partikel' && start) lage.set(reduziert() ? heim : start); }

  /* ---------- Schleife: läuft nur, wenn der Footer nahe am Viewport ist ---------- */
  let sichtbar = false, laeuft = false, angehalten = false, uhrT = performance.now(), t = 0, raf = 0;
  function bild(jetzt) {
    raf = 0;
    const dt = Math.min(0.05, (jetzt - uhrT) / 1000); uhrT = jetzt;
    const still = reduziert();
    if (!still) t += dt;
    const k = still ? 1 : nachLerp(dt);
    if (!still) { zeiger.x += (zeiger.zx - zeiger.x) * k; zeiger.y += (zeiger.zy - zeiger.y) * k; }
    else { zeiger.x = zeiger.y = 0; }
    const seit = jetzt - auftrittT0;
    objekt.visible = modus === 'objekt'; zeile.visible = modus === 'endszene'; partikel.visible = modus === 'partikel';

    if (modus === 'objekt') {
      const p = still ? 1 : aus(seit / W.auftritt);
      const idle = still ? 0 : Math.sin(t * 0.5) * 0.12;
      objektLetter.rotation.set(0.32 + zeiger.y * W.neigungX + (1 - p) * 0.6, -0.62 + zeiger.x * W.neigungY + idle + (1 - p) * 1.4, 0.04);
      objekt.position.y = objekt.userData.basisY + (still ? 0 : Math.sin(t * 0.8) * 0.06 * objekt.scale.y) - (1 - p) * 3;
      objekt.position.x = objekt.userData.basisX;
    } else if (modus === 'endszene') {
      zeile.rotation.set(0.26 + zeiger.y * 0.12, -0.18 + zeiger.x * 0.22, 0);
      zeilenLettern.forEach((z, i) => {
        const p = still ? 1 : aus((seit - i * W.versatz) / W.auftritt);
        // Gauß um die Zeigerposition: die Letter unter dem Zeiger hebt sich und dreht sich ihm zu
        const lx = zeile.position.x + z.halter.position.x * zeile.scale.x;
        const nah = zeiger.aktiv && !still ? Math.exp(-Math.pow((zeiger.wx - lx) / (1.1 * zeile.scale.x), 2)) : 0;
        const kk = still ? 1 : k;
        z.hub += (nah * 0.55 - z.hub) * kk;
        z.ry += ((zeiger.aktiv ? Math.max(-1, Math.min(1, (zeiger.wx - lx) / (2.5 * zeile.scale.x))) * 0.5 * nah : 0) - z.ry) * kk;
        z.rx += (nah * -0.18 - z.rx) * kk;
        z.halter.position.y = (1 - p) * 4 + (still ? 0 : Math.sin(t * 1.1 + i * 0.7) * 0.025);
        z.halter.position.z = z.hub;
        z.l.rotation.set(z.rx + (1 - p) * -1.1, z.ry, 0);
      });
    } else if (modus === 'partikel' && punkte) {
      partikel.rotation.set(zeiger.y * 0.1, zeiger.x * 0.22, 0);
      const s = partikel.scale.x, r = W.abstossRadius * KEGEL;
      const px = (zeiger.wx - partikel.position.x) / s, py = (zeiger.wy - partikel.position.y) / s, n = hitze.length;
      for (let i = 0; i < n; i++) {
        const i3 = i * 3;
        let hx = heim[i3], hy = heim[i3 + 1], hz = heim[i3 + 2], h = 0;
        if (!still) {
          const q = aus((seit - verzug[i]) / W.partikelAuftritt);
          if (q < 1) { hx = start[i3] + (hx - start[i3]) * q; hy = start[i3 + 1] + (hy - start[i3 + 1]) * q; hz = start[i3 + 2] + (hz - start[i3 + 2]) * q; }
          if (zeiger.aktiv) {
            const dx = heim[i3] - px, dy = heim[i3 + 1] - py, d = Math.hypot(dx, dy);
            if (d < r) { const f = Math.pow(1 - d / r, 2); hx += dx / (d + 1e-4) * f * 0.9; hy += dy / (d + 1e-4) * f * 0.9; hz += f * 1.2; h = f; }
          }
          lage[i3] += (hx - lage[i3]) * k; lage[i3 + 1] += (hy - lage[i3 + 1]) * k; lage[i3 + 2] += (hz - lage[i3 + 2]) * k;
          hitze[i] += (Math.min(1, h * 2.2) - hitze[i]) * k;
        } else { lage[i3] = hx; lage[i3 + 1] = hy; lage[i3 + 2] = hz; hitze[i] = 0; }
      }
      punkte.geometry.attributes.position.needsUpdate = true;
      punkte.geometry.attributes.aHitze.needsUpdate = true;
      pMat.uniforms.uGroesse.value = renderer.getPixelRatio() * Math.max(2.2, pxJeEinheit * 0.045);
    }
    renderer.render(szene, kamera);
    if (!html.classList.contains('bereit')) html.classList.add('bereit');
    if (laeuft && !still) raf = requestAnimationFrame(bild);
  }
  function anwerfen() { if (!raf) { uhrT = performance.now(); raf = requestAnimationFrame(bild); } }
  function steuern() { laeuft = sichtbar && !angehalten; if (laeuft || reduziert()) anwerfen(); }

  let angelegt = false;
  async function anlegen() {
    if (angelegt) return angelegt; angelegt = (async () => {
    try { await document.fonts.load('800 360px "TikTok Sans"'); } catch (e) {}
    baueWortmarke(); einpassen();
    // Shader vorab übersetzen, damit der Variantenwechsel nicht ruckelt (SwiftShader braucht dafür Sekunden)
    objekt.visible = zeile.visible = partikel.visible = true; renderer.compile(szene, kamera);
    auftritt(); steuern();
    })(); return angelegt;
  }
  new IntersectionObserver(e => { sichtbar = e[0].isIntersecting; if (sichtbar) anlegen(); steuern(); }, { rootMargin: W.rootMargin }).observe(fuss);
  new ResizeObserver(() => { if (angelegt) { einpassen(); if (reduziert()) anwerfen(); } }).observe(leinwand);

  function setzeModus(m, neu = true) { modus = m; setzeKnoepfe(); if (neu) auftritt(); anwerfen(); }
  knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.modus); }));
  setzeKnoepfe();

  /* ---------- Vorführung: gedachter Zeiger fährt über die Bühne, Varianten wechseln ---------- */
  let vUhr = 0, vZeit = 0, vLetzt = 0, vIdx = 0;
  const FOLGE = ['objekt', 'endszene', 'partikel'], RUNDE = 6500;
  function vorfuehrSchritt(jetzt) {
    vZeit += Math.min(50, jetzt - vLetzt); vLetzt = jetzt;   // eigene Uhr: lange Bilder (Shader-Übersetzung) zählen nicht
    const s = vZeit / 1000, b = band.getBoundingClientRect();
    const x = b.left + b.width * (0.5 + 0.42 * Math.sin(s * 0.9)), y = b.top + b.height * (0.5 + 0.28 * Math.sin(s * 1.7));
    zeigerAus(x, y);
    zeigerEl.style.transform = `translate(${x}px, ${y}px)`;
    if (vZeit > RUNDE * (vIdx + 1)) { vIdx++; setzeModus(FOLGE[vIdx % 3]); }
    vUhr = requestAnimationFrame(vorfuehrSchritt);
  }
  function stoppeVorfuehrung() { if (!vorf) return; vorf = false; cancelAnimationFrame(vUhr); zeigerEl.classList.remove('an'); zeiger.aktiv = false; zeiger.zx = zeiger.zy = 0; zeiger.wx = zeiger.wy = 99; }
  api = {
    neustart() { stoppeVorfuehrung(); angehalten = false; setzeModus(modus); steuern(); },
    vorfuehren() {
      stoppeVorfuehrung(); angehalten = false; vorf = true; vIdx = 0; vZeit = 0;
      anlegen().then(() => {
        setzeModus('objekt'); steuern();
        if (vorf && !reduziert()) { vLetzt = performance.now(); zeigerEl.classList.add('an'); vUhr = requestAnimationFrame(vorfuehrSchritt); }
      });
      if (reduziert()) { let i = 0; const wechsel = () => { if (!vorf) return; setzeModus(FOLGE[i++ % 3]); vUhr = setTimeout(wechsel, 3000); }; wechsel(); }
    },
    pause() { angehalten = true; cancelAnimationFrame(vUhr); clearTimeout(vUhr); steuern(); },
    weiter() { angehalten = false; if (vorf && !reduziert()) { vLetzt = performance.now(); vUhr = requestAnimationFrame(vorfuehrSchritt); } steuern(); },
  };
}

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