Zum Inhalt
← 3D & WebGL

2.5D-Bilder mit Tiefenkarte

Ein Rosengarten-Panorama mit Tiefenkarte: Die Sonne folgt dem Zeiger, sinkt sie tief, glüht der Fels und im Tal gehen Lichter an.

Hotel, Weingut, Gastgeber mit Panorama: Die Sonne folgt dem Zeiger, Eigenschatten und Alpenglühen; mit Parallax und Tiefenkarte als Darstellung.

Zu einem Foto gehört eine Tiefenkarte, heute meist per KI-Tiefenschätzung. Parallax: Der Shader verschiebt die uv abhängig von Tiefe und Zeiger oder Gyroskop, das Foto bekommt räumliche Tiefe. Relighting: Aus der Tiefenkarte entstehen Normalen, eine Lichtquelle am Zeiger beleuchtet das Bild mit Lambert, Glanz und Eigenschatten (Raymarching über die Tiefe). Optional als verschobenes Mesh in einer 3D-Szene.

Statische Fotos werden lebendig und räumlich, ohne 3D-Modell. Licht, das dem Zeiger folgt, wirkt magisch.

Wann einsetzen

Hero-Fotos, Porträts, Architektur, Landschaft, Produktfotos, Kunst.

Wann nicht

Bei Bildern mit feinen Kanten wie Haaren ohne saubere Tiefenkarte: Es entstehen Artefakte.

Varianten

  • Parallax per Zeiger
  • Gyro-Parallax auf dem Telefon
  • Licht am Zeiger mit Eigenschatten
  • Verschobenes Mesh (Displacement)
  • Tiefen-Scan (Variante scan): Eine Lichtfront läuft durch die Tiefenkarte von fern nach nah, mit heller Linie an der Front; der Zeiger stellt sie auf die Ebene darunter

Umsetzung

Parallax klein halten (0.01–0.03 uv), sonst reißen Kanten; Ränder per Zoom 1.05 verdecken. Eigenschatten nur lokal rechnen: kurzer Strahl und nur kleine Höhensprünge, sonst werfen Vordergrund-Objekte Geisterschatten auf den fernen Hintergrund. Bild und Tiefenkarte in einer Textur-Auflösung, Normalen im Shader aus dem Gradienten. Aufnahme wegen handwerklicher Qualität und Aktualität (08/2026), weitere Award-Belege nicht verifiziert.

Robust umsetzen

Telefon. Gyroskop (auf iOS mit Erlaubnis) oder langsame Auto-Bewegung.

Ohne Bewegung. Statisches Bild, Licht fest.

Barrierefrei. alt-Text bzw. aria-label am Bild, das Motiv beschreibt das Original. Text liegt als HTML über dem Bild.

Code

<!DOCTYPE html>
<!--
  2.5D-Bilder mit Tiefenkarte
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/tiefenkarten-relighting/
  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>2.5D-Bilder mit Tiefenkarte · 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: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; font-display: swap; }
@font-face { font-family: 'Mona Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/mona-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  --sand: #f3eee7; --creme: #fef2e3; --text: #3e3a37; --linie: #d5d1c8; --meta: #62645f; --haus: #6e5a47;
  --weich: cubic-bezier(0.37, 0, 0.63, 1);   /* Bilder, Hero: sine in-out */
  --ui: cubic-bezier(0.4, 0, 0.2, 1);        /* Buchungsleiste, Knöpfe */
  --rand: clamp(14px, 2.6vw, 36px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--sand); color: var(--text); font-family: 'Mona Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; min-height: 100svh; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
.label { font-size: 12px; font-weight: 500; font-stretch: 118%; letter-spacing: .06em; text-transform: uppercase; }
.mono { font: 500 10.5px/1.5 'Geist Mono', ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }

/* ---------- Kopf ---------- */
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 18px var(--rand); }
.marke { font: italic 400 26px/1 'Newsreader', serif; font-variation-settings: 'opsz' 36; letter-spacing: -.01em; }
.marke small { font: 500 10px/1 'Mona Sans', sans-serif; font-stretch: 125%; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); margin-left: 10px; font-style: normal; vertical-align: 3px; }
.kopf nav { display: flex; gap: 30px; }
.kopf nav a { transition: color .2s var(--ui); }
.kopf nav a:hover, .kopf nav a:focus-visible { color: var(--haus); }
.rechts { display: flex; justify-content: flex-end; align-items: center; gap: 22px; }
.sprache { display: flex; gap: 10px; color: var(--meta); }
.sprache b { color: var(--text); font-weight: 600; }
.buchen-k { height: 38px; padding: 0 18px; display: inline-flex; align-items: center; border: 1px solid var(--text); border-radius: 2px; transition: background-color .2s var(--ui), color .2s var(--ui); }
.buchen-k:hover { background: var(--text); color: var(--sand); }

/* ---------- Bühne ---------- */
.buehne { position: relative; margin: 0 var(--rand); height: calc(100svh - 74px - 96px); min-height: 520px; max-height: 860px; border-radius: 3px; overflow: hidden; background: #6f7894; isolation: isolate; touch-action: pan-y; cursor: none; }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1.1s var(--weich); }
.bild-bereit .buehne canvas { opacity: 1; }
.schleier { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(180deg, rgba(20, 18, 24, .28) 0%, rgba(20, 18, 24, 0) 26%, rgba(20, 18, 24, 0) 52%, rgba(20, 18, 24, .5) 100%); }
.ohne-gl .buehne { cursor: default; }
.ohne-gl .buehne canvas { opacity: 1; }

.text { position: absolute; z-index: 2; left: clamp(20px, 3.4vw, 52px); bottom: clamp(22px, 3.4vw, 48px); max-width: 760px; color: var(--creme); }
.text .label { opacity: .85; }
h1 { font: 300 clamp(2.5rem, 1.5rem + 4vw, 5.5rem)/1.05 'Newsreader', serif; font-variation-settings: 'opsz' 72; letter-spacing: -.015em; margin-top: 14px; text-wrap: balance; }
h1 em { font-style: italic; font-weight: 300; }

.uhr { position: absolute; z-index: 2; right: clamp(16px, 2.4vw, 32px); top: clamp(16px, 2.4vw, 30px); color: var(--creme); text-align: right; display: grid; gap: 4px; }
.uhr .zeit { font: 300 30px/1 'Newsreader', serif; font-variation-settings: 'opsz' 36; font-variant-numeric: tabular-nums; }
.uhr .label { font-size: 10.5px; opacity: .85; }
.uhr { transition: opacity .4s var(--weich); }
.ohne-licht .uhr { opacity: 0; }

/* Sonne als Zeiger */
.sonne { position: absolute; z-index: 3; left: 0; top: 0; width: 46px; height: 46px; margin: -23px 0 0 -23px; pointer-events: none; opacity: 0; transition: opacity .4s var(--weich); }
.sonne::before { content: ''; position: absolute; inset: 15px; border-radius: 50%; background: #fff6e4; box-shadow: 0 0 22px 8px rgba(255, 214, 160, .55); }
.sonne::after { content: ''; position: absolute; inset: 0; border-radius: 50%; border: 1px solid rgba(254, 242, 227, .6); }
.buehne:hover .sonne, .vorfuehrung .sonne, .zieht .sonne { opacity: 1; }

/* Bedienleiste */
.pult { position: absolute; z-index: 4; left: clamp(14px, 2vw, 24px); top: clamp(14px, 2vw, 24px); display: grid; gap: 8px; justify-items: start; cursor: default; }
.varianten { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: rgba(30, 27, 26, .42); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }
.varianten button { height: 28px; padding: 0 12px; border-radius: 999px; color: rgba(254, 242, 227, .78); transition: background-color .2s var(--ui), color .2s var(--ui); }
.varianten button:hover { color: var(--creme); }
.varianten button[aria-pressed='true'] { background: var(--creme); color: var(--text); }
.werte { color: rgba(254, 242, 227, .8); background: rgba(30, 27, 26, .32); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); padding: 5px 10px; border-radius: 3px; max-width: 380px; letter-spacing: .08em; }
.werte b { color: var(--creme); font-weight: 500; }

[data-nur][hidden] { display: none !important; }
/* Pause-Knopf (WCAG 2.2.2): im Morgen-Scan und auf Touch, wo das Licht von selbst wandert */
.halt { position: absolute; z-index: 4; right: clamp(14px, 2vw, 24px); bottom: clamp(16px, 2.4vw, 30px); display: none; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 999px; color: var(--creme); background: rgba(30, 27, 26, .5); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border: 1px solid rgba(254, 242, 227, .35); cursor: pointer; transition: background-color .2s var(--ui); }
.halt:hover { background: rgba(30, 27, 26, .7); }
.halt:focus-visible { outline: 2px solid var(--creme); outline-offset: 2px; }
.halt svg { width: 11px; height: 11px; }
.halt .i-weiter, .halt[aria-pressed='true'] .i-halt { display: none; }
.halt[aria-pressed='true'] .i-weiter { display: block; }
.v-scan .halt { display: inline-flex; }
@media (hover: none) { .halt { display: inline-flex; } }
.reduziert .halt, .vorschau .halt, .ohne-gl .halt { display: none !important; }
.v-scan .buehne { cursor: crosshair; }
.v-scan .sonne { display: none; }
.v-scan .schleier { background: linear-gradient(180deg, rgba(20, 18, 24, .3) 0%, rgba(20, 18, 24, 0) 24%, rgba(20, 18, 24, 0) 44%, rgba(20, 18, 24, .6) 74%, rgba(20, 18, 24, .7) 100%); }
.v-scan .text { max-width: 820px; }

/* ---------- Buchungsleiste direkt unter dem Bild ---------- */
.leiste { margin: -36px calc(var(--rand) + clamp(16px, 3vw, 48px)) 0; position: relative; z-index: 5; background: var(--creme); border-radius: 2px; box-shadow: 0 12px 40px rgba(62, 58, 55, .12); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto; align-items: stretch; }
.feld { text-align: left; padding: 16px 22px; border-right: 1px solid var(--linie); display: grid; gap: 6px; transition: background-color .2s var(--ui); }
.feld:hover { background: #fbe9d4; }
.feld .label { color: var(--meta); font-size: 11px; }
.feld strong { font: 400 21px/1.1 'Newsreader', serif; font-variation-settings: 'opsz' 24; }
.feld small { font-size: 12.5px; color: var(--meta); }
.los { display: grid; align-content: center; gap: 6px; padding: 12px 18px; }
.los button { height: 50px; padding: 0 26px; background: var(--haus); color: var(--creme); border-radius: 2px; transition: background-color .2s var(--ui); }
.los button:hover { background: #5a4939; }
.los small { font-size: 12px; color: var(--meta); text-align: center; }
.unten { display: flex; justify-content: space-between; gap: 20px; padding: 26px var(--rand) 40px; color: var(--meta); font-size: 14px; }
.unten p:first-child { max-width: 60ch; }
.unten em { font: italic 400 17px/1 'Newsreader', serif; color: var(--text); }

/* ---------- Vorschau ---------- */
.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .unten { display: none; }
.vorschau .buehne { margin: 0; height: calc(100vh - 62px); border-radius: 0; }
.vorschau .leiste { margin: -34px 28px 0; }
.vorschau .feld small, .vorschau .los small { display: none; }

/* ---------- Reduziert ---------- */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
.reduziert .buehne { cursor: crosshair; }

/* ---------- Schmal ---------- */
@media (max-width: 1080px) { .kopf nav { display: none; } .kopf { grid-template-columns: 1fr auto; } .feld small { display: none; } }
@media (max-width: 760px) {
  .kopf { padding: 14px 16px; }
  .marke small, .sprache { display: none; }
  .buehne { height: auto; aspect-ratio: 4 / 5.6; min-height: 0; margin: 0 10px; }
  h1 { font-size: 2.35rem; }
  .uhr { right: auto; left: 16px; text-align: left; }
  .uhr .zeit { font-size: 22px; }
  .v-scan .text { bottom: 84px; }
  .v-scan .schleier { background: linear-gradient(180deg, rgba(20, 18, 24, .3) 0%, rgba(20, 18, 24, 0) 22%, rgba(20, 18, 24, 0) 40%, rgba(20, 18, 24, .72) 66%, rgba(20, 18, 24, .78) 100%); }
  .halt { right: 12px; bottom: 14px; }
  .leiste { position: fixed; left: 10px; right: 10px; bottom: 10px; margin: 0; grid-template-columns: 1fr auto; box-shadow: 0 8px 30px rgba(62, 58, 55, .25); }
  .feld:nth-child(2), .feld:nth-child(3) { display: none; }
  .feld { border: 0; padding: 12px 14px; }
  .feld strong { font-size: 17px; }
  .los { padding: 8px; }
  .los button { height: 44px; padding: 0 16px; }
  .los small { display: none; }
  .unten { flex-direction: column; padding-bottom: 120px; }
}
@media (hover: none) { .buehne { cursor: default; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
</style>
</head>
<body>
<header class="kopf">
  <p class="marke">Ansitz Ploner<small>Bozen · St. Magdalena</small></p>
  <nav class="label" aria-label="Hauptmenü"><a href="#">Zimmer</a><a href="#">Küche &amp; Keller</a><a href="#">Weinberg</a><a href="#">Anreise</a></nav>
  <div class="rechts label"><p class="sprache"><b>DE</b><a href="#">IT</a><a href="#">EN</a></p><a class="buchen-k" href="#">Buchen</a></div>
</header>

<section class="buehne" data-buehne aria-labelledby="titel">
  <canvas data-canvas role="img" aria-label="Blick von der Terrasse des Ansitz Ploner über die Weinberge von St. Magdalena auf den Rosengarten. Das Licht folgt dem Zeiger wie die Abendsonne."></canvas>
  <div class="schleier" aria-hidden="true"></div>
  <div class="text">
    <p class="label">Ansitz Ploner · seit 1897 in Familienhand</p>
    <h1 id="titel"><span data-welt-text="sonne">Wenn der Rosengarten glüht, decken wir <em>draußen.</em></span><span data-welt-text="scan" hidden>Erst die Gipfel, dann das Tal, dann der <em>Frühstückstisch.</em></span></h1>
  </div>
  <div class="uhr" aria-hidden="true"><span class="zeit" data-zeit>16:20</span><span class="label" data-licht>Abendlicht</span></div>
  <div class="sonne" data-sonne aria-hidden="true"></div>
  <button type="button" class="halt mono" data-halt aria-pressed="false" aria-label="Bewegung anhalten"><svg class="i-halt" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1h3v10H2zM7 1h3v10H7z" fill="currentColor"/></svg><svg class="i-weiter" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1l9 5-9 5z" fill="currentColor"/></svg>Anhalten</button>
  <div class="pult">
    <div class="varianten mono" role="group" aria-label="Variante">
      <button type="button" data-variante="sonne" aria-pressed="true">Abendsonne</button>
      <button type="button" data-variante="scan" aria-pressed="false">Morgen-Scan</button>
    </div>
    <div class="varianten mono" role="group" aria-label="Darstellung" data-nur="sonne">
      <button type="button" data-modus="licht" aria-pressed="true">Licht &amp; Schatten</button>
      <button type="button" data-modus="parallax" aria-pressed="false">Parallax</button>
      <button type="button" data-modus="tiefe" aria-pressed="false">Tiefenkarte</button>
    </div>
    <p class="werte mono" data-werte aria-live="polite"></p>
  </div>
</section>

<form class="leiste" aria-label="Zimmer finden" onsubmit="return false">
  <button type="button" class="feld"><span class="label">Anreise</span><strong>Fr, 14. Nov</strong><small>Mindestaufenthalt 2 Nächte</small></button>
  <button type="button" class="feld"><span class="label">Abreise</span><strong>So, 16. Nov</strong><small>2 Nächte</small></button>
  <button type="button" class="feld"><span class="label">Gäste</span><strong>2 Erwachsene</strong><small>Kinder ab 6 Jahren</small></button>
  <div class="los"><button type="submit" class="label">Verfügbarkeit prüfen</button><small>ab 189 € / Nacht, mit Frühstück</small></div>
</form>

<div class="unten">
  <p>Direkt gebucht: Kellerführung mit Verkostung von St. Magdalener und Lagrein, späte Abreise bis 13 Uhr.</p>
  <p><em>Familie Ploner</em> · Rentscher Straße, Bozen</p>
</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 reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const buehne = document.querySelector('[data-buehne]');
const canvas = document.querySelector('[data-canvas]');
const sonneEl = document.querySelector('[data-sonne]');
const zeitEl = document.querySelector('[data-zeit]');
const lichtEl = document.querySelector('[data-licht]');
const werteEl = document.querySelector('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];

/* ---------- Werte (meta.json → messwerte: Lerp 0.05–0.1, Parallax 0.01–0.03 uv; Zoom 1.05) ---------- */
const W = {
  lerpLicht: 0.06, lerpMaus: 0.08,
  parallax: { licht: 0.012, parallax: 0.026, tiefe: 0.026 },
  zoom: 1.05,
  schritte: 24,          // Schatten-Raymarch über die Tiefenkarte
  tiefe: 0.22,           // Tiefe 0..1 → Höhe im Bildraum
  lichtAbstand: 0.3,     // Lampe vor der Bildebene
};
let modus = 'licht', variante = 'sonne';
/* Variante scan: Licht erreicht die Landschaft Ebene für Ebene (Tiefe fern → nah) */
const SCAN = { von: 0.12, bis: 1.0, kante: 0.05, linie: 0.006, steigen: 6500, halten: 2500, sinken: 1600 };
function zeigeWerte() {
  const p = W.parallax[modus];
  if (variante === 'scan') {
    werteEl.innerHTML = reduziert() ? 'Morgenbild <b>ganz im Licht</b> · kein Scan' : `Scan Tiefe <b>${SCAN.von} → ${SCAN.bis}</b> in <b>${SCAN.steigen} ms</b> sine in-out · Kante <b>${SCAN.kante}</b> · Linie <b>${SCAN.linie}</b> · halten <b>${SCAN.halten} ms</b>`;
    return;
  }
  werteEl.innerHTML = reduziert()
    ? 'Statisches Bild · Licht <b>fest</b> · Parallax <b>aus</b>'
    : modus === 'licht' ? `Licht <b>lerp ${W.lerpLicht}</b> · Schatten <b>${W.schritte} Schritte</b> · Parallax <b>${p} uv</b> · Zoom <b>${W.zoom}</b>`
    : modus === 'parallax' ? `Parallax <b>${p} uv</b> · <b>lerp ${W.lerpMaus}</b> · Zoom <b>${W.zoom}</b> · Licht <b>aus</b>`
    : `Tiefenkarte <b>hell = nah</b> · Normalen aus Gradient · Parallax <b>${p} uv</b>`;
}

/* ---------- Bild und Tiefenkarte: hier im Canvas gemalt, im Projekt Foto + KI-Tiefenschätzung ---------- */
const BB = 1280, BH = 720, A = BB / BH;
function malen() {
  const farbe = new Uint8ClampedArray(BB * BH * 4), tiefe = new Uint8ClampedArray(BB * BH * 4);
  // Wertrauschen
  const P = new Uint8Array(512); { let s = 1897; for (let i = 0; i < 256; i++) P[i] = i; for (let i = 255; i > 0; i--) { s = (s * 16807) % 2147483647; const j = s % (i + 1); [P[i], P[j]] = [P[j], P[i]]; } for (let i = 0; i < 256; i++) P[i + 256] = P[i]; }
  const g = (x, y) => P[P[x & 255] + (y & 255)] / 255;
  const rausch = (x, y) => { const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi, u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
    const a = g(xi, yi), b = g(xi + 1, yi), c = g(xi, yi + 1), d = g(xi + 1, yi + 1); return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; };
  const fbm = (x, y, o = 4) => { let s = 0, a = 0.5, f = 1; for (let i = 0; i < o; i++) { s += a * rausch(x * f, y * f); f *= 2.03; a *= 0.5; } return s / (1 - Math.pow(0.5, o)); };
  const grat = (x, y, o = 4) => { let s = 0, a = 0.5, f = 1; for (let i = 0; i < o; i++) { s += a * (1 - Math.abs(rausch(x * f, y * f) * 2 - 1)); f *= 2.1; a *= 0.5; } return s / (1 - Math.pow(0.5, o)); };
  const mix = (a, b, t) => a + (b - a) * t;
  const mixF = (a, b, t) => [mix(a[0], b[0], t), mix(a[1], b[1], t), mix(a[2], b[2], t)];
  const hex = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
  const klemm = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
  const glatt = (a, b, v) => { const t = klemm((v - a) / (b - a)); return t * t * (3 - 2 * t); };

  // Gipfel des Rosengartens (x, Breite, Höhe): massige Wände, dazwischen die schlanken Vajolettürme
  const GIPFEL = [[0.2, 0.09, 0.05], [0.3, 0.07, 0.085], [0.385, 0.05, 0.12], [0.445, 0.014, 0.15], [0.462, 0.011, 0.162], [0.478, 0.012, 0.155], [0.493, 0.01, 0.142],
    [0.545, 0.05, 0.13], [0.6, 0.035, 0.118], [0.66, 0.06, 0.125], [0.73, 0.05, 0.1], [0.81, 0.08, 0.07]];
  const SPALTEN = new Float32Array(BB * 3);   // je Spalte: Himmelslinie, Felsfuß, Hügelkamm
  for (let i = 0; i < BB; i++) {
    const x = i / BB;
    let h = 0;
    for (const [c, w, ht] of GIPFEL) { const u = Math.abs(x - c) / w; if (u < 1) h = Math.max(h, ht * (1 - Math.pow(u, w < 0.02 ? 1.6 : 2.6))); }
    // zerklüftete Oberkante: kleine Zacken, nur wo Fels ist
    h += (grat(x * 70, 3.1, 3) - 0.6) * 0.022 + (fbm(x * 18, 5.5, 2) - 0.5) * 0.02;
    const massiv = glatt(0.06, 0.2, x) * (1 - glatt(0.88, 0.98, x));
    SPALTEN[i * 3] = 0.44 - (h + 0.075) * massiv - (fbm(x * 6, 9.3, 3) - 0.5) * 0.025;
    SPALTEN[i * 3 + 1] = 0.455 + (fbm(x * 9, 4.2, 3) - 0.5) * 0.04 - massiv * 0.01;
    SPALTEN[i * 3 + 2] = 0.565 + (fbm(x * 4.5, 1.7, 4) - 0.5) * 0.05 + Math.sin(x * 5.2 + 0.6) * 0.012;
  }
  const hFarbe = [hex('#4d5a80'), hex('#8e97b3'), hex('#d9c3b4'), hex('#efd6c0')];
  const fels = hex('#d6cabe'), felsDunkel = hex('#8d8178'), geroell = hex('#b9b0a4'), wald = hex('#25311f'), waldHell = hex('#3c4a2e');
  const huegel = hex('#26321f'), huegelHell = hex('#56654c');
  const rebeG = hex('#7a8238'), rebeY = hex('#c8a03c'), rebeO = hex('#b4652c'), boden = hex('#3c2f24');
  const mauer = hex('#ece5d8'), dach = hex('#7c4733'), zypresse = hex('#1f2a1d');

  for (let j = 0; j < BH; j++) {
    const y = j / BH;
    for (let i = 0; i < BB; i++) {
      const x = i / BB, k = (j * BB + i) * 4;
      const himmelY = SPALTEN[i * 3], fussY = SPALTEN[i * 3 + 1], kamm = SPALTEN[i * 3 + 2];
      const rebY = 0.7 - x * 0.05 + Math.sin(x * 7) * 0.012;
      let c, d = 0, felsM = 0, rebM = 0;
      if (y > rebY) {
        // Pergl-Zeilen am Hang: verlaufen quer, Abstand wächst zum Betrachter hin
        const tief = (y - rebY) / (1 - rebY);
        const r = Math.pow(tief, 0.62) * 15 + (fbm(x * 3, 2.2, 2) - 0.5) * 0.9 + x * 1.4;
        const f = r - Math.floor(r);
        const kante = (fbm(x * 40 / (0.4 + tief), Math.floor(r) * 3.1, 3) - 0.5) * 0.35;
        const laub = glatt(0.08, 0.22 + kante * 0.3, f) * (1 - glatt(0.62 + kante, 0.8 + kante, f));
        const n = fbm(x * 70 / (0.35 + tief), y * 70 / (0.35 + tief), 4), m = fbm(x * 5, y * 5 + Math.floor(r) * 0.3, 2);
        c = mixF(mixF(rebeG, rebeY, glatt(0.3, 0.6, m)), rebeO, glatt(0.5, 0.78, n * 0.55 + m * 0.55));
        c = c.map(v => v * (0.62 + n * 0.55 + (1 - f) * 0.18));
        c = mixF(mixF(boden, [92, 74, 52], n * 0.5), c, laub);
        c = c.map(v => v * (0.78 + 0.28 * tief));
        d = 0.6 + tief * 0.36 + laub * (0.02 + tief * 0.03) + n * 0.012;
        rebM = laub;
      } else if (y > kamm) {
        // Hügel im Dunst
        const n = fbm(x * 220, y * 160, 3), t = glatt(kamm, kamm + 0.1, y), dunst = 1 - glatt(kamm, kamm + 0.06, y);
        c = mixF(huegel, huegelHell, glatt(0.4, 0.8, n) * 0.5 + fbm(x * 8, y * 8, 2) * 0.3);
        c = c.map(v => v * (0.7 + n * 0.5));
        c = mixF(c, [118, 126, 128], dunst * 0.25);
        d = 0.48 + t * 0.1 + n * 0.012;
      } else if (y > fussY + 0.025 + (fbm(x * 70, 2.7, 3) - 0.5) * 0.035) {
        // Wald unter dem Geröll
        const n = fbm(x * 160, y * 110, 3), t = glatt(fussY + 0.03, kamm, y);
        c = mixF(waldHell, wald, t * 0.7 + n * 0.4); c = c.map(v => v * (0.75 + n * 0.45));
        d = 0.36 + t * 0.1 + n * 0.02;
      } else if (y > himmelY) {
        // Fels: große Pfeiler, feine Rinnen, waagerechte Schichtung, unten Geröll
        // Pfeiler brechen in der Höhe ab und versetzen sich: Verzug in x nach y, Rinnen mit eigener Länge
        const warp = (fbm(x * 7, y * 5, 3) - 0.5) * 0.022 + (fbm(x * 40, y * 14, 2) - 0.5) * 0.004;
        const gross = Math.pow(grat((x + warp) * 24, y * 4.2, 3), 1.6), fein = Math.pow(grat((x + warp) * 78, y * 10, 3), 1.4);
        const schicht = fbm(x * 3, y * 34, 2);
        // große Wandflächen: mal im Licht, mal abgewandt
        const flaeche = fbm(x * 11 + warp * 20, y * 4.5, 3);
        const vert = (y - himmelY) / Math.max(0.01, fussY - himmelY);
        const geroellT = glatt(0.68, 1.02, vert + (fbm(x * 30, 7.7, 2) - 0.5) * 0.3);
        const rel = gross * 0.62 + fein * 0.2 + flaeche * 0.18;
        c = mixF(felsDunkel, fels, glatt(0.2, 0.8, rel) * 0.9 + schicht * 0.1);
        // Tageslicht im „Foto“: von links oben, Pfeiler werfen weiche Kanten
        const l = (Math.pow(grat((x + warp - 0.003) * 24, y * 4.2, 3), 1.6) - gross) * 0.7 + (Math.pow(grat((x + warp - 0.001) * 78, y * 10, 3), 1.4) - fein) * 0.3;
        c = c.map(v => v * klemm(0.8 + l * 1.7 + rel * 0.14 + (1 - vert) * 0.05 - schicht * 0.04 + (flaeche - 0.5) * 0.34, 0.5, 1.12));
        c = mixF(c, geroell.map(v => v * (0.85 + fein * 0.2)), geroellT);
        // Dunst zum Fuß hin
        c = mixF(c, [150, 150, 160], glatt(0.6, 1, vert) * 0.1);
        // Bänder und Simse quer durch die Wand
        c = c.map(v => v * (1 - glatt(0.62, 0.7, fbm(x * 5, y * 60, 3)) * glatt(0.4, 0.7, fbm(x * 14, y * 3, 2)) * 0.07 * (1 - geroellT)));
        d = 0.16 + vert * 0.12 + rel * 0.085 * (1 - geroellT * 0.6);
        felsM = 1 - geroellT * 0.7;
      } else {
        // Himmel
        const t = y / 0.45;
        c = t < 0.55 ? mixF(hFarbe[0], hFarbe[1], t / 0.55) : t < 0.88 ? mixF(hFarbe[1], hFarbe[2], (t - 0.55) / 0.33) : mixF(hFarbe[2], hFarbe[3], klemm((t - 0.88) / 0.12));
        const w = fbm(x * 3 + y * 2, y * 18, 4);
        c = mixF(c, [244, 226, 214], glatt(0.6, 0.85, w) * 0.22 * (1 - t * 0.6));
        d = 0;
      }
      farbe[k] = c[0]; farbe[k + 1] = c[1]; farbe[k + 2] = c[2]; farbe[k + 3] = 255;
      tiefe[k] = d * 255; tiefe[k + 1] = felsM * 255; tiefe[k + 2] = rebM * 255; tiefe[k + 3] = 0;
    }
  }
  // Lichter im Tal (Bozen, Höfe): Alphakanal der Tiefenkarte, leuchten erst in der Dämmerung
  { let s = 7; const z = () => (s = (s * 16807) % 2147483647) / 2147483647;
    for (let n = 0; n < 260; n++) {
      const i = Math.floor(z() * BB), x = i / BB, kamm = SPALTEN[i * 3 + 2], rebY = 0.7 - x * 0.05 + Math.sin(x * 7) * 0.012;
      const j = Math.floor((kamm + 0.012 + Math.pow(z(), 0.7) * (rebY - kamm - 0.016)) * BH);
      const gr = z() < 0.2 ? 2 : 1;
      for (let a = 0; a < gr; a++) for (let b = 0; b < gr; b++) { const k = ((j + b) * BB + i + a) * 4; if (k < tiefe.length) tiefe[k + 3] = 140 + 115 * z(); }
    } }
  // Kirchlein mit Spitzturm und zwei Zypressen, als Flächen darübergelegt
  const setze = (i, j, c, d) => { if (i < 0 || j < 0 || i >= BB || j >= BH) return; const k = (j * BB + i) * 4; farbe[k] = c[0]; farbe[k + 1] = c[1]; farbe[k + 2] = c[2]; tiefe[k] = d * 255; tiefe[k + 1] = 0; tiefe[k + 2] = 0; };
  const kx = 0.7 * BB, ky = 0.672 * BH;
  for (let j = -110; j <= 0; j++) for (let i = -60; i <= 60; i++) {
    const X = kx + i, Y = ky + j, u = i / 60;
    let c = null, d = 0.64;
    if (j > -34 && i > -54 && i < 30) { c = i < -16 ? mauer.map(v => v * 0.86) : mauer; d = 0.64 + (i < -16 ? 0.0 : 0.006); if (j > -22 && j < -8 && (i === -36 || i === 6 || i === 18)) c = [70, 60, 52]; }
    else if (j <= -34 && j > -52 && i > -58 + (j + 52) * 0.0 && i < 30 && Math.abs(i + 12) < 44 - (-34 - j) * 2.2) { c = dach; d = 0.645; }
    if (i > 30 && i < 52 && j > -78) { c = mauer.map(v => v * (i < 41 ? 0.9 : 1)); d = 0.648; if (j > -70 && j < -60 && i > 37 && i < 45) c = [60, 52, 46]; }
    if (j <= -78 && Math.abs(i - 41) < (110 + j) * 0.34) { c = dach.map(v => v * (i < 41 ? 0.8 : 1)); d = 0.65; }
    if (c) setze(Math.round(X), Math.round(Y), c, d + 0.002 * (j / -110));
  }
  [[0.655, 0.69, 16, 120], [0.745, 0.683, 12, 92], [0.33, 0.70, 11, 70]].forEach(([zx, zy, r, h]) => {
    for (let j = -h; j <= 4; j++) for (let i = -r; i <= r; i++) {
      const t = -j / h, br = r * (t < 0.22 ? Math.sqrt(Math.max(0, t + 0.03) / 0.25) : Math.pow(Math.max(0, 1 - t) / 0.78, 0.85));
      if (Math.abs(i) > br || br < 0.6) continue;
      const n = fbm((zx * BB + i) * 0.25, (zy * BH + j) * 0.25, 2);
      const run = Math.sqrt(1 - (i / Math.max(1, br)) ** 2);
      setze(Math.round(zx * BB + i), Math.round(zy * BH + j), zypresse.map(v => v * (0.75 + n * 0.6 + (i < 0 ? 0.15 : -0.1))), 0.66 + run * 0.025);
    }
  });
  return { farbe, tiefe };
}

/* ---------- Shader: Parallax, Normalen aus der Tiefe, Licht mit Eigenschatten ---------- */
const FRAG = /* glsl */`
precision highp float;
uniform sampler2D tBild, tTiefe;
uniform vec2 uRes, uMaus, uLicht;
uniform float uBildA, uZoom, uParallax, uTiefe, uAbstand, uSonne, uModus, uLichtAn, uScan, uScanAn;
varying vec2 vUv;
#define SCHRITTE ${W.schritte}
float tiefe(vec2 uv) { return texture2D(tTiefe, uv).r; }
void main() {
  // cover: Bild füllt die Bühne, mit Zoom gegen Randrisse beim Verschieben
  float ca = uRes.x / uRes.y; vec2 s = vec2(1.0);
  if (ca > uBildA) s.y = uBildA / ca; else s.x = ca / uBildA;
  vec2 uv = (vUv - 0.5) * s / uZoom + 0.5;
  // Parallax: uv += (tiefe - Mitte) · Maus · Stärke, zwei Iterationen
  vec2 p = uv;
  for (int i = 0; i < 2; i++) p = uv + uMaus * uParallax * (tiefe(p) - 0.45);
  vec3 bild = texture2D(tBild, p).rgb;
  vec4 info = texture2D(tTiefe, p);
  float d = info.r, fels = info.g, rebe = info.b, lampen = info.a;
  if (uModus > 1.5) { gl_FragColor = vec4(vec3(pow(d, 0.8)) * vec3(0.98, 0.95, 0.9), 1.0); return; }
  if (uLichtAn < 0.5) { gl_FragColor = vec4(bild, 1.0); return; }
  // Normalen aus dem Tiefengradienten (Kanten begrenzt)
  vec2 px = 1.5 / vec2(${BB}.0, ${BH}.0);
  float dx = clamp(tiefe(p + vec2(px.x, 0.0)) - tiefe(p - vec2(px.x, 0.0)), -0.02, 0.02);
  float dy = clamp(tiefe(p + vec2(0.0, px.y)) - tiefe(p - vec2(0.0, px.y)), -0.02, 0.02);
  vec3 n = normalize(vec3(-dx * 18.0, dy * 18.0, 1.0));
  if (uScanAn > 0.5) {
    // Variante scan: was die Lichtfront schon erreicht hat, steht im Morgenlicht; an der Front eine helle Linie
    float himmelS = 1.0 - step(0.004, d);
    float erreicht = 1.0 - smoothstep(uScan - ${SCAN.kante}, uScan, d);
    vec3 Ls = normalize(vec3(-0.55, 0.35, 0.75));
    float lam = max(dot(n, Ls), 0.0);
    vec3 farbeS = mix(bild * vec3(0.3, 0.34, 0.5), bild * (vec3(0.62, 0.62, 0.68) + vec3(1.0, 0.86, 0.7) * (0.28 + lam * 0.3)), erreicht);
    farbeS += vec3(1.0, 0.78, 0.5) * exp(-abs(d - uScan) / ${SCAN.linie}) * 0.55 * (1.0 - himmelS);
    if (himmelS > 0.5) farbeS = mix(bild * vec3(0.38, 0.42, 0.62), bild * vec3(1.02, 0.98, 0.96), smoothstep(0.1, 0.7, uScan));
    farbeS += vec3(1.0, 0.72, 0.42) * lampen * (1.0 - smoothstep(0.2, 0.5, uScan)) * 1.4;   // Fenster gehen aus, wenn das Licht kommt
    gl_FragColor = vec4(farbeS, 1.0);
    #include <colorspace_fragment>
    return;
  }
  // Punkt und Lampe im Bildraum (x mit Seitenverhältnis, z = Tiefe)
  vec3 P = vec3(p.x * uBildA, 1.0 - p.y, d * uTiefe);
  vec3 Lp = vec3(uLicht.x * uBildA, 1.0 - uLicht.y, uTiefe + uAbstand);
  vec3 L = normalize(Lp - P);
  float diff = max(dot(n, L), 0.0);
  // Eigenschatten: Strahl über die Tiefenkarte zur Lampe
  float sch = 1.0;
  vec3 r = P;
  // nur lokal (bis 0,09 Bildbreite) und nur kleine Höhensprünge zählen: Eigenschatten statt Geisterschatten
  vec3 st = L / max(length(L.xy), 0.2) * (0.09 / float(SCHRITTE));
  for (int i = 1; i <= SCHRITTE; i++) {
    r += st;
    vec2 q = vec2(r.x / uBildA, 1.0 - r.y);
    if (q.x < 0.0 || q.x > 1.0 || q.y < 0.0 || q.y > 1.0) break;
    float dh = tiefe(q) * uTiefe - r.z;
    if (dh > 0.0015 && dh < 0.016) sch = min(sch, 1.0 - smoothstep(0.0015, 0.008, dh) * (1.0 - float(i) / float(SCHRITTE)) * 0.85);
  }
  // Lichtfarbe nach Sonnenstand: hoch = weiß, tief = Enrosadira (rosa-orange)
  float hoch = smoothstep(0.08, 0.62, uSonne);
  vec3 lichtF = mix(vec3(1.0, 0.46, 0.38) * 1.35, vec3(1.0, 0.96, 0.9) * 1.05, hoch);
  vec3 umgebung = mix(vec3(0.26, 0.29, 0.46), vec3(0.5, 0.52, 0.58), hoch);
  float himmel = 1.0 - step(0.004, d);
  vec3 farbe;
  if (himmel > 0.5) {
    // Himmel: dunkler und wärmer am Horizont, je tiefer die Sonne steht; Schein um die Sonne
    float hz = smoothstep(0.0, 0.42, p.y);
    vec3 abend = mix(vec3(0.42, 0.45, 0.66), vec3(1.25, 0.78, 0.66), hz);
    farbe = bild * mix(abend, vec3(1.0), hoch);
    float schein = exp(-length((p - uLicht) * vec2(uBildA, 1.0)) * 5.0);
    farbe += lichtF * schein * 0.22;
  } else {
    float glanz = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 18.0) * (0.12 + rebe * 0.25);
    float licht = diff * sch;
    // Fels glüht stärker (Kalk streut warmes Licht)
    vec3 lf = lichtF * (1.0 + fels * (1.0 - hoch) * 0.45);
    farbe = bild * (umgebung * (0.55 + 0.45 * sch) + lf * licht * 1.05) + lf * glanz * sch;
  }
  // Fenster im Tal gehen an, wenn die Sonne sinkt
  farbe += vec3(1.0, 0.72, 0.42) * lampen * (1.0 - smoothstep(0.08, 0.32, uSonne)) * 1.6;
  gl_FragColor = vec4(farbe, 1.0);
  #include <colorspace_fragment>
}`;

/* ---------- Aufbau ---------- */
let api = { neustart() {}, vorfuehren() {}, pause() {}, weiter() {} };
let setzeModus = m => { modus = m; html.classList.toggle('ohne-licht', m !== 'licht'); knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === m))); zeigeWerte(); };
let stoppeVorfuehrung = () => {};
function setzeVarianteDom(v, url) {
  variante = v === 'scan' ? 'scan' : 'sonne';
  html.classList.toggle('v-scan', variante === 'scan');
  document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
  document.querySelectorAll('[data-welt-text]').forEach(el => { el.hidden = el.dataset.weltText !== variante; });
  document.querySelectorAll('[data-nur]').forEach(el => { el.hidden = el.dataset.nur !== variante; });
  if (!canvas.dataset.alt) canvas.dataset.alt = canvas.getAttribute('aria-label');
  canvas.setAttribute('aria-label', variante === 'scan' ? 'Blick von der Terrasse des Ansitz Ploner auf den Rosengarten am Morgen: Das erste Licht trifft die Gipfel, wandert über Wald und Hügel ins Tal und erreicht zuletzt die Weinberge.' : canvas.dataset.alt);
  if (url) { const q = new URLSearchParams(location.search); q.set('v', variante); history.replaceState(null, '', '?' + q.toString().replace(/=(?=&|$)/g, '')); }
  zeigeWerte();
}
let setzeVariante = setzeVarianteDom;
const halt = document.querySelector('[data-halt]');
const setzeHalt = an => halt.setAttribute('aria-pressed', String(an));
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { stoppeVorfuehrung(); setzeVariante(b.dataset.variante, true); }));
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.modus); }));
setzeModus('licht');

function uhrzeit(sonne) {
  // Sonne hoch = 13:30, tief = 16:50 (Sonnenuntergang Bozen Mitte November)
  const min = Math.round(16 * 60 + 50 - sonne * 200);
  zeitEl.textContent = `${Math.floor(min / 60)}:${String(min % 60).padStart(2, '0')}`;
  lichtEl.textContent = sonne < 0.18 ? 'Enrosadira · Alpenglühen' : sonne < 0.45 ? 'Abendlicht' : sonne < 0.75 ? 'Nachmittag' : 'Mittagslicht';
}

const { farbe, tiefe } = malen();
let renderer = null;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) { try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' }); } catch (e) { renderer = null; } }

if (!renderer) {
  // Ohne WebGL: das gemalte Bild als stilles Foto
  html.classList.add('ohne-gl');
  canvas.width = BB; canvas.height = BH; canvas.style.objectFit = 'cover';
  canvas.getContext('2d').putImageData(new ImageData(farbe, BB, BH), 0, 0);
  uhrzeit(0.5);
  setzeVarianteDom(new URLSearchParams(location.search).get('v'));
  werteEl.innerHTML = 'WebGL <b>nicht verfügbar</b> · Standbild ohne Licht';
} else {
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  // DataTexture: Zeile 0 = oberer Bildrand, passt zu vUv (y nach unten)
  const tex = (daten, srgb) => { const t = new THREE.DataTexture(daten, BB, BH, THREE.RGBAFormat); t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace; t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = false; t.needsUpdate = true; return t; };
  const tBild = tex(farbe, true), tTiefe = tex(tiefe, false);
  const U = {
    tBild: { value: tBild }, tTiefe: { value: tTiefe },
    uRes: { value: new THREE.Vector2(1, 1) }, uMaus: { value: new THREE.Vector2() }, uLicht: { value: new THREE.Vector2(0.3, 0.3) },
    uBildA: { value: A }, uZoom: { value: W.zoom }, uParallax: { value: W.parallax.licht }, uTiefe: { value: W.tiefe }, uAbstand: { value: W.lichtAbstand },
    uSonne: { value: 0.5 }, uModus: { value: 0 }, uLichtAn: { value: 1 }, uScan: { value: SCAN.von }, uScanAn: { value: 0 },
  };
  const material = new THREE.ShaderMaterial({ uniforms: U, vertexShader: 'varying vec2 vUv; void main(){ vUv = vec2(uv.x, 1.0 - uv.y); gl_Position = vec4(position.xy, 0.0, 1.0); }', fragmentShader: FRAG, depthTest: false, depthWrite: false });
  const szene = new THREE.Scene(); szene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
  const kamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

  let B = 1, H = 1;
  function groesse() {
    const r = buehne.getBoundingClientRect(); B = r.width; H = r.height;
    renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 1.5));
    renderer.setSize(B, H, false);
    U.uRes.value.set(B, H);
    zeichnen();
  }
  new ResizeObserver(groesse).observe(buehne);

  // Zustand: Ziel und Ist für Licht (Bildkoordinaten 0..1) und Maus (−1..1)
  const Z = { licht: new THREE.Vector2(0.3, 0.32), zielLicht: new THREE.Vector2(0.3, 0.32), maus: new THREE.Vector2(), zielMaus: new THREE.Vector2(), uhr: 0, scan: SCAN.von, zielScan: null, scanZeit: 0 };
  // Tiefe unter einem Bildpunkt (für den Scan per Zeiger)
  const tiefeBei = v => { const i = Math.min(BB - 1, Math.max(0, Math.floor(v.x * BB))), j = Math.min(BH - 1, Math.max(0, Math.floor(v.y * BH))); return tiefe[(j * BB + i) * 4] / 255; };
  // Auto-Scan: steigen (sine in-out), halten, zurück
  const sinus = x => 0.5 - 0.5 * Math.cos(Math.PI * x);
  function scanAuto(ms) {
    const R = SCAN.steigen + SCAN.halten + SCAN.sinken, t = ms % R;
    const f = t < SCAN.steigen ? sinus(t / SCAN.steigen) : t < SCAN.steigen + SCAN.halten ? 1 : 1 - sinus((t - SCAN.steigen - SCAN.halten) / SCAN.sinken);
    return SCAN.von + (SCAN.bis - SCAN.von) * f;
  }
  function scanUhr(s) {
    const f = THREE.MathUtils.clamp((s - SCAN.von) / (SCAN.bis - SCAN.von), 0, 1), min = Math.round(7 * 60 + 5 + f * 55);
    zeitEl.textContent = `${Math.floor(min / 60)}:${String(min % 60).padStart(2, '0')}`;
    lichtEl.textContent = s < 0.38 ? 'Erstes Licht am Rosengarten' : s < 0.6 ? 'Licht im Tal' : 'Sonne auf der Terrasse';
  }
  const FEST = new THREE.Vector2(0.24, 0.62);   // reduzierte Bewegung: tiefe Sonne, Rosengarten glüht
  // Bühnenpunkt → Bildkoordinate (cover + Zoom rückwärts)
  function bildPunkt(ax, ay) {
    const ca = B / H; let sx = 1, sy = 1; if (ca > A) sy = A / ca; else sx = ca / A;
    return new THREE.Vector2((ax - 0.5) * sx / W.zoom + 0.5, (ay - 0.5) * sy / W.zoom + 0.5);
  }
  function buehnenPunkt(v) {
    const ca = B / H; let sx = 1, sy = 1; if (ca > A) sy = A / ca; else sx = ca / A;
    return [((v.x - 0.5) * W.zoom / sx + 0.5) * B, ((v.y - 0.5) * W.zoom / sy + 0.5) * H];
  }
  function zeige(ax, ay) {
    if (variante === 'scan') { const t = tiefeBei(bildPunkt(ax, ay)); Z.zielScan = t < 0.004 ? 0.3 : t + 0.02; }
    Z.zielLicht.copy(bildPunkt(ax, ay));
    Z.zielMaus.set(ax * 2 - 1, ay * 2 - 1);
    if (reduziert()) { Z.licht.copy(Z.zielLicht); Z.maus.set(0, 0); }
    anstossen();
  }
  const lokal = e => { const r = buehne.getBoundingClientRect(); return [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height]; };
  buehne.addEventListener('pointermove', e => {
    if (html.classList.contains('vorfuehrung') || e.target.closest('[data-fundus-pult]')) return;
    if (e.pointerType === 'mouse' && !reduziert()) zeige(...lokal(e));
    else if (e.pointerType !== 'mouse' && zieht) zeige(...lokal(e));
  });
  let zieht = false;
  buehne.addEventListener('pointerdown', e => {
    if (e.target.closest('button, a')) return;
    stoppeVorfuehrung(); zieht = true; html.classList.add('zieht'); zeige(...lokal(e));
  });
  addEventListener('pointerup', () => { zieht = false; html.classList.remove('zieht'); });
  buehne.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) { Z.zielMaus.set(0, 0); Z.zielScan = null; anstossen(); } });

  // Telefon: Gyroskop neigt das Bild (iOS fragt beim ersten Tippen um Erlaubnis)
  let gyro = false;
  function aufGyro(e) { if (e.gamma == null || reduziert()) return; gyro = true; Z.zielMaus.set(THREE.MathUtils.clamp(e.gamma / 25, -1, 1), THREE.MathUtils.clamp((e.beta - 45) / 25, -1, 1)); anstossen(); }
  if (beruehrung && 'DeviceOrientationEvent' in window) {
    if (typeof DeviceOrientationEvent.requestPermission === 'function') buehne.addEventListener('click', () => DeviceOrientationEvent.requestPermission().then(r => { if (r === 'granted') addEventListener('deviceorientation', aufGyro); }).catch(() => {}), { once: true });
    else addEventListener('deviceorientation', aufGyro);
  }

  let laeuft = false, letzte = performance.now(), sichtbar = true, angehalten = false, autoZeit = 0;
  function anstossen() { if (!laeuft && sichtbar && !angehalten) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
  function zeichnen() {
    const still = reduziert();
    const l = Z.licht;
    U.uLicht.value.copy(l);
    U.uMaus.value.copy(still ? new THREE.Vector2() : Z.maus);
    U.uParallax.value = still ? 0 : W.parallax[modus];
    U.uModus.value = modus === 'tiefe' ? 2 : modus === 'parallax' ? 1 : 0;
    U.uLichtAn.value = modus === 'licht' ? 1 : 0;
    const sonne = THREE.MathUtils.clamp(1 - (l.y - 0.1) * 1.6, 0, 1);
    U.uSonne.value = sonne;
    U.uScanAn.value = variante === 'scan' ? 1 : 0;
    U.uScan.value = still ? 1.1 : Z.scan;
    if (variante === 'scan') scanUhr(still ? 1 : Z.scan); else uhrzeit(sonne);
    const [sx, sy] = buehnenPunkt(l);
    sonneEl.style.transform = `translate(${sx.toFixed(1)}px, ${sy.toFixed(1)}px)`;
    renderer.render(szene, kamera);
  }
  function bild(t) {
    if (!sichtbar || angehalten) { laeuft = false; return; }
    const dt = Math.min(64, t - letzte) / 1000; letzte = t;
    Z.uhr += dt;
    // Telefon ohne Gyro und ohne Finger: Sonne zieht langsam von selbst (Auto-Bewegung)
    if (beruehrung && !gyro && !zieht && !reduziert() && !html.classList.contains('vorfuehrung')) {
      autoZeit += dt; const q = autoZeit / 9;
      Z.zielLicht.set(0.5 + Math.sin(q * 2.1) * 0.3, 0.42 + Math.sin(q * 1.3) * 0.3);
      Z.zielMaus.set(Math.sin(q * 1.7) * 0.8, Math.cos(q * 1.1) * 0.5);
    }
    const kl = 1 - Math.pow(1 - W.lerpLicht, dt * 60), km = 1 - Math.pow(1 - W.lerpMaus, dt * 60);
    Z.licht.lerp(Z.zielLicht, kl); Z.maus.lerp(Z.zielMaus, km);
    // Scan: ohne Zeiger läuft die Schleife, mit Zeiger folgt die Lichtfront der Tiefe darunter (lerp wie das Licht)
    if (variante === 'scan') {
      if (Z.zielScan == null) { Z.scanZeit += dt * 1000; Z.scan += (scanAuto(Z.scanZeit) - Z.scan) * Math.min(1, kl * 3); }
      else Z.scan += (Z.zielScan - Z.scan) * kl;
    }
    zeichnen();
    const ruhig = Z.licht.distanceTo(Z.zielLicht) < 1e-4 && Z.maus.distanceTo(Z.zielMaus) < 1e-4;
    if ((ruhig || reduziert()) && !html.classList.contains('vorfuehrung') && !(beruehrung && !gyro) && !(variante === 'scan' && !reduziert())) { laeuft = false; return; }
    requestAnimationFrame(bild);
  }
  new IntersectionObserver(([e]) => { sichtbar = e.isIntersecting; if (sichtbar) anstossen(); }).observe(buehne);
  const altSetze = setzeModus; setzeModus = m => { altSetze(m); zeichnen(); };

  /* ---------- Vorführung: die Sonne geht unter und wieder auf, das Bild wiegt mit ---------- */
  let uhr = null, vStart = 0, vZeit = 0, vPausiert = false;
  const RUNDE = 11000;
  function vSchritt(t) {
    const q = ((t - vStart) % RUNDE) / RUNDE;
    // Bahn: oben links → rechts → tief über die Mitte (Glühen, verweilt) → zurück
    const w = q * Math.PI * 2;
    const ax = 0.5 - Math.cos(w) * 0.32 + Math.sin(w * 2) * 0.05;
    const ay = 0.38 + Math.sin(w - Math.PI / 2) * 0.3 * (q > 0.3 && q < 0.7 ? 1.08 : 1);
    Z.zielLicht.copy(bildPunkt(ax, Math.min(0.78, ay + 0.06)));
    Z.zielMaus.set(Math.sin(w) * 0.9, Math.cos(w * 1.5) * 0.4);
    anstossen();
    uhr = requestAnimationFrame(vSchritt);
  }
  api.vorfuehren = () => {
    cancelAnimationFrame(uhr); angehalten = false; vPausiert = false;
    html.classList.add('vorfuehrung');
    if (reduziert()) { zeichnen(); return; }
    vStart = performance.now(); uhr = requestAnimationFrame(vSchritt);
  };
  api.pause = () => { setzeHalt(true); cancelAnimationFrame(uhr); uhr = null; angehalten = true; if (html.classList.contains('vorfuehrung')) { vPausiert = true; vZeit = performance.now() - vStart; } };
  api.weiter = () => { setzeHalt(false); angehalten = false; if (!vPausiert || !html.classList.contains('vorfuehrung')) { anstossen(); return; } vPausiert = false; vStart = performance.now() - vZeit; anstossen(); uhr = requestAnimationFrame(vSchritt); };
  stoppeVorfuehrung = () => { if (!html.classList.contains('vorfuehrung')) return; cancelAnimationFrame(uhr); uhr = null; vPausiert = false; html.classList.remove('vorfuehrung'); };
  api.neustart = () => { stoppeVorfuehrung(); angehalten = false; Z.licht.copy(reduziert() ? FEST : new THREE.Vector2(0.3, 0.32)); Z.zielLicht.copy(Z.licht); Z.maus.set(0, 0); Z.zielMaus.set(0, 0); setzeModus('licht'); anstossen(); };

  /* ---------- Varianten (Regel 13): Abendsonne (Grundvariante) oder Morgen-Scan ---------- */
  setzeVariante = (v, url) => {
    setzeVarianteDom(v, url);
    Z.scan = SCAN.von; Z.scanZeit = 0; Z.zielScan = null;
    setzeModus('licht'); anstossen();
  };
  halt.addEventListener('click', () => { stoppeVorfuehrung(); if (halt.getAttribute('aria-pressed') === 'true') api.weiter(); else api.pause(); });

  if (reduziert()) { Z.licht.copy(FEST); Z.zielLicht.copy(FEST); }
  setzeVariante(new URLSearchParams(location.search).get('v'));
  groesse();
  anstossen();
  requestAnimationFrame(() => requestAnimationFrame(() => html.classList.add('bild-bereit')));
}

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