Zum Inhalt
← 3D & WebGL

No-Code-Shader-Hintergründe als Embed (Unicorn Studio, Paper Shaders)

Ein Shader-Hintergrund aus dem No-Code-Editor: Lichtstrahlen fallen wie durchs Nordfenster einer Görlitzer Uhrenmanufaktur, umschaltbar auf Körnung und Raster, mit Poster-Fallback.

Kampagne, Produkt-Launch, Prototyp oder Team ohne Shader-Wissen: Szene im Editor (Unicorn Studio, Paper Shaders) bauen, als Embed einbinden, Szenen über Presets und Theme-Variablen tauschen.

Ein animierter WebGL-Hintergrund wird nicht programmiert, sondern in einem visuellen Editor gebaut. Typisch sind Verlauf, Lichtstrahlen, Noise, Dither, Flüssigkeit oder Glas. Unicorn Studio arbeitet mit Ebenen und Effekten und exportiert ein Embed oder eine JSON-Datei. Paper Shaders gibt es als npm-Paket (@paper-design/shaders bzw. -react) mit Parametern. Eingebunden wird ein Container mit Projekt-ID (data-us-project) oder eine React-Komponente mit Farb-, Geschwindigkeits- und Dichteparametern. Die Laufzeit pausiert außerhalb des Viewports und bei verdecktem Tab. Lazy-Load sowie DPI- und FPS-Grenzen steuern die Last. Typische Orte: Hero, Footer-Bühne, CTA-Band, Karten-Hintergründe.

Shader-Ästhetik wie auf Award-Seiten, ohne dass ein WebGL-Entwickler nötig ist. Designer stellen Bewegung, Farbe und Interaktion selbst ein. Gegenüber einem Video bleibt der Hintergrund gestochen scharf, reagiert auf die Maus und ist oft kleiner.

Wann einsetzen

Für Atmosphäre in Hero und Footer, bei Produkt-Launches und auf Seiten ohne starkes Fotomaterial. In Template-Baukästen lassen sich Szenen austauschen, die Parameter werden zu Theme-Variablen.

Wann nicht

Wenn der Shader nur generischer Glow oder Aurora ist, das wirkt nach Template oder KI. Nicht ohne Fallback bei einer Zielgruppe mit schwacher Hardware. Nicht mehrere Szenen gleichzeitig im Viewport. Nicht, wenn Inhalte im Canvas stecken müssten, denn dort sind sie nicht durchsuchbar.

Varianten

  • Unicorn-Studio-Embed per data-us-project (Szene vom CDN) oder data-us-project-src (selbst gehostetes JSON)
  • Paper Shaders als React-Komponente (MeshGradient, GrainGradient, GodRays, Dithering, Warp, PulsingBorder u. a.)
  • Ambient-Szene im Footer mit reduzierter Deckkraft (every.to: opacity .6)
  • Maus-reaktive Szene (Spotlight, Verzerrung), siehe cursor-masken-reveal
  • Statischer Export als Bild oder Video als Fallback
  • Plugin-Variante für Framer oder Webflow
  • Selbst gebaut statt eingebettet: derselbe Effekt als eigener Shader ohne Paket und ohne Verbindung zu Dritten, dafür mehr Aufwand und eigene Wartung (Variante eigen der Demo)

Umsetzung

Unicorn Studio: unicornStudio.umd.js in gepinnter Version laden (z. B. v2.4.3). Container mit data-us-project=ID, data-us-lazyload=true, data-us-dpi 1–1.5, data-us-scale 0.5–1, data-us-fps 30–60 sowie data-us-alttext und data-us-arialabel. UnicornStudio.init() nach dem Rendern aufrufen, in SPAs beim Unmount scene.destroy(). Mit scene.setVariable() an Theme oder Markenfarbe koppeln. Paper Shaders: npm i @paper-design/shaders-react, Version pinnen (0.0.x hat Breaking Changes). Zum Beispiel <MeshGradient colors={[…]} distortion swirl speed={0.2} />, Container absolut hinter dem Inhalt. Immer ein Poster oder einen CSS-Verlauf als Fallback in den Container legen, Canvas mit aria-hidden. Die Last im Performance-Panel auf einem Mittelklasse-Handy prüfen. Die unter den Quellen genannte llms.txt von Unicorn Studio ist Herstellerdoku zur Laufzeit-API, keine Empfehlung für die Kundenseite; eine eigene llms.txt ist optional und ohne belegte Wirkung bei Google (für AI Overviews und AI Mode laut Google keine neuen Dateien nötig).

Robust umsetzen

Telefon. DPI auf 1 und Scale auf 0.5–0.75 senken, 30 fps, Maus-Interaktion abschalten (interactivity.mouse.disableMobile). Bei schwacher GPU ein Standbild zeigen.

Ohne Bewegung. Szene anhalten (speed 0 oder fester Frame) oder durch ein Standbild ersetzen. Die Farbwirkung bleibt.

Barrierefrei. Canvas ist rein dekorativ (aria-hidden) oder bekommt einen sinnvollen Alt-Text. Den Textkontrast an der hellsten und an der dunkelsten Stelle des bewegten Hintergrunds prüfen, bei Bedarf ein Overlay legen. Keine schnellen Helligkeitswechsel.

Code

<!DOCTYPE html>
<!--
  No-Code-Shader-Hintergründe als Embed (Unicorn Studio, Paper Shaders)
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/shader-embed-no-code/
  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>No-Code-Shader-Hintergründe als Embed · 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: 'Bodoni Moda'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bodoni-moda:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Bodoni Moda'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bodoni-moda:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 400 900; font-style: italic; font-display: swap; }

/* Rezept „Manufaktur-Stille“: Anthrazit und Weiß im Wechsel, Messing nur als Linie.
   Der Shader liest seine Farben aus diesen Variablen (wie scene.setVariable() bzw. colors=[…]). */
:root {
  --anthrazit: #23282f; --weiss: #ffffff; --flaeche: #f4f4f2; --meta: #71757a; --linie: #d9dadb; --messing: #a88a5a;
  --licht: #f1ede4;            /* Strahlen */
  --schein: #e8e2d6;           /* Bloom / Mitte */
  --tiefe: #3b414a;            /* Körnung, mittlerer Ton */
  --raster: #5c626b;           /* Dither, Vordergrund */
  --hell-leise: rgba(255, 255, 255, .66); --hell-linie: rgba(255, 255, 255, .16);
  --kurve: cubic-bezier(0.1, 0.6, 0.4, 1);   /* Rezept-Kurve, nur für UI */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--anthrazit); color: var(--weiss); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; font-size: .9375rem; line-height: 1.47; }
a { color: inherit; }
.label { font: 500 .75rem/1.3 'Instrument Sans', system-ui, sans-serif; letter-spacing: .08em; text-transform: uppercase; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.serif { font-family: 'Bodoni Moda', 'Bodoni 72', Didot, serif; font-optical-sizing: auto; font-weight: 400; }
:focus-visible { outline: 1px solid var(--messing); outline-offset: 4px; }

/* ---------- Szene: eine Leinwand, fest hinter der Seite (wie der Embed-Container) ---------- */
.szene { position: fixed; inset: 0; z-index: -1; background: var(--anthrazit); }
.szene canvas, .szene .poster { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* Poster: der statische Fallback im Container, sichtbar ohne WebGL */
.szene .poster { opacity: 0; transition: opacity .5s linear;
  background:
    radial-gradient(70% 60% at 16% -6%, rgba(232, 226, 214, .30), transparent 70%),
    repeating-conic-gradient(from 118deg at 16% -10%, rgba(241, 237, 228, .07) 0deg 2.2deg, transparent 2.2deg 6.5deg),
    var(--anthrazit);
  -webkit-mask: linear-gradient(170deg, #000 20%, rgba(0,0,0,.5) 80%); mask: linear-gradient(170deg, #000 20%, rgba(0,0,0,.5) 80%); }
.ohne-webgl .szene .poster { opacity: 1; }
.ohne-webgl .szene canvas { display: none; }

/* ---------- Kopf ---------- */
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; }
.kopf nav { display: flex; gap: clamp(18px, 2.4vw, 36px); }
.kopf nav a, .kopf .rechts a { text-decoration: none; color: var(--hell-leise); transition: color 150ms var(--kurve); }
.kopf nav a:hover, .kopf .rechts a:hover { color: var(--weiss); }
.marke { justify-self: center; text-align: center; text-decoration: none; display: grid; gap: 5px; }
.marke b { font: 400 22px/1 'Bodoni Moda', serif; letter-spacing: .3em; margin-right: -.3em; }
.marke small { font: 500 9px/1 'Instrument Sans', sans-serif; letter-spacing: .32em; margin-right: -.32em; color: var(--hell-leise); }
.kopf .rechts { justify-self: end; display: flex; align-items: center; gap: 22px; }
.knopf { display: inline-flex; align-items: center; height: 42px; padding: 0 20px; border: 1px solid var(--messing); color: var(--weiss) !important; text-decoration: none;
  transition: background 250ms var(--kurve); }
.knopf:hover { background: rgba(168, 138, 90, .16); }
.knopf--klein { height: 36px; padding: 0 14px; }

/* ---------- Bühne ---------- */
.held { position: relative; min-height: max(100svh, 680px); display: grid; grid-template-rows: auto 1fr auto; padding: 28px clamp(20px, 4vw, 56px) 22px; isolation: isolate; }
/* Lesbarkeit: leichte Abdunklung links unten, wo der Text steht */
.held::before { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background: linear-gradient(20deg, rgba(35, 40, 47, .72) 0%, rgba(35, 40, 47, .25) 38%, transparent 60%); }
.held__inhalt { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); align-items: center; gap: clamp(24px, 4vw, 72px); padding: clamp(24px, 4vh, 56px) 0 clamp(28px, 5vh, 60px); }
.held__text { align-self: end; max-width: 560px; }
.held__ober { display: flex; align-items: center; gap: 14px; color: var(--hell-leise); margin-bottom: 26px; }
.held__ober::before { content: ''; width: 36px; height: 1px; background: var(--messing); }
.titel { font-size: clamp(2.5rem, 5.5vw, 5.5rem); line-height: 1.05; letter-spacing: -.01em; text-wrap: balance; }
.titel em { font-style: italic; }
.held__lauf { margin-top: 24px; max-width: 42ch; color: rgba(255, 255, 255, .78); text-wrap: pretty; }
.held__aktion { margin-top: 32px; display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
.textlink { text-decoration: none; padding-bottom: 4px; border-bottom: 1px solid var(--hell-linie); transition: border-color 150ms var(--kurve); }
.textlink:hover { border-color: var(--messing); }

.uhr { justify-self: center; width: min(100%, 470px, 62vh); filter: drop-shadow(26px 34px 34px rgba(8, 10, 13, .55)); }
.uhr svg { display: block; width: 100%; height: auto; overflow: visible; }

.fakten { position: absolute; right: clamp(20px, 4vw, 56px); bottom: 92px; display: grid; gap: 10px; text-align: right; color: var(--hell-leise); font-variant-numeric: tabular-nums; }

.fakten dt, .fakten dd { display: inline; }
.fakten b { color: var(--weiss); font-weight: 500; margin-left: 10px; }

/* ---------- Leiste: Szenen-Umschalter + Werte (Fundus-Bedienung) ---------- */
.leiste { display: flex; justify-content: space-between; align-items: center; gap: 12px 28px; flex-wrap: wrap; padding-top: 16px; border-top: 1px solid var(--hell-linie); color: rgba(255, 255, 255, .55); }
.szenen { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
.szenen > span { margin-right: 8px; }
.szenen button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: rgba(255, 255, 255, .6); background: transparent; border: 1px solid var(--hell-linie); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer;
  transition: background 150ms var(--kurve), color 150ms var(--kurve), border-color 150ms var(--kurve); }
.szenen button:hover { color: var(--weiss); }
.szenen button[aria-pressed='true'] { background: var(--weiss); color: var(--anthrazit); border-color: var(--weiss); }
.szenen button:disabled { opacity: .35; cursor: not-allowed; }
/* Pause (WCAG 2.2.2): die Szene läuft sonst endlos */
.szenen .ruhe { margin-left: 8px; width: 28px; padding: 0; display: inline-grid; place-items: center; }
.ruhe span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }   /* nur Zeichen sichtbar, Name für Screenreader */
.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; }
.reduziert .ruhe { display: none; }
.werte { display: flex; flex-wrap: wrap; gap: 3px 14px; justify-content: flex-end; max-width: 760px; }
.werte span { white-space: nowrap; }
.werte b { color: rgba(255, 255, 255, .92); font-weight: 500; }

/* ---------- Variante eigen: eigener Lichtpass statt Embed, mit Vergleich ---------- */
[data-nur][hidden], [data-welt-text][hidden] { display: none !important; }
.szenen .eigen-name { color: var(--weiss); margin-right: 8px; }
.trenner { position: absolute; z-index: 1; top: 0; bottom: 0; left: 40%; width: 44px; margin-left: -22px; display: none; cursor: ew-resize; touch-action: none; }
.trenner::before { content: ''; position: absolute; left: 21.5px; top: 0; bottom: 0; width: 1px; background: rgba(255, 255, 255, .55); }
.trenner span { position: absolute; top: 96px; white-space: nowrap; padding: 5px 9px; background: rgba(35, 40, 47, .82); color: var(--weiss); }
.trenner span:first-child { right: 30px; }
.trenner span:last-child { left: 30px; }
.vergleich-an .trenner { display: block; }
.vergleich-an .leiste { position: relative; z-index: 2; }   /* Trenner liegt hinter der Leiste, Knöpfe bleiben klickbar */
.grenze { width: 120px; accent-color: var(--messing); }
.grenze-feld { display: none; align-items: center; gap: 8px; margin-left: 8px; }
.vergleich-an .grenze-feld { display: inline-flex; }
.vergleich .werk__raster { display: block; padding-bottom: clamp(64px, 10vh, 120px); border-bottom: 1px solid var(--linie); }
.werk.vergleich { padding-bottom: 0; }
.vergleich h2 { max-width: 22ch; }
.vergleich .einleitung { max-width: 60ch; }
.tabelle { width: 100%; margin-top: 40px; border-collapse: collapse; font-size: .9375rem; }
.tabelle th, .tabelle td { text-align: left; vertical-align: top; padding: 16px 20px 16px 0; border-bottom: 1px solid var(--linie); }
.tabelle thead th { color: var(--meta); font-weight: 500; font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; }
.tabelle tbody th { width: 16%; font-weight: 500; }
.tabelle td { width: 42%; color: #4c525a; }
.tabelle td b { color: var(--anthrazit); font-weight: 500; }
.vergleich .hinweis { margin-top: 18px; font-size: .8125rem; color: var(--meta); }

/* ---------- Weiße Sektion: deckt die Szene ab, dort pausiert sie ---------- */
.werk { background: var(--weiss); color: var(--anthrazit); padding: clamp(72px, 12vh, 140px) clamp(20px, 4vw, 56px); }
.werk__raster { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(32px, 6vw, 120px); max-width: 1240px; margin: 0 auto; }
.werk .label { color: var(--meta); margin-bottom: 22px; }
.werk h2 { font-size: clamp(2rem, 3.6vw, 3.5rem); line-height: 1.08; letter-spacing: -.01em; text-wrap: balance; }
.werk p { margin-top: 22px; max-width: 46ch; color: #4c525a; text-wrap: pretty; }
.zahlen { display: grid; grid-template-columns: 1fr 1fr; align-self: end; border-top: 1px solid var(--linie); }
.zahlen div { padding: 26px 0 24px; border-bottom: 1px solid var(--linie); }
.zahlen div:nth-child(odd) { padding-right: 20px; }
.zahlen dt { color: var(--meta); }
.zahlen dd { font: 400 clamp(2.2rem, 3.4vw, 3.2rem)/1 'Bodoni Moda', serif; font-variant-numeric: tabular-nums lining-nums; margin-top: 12px; }
.zahlen dd small { font: 500 .8125rem/1 'Instrument Sans', sans-serif; letter-spacing: .04em; margin-left: 6px; color: var(--meta); }

/* ---------- Fuß: dieselbe Szene als Ambient, Deckkraft .6 (every.to) ---------- */
.fuss { position: relative; padding: clamp(80px, 14vh, 150px) clamp(20px, 4vw, 56px) 28px; isolation: isolate; }
.fuss::before { content: ''; position: absolute; inset: 0; z-index: -1; background: var(--anthrazit); opacity: .4; pointer-events: none; }
.fuss__raster { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 48px; max-width: 1240px; margin: 0 auto; align-items: end; }
.fuss h2 { font-size: clamp(2.2rem, 4.4vw, 4.4rem); line-height: 1.05; letter-spacing: -.01em; margin: 18px 0 26px; }
.fuss h2 em { font-style: italic; }
.fuss .label { color: var(--hell-leise); }
.fuss address { font-style: normal; color: rgba(255, 255, 255, .78); display: grid; gap: 4px; font-variant-numeric: tabular-nums; }
.fuss address .label { margin-top: 18px; }
.fuss__unten { max-width: 1240px; margin: clamp(64px, 10vh, 110px) auto 0; padding-top: 18px; border-top: 1px solid var(--hell-linie); display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: rgba(255, 255, 255, .5); }
.fuss__unten nav { display: flex; gap: 22px; flex-wrap: wrap; }
.fuss__unten a { text-decoration: none; }

/* Reduzierte Bewegung: Szene steht als Standbild, Farbwirkung bleibt */
.reduziert * { transition-duration: .01ms !important; }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .kopf nav, .vorschau .kopf .rechts .textlink-k { visibility: hidden; }
.vorschau .szenen, .vorschau .werte .lauf { display: none; }
.vorschau .werte { justify-content: flex-start; max-width: none; }

@media (max-width: 980px) {
  .kopf nav a:nth-child(n+3) { display: none; }
  .fakten { display: none; }
}
@media (max-width: 760px) {
  .kopf { grid-template-columns: auto 1fr; }
  .kopf nav { display: none; }
  .marke { justify-self: start; text-align: left; }
  .marke b { font-size: 18px; letter-spacing: .24em; }
  .kopf .rechts .textlink-k { display: none; }
  .held { padding: 18px 18px 18px; min-height: auto; }
  .held__inhalt { grid-template-columns: 1fr; padding: 18px 0 36px; gap: 8px; }
  .uhr { order: -1; width: min(72vw, 300px); }
  .held__ober { margin-bottom: 18px; }
  .titel { font-size: clamp(2.5rem, 12vw, 3.4rem); }
  .held__lauf { margin-top: 18px; }
  .held__aktion { margin-top: 26px; gap: 22px; }
  .werte { justify-content: flex-start; }
  .werk__raster, .fuss__raster { grid-template-columns: 1fr; }
  .zahlen dd { font-size: 2.1rem; }
}
@media (max-width: 760px) {
  .tabelle thead { display: none; }
  .tabelle, .tabelle tbody, .tabelle tr, .tabelle th, .tabelle td { display: block; width: auto; }
  .tabelle tr { padding: 14px 0; border-bottom: 1px solid var(--linie); }
  .tabelle th, .tabelle td { border: 0; padding: 4px 0; }
  .tabelle td::before { content: attr(data-spalte); display: block; color: var(--meta); font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; margin-top: 6px; }
  .trenner span { top: 72px; }
}
@media (hover: none) { .szenen button, .knopf, .textlink { transition: none; } }
.vorschau .trenner span { top: 70px; }
</style>
</head>
<body>
<div class="szene" aria-hidden="true" data-szene>
  <div class="poster"></div>
  <canvas data-canvas></canvas>
</div>

<section class="held" data-held>
  <header class="kopf">
    <nav class="label" aria-label="Hauptnavigation">
      <a href="#">Kollektion</a>
      <a href="#">Manufaktur</a>
      <a href="#">Kaliber</a>
    </nav>
    <a class="marke" href="#" aria-label="Hagedorn Görlitz, Startseite"><b>HAGEDORN</b><small>GÖRLITZ</small></a>
    <div class="rechts label">
      <a class="textlink-k" href="#">Boutique</a>
      <a class="knopf knopf--klein" href="#">Termin</a>
    </div>
  </header>

  <div class="held__inhalt">
    <div class="held__text">
      <p class="label held__ober">Neuheit 2026 · Kaliber HG 11</p>
      <h1 class="titel serif">Gefertigt im <em>Nordlicht</em>.</h1>
      <p class="held__lauf">Die Neiße 38 entsteht an Werktischen mit Fenster nach Norden. Das Licht bleibt dort den ganzen Tag gleich, ohne harte Schatten. Handaufzug, 62 Stunden Gangreserve, 48 Stück im Jahr.</p>
      <div class="held__aktion label">
        <a class="knopf" href="#">Termin in der Boutique</a>
        <a class="textlink" href="#">Das Kaliber ansehen</a>
      </div>
    </div>

    <figure class="uhr" aria-label="Hagedorn Neiße 38, Edelstahl, Zifferblatt Silber gekörnt">
      <svg viewBox="0 0 400 520" role="img" aria-hidden="true">
        <defs>
          <linearGradient id="g-stahl" x1=".15" y1="0" x2=".85" y2="1"><stop offset="0" stop-color="#eef0f2"/><stop offset=".22" stop-color="#a7acb2"/><stop offset=".48" stop-color="#4a5057"/><stop offset=".66" stop-color="#8d9399"/><stop offset=".84" stop-color="#d7dadd"/><stop offset="1" stop-color="#5d636a"/></linearGradient>
          <linearGradient id="g-luenette" x1="1" y1="1" x2="0" y2="0"><stop offset="0" stop-color="#f6f7f8"/><stop offset=".35" stop-color="#9ba1a7"/><stop offset=".6" stop-color="#3f454c"/><stop offset="1" stop-color="#e4e6e8"/></linearGradient>
          <linearGradient id="g-horn" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#5a6067"/><stop offset=".4" stop-color="#e8eaec"/><stop offset=".62" stop-color="#a5abb1"/><stop offset="1" stop-color="#4f555c"/></linearGradient>
          <radialGradient id="g-rehaut" cx=".5" cy=".5" r=".5"><stop offset=".9" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".22"/></radialGradient>
          <linearGradient id="g-leder" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#1c2026"/><stop offset=".5" stop-color="#2a2f36"/><stop offset="1" stop-color="#1a1d22"/></linearGradient>
          <linearGradient id="g-aus" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff"/></linearGradient>
          <linearGradient id="g-aus2" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff"/></linearGradient>
          <mask id="m-oben"><rect x="140" y="0" width="120" height="140" fill="url(#g-aus)"/></mask>
          <mask id="m-unten"><rect x="140" y="380" width="120" height="140" fill="url(#g-aus2)"/></mask>
          <radialGradient id="g-blatt" cx=".36" cy=".3" r=".8"><stop offset="0" stop-color="#f7f6f2"/><stop offset=".62" stop-color="#e5e3dd"/><stop offset="1" stop-color="#cbc8c0"/></radialGradient>
          <radialGradient id="g-klein" cx=".4" cy=".35" r=".8"><stop offset="0" stop-color="#e9e7e1"/><stop offset="1" stop-color="#d4d1c9"/></radialGradient>
          <linearGradient id="g-glas" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".34"/><stop offset=".45" stop-color="#fff" stop-opacity="0"/></linearGradient>
          <filter id="f-korn" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="1.6" numOctaves="2" seed="4"/><feColorMatrix values="0 0 0 0 .5  0 0 0 0 .5  0 0 0 0 .5  0 0 0 .09 0"/><feComposite in2="SourceGraphic" operator="in"/></filter>
          <clipPath id="c-blatt"><circle cx="200" cy="260" r="134"/></clipPath>
        </defs>
        <!-- Band, läuft ins Dunkel aus -->
        <g mask="url(#m-oben)"><rect x="156" y="0" width="88" height="140" fill="url(#g-leder)"/><path d="M162.5 0V134M237.5 0V134" stroke="#9aa0a6" stroke-opacity=".35" stroke-width=".7" stroke-dasharray="3 2.4"/></g>
        <g mask="url(#m-unten)"><rect x="156" y="380" width="88" height="140" fill="url(#g-leder)"/><path d="M162.5 386V520M237.5 386V520" stroke="#9aa0a6" stroke-opacity=".35" stroke-width=".7" stroke-dasharray="3 2.4"/></g>
        <!-- Bandanstöße -->
        <path d="M141 166L144.5 100Q146 90 155 89L159 89Q167 90 167.5 100L168 150Z" fill="url(#g-horn)"/>
        <path d="M259 166L255.5 100Q254 90 245 89L241 89Q233 90 232.5 100L232 150Z" fill="url(#g-horn)"/>
        <path d="M141 354L144.5 420Q146 430 155 431L159 431Q167 430 167.5 420L168 370Z" fill="url(#g-horn)"/>
        <path d="M259 354L255.5 420Q254 430 245 431L241 431Q233 430 232.5 420L232 370Z" fill="url(#g-horn)"/>
        <!-- Krone -->
        <rect x="348" y="245" width="18" height="30" rx="3" fill="url(#g-horn)"/>
        <path d="M352 247v26M356 247v26M360 247v26" stroke="#5f656c" stroke-width=".9"/>
        <!-- Gehäuse und Lünette -->
        <circle cx="200" cy="260" r="152" fill="url(#g-stahl)" stroke="#2b3036" stroke-width="1"/>
        <circle cx="200" cy="260" r="145" fill="none" stroke="url(#g-luenette)" stroke-width="6"/>
        <circle cx="200" cy="260" r="148.5" fill="none" stroke="#fff" stroke-opacity=".35" stroke-width=".6"/>
        <!-- Zifferblatt -->
        <circle cx="200" cy="260" r="138" fill="#9aa0a6"/>
        <circle cx="200" cy="260" r="134" fill="url(#g-blatt)"/>
        <circle cx="200" cy="260" r="134" fill="#fff" filter="url(#f-korn)"/>
        <circle cx="200" cy="260" r="134" fill="url(#g-rehaut)"/>
        <g data-minuten stroke="#23282f"></g>
        <g data-indizes></g>
        <text x="200" y="198" text-anchor="middle" font-family="Bodoni Moda, serif" font-size="14" letter-spacing="4.2" fill="#23282f">HAGEDORN</text>
        <text x="200" y="212" text-anchor="middle" font-family="Instrument Sans, sans-serif" font-size="6.2" font-weight="500" letter-spacing="2.4" fill="#5c626b">GÖRLITZ</text>
        <text x="200" y="298" text-anchor="middle" font-family="Instrument Sans, sans-serif" font-size="5.6" font-weight="500" letter-spacing="1.8" fill="#7a8087">HANDAUFZUG</text>
        <!-- kleine Sekunde -->
        <circle cx="200" cy="334" r="30" fill="url(#g-klein)"/>
        <g fill="none" stroke="#23282f" stroke-opacity=".06"><circle cx="200" cy="334" r="26"/><circle cx="200" cy="334" r="22"/><circle cx="200" cy="334" r="18"/><circle cx="200" cy="334" r="14"/><circle cx="200" cy="334" r="10"/></g>
        <g data-sekundenteilung stroke="#23282f"></g>
        <g data-sekunde transform="translate(200 334)"><path d="M0 6V-26" stroke="#a88a5a" stroke-width="1.1" stroke-linecap="round"/><circle r="2.2" fill="#a88a5a"/></g>
        <!-- Zeiger -->
        <g data-stunde transform="translate(200 260)">
          <path d="M0-74C5.6-52 6.2-22 4.2 10H-4.2C-6.2-22-5.6-52 0-74Z" fill="#2a2f36"/>
          <path d="M0-70V4" stroke="#fff" stroke-opacity=".28" stroke-width=".7"/>
        </g>
        <g data-minute transform="translate(200 260)">
          <path d="M0-116C4.2-84 5-34 3.4 12H-3.4C-5-34-4.2-84 0-116Z" fill="#2a2f36"/>
          <path d="M0-112V6" stroke="#fff" stroke-opacity=".28" stroke-width=".7"/>
        </g>
        <circle cx="200" cy="260" r="5.5" fill="url(#g-horn)" stroke="#2a2f36" stroke-width="1"/>
        <!-- Glas: Reflex von oben links -->
        <path clip-path="url(#c-blatt)" d="M60 260A140 140 0 0 1 200 120C150 160 110 205 86 268Z" fill="url(#g-glas)"/>
      </svg>
    </figure>
  </div>

  <dl class="fakten label" aria-label="Kenndaten">
    <div><dt>Gangreserve</dt><dd><b>62 h</b></dd></div>
    <div><dt>Durchmesser</dt><dd><b>38,5 mm</b></dd></div>
    <div><dt>Referenz</dt><dd><b>HG 38.11</b></dd></div>
  </dl>

  <div class="trenner mono" data-trenner aria-hidden="true"><span>Embed-Preset</span><span>Eigener Pass</span></div>
  <footer class="leiste mono">
    <div class="szenen">
      <div role="group" aria-label="Variante" style="display: contents">
        <span>Weg</span>
        <button type="button" data-variante="embed" aria-pressed="true">Embed</button>
        <button type="button" data-variante="eigen" aria-pressed="false">Selbst gebaut</button>
      </div>
      <span aria-hidden="true">·</span>
      <div role="group" aria-label="Szene wählen" style="display: contents" data-nur="embed">
        <span>Szene</span>
        <button type="button" data-s="0" aria-pressed="true">Lichtstrahlen</button>
        <button type="button" data-s="1" aria-pressed="false">Körnung</button>
        <button type="button" data-s="2" aria-pressed="false">Raster</button>
      </div>
      <div style="display: contents" data-nur="eigen" hidden>
        <span class="eigen-name">Lichtschächte, eigener Pass</span>
        <button type="button" data-vergleich aria-pressed="false">Vergleich</button>
        <label class="grenze-feld"><span>Grenze</span><input class="grenze" type="range" min="10" max="90" step="1" value="40" data-grenze aria-label="Grenze zwischen Embed-Preset und eigenem Pass, Prozent der Breite"></label>
      </div>
      <button type="button" class="ruhe" 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>
    <p class="werte" data-werte aria-live="polite"></p>
  </footer>
</section>

<section class="werk vergleich" data-nur="eigen" hidden aria-labelledby="vergleich-titel">
  <div class="werk__raster">
    <p class="label">Für die Website · Embed oder selbst gebaut?</p>
    <h2 class="serif" id="vergleich-titel">Dasselbe Licht, zwei Wege.</h2>
    <p class="einleitung">Oben im Bild läuft der eigene Pass: ein Shader ohne Paket lässt das Licht durch ein Sprossenfenster mit drei Feldern fallen, 40 Proben je Pixel sammeln den Staub im Strahl. Mit „Vergleich“ in der Leiste steht links das Editor-Preset der Lichtstrahlen daneben, so wie ein No-Code-Embed es liefert.</p>
    <table class="tabelle">
      <thead><tr><th scope="col">Frage</th><th scope="col">Embed (Unicorn Studio, Paper Shaders)</th><th scope="col">Selbst gebaut (dieser Pass)</th></tr></thead>
      <tbody>
        <tr><th scope="row">Aufwand</th><td data-spalte="Embed">Szene im Editor bauen, <b>Parameter statt Code</b>. Schnell, auch ohne Shader-Kenntnis.</td><td data-spalte="Selbst gebaut">Shader schreiben und abstimmen. <b>Deutlich mehr Zeit</b>, dafür ein Effekt, den sonst niemand hat.</td></tr>
        <tr><th scope="row">Gewicht</th><td data-spalte="Embed">Fremdes Skript, bei Unicorn Studio laut Drittquelle <b>rund 29 KB gzip</b>, dazu die Szenendatei.</td><td data-spalte="Selbst gebaut"><b>Kein Paket.</b> Ein Fragment-Shader in der Seite, ein Durchlauf, halbe Auflösung.</td></tr>
        <tr><th scope="row">Kontrolle</th><td data-spalte="Embed">Was der Editor anbietet: Presets, Farben, Tempo, Variablen.</td><td data-spalte="Selbst gebaut"><b>Alles</b>: Form der Lichtquelle (hier das Nordfenster mit Sprossen), Abfall, Farben direkt aus dem Theme.</td></tr>
        <tr><th scope="row">Datenschutz</th><td data-spalte="Embed">Projekt-ID lädt vom Anbieter; selbst gehostetes JSON ist möglich. Datenschutz vom Kunden prüfen lassen.</td><td data-spalte="Selbst gebaut"><b>Alles vom eigenen Server</b>, keine Verbindung zu Dritten.</td></tr>
        <tr><th scope="row">Pflege</th><td data-spalte="Embed">Version pinnen, Updates und Änderungen des Anbieters verfolgen; Tarif und Lizenz prüfen.</td><td data-spalte="Selbst gebaut">Eigene Wartung. Keine fremden Updates, aber auch niemand, der Fehler für einen behebt.</td></tr>
        <tr><th scope="row">Passt für</th><td data-spalte="Embed">Prototyp, Kampagne, Teams ohne Shader-Wissen, Szenen zum Austauschen.</td><td data-spalte="Selbst gebaut">Signatur-Effekt der Marke, strenge Vorgaben zu Ladezeit oder Datenschutz, Licht mit Bezug zum Inhalt.</td></tr>
      </tbody>
    </table>
    <p class="hinweis">Beide Wege brauchen dasselbe drumherum: Standbild als Fallback, Pause-Knopf, Ruhe außerhalb des Viewports und bei reduzierter Bewegung.</p>
  </div>
</section>
<section class="werk">
  <div class="werk__raster">
    <div>
      <p class="label">Manufaktur · Brüderstraße 12</p>
      <h2 class="serif">Warum wir am Nordfenster arbeiten.</h2>
      <p>Uhrmacher brauchen Licht, das sich nicht bewegt. Ein Fenster nach Norden lässt keine direkte Sonne herein, die Farben der Oberflächen bleiben von morgens bis abends gleich. So sehen wir jeden Kratzer im Schliff, bevor ein Werk das Haus verlässt.</p>
      <p>Jede Neiße 38 wird von einer Uhrmacherin vollständig montiert, reguliert und in fünf Lagen geprüft.</p>
    </div>
    <dl class="zahlen">
      <div><dt class="label">Gangreserve</dt><dd>62<small>Stunden</small></dd></div>
      <div><dt class="label">Durchmesser</dt><dd>38,5<small>mm</small></dd></div>
      <div><dt class="label">Halbschwingungen</dt><dd>21.600<small>pro Stunde</small></dd></div>
      <div><dt class="label">Auflage</dt><dd>48<small>Stück im Jahr</small></dd></div>
    </dl>
  </div>
</section>

<footer class="fuss" data-fuss>
  <div class="fuss__raster">
    <div>
      <p class="label">Boutique Görlitz</p>
      <h2 class="serif">Eine Stunde <em>am Werktisch</em>.</h2>
      <a class="knopf label" href="#">Termin vereinbaren</a>
    </div>
    <address>
      <span>Hagedorn Uhren</span>
      <span>Brüderstraße 12, 02826 Görlitz</span>
      <span class="label">Dienstag bis Samstag</span>
      <span>10:00 bis 18:00 Uhr, nach Vereinbarung</span>
    </address>
  </div>
  <div class="fuss__unten label">
    <span>© 2026 Hagedorn Uhren</span>
    <nav aria-label="Rechtliches"><a href="#">Pflege und Service</a><a href="#">Impressum</a><a href="#">Datenschutz</a></nav>
  </div>
</footer>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const touch = matchMedia('(hover: none)').matches;
  const canvas = document.querySelector('[data-canvas]');
  const held = document.querySelector('[data-held]');
  const fuss = document.querySelector('[data-fuss]');
  const werteEl = document.querySelector('[data-werte]');

  /* ---------- Szenen-Presets: so liefert sie ein No-Code-Editor (Parameter statt Code) ----------
     GodRays: Werte gemessen bei Daylight (Paper Shaders). Körnung und Raster: eigene Presets. */
  const SZENEN = [
    { name: 'GodRays', speed: 0.75, p: { density: 0.3, spotty: 0.3, midIntensity: 0.4, midSize: 0.2, intensity: 0.8, bloom: 0.4 } },
    { name: 'GrainGradient', speed: 0.5, p: { softness: 0.7, intensity: 0.5, noise: 0.35 } },
    { name: 'Dithering', speed: 0.4, p: { type: '4x4', pxSize: 3, intensity: 0.85 } },
  ];
  // Embed-Laufzeit: entspricht data-us-dpi / data-us-scale / data-us-fps
  const LAUF = touch ? { dpi: 1, scale: 0.5, fps: 30 } : { dpi: 1.5, scale: 1, fps: 60 };
  const WECHSEL_MS = 450;   // Überblendung zwischen Szenen (Rezept: Bildwechsel 400–500 ms)
  // Variante eigen: derselbe Effekt als eigener Pass, kein Paket. Halbe Auflösung reicht für weiches Licht.
  const EIGEN = { name: 'Lichtschacht', speed: 0.6, p: { proben: 40, abfall: 0.97, felder: 3, sprosse: 0.1, oeffnung: '.34 rad' }, scale: 0.5 };
  const VARIANTEN = ['embed', 'eigen'];
  let variante = VARIANTEN.includes(new URLSearchParams(location.search).get('v')) ? new URLSearchParams(location.search).get('v') : 'embed';
  let grenze = 0.5, vergleich = false;

  /* ---------- WebGL: Fullscreen-Dreieck, ein Fragment-Shader mit drei Szenen ---------- */
  const VS = 'attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }';
  const FS = `
precision highp float;
uniform vec2 u_res; uniform float u_t; uniform vec2 u_ptr; uniform float u_ptrMix;
uniform float u_a, u_b, u_mix, u_px;
uniform vec3 u_back, u_licht, u_schein, u_tiefe, u_raster, u_hell;
uniform float u_density, u_spotty, u_midI, u_midS, u_inten, u_bloom;
uniform float u_soft, u_gInten, u_noise, u_dInten;
uniform float u_eigen, u_split;

float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float noise(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + 1.0), u.x), u.y); }
float fbm(vec2 p){ float v = 0.0, a = 0.5; for (int i = 0; i < 3; i++) { v += a * noise(p); p = p * 2.03 + 17.1; a *= 0.5; } return v / 0.875; }
float bayer2(vec2 a){ a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
float bayer4(vec2 a){ return bayer2(0.5 * a) * 0.25 + bayer2(a); }

// Lichtstrahlen: Ursprung oben links (das Nordfenster), Strahlen als Rauschen auf dem Einheitskreis
vec3 godrays(vec2 uv, float t){
  float asp = u_res.x / u_res.y;
  vec2 o = vec2(0.14 + u_ptr.x * 0.12 * u_ptrMix, 1.14);
  vec2 p = (uv - o) * vec2(asp, 1.0);
  float r = length(p); vec2 dir = p / max(r, 1e-4);
  float f0 = mix(3.0, 20.0, u_density);
  float rays = 0.0;
  for (int i = 0; i < 3; i++) {
    float fi = float(i), f = f0 * (1.0 + fi * 0.85);
    float n = noise(dir * f + vec2(t * (0.10 + 0.04 * fi), -t * (0.06 + 0.03 * fi)) + fi * 13.7);
    n = smoothstep(0.36, 0.95, n);
    float s = noise(dir * f * 1.7 + vec2(r * 5.0 - t * 0.5, fi * 3.1));
    n *= mix(1.0, smoothstep(0.15, 0.85, s), u_spotty);
    rays += n * (1.0 - fi * 0.28);
  }
  rays *= 0.52;
  float fall = exp(-r * 1.0);
  float mid = u_midI * exp(-pow(r / (u_midS * 2.6), 2.0));
  vec3 col = u_back;
  col += u_licht * rays * fall * u_inten * 0.5;
  col += u_schein * (mid * 0.55 + rays * fall * 0.22) * u_bloom;
  return col;
}

// Körnung: zwei weiche Formen, deren Kante per Pixel-Rauschen aufgeraut wird
vec3 grain(vec2 uv, float t){
  float asp = u_res.x / u_res.y; vec2 p = uv * vec2(asp, 1.0);
  vec2 c1 = vec2(asp * (0.22 + 0.10 * sin(t * 0.21)), 0.80 + 0.08 * cos(t * 0.17));
  vec2 c2 = vec2(asp * (0.80 + 0.08 * cos(t * 0.19 + 1.3)), 0.22 + 0.10 * sin(t * 0.23 + 0.4));
  c1 = mix(c1, vec2((u_ptr.x * 0.5 + 0.5) * asp, u_ptr.y * 0.5 + 0.5), 0.3 * u_ptrMix);
  float w = (fbm(p * 1.4 + t * 0.06) - 0.5) * 0.45;
  float v1 = 1.0 - smoothstep(0.0, 0.55 + u_soft * 0.5, length(p - c1) + w);
  float v2 = 1.0 - smoothstep(0.0, 0.5 + u_soft * 0.45, length(p - c2) - w);
  float g = hash(floor(gl_FragCoord.xy / max(1.0, u_px / 3.0)));
  float sh = v1 * 0.95 + v2 * 0.6 + (g - 0.5) * u_noise;
  vec3 col = mix(u_back, u_tiefe, smoothstep(0.12, 0.62, sh));
  col = mix(col, u_hell, smoothstep(0.72, 1.2, sh) * u_gInten);
  return col;
}

// Raster: geordnetes 4x4-Dithering einer wandernden Lichtform
vec3 dither(vec2 uv, float t){
  vec2 cell = floor(gl_FragCoord.xy / u_px);
  vec2 q = (cell + 0.5) * u_px / u_res;
  float asp = u_res.x / u_res.y; vec2 p = q * vec2(asp, 1.0);
  vec2 c = vec2(asp * (0.22 + 0.06 * sin(t * 0.3)) + u_ptr.x * 0.18 * u_ptrMix, 1.02);
  float n = fbm(p * 1.7 + vec2(t * 0.12, -t * 0.08));
  float d = length((p - c) * vec2(1.0, 1.15));
  float v = (1.0 - smoothstep(0.05, 1.35, d)) * (0.5 + 0.7 * n);
  v = clamp(v * u_dInten, 0.0, 1.0);
  float an = step(bayer4(cell) + 0.03, v);
  return mix(u_back, u_raster, an);
}

// Variante eigen: Lichtschacht durch ein Sprossenfenster, ohne Bibliothek.
// Drei Felder als Winkelmaske um die Lichtquelle, Staub über 40 Proben entlang des Strahls (radiale Sammlung).
vec3 lichtschacht(vec2 uv, float t){
  float asp = u_res.x / u_res.y;
  vec2 L = vec2((0.10 + u_ptr.x * 0.08 * u_ptrMix) * asp, 1.12);
  vec2 p = uv * vec2(asp, 1.0);
  vec2 d = p - L; float r = length(d);
  float th = atan(d.x, -d.y);                          // 0 = senkrecht nach unten
  float mitte = 0.60 + 0.035 * sin(t * 0.11);           // Licht wandert langsam
  float a = (th - mitte) / 0.34 * 1.5 + 1.5;            // 0..3 über die drei Fensterfelder
  float weich = 0.03 + 0.10 * r;                        // Halbschatten wächst mit dem Abstand
  float rahmen = smoothstep(-weich, weich, a) * (1.0 - smoothstep(3.0 - weich, 3.0 + weich, a));
  float f = fract(a);
  float sprosse = smoothstep(0.0, 0.05 + weich, f) * (1.0 - smoothstep(0.95 - weich, 1.0, f));
  float acc = 0.0, w = 1.0, wsum = 0.0;
  vec2 schritt = -d / 40.0; vec2 s = p + schritt * 0.5;
  for (int i = 0; i < 40; i++) {
    acc += noise(s * vec2(2.2, 3.4) + vec2(t * 0.07, -t * 0.05)) * w;
    wsum += w; w *= 0.97; s += schritt;
  }
  float staub = smoothstep(0.30, 0.72, acc / wsum);
  float wolke = 0.85 + 0.15 * noise(vec2(t * 0.2, 3.0));
  float strahl = rahmen * sprosse * mix(0.5, 1.0, staub) * exp(-r * 0.85) * wolke;
  float glow = exp(-r * r * 2.6);
  vec3 col = u_back + u_licht * strahl * 0.40 + u_schein * (glow * 0.16 + rahmen * exp(-r * 1.4) * 0.05);
  return col;
}

vec3 szene(float m, vec2 uv, float t){
  if (m < 0.5) return godrays(uv, t);
  if (m < 1.5) return grain(uv, t);
  return dither(uv, t);
}

void main(){
  vec2 uv = gl_FragCoord.xy / u_res;
  vec3 col;
  if (u_eigen > 0.5) {
    // Vergleich: links das Editor-Preset (GodRays), rechts der eigene Pass
    col = (u_split > 0.0 && uv.x < u_split) ? godrays(uv, u_t) : lichtschacht(uv, u_t);
  } else {
    col = szene(u_a, uv, u_t);
    if (u_mix > 0.001) col = mix(col, szene(u_b, uv, u_t), u_mix);
  }
  col += (hash(gl_FragCoord.xy + fract(u_t) * 91.0) - 0.5) / 255.0 * 1.5;   // gegen Banding
  gl_FragColor = vec4(col, 1.0);
}`;

  let gl = null, U = {};
  try {
    gl = canvas.getContext('webgl', { antialias: false, alpha: false, powerPreference: 'low-power' });
    if (gl) {
      const sh = (typ, src) => { const s = gl.createShader(typ); gl.shaderSource(s, src); gl.compileShader(s);
        if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; };
      const prog = gl.createProgram();
      gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
      gl.linkProgram(prog);
      if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error('link');
      gl.useProgram(prog);
      const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
      const loc = gl.getAttribLocation(prog, 'a'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
      ['u_res', 'u_t', 'u_ptr', 'u_ptrMix', 'u_a', 'u_b', 'u_mix', 'u_px', 'u_back', 'u_licht', 'u_schein', 'u_tiefe', 'u_raster', 'u_hell',
       'u_density', 'u_spotty', 'u_midI', 'u_midS', 'u_inten', 'u_bloom', 'u_soft', 'u_gInten', 'u_noise', 'u_dInten', 'u_eigen', 'u_split']
        .forEach(n => U[n] = gl.getUniformLocation(prog, n));
      canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); gl = null; html.classList.add('ohne-webgl'); });
    }
  } catch (e) { gl = null; }
  if (!gl) html.classList.add('ohne-webgl');

  // Farben aus den CSS-Variablen (Theme-Kopplung wie scene.setVariable())
  const rgb = name => { const h = getComputedStyle(html).getPropertyValue(name).trim().replace('#', '');
    return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16) / 255); };
  function farbenSetzen() {
    if (!gl) return;
    gl.uniform3fv(U.u_back, rgb('--anthrazit')); gl.uniform3fv(U.u_licht, rgb('--licht')); gl.uniform3fv(U.u_schein, rgb('--schein'));
    gl.uniform3fv(U.u_tiefe, rgb('--tiefe')); gl.uniform3fv(U.u_raster, rgb('--raster')); gl.uniform3fv(U.u_hell, rgb('--linie'));
    const g = SZENEN[0].p, k = SZENEN[1].p, d = SZENEN[2].p;
    gl.uniform1f(U.u_density, g.density); gl.uniform1f(U.u_spotty, g.spotty); gl.uniform1f(U.u_midI, g.midIntensity);
    gl.uniform1f(U.u_midS, g.midSize); gl.uniform1f(U.u_inten, g.intensity); gl.uniform1f(U.u_bloom, g.bloom);
    gl.uniform1f(U.u_soft, k.softness); gl.uniform1f(U.u_gInten, k.intensity); gl.uniform1f(U.u_noise, k.noise); gl.uniform1f(U.u_dInten, d.intensity);
  }
  farbenSetzen();

  // Auflösung: dpi × scale, begrenzt (data-us-dpi / data-us-scale)
  let faktor = 1;
  function groesse() {
    if (!gl) return;
    // Galerie-Vorschau wird verkleinert angezeigt: dort reicht scale .6
    const sc = variante === 'eigen' ? Math.min(LAUF.scale, EIGEN.scale) : LAUF.scale;
    faktor = Math.min(devicePixelRatio || 1, LAUF.dpi) * (html.classList.contains('vorschau') ? Math.min(sc, 0.6) : sc);
    const w = Math.max(1, Math.round(innerWidth * faktor)), h = Math.max(1, Math.round(innerHeight * faktor));
    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); }
  }

  let aktiv = 0, ziel = 0, mix = 0, wechselStart = 0;
  let zeit = 14.0, letzte = 0, rafId = 0, sichtbar = true, laeuft = true;
  const ptr = { x: 0, y: 0, zx: 0, zy: 0, mix: 0, zmix: 0 };

  function zeichnen() {
    if (!gl) return;
    groesse();
    gl.uniform2f(U.u_res, canvas.width, canvas.height);
    gl.uniform1f(U.u_t, zeit);
    gl.uniform2f(U.u_ptr, ptr.x, ptr.y); gl.uniform1f(U.u_ptrMix, ptr.mix);
    gl.uniform1f(U.u_a, aktiv); gl.uniform1f(U.u_b, ziel); gl.uniform1f(U.u_mix, mix);
    gl.uniform1f(U.u_px, Math.max(1, SZENEN[2].p.pxSize * faktor));
    gl.uniform1f(U.u_eigen, variante === 'eigen' ? 1 : 0); gl.uniform1f(U.u_split, variante === 'eigen' && vergleich ? grenze : -1);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }

  function schritt(t) {
    rafId = 0;
    if (!gl || !laeuft || !sichtbar || reduziert()) return;
    if (letzte && t - letzte < 1000 / LAUF.fps - 2) { rafId = requestAnimationFrame(schritt); return; }   // FPS-Deckel
    const dt = Math.min(0.05, letzte ? (t - letzte) / 1000 : 0);
    letzte = t;
    // Zeit läuft linear im Shader; Geschwindigkeit je Szene, beim Wechsel überblendet
    const sp = variante === 'eigen' ? EIGEN.speed : SZENEN[aktiv].speed + (SZENEN[ziel].speed - SZENEN[aktiv].speed) * mix;
    zeit += dt * sp;
    if (aktiv !== ziel) {
      mix = Math.min(1, (t - wechselStart) / WECHSEL_MS);
      if (mix >= 1) { aktiv = ziel; mix = 0; }
    }
    ptr.x += (ptr.zx - ptr.x) * 0.05; ptr.y += (ptr.zy - ptr.y) * 0.05; ptr.mix += (ptr.zmix - ptr.mix) * 0.04;
    zeichnen();
    rafId = requestAnimationFrame(schritt);
  }
  function starten() { if (!gl || reduziert()) return; letzte = 0; if (!rafId) rafId = requestAnimationFrame(schritt); }
  function anhalten() { cancelAnimationFrame(rafId); rafId = 0; }

  // Pausieren, wenn weder Bühne noch Fuß zu sehen sind (die weiße Sektion deckt die Szene ab)
  const sicht = new Map();
  const io = new IntersectionObserver(eintraege => {
    eintraege.forEach(e => sicht.set(e.target, e.isIntersecting));
    sichtbar = [...sicht.values()].some(Boolean);
    sichtbar && laeuft ? starten() : anhalten();
  });
  io.observe(held); io.observe(fuss);
  document.addEventListener('visibilitychange', () => { document.hidden ? anhalten() : (laeuft && starten()); });
  addEventListener('resize', () => { if (!rafId) zeichnen(); });

  // Maus: Lichtquelle folgt dem Zeiger leicht (auf Touch aus, wie interactivity.mouse.disableMobile)
  if (!touch) {
    held.addEventListener('pointermove', e => {
      if (vorfuehrung) return;
      const r = held.getBoundingClientRect();
      ptr.zx = ((e.clientX - r.left) / r.width) * 2 - 1; ptr.zy = 1 - ((e.clientY - r.top) / r.height) * 2; ptr.zmix = 1;
    });
    held.addEventListener('pointerleave', () => { if (!vorfuehrung) ptr.zmix = 0; });
  }

  /* ---------- Werte-Anzeige ---------- */
  const zahl = v => typeof v === 'number' ? String(v).replace(/^0\./, '.') : v;
  function werteZeigen(i) {
    if (!gl) { werteEl.innerHTML = '<span>Fallback <b>Poster (CSS)</b></span><span>WebGL <b>nicht verfügbar</b></span>'; return; }
    const s = variante === 'eigen' ? EIGEN : SZENEN[i];
    const sc = variante === 'eigen' ? Math.min(LAUF.scale, EIGEN.scale) : LAUF.scale;
    const teile = [['', s.name], ...Object.entries(s.p), ['speed', s.speed], ['dpi', LAUF.dpi, 1], ['scale', sc, 1], ['fps', LAUF.fps, 1]];
    werteEl.innerHTML = teile.map(([k, v, lauf]) => k ? `<span${lauf ? ' class="lauf"' : ''}>${k} <b>${zahl(v)}</b></span>` : `<span><b>${v}</b></span>`).join('');
  }

  /* ---------- Szenen wechseln ---------- */
  const knoepfe = [...document.querySelectorAll('[data-s]')];
  const ruheKnopf = document.querySelector('[data-ruhe]');
  function setzen(i, sofort) {
    knoepfe.forEach(b => b.setAttribute('aria-pressed', String(+b.dataset.s === i)));
    werteZeigen(i);
    if (!gl) return;
    if (sofort || reduziert() || !rafId) { aktiv = ziel = i; mix = 0; zeichnen(); return; }
    if (i === ziel) return;
    if (aktiv !== ziel) aktiv = ziel;          // laufende Überblendung abschließen
    ziel = i; mix = 0; wechselStart = performance.now();
  }
  knoepfe.forEach(b => {
    if (!gl) { b.disabled = true; b.title = 'WebGL ist hier nicht verfügbar'; }
    b.addEventListener('click', () => { if (vorfuehrung) pause(); setzen(+b.dataset.s); weiterLaufen(); });
  });

  /* ---------- Variante (Regel 13): Embed-Preset oder eigener Pass ---------- */
  const vKnoepfe = [...document.querySelectorAll('[data-variante]')];
  const vergleichKnopf = document.querySelector('[data-vergleich]');
  const grenzeEl = document.querySelector('[data-grenze]');
  const trenner = document.querySelector('[data-trenner]');
  function grenzeSetzen(g) {
    grenze = Math.min(0.9, Math.max(0.1, g));
    grenzeEl.value = Math.round(grenze * 100);
    trenner.style.left = (grenze * 100) + '%';
    if (!rafId) zeichnen();
  }
  function vergleichSetzen(an) {
    vergleich = an && !!gl;
    vergleichKnopf.setAttribute('aria-pressed', String(vergleich));
    held.classList.toggle('vergleich-an', vergleich);
    if (!rafId) zeichnen();
  }
  function varianteSetzen(v, url) {
    variante = VARIANTEN.includes(v) ? v : 'embed';
    vKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
    document.querySelectorAll('[data-nur]').forEach(el => { el.hidden = el.dataset.nur !== variante; });
    if (variante !== 'eigen') vergleichSetzen(false);
    if (url) {
      const q = new URLSearchParams(location.search);
      variante === 'embed' ? q.delete('v') : q.set('v', variante);
      const qs = q.toString();
      history.replaceState(null, '', location.pathname + (qs ? '?' + qs : '') + location.hash);
    }
    werteZeigen(ziel);
    if (!rafId) zeichnen();
  }
  vKnoepfe.forEach(b => b.addEventListener('click', () => { if (vorfuehrung) pause(); varianteSetzen(b.dataset.variante, true); weiterLaufen(); }));
  if (!gl) vergleichKnopf.disabled = true;
  vergleichKnopf.addEventListener('click', () => { if (vorfuehrung) pause(); vergleichSetzen(!vergleich); weiterLaufen(); });
  grenzeEl.addEventListener('input', () => { if (vorfuehrung) pause(); grenzeSetzen(grenzeEl.value / 100); });
  // Trenner ziehen (Maus und Finger); der Regler in der Leiste ist die Tastatur-Alternative
  trenner.addEventListener('pointerdown', e => {
    if (vorfuehrung) pause();
    trenner.setPointerCapture(e.pointerId);
    const zieh = ev => grenzeSetzen(ev.clientX / innerWidth);
    const los = () => { trenner.removeEventListener('pointermove', zieh); trenner.removeEventListener('pointerup', los); trenner.removeEventListener('pointercancel', los); };
    trenner.addEventListener('pointermove', zieh); trenner.addEventListener('pointerup', los); trenner.addEventListener('pointercancel', los);
    zieh(e);
  });

  /* ---------- Uhr: Minuterie, Indizes, echte Zeit ---------- */
  const NS = 'http://www.w3.org/2000/svg';
  const linie = (g, cx, cy, r1, r2, a, w, op) => { const l = document.createElementNS(NS, 'line'), s = Math.sin(a), c = -Math.cos(a);
    l.setAttribute('x1', cx + s * r1); l.setAttribute('y1', cy + c * r1); l.setAttribute('x2', cx + s * r2); l.setAttribute('y2', cy + c * r2);
    l.setAttribute('stroke-width', w); l.setAttribute('stroke-opacity', op); g.appendChild(l); };
  const gMin = document.querySelector('[data-minuten]'), gIdx = document.querySelector('[data-indizes]'), gSek = document.querySelector('[data-sekundenteilung]');
  for (let i = 0; i < 60; i++) linie(gMin, 200, 260, i % 5 ? 126 : 122, 130, i / 60 * Math.PI * 2, i % 5 ? .6 : 1.1, i % 5 ? .55 : .85);
  for (let i = 0; i < 60; i += (i % 15 ? 1 : 1)) linie(gSek, 200, 334, i % 15 ? 26.5 : 23.5, 29, i / 60 * Math.PI * 2, i % 15 ? .4 : .8, .6);
  for (let i = 0; i < 12; i++) {
    const a = i * 30, doppel = i === 0;
    [doppel ? -4 : 0, doppel ? 4 : null].filter(v => v !== null).forEach(dx => {
      const r = document.createElementNS(NS, 'rect');
      r.setAttribute('x', 200 + dx - 2.2); r.setAttribute('y', 260 - 117); r.setAttribute('width', 4.4); r.setAttribute('height', 22); r.setAttribute('rx', .8);
      r.setAttribute('fill', 'url(#g-horn)'); r.setAttribute('stroke', '#5f656c'); r.setAttribute('stroke-width', '.5');
      r.setAttribute('transform', `rotate(${a} 200 260)`); gIdx.appendChild(r);
    });
  }
  const hStd = document.querySelector('[data-stunde]'), hMin = document.querySelector('[data-minute]'), hSek = document.querySelector('[data-sekunde]');
  function uhrStellen() {
    // Galerie-Vorschau: 10:10 Uhr wie in der Uhrenfotografie, sonst echte Zeit
    const d = html.classList.contains('vorschau') ? new Date(2026, 10, 1, 10, 10, 31) : new Date(), s = d.getSeconds(), m = d.getMinutes() + s / 60, h = (d.getHours() % 12) + m / 60;
    hStd.setAttribute('transform', `translate(200 260) rotate(${h * 30})`);
    hMin.setAttribute('transform', `translate(200 260) rotate(${m * 6})`);
    hSek.setAttribute('transform', `translate(200 334) rotate(${s * 6})`);
  }
  uhrStellen();
  setInterval(() => { if (!reduziert() && laeuft) uhrStellen(); }, 1000);   // angehalten: auch der Sekundenzeiger steht

  /* ---------- Vorführung: gedachter Zeiger zieht die Lichtquelle, Szenen wechseln ---------- */
  let vorfuehrung = false, folge = null, zeigerUhr = 0;
  const FOLGE = [[0, 8000], [1, 5000], [2, 5000]];
  function zeigerWandern(t0) {
    const s = tt => {
      const a = (tt - t0) / 1000;
      ptr.zx = Math.sin(a * 0.7) * 0.9; ptr.zy = Math.cos(a * 0.5) * 0.4; ptr.zmix = 1;
      zeigerUhr = requestAnimationFrame(s);
    };
    zeigerUhr = requestAnimationFrame(s);
  }
  function vorfuehren() {
    pause(); vorfuehrung = true; html.classList.add('vorfuehrung');
    uhrStellen();
    let i = 0;
    weiterLaufen();
    if (variante === 'eigen') {
      // eigener Pass 5 s allein, dann 7 s Vergleich mit wanderndem Trenner
      const runde = () => {
        vergleichSetzen(false);
        folge = setTimeout(() => {
          vergleichSetzen(true); const t0 = performance.now();
          const wandern = () => { if (!vorfuehrung) return; const a = (performance.now() - t0) / 7000;
            grenzeSetzen(0.4 - 0.22 * Math.cos(a * Math.PI * 2)); if (a < 1) folge = setTimeout(wandern, 33); else runde(); };
          wandern();
        }, 5000);
      };
      runde();
      if (!reduziert()) zeigerWandern(performance.now());
      return;
    }
    const naechste = () => { const [s, dauer] = FOLGE[i % FOLGE.length]; i++; setzen(s, i === 1); folge = setTimeout(naechste, dauer); };
    naechste();
    if (!reduziert()) zeigerWandern(performance.now());
  }
  function pause() {
    clearTimeout(folge); cancelAnimationFrame(zeigerUhr); vorfuehrung = false; html.classList.remove('vorfuehrung');
    ptr.zmix = 0; laeuft = false; anhalten(); ruheKnopf.setAttribute('aria-pressed', 'true');
  }
  function weiterLaufen() { laeuft = true; ruheKnopf.setAttribute('aria-pressed', 'false'); if (reduziert()) zeichnen(); else starten(); }
  ruheKnopf.addEventListener('click', () => laeuft ? pause() : weiterLaufen());
  function neustart() { pause(); zeit = 14.0; ptr.x = ptr.y = ptr.mix = 0; weiterLaufen(); setzen(0, true); }

  setzen(0, true);
  grenzeSetzen(0.4);
  varianteSetzen(variante, false);
  weiterLaufen();
  // ?reduziert kann nachträglich gesetzt werden: dann Standbild
  new MutationObserver(() => { if (reduziert()) { anhalten(); aktiv = ziel; mix = 0; zeichnen(); } else if (laeuft) starten(); })
    .observe(html, { attributes: true, attributeFilter: ['class'] });

})();
</script>
</body>
</html>