Zum Inhalt
← 3D & WebGL

Gaussian Splats (gescannte Räume)

Ein gescannter Raum als 47 000 Gauß-Splats: Kamera fliegt zwischen Standpunkten, der Blick lässt sich ziehen, der Raum baut sich von der Leuchte aus auf.

Ein realer Ort wird mit Fotos oder Video gescannt und als Hunderttausende bis Millionen farbige 3D-Gauß-Ellipsoide (Splats) gespeichert. Im Browser projiziert ein Splat-Renderer jedes Ellipsoid auf den Bildschirm, sortiert alle nach Tiefe und blendet sie mit Alpha übereinander. Das Ergebnis wirkt wie ein Foto, durch das man sich frei bewegen kann. Splats lassen sich in einer three.js-Szene mit normalen Meshes mischen. Die Kamera fährt per Scroll, Knopf oder Ziehen durch den Raum. Per Shader lassen sich Splats einblenden, auflösen oder umfärben.

Ein echter Ort wird im Browser begehbar. Das wirkt glaubwürdiger als ein modelliertes 3D-Modell, weil Licht, Material und Unordnung echt sind. Die Technik ist noch neu, der Effekt überrascht.

Wann einsetzen

Architektur, Immobilien, Gastronomie und Hotels, Museen, Event-Locations, Drehorte, Produktscans.

Wann nicht

Bei knappem Ladebudget: Rohdateien haben oft 10 bis 100 MB, komprimiert deutlich weniger. Wenn die Gestaltung stilisiert statt realistisch sein soll. Auf Seiten, die vor allem gelesen werden.

Varianten

  • Kamerafahrt zwischen festen Standpunkten oder per Scroll
  • Reveal: der Raum baut sich per Shader von einem Punkt aus auf
  • Splat und Mesh gemischt, etwa ein Produktmodell im gescannten Raum
  • Stilisiert als Punkte oder Dither

Umsetzung

Einen fertigen Renderer nehmen, etwa Spark (World Labs) als three.js-Objekt; er liest PLY, SPZ, SOGS, SPLAT und KSPLAT. Komprimierte Formate wie SPZ nutzen und die Splatzahl begrenzen; World Labs liefert 100 000 Splats je Szene. Die Sortierung kostet bei jeder Kamerabewegung Rechenzeit, die Füllrate wächst mit großen, nahen Splats. Die Kamera auf Standpunkte und kleine Schwenks begrenzen, damit niemand in die unscharfen Randbereiche des Scans fliegt. Auf Award-Seiten ist das Muster in dieser Recherche nicht belegt.

Robust umsetzen

Telefon. Splatzahl etwa halbieren und die Pixel Ratio begrenzen. Bei schwachen Geräten ein Video oder Standbild als Ersatz.

Ohne Bewegung. Feste Kamerapositionen statt Fahrten, Wechsel als Blende, kein Reveal.

Barrierefrei. Den Ort in Text beschreiben. Standpunkte als Knöpfe mit Tastatur bedienbar machen (auch Pfeiltasten), Wechsel per aria-live ansagen. Ziehen ist nur ein Zusatz.

Code

<!DOCTYPE html>
<!--
  Gaussian Splats (gescannte Räume)
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/gaussian-splats/
  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>Gaussian Splats (gescannte Räume) · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
@font-face { font-family: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }

:root {
  /* Rezept „Bild zuerst“, Film-Variante: neutrale dunkle Fläche, ein Textton */
  --grund: #0e0e0e; --text: #f2f2f2; --leise: #8a8a8a; --linie: rgba(242, 242, 242, .14); --platzhalter: #1a1a1a;
  --kurve: cubic-bezier(0.455, 0.03, 0.515, 0.955);   /* easeInOutQuad, gemessen bei Nadav Kander */
  --ui: 200ms; --blende: 350ms;
  --rand: clamp(18px, 2.6vw, 40px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; }
.mono { font: 400 12px/1.4 'Fragment Mono', ui-monospace, monospace; letter-spacing: .02em; font-variant-numeric: tabular-nums; color: var(--leise); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ---------- Bühne: der Scan bekommt die ganze Fläche, Interface in die Ecken ---------- */
.buehne { position: relative; height: 100svh; min-height: 620px; overflow: hidden; isolation: isolate; background: var(--grund); touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; }
.buehne.zieht { cursor: grabbing; }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; }
[data-canvas] { opacity: 0; transition: opacity var(--blende) var(--kurve); }
.gl-bereit [data-canvas] { opacity: 1; }
.gl-bereit [data-canvas].blende { opacity: 0; }
.ersatz { display: none; }
.ohne-webgl .ersatz { display: block; }
/* sanfte Vignette nur am Rand, damit die Ecktypo lesbar bleibt; das Bild selbst bleibt unverändert */
.buehne::after { content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(to bottom, rgba(14,14,14,.72) 0, rgba(14,14,14,0) 120px), linear-gradient(to top, rgba(14,14,14,.82) 0, rgba(14,14,14,0) 210px); }

.ecke { position: absolute; z-index: 2; }
.kopf { inset: 0 0 auto; display: flex; justify-content: space-between; align-items: baseline; gap: 24px; padding: var(--rand); }
.marke { font: 400 1.4rem/1 'Instrument Serif', serif; letter-spacing: -.01em; text-decoration: none; }
.marke + .mono { margin-top: 6px; }
.nav { display: flex; gap: clamp(14px, 2vw, 28px); list-style: none; font-size: .875rem; }
.nav a { text-decoration: none; color: var(--leise); transition: color var(--ui) var(--kurve); }
.nav a:hover, .nav a:focus-visible, .nav a[aria-current] { color: var(--text); }
.nav a:focus-visible { outline: 1px solid var(--text); outline-offset: 4px; }

.unten { inset: auto 0 0; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 24px; padding: var(--rand); pointer-events: none; }
.unten > * { pointer-events: auto; }
.ort h1 { font: 400 clamp(1.6rem, 3vw, 2.75rem)/1.1 'Instrument Serif', serif; letter-spacing: -.01em; margin: 10px 0 8px; }
.ort h1 em { font-style: italic; color: var(--leise); }
.ort .zeile { display: flex; flex-wrap: wrap; gap: 4px 18px; }
.ort .zeile b { color: var(--text); font-weight: 400; }

.steuer { display: grid; justify-items: end; gap: 12px; }
.standpunkte { display: flex; align-items: baseline; gap: 14px; }
.zaehler { font: 400 12px/1 'Fragment Mono', monospace; font-variant-numeric: tabular-nums; color: var(--text); min-width: 5.2ch; text-align: right; }
.zaehler span { color: var(--leise); }
.knopfreihe { display: flex; gap: 4px; }
.knopfreihe button { font: 400 12px/1 'Fragment Mono', monospace; letter-spacing: .02em; color: var(--leise); background: rgba(14, 14, 14, .5); border: 1px solid var(--linie); border-radius: 2px; height: 30px; padding: 0 11px; cursor: pointer; transition: color var(--ui) var(--kurve), border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.knopfreihe button:hover { color: var(--text); border-color: rgba(242,242,242,.4); }
.knopfreihe button:focus-visible { outline: 1px solid var(--text); outline-offset: 2px; }
.knopfreihe button[aria-pressed='true'] { color: var(--grund); background: var(--text); border-color: var(--text); }
.modi { display: flex; align-items: center; gap: 10px; }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px 14px; max-width: 600px; font-size: 11px; }
.werte b { color: var(--text); font-weight: 400; }

.hinweis { position: absolute; z-index: 2; left: 50%; top: 50%; translate: -50% -50%; display: flex; align-items: center; gap: 10px; padding: 9px 14px 9px 12px; border-radius: 99px; background: rgba(14,14,14,.55); border: 1px solid var(--linie); pointer-events: none; opacity: 0; transition: opacity var(--blende) var(--kurve); }
.hinweis.an { opacity: 1; }
.hinweis svg { width: 18px; height: 18px; }

/* gedachter Zeiger in der Vorführung (zieht durch den Raum) */
.zeiger { position: absolute; z-index: 3; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border: 1px solid rgba(242,242,242,.85); border-radius: 50%; pointer-events: none; display: none; transition: transform 1.4s var(--kurve), background-color var(--ui) linear; }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueckt { background: rgba(242,242,242,.25); }

/* ---------- Unter der Bühne: Ablauf und Locationliste ---------- */
.mehr { padding: clamp(56px, 8vw, 120px) var(--rand) clamp(40px, 6vw, 80px); display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 clamp(16px, 2vw, 32px); }
.mehr .einleitung { grid-column: 1 / 6; }
.mehr .einleitung h2 { font: 400 clamp(1.6rem, 3vw, 2.4rem)/1.1 'Instrument Serif', serif; letter-spacing: -.01em; margin: 12px 0 16px; }
.mehr .einleitung p:not(.mono) { color: var(--leise); line-height: 1.55; max-width: 44ch; font-size: .95rem; }
.ablauf { grid-column: 7 / 13; list-style: none; display: grid; gap: 0; border-top: 1px solid var(--linie); align-self: start; }
.ablauf li { display: grid; grid-template-columns: 3.5ch 9rem 1fr; gap: 16px; align-items: baseline; padding: 16px 0 15px; border-bottom: 1px solid var(--linie); }
.ablauf li b { font: 400 1.35rem/1.1 'Instrument Serif', serif; color: var(--text); }
.ablauf li span:last-child { font: 400 .95rem/1.5 'Instrument Sans', sans-serif; letter-spacing: 0; color: var(--leise); }
.liste { grid-column: 1 / 13; margin-top: clamp(56px, 7vw, 96px); list-style: none; border-top: 1px solid var(--linie); }
.liste li { display: grid; grid-template-columns: 3.5ch 1fr 1fr 9rem 6rem; gap: 16px; align-items: baseline; padding: 14px 0 13px; border-bottom: 1px solid var(--linie); }
.liste .titel { font: 400 1.35rem/1.1 'Instrument Serif', serif; }
.liste li.aktiv .titel::after { content: ' · offen'; font: 400 12px 'Fragment Mono', monospace; color: var(--leise); }
.liste .rechts { text-align: right; }
.fuss { display: flex; justify-content: space-between; gap: 24px; flex-wrap: wrap; padding: 28px var(--rand) 36px; border-top: 1px solid var(--linie); }

/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .mehr, .vorschau .fuss, .vorschau .nav { display: none; }
.vorschau .buehne { height: 100vh; }

:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
:root.reduziert [data-canvas] { transition: opacity 150ms linear !important; }

@media (max-width: 900px) {
  .unten { grid-template-columns: 1fr; gap: 16px; }
  .steuer { justify-items: start; }
  .werte { justify-content: flex-start; }
  .mehr .einleitung, .ablauf { grid-column: 1 / 13; }
  .ablauf { margin-top: 32px; }
  .liste li { grid-template-columns: 3.5ch 1fr auto; }
  .liste li > :nth-child(3), .liste li > :nth-child(4) { display: none; }
}
@media (max-width: 600px) {
  .buehne { min-height: 700px; }
  .nav { display: none; }
  .kopf .mono.ort-kiel { display: none; }
  .ort h1 { font-size: 1.75rem; }
  .ort .zeile .lang { display: none; }
  .standpunkte { flex-wrap: wrap; }
  .modi { flex-wrap: wrap; }
  .werte { font-size: 10.5px; gap: 2px 10px; }
  .ablauf li { grid-template-columns: 3ch 1fr; }
  .ablauf li span:last-child { grid-column: 2; }
  .buehne::after { background: linear-gradient(to bottom, rgba(14,14,14,.72) 0, rgba(14,14,14,0) 110px), linear-gradient(to top, rgba(14,14,14,.92) 0, rgba(14,14,14,.6) 220px, rgba(14,14,14,0) 360px); }
}
@media (hover: none) {
  .buehne { cursor: auto; }
  .hinweis .maus { display: none; }
}
@media (hover: hover) { .hinweis .finger { display: none; } }
</style>
</head>
<body>
<section class="buehne" data-buehne aria-labelledby="ort-titel" aria-describedby="ort-text">
  <canvas class="ersatz" data-ersatz aria-hidden="true"></canvas>
  <canvas data-canvas aria-hidden="true"></canvas>

  <header class="ecke kopf">
    <div>
      <a class="marke" href="#">Halbtotale</a>
      <p class="mono ort-kiel">Locations &amp; Raumscans · Kiel</p>
    </div>
    <ul class="nav">
      <li><a href="#locations" aria-current="page">Locations</a></li>
      <li><a href="#ablauf">Scans</a></li>
      <li><a href="#kontakt">Anfrage</a></li>
    </ul>
  </header>

  <div class="hinweis mono" data-hinweis aria-hidden="true">
    <svg class="maus" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1"><path d="M3 9h12M3 9l2.5-2.5M3 9l2.5 2.5M15 9l-2.5-2.5M15 9l-2.5 2.5"/></svg>
    <svg class="finger" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1"><path d="M3 9h12M3 9l2.5-2.5M3 9l2.5 2.5M15 9l-2.5-2.5M15 9l-2.5 2.5"/></svg>
    <span class="maus">Ziehen zum Umsehen</span><span class="finger">Wischen zum Umsehen</span>
  </div>

  <div class="ecke unten">
    <div class="ort">
      <p class="mono">Location 014 · Kiel-Holtenau</p>
      <h1 id="ort-titel">Wärterstube <em>an der Schleuse</em></h1>
      <p class="mono zeile"><span>gescannt <b>14.09.2026</b></span><span class="lang">412 Fotos · 18 Min.</span><span>splats <b data-anzahl>–</b></span><span class="lang">frei ab <b>sofort</b></span></p>
      <p class="sr" id="ort-text">Gescannter Raum: eine kleine Wärterstube mit Dielenboden, Holztisch, zwei Stühlen und einer Pendelleuchte. Durch ein großes Sprossenfenster sieht man in der Dämmerung auf den Kanal und das andere Ufer. Links steht eine Zimmerpflanze, rechts eine Tür.</p>
    </div>
    <div class="steuer">
      <div class="standpunkte">
        <span class="zaehler" data-zaehler aria-hidden="true">01 <span>/ 03</span></span>
        <div class="knopfreihe" role="group" aria-label="Standpunkt wählen" data-standpunkte>
          <button type="button" data-stand="0" aria-pressed="true">Tür</button>
          <button type="button" data-stand="1" aria-pressed="false">Tisch</button>
          <button type="button" data-stand="2" aria-pressed="false">Fenster</button>
        </div>
      </div>
      <div class="modi">
        <span class="mono">Variante</span>
        <div class="knopfreihe" role="group" aria-label="Variante wählen" data-modi>
          <button type="button" data-modus="fahrt" aria-pressed="true">Kamerafahrt</button>
          <button type="button" data-modus="reveal" aria-pressed="false">Reveal</button>
          <button type="button" data-modus="punkte" aria-pressed="false">Punkte</button>
        </div>
      </div>
      <p class="mono werte" data-werte></p>
    </div>
  </div>
  <p class="sr" aria-live="polite" data-ansage></p>
  <div class="zeiger" data-zeiger aria-hidden="true"></div>
</section>

<section class="mehr" id="ablauf">
  <div class="einleitung">
    <p class="mono">Wie wir scannen</p>
    <h2>Begehen, bevor Sie anreisen.</h2>
    <p>Wir scannen Drehorte in Kiel und an der Förde und stellen sie als begehbare Räume ins Netz. Regie, Kamera und Szenenbild sehen Licht, Fluchten und Wandflächen vorab und planen Einstellungen, ohne dass jemand anreist.</p>
  </div>
  <ol class="ablauf mono">
    <li><span>01</span><b>Aufnahme</b><span>300 bis 500 Fotos mit fester Belichtung, ein Rundgang in Augenhöhe, einer knapp über dem Boden.</span></li>
    <li><span>02</span><b>Training</b><span>Aus den Fotos entstehen über Nacht Hunderttausende farbige Gauß-Ellipsoide.</span></li>
    <li><span>03</span><b>Auslieferung</b><span>Wir dünnen auf 50 000 bis 100 000 Splats aus, komprimiert als .spz, wenige MB pro Raum.</span></li>
  </ol>
  <ol class="liste" id="locations">
    <li class="aktiv"><span class="mono">014</span><span class="titel">Wärterstube an der Schleuse</span><span class="mono">Holtenau</span><span class="mono">Innen · 28 m²</span><span class="mono rechts">2026</span></li>
    <li><span class="mono">013</span><span class="titel">Segelmacherei Petersen</span><span class="mono">Friedrichsort</span><span class="mono">Halle · 310 m²</span><span class="mono rechts">2026</span></li>
    <li><span class="mono">012</span><span class="titel">Kesselhaus am Werftpark</span><span class="mono">Gaarden</span><span class="mono">Halle · 540 m²</span><span class="mono rechts">2025</span></li>
    <li><span class="mono">011</span><span class="titel">Bootsschuppen mit Slipanlage</span><span class="mono">Möltenort</span><span class="mono">Außen · 120 m²</span><span class="mono rechts">2025</span></li>
  </ol>
</section>
<footer class="fuss mono" id="kontakt"><span>Halbtotale · Kanalstraße 9 · 24159 Kiel</span><span>0431 220 714 · anfrage@halbtotale.example</span></footer>

<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 schmal = () => innerWidth <= 600;
const $ = s => document.querySelector(s);
const buehne = $('[data-buehne]'), canvas = $('[data-canvas]'), ersatz = $('[data-ersatz]');
const werteEl = $('[data-werte]'), ansage = $('[data-ansage]'), zaehler = $('[data-zaehler]'), hinweis = $('[data-hinweis]'), zeiger = $('[data-zeiger]');
const standKnoepfe = [...document.querySelectorAll('[data-stand]')], modusKnoepfe = [...document.querySelectorAll('[data-modus]')];

/* ---------- Werte (meta.json → messwerte: World Labs 100k Splats pro Szene im .spz-Format;
   Kurve aus dem Rezept „Bild zuerst“: easeInOutQuad, Nadav Kander) ---------- */
const W = {
  splats: { desktop: 42000, mobil: 20000 },  // Ziel; World Labs liefert 100k pro Szene, hier weniger wegen Software-Rendering
  kurve: [0.455, 0.03, 0.515, 0.955],        // cubic-bezier, für Kamerafahrt und Reveal
  fahrtMs: 2400,       // Flug zwischen zwei Standpunkten
  revealMs: 2600,      // Reveal: Radius um die Leuchte wächst von 0 auf 6,5 m
  revealRand: 0.9,     // Breite der Aufbaukante in m (Splats wachsen dort von 0 auf volle Größe)
  revealRadius: 6.5,
  sigma: 2.5,          // Quad deckt ±2,5 σ der Gaußglocke ab (Rest < 5 % Deckkraft)
  maxRadiusPx: 320,    // Obergrenze je Splat, schützt die Füllrate bei nahen Splats
  ziehen: { gier: 0.55, nick: 0.16, empfindlich: 0.004, daempfung: 6 },  // Blick schwenken: Grenzen in rad, rad/px, 1/s
  atmen: { amp: 0.035, hz: 0.07 },  // ruhige Eigenbewegung der Kamera (m, Hz), nur Variante Kamerafahrt
  punktPx: 1.3,        // Variante Punkte: fester Radius in px (bei DPR 1)
  takt: 4200,          // Vorführung: Takt je Standpunkt
  blendeMs: 150,       // reduzierte Bewegung: Standpunktwechsel als harte Blende
};
const STAND = [
  { name: 'Tür',     pos: [2.15, 1.58, 1.45], ziel: [-0.55, 1.15, -2.5], text: 'Blick von der Tür über Tisch und Fenster.' },
  { name: 'Tisch',   pos: [-1.8, 1.38, 0.15], ziel: [0.75, 1.05, -2.7], text: 'Am Tisch, Blick unter der Leuchte zum Fenster.' },
  { name: 'Fenster', pos: [0.55, 1.62, -1.2], ziel: [-0.35, 1.45, -6.0], text: 'Vor dem Fenster, Blick auf den Kanal in der Dämmerung.' },
];
let stand = 0, modus = 'fahrt';
const istMobil = beruehrung || schmal();
const N_ZIEL = istMobil ? W.splats.mobil : W.splats.desktop;
const zahl = n => n.toLocaleString('de-DE').replace(/\./g, ' ');

/* ======================================================================
   Der „Scan“: prozedural erzeugte Splats (Mittelpunkt, Farbe, Deckkraft,
   3D-Kovarianz). Echte Projekte laden hier eine .spz/.ply-Datei.
   ====================================================================== */
const zufall = (s => () => (s = (s * 16807) % 2147483647) / 2147483647)(1409);
const hash = (x, y) => { const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return s - Math.floor(s); };
function rauschen(x, y) { const ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy, u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy);
  const a = hash(ix, iy), b = hash(ix + 1, iy), c = hash(ix, iy + 1), d = hash(ix + 1, iy + 1); return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; }
const fbm = (x, y) => rauschen(x, y) * 0.55 + rauschen(x * 2.3, y * 2.3) * 0.3 + rauschen(x * 5.1, y * 5.1) * 0.15;

const RAUM = { x0: -2.8, x1: 2.8, y1: 2.85, z0: -4, z1: 2.2 };
const FENSTER = { x0: -1.45, x1: 1.45, y0: 0.95, y1: 2.45, z: -4 };
const LAMPE = [0.15, 2.05, -1.75];
const LICHTRICHTUNG = (() => { const v = [0.12, -0.62, 1]; const l = Math.hypot(...v); return v.map(c => c / l); })();

function imFensterglas(x, y) {
  if (x < FENSTER.x0 || x > FENSTER.x1 || y < FENSTER.y0 || y > FENSTER.y1) return false;
  const u = (x - FENSTER.x0) / (FENSTER.x1 - FENSTER.x0) * 3, v = (y - FENSTER.y0) / (FENSTER.y1 - FENSTER.y0) * 2;
  const du = Math.abs(u - Math.round(u)), dv = Math.abs(v - Math.round(v));
  return du > 0.045 && dv > 0.06;
}
// gebackenes Licht: Umgebung + Fensterlicht (mit Sprossenschatten) + warme Pendelleuchte + Ecken-Abdunklung
function licht(p, n) {
  let r = 0.16, g = 0.17, b = 0.2;
  // Fensterlicht: Strahl zurück zur Fensterebene verfolgen
  const t = (p[2] - FENSTER.z) / LICHTRICHTUNG[2];
  if (t > 0) {
    const wx = p[0] - LICHTRICHTUNG[0] * t, wy = p[1] - LICHTRICHTUNG[1] * t;
    const k = Math.max(0, -(n[0] * LICHTRICHTUNG[0] + n[1] * LICHTRICHTUNG[1] + n[2] * LICHTRICHTUNG[2]));
    if (imFensterglas(wx, wy)) { const s = k * 0.95 * Math.exp(-t * 0.12); r += s * 0.62; g += s * 0.74; b += s * 0.95; }
  }
  // diffuses Himmelslicht vom Fenster
  const dz = p[2] - FENSTER.z, dx = p[0] * 0.5, sky = 0.55 / (1 + (dz * dz + dx * dx) * 0.18) * Math.max(0.25, n[2] + 0.4);
  r += sky * 0.42; g += sky * 0.5; b += sky * 0.68;
  // Pendelleuchte
  const lx = LAMPE[0] - p[0], ly = LAMPE[1] - 0.12 - p[1], lz = LAMPE[2] - p[2], d2 = lx * lx + ly * ly + lz * lz, d = Math.sqrt(d2);
  const kl = Math.max(0, (n[0] * lx + n[1] * ly + n[2] * lz) / d) * 2.1 / (1 + d2 * 0.9) * (ly > -0.1 ? 1 : 0.35);
  r += kl * 1.0; g += kl * 0.64; b += kl * 0.34;
  // Abdunklung in Ecken und Kanten
  const kante = Math.min(p[0] - RAUM.x0, RAUM.x1 - p[0], p[1], RAUM.y1 - p[1], p[2] - RAUM.z0 + 0.2);
  const ao = 0.55 + 0.45 * Math.min(1, kante / 0.6);
  return [r * ao, g * ao, b * ao];
}
const ton = c => 1 - Math.exp(-c * 1.25);

function erzeugeSzene(dichte) {
  const P = [], F = [], C = [];
  // Kovarianz aus zwei Tangenten (t1, t2), Normale n und Skalen s1, s2, sn
  function splat(p, rgb, alpha, t1, t2, n, s1, s2, sn) {
    const c = [0, 0, 0, 0, 0, 0];
    for (const [a, s] of [[t1, s1], [t2, s2], [n, sn]]) {
      const q = s * s;
      c[0] += a[0] * a[0] * q; c[1] += a[0] * a[1] * q; c[2] += a[0] * a[2] * q;
      c[3] += a[1] * a[1] * q; c[4] += a[1] * a[2] * q; c[5] += a[2] * a[2] * q;
    }
    P.push(p[0], p[1], p[2], alpha); F.push(rgb[0], rgb[1], rgb[2], zufall()); C.push(...c);
  }
  function drehe(t1, t2, w) { const c = Math.cos(w), s = Math.sin(w); return [t1.map((v, i) => v * c + t2[i] * s), t1.map((v, i) => -v * s + t2[i] * c)]; }
  // Fläche: Ursprung o, Kanten u, v (Länge = Ausdehnung), Normale n, Albedo-Funktion
  function flaeche(o, u, v, n, albedo, opt = {}) {
    const lu = Math.hypot(...u), lv = Math.hypot(...v), A = lu * lv;
    const cnt = Math.round(A * dichte * (opt.dichte || 1));
    const tu = u.map(c => c / lu), tv = v.map(c => c / lv), abst = 1 / Math.sqrt(dichte * (opt.dichte || 1));
    // geschichtete Stichprobe (Raster mit Zufallsversatz): gleichmäßige Deckung ohne Löcher
    const nu = Math.max(1, Math.round(Math.sqrt(cnt * lu / lv))), nv = Math.max(1, Math.round(cnt / nu));
    for (let i = 0; i < nu * nv; i++) {
      const a = (i % nu + zufall()) / nu, b = (Math.floor(i / nu) + zufall()) / nv;
      const p = [o[0] + u[0] * a + v[0] * b, o[1] + u[1] * a + v[1] * b, o[2] + u[2] * a + v[2] * b];
      if (opt.weg && opt.weg(p, a * lu, b * lv)) continue;
      const alb = albedo(p, a * lu, b * lv);
      const L = opt.leuchtet ? [1, 1, 1] : licht(p, n);
      const e = alb.e || 0;
      const rgb = [ton(alb[0] * L[0] + e * alb[0]), ton(alb[1] * L[1] + e * alb[1]), ton(alb[2] * L[2] + e * alb[2])];
      const [t1, t2] = drehe(tu, tv, (opt.richtung ?? zufall() * Math.PI));
      const s = abst * (0.4 + zufall() * 0.32);
      const an = opt.aniso ?? (1 + zufall() * 1.2);
      splat(p, rgb, opt.alpha ?? (0.72 + zufall() * 0.25), t1, t2, n, s * an, s / Math.sqrt(an), s * 0.08);
    }
  }
  function kiste(min, max, albedo, opt = {}) {
    const [x0, y0, z0] = min, [x1, y1, z1] = max;
    const seiten = [
      [[x0, y1, z0], [x1 - x0, 0, 0], [0, 0, z1 - z0], [0, 1, 0]],
      [[x0, y0, z1], [x1 - x0, 0, 0], [0, y1 - y0, 0], [0, 0, 1]],
      [[x0, y0, z0], [x1 - x0, 0, 0], [0, y1 - y0, 0], [0, 0, -1]],
      [[x1, y0, z0], [0, 0, z1 - z0], [0, y1 - y0, 0], [1, 0, 0]],
      [[x0, y0, z0], [0, 0, z1 - z0], [0, y1 - y0, 0], [-1, 0, 0]],
    ];
    if (opt.unten) seiten.push([[x0, y0, z0], [x1 - x0, 0, 0], [0, 0, z1 - z0], [0, -1, 0]]);
    for (const [o, u, v, n] of seiten) flaeche(o, u, v, n, albedo, opt);
  }
  const holz = (basis, maser = 9) => (p, a, b) => { const m = fbm(p[0] * 1.2 + p[1] * 6, p[2] * maser) * 0.5 + 0.75; return [basis[0] * m, basis[1] * m, basis[2] * m]; };

  // Dielenboden: Bretter entlang z, je Brett eigener Ton, feine Fugen
  flaeche([RAUM.x0, 0, RAUM.z0], [RAUM.x1 - RAUM.x0, 0, 0], [0, 0, RAUM.z1 - RAUM.z0], [0, 1, 0], p => {
    const brett = Math.floor((p[0] + 3) / 0.21), fuge = ((p[0] + 3) / 0.21) % 1, ton = 0.78 + hash(brett, 3) * 0.35;
    const m = fbm(p[0] * 9, p[2] * 0.9 + brett * 7) * 0.4 + 0.8, f = fuge < 0.06 ? 0.45 : 1;
    return [0.6 * ton * m * f, 0.42 * ton * m * f, 0.28 * ton * m * f];
  }, { dichte: 1.15, richtung: Math.PI / 2, aniso: 2.2 });
  // Rückwand mit Fensteröffnung: unten Holzpaneel in Kanalblau-Grün, oben Kalkputz
  const wand = (p) => {
    const m = fbm(p[0] * 3, p[1] * 3) * 0.22 + 0.86;
    if (p[1] < 0.95) { const l = Math.abs(((p[0] + p[2] + 9) / 0.12) % 1 - 0.5) < 0.05 ? 0.7 : 1; return [0.2 * m * l, 0.33 * m * l, 0.33 * m * l]; }
    if (p[1] < 1.0) return [0.45, 0.42, 0.36];
    return [0.78 * m, 0.74 * m, 0.66 * m];
  };
  flaeche([RAUM.x0, 0, RAUM.z0], [RAUM.x1 - RAUM.x0, 0, 0], [0, RAUM.y1, 0], [0, 0, 1], wand, {
    weg: p => p[0] > FENSTER.x0 - 0.02 && p[0] < FENSTER.x1 + 0.02 && p[1] > FENSTER.y0 - 0.02 && p[1] < FENSTER.y1 + 0.02 });
  // Seitenwände
  flaeche([RAUM.x0, 0, RAUM.z0], [0, 0, RAUM.z1 - RAUM.z0], [0, RAUM.y1, 0], [1, 0, 0], wand, { dichte: 0.85 });
  flaeche([RAUM.x1, 0, RAUM.z0], [0, 0, RAUM.z1 - RAUM.z0], [0, RAUM.y1, 0], [-1, 0, 0], wand, { dichte: 0.85,
    weg: p => p[2] > -0.9 && p[2] < 0.1 && p[1] < 2.05 });
  // Decke mit Balken
  flaeche([RAUM.x0, RAUM.y1, RAUM.z0], [RAUM.x1 - RAUM.x0, 0, 0], [0, 0, RAUM.z1 - RAUM.z0], [0, -1, 0], p => {
    const balken = ((p[2] + 4) / 1.1) % 1 < 0.16; return balken ? [0.32, 0.22, 0.15] : [0.62, 0.6, 0.55];
  }, { dichte: 0.5 });
  // Tür rechts (Holz, dunkel) mit Zarge
  flaeche([RAUM.x1 - 0.01, 0, -0.9], [0, 0, 1.0], [0, 2.05, 0], [-1, 0, 0], (p) => {
    const feld = (Math.abs(p[1] - 0.6) < 0.38 || Math.abs(p[1] - 1.5) < 0.38) && Math.abs(p[2] + 0.4) < 0.36;
    const m = fbm(p[2] * 3, p[1] * 12) * 0.3 + 0.8; return feld ? [0.3 * m, 0.19 * m, 0.12 * m] : [0.38 * m, 0.25 * m, 0.16 * m];
  }, { dichte: 1.3, richtung: 0, aniso: 1.8 });
  // Fenster: Sprossen und Rahmen (weiß lackiert), Fensterbank, Glas mit leichtem Schimmer
  const fb = FENSTER, rahmen = [0.86, 0.85, 0.8];
  for (let i = 0; i <= 3; i++) { const x = fb.x0 + (fb.x1 - fb.x0) * i / 3; kiste([x - 0.035, fb.y0, fb.z - 0.06], [x + 0.035, fb.y1, fb.z + 0.02], () => rahmen, { dichte: 2.2 }); }
  for (let j = 0; j <= 2; j++) { const y = fb.y0 + (fb.y1 - fb.y0) * j / 2; kiste([fb.x0, y - 0.035, fb.z - 0.06], [fb.x1, y + 0.035, fb.z + 0.02], () => rahmen, { dichte: 2.2 }); }
  kiste([fb.x0 - 0.12, fb.y0 - 0.06, fb.z], [fb.x1 + 0.12, fb.y0 - 0.02, fb.z + 0.26], () => [0.82, 0.8, 0.75], { dichte: 2 });
  // Heizkörper unter dem Fenster
  for (let i = 0; i < 12; i++) { const x = -0.72 + i * 0.13; kiste([x, 0.2, -3.97], [x + 0.06, 0.74, -3.88], p => { const m = 0.85 + fbm(p[1] * 8, x * 3) * 0.2; return [0.5 * m, 0.52 * m, 0.5 * m]; }, { dichte: 2.4, richtung: Math.PI / 2, aniso: 2.6, alpha: 0.9 }); }
  // Tisch: Platte und Beine
  const tisch = holz([0.5, 0.33, 0.2], 10);
  kiste([-0.85, 0.72, -2.25], [0.95, 0.78, -1.25], tisch, { dichte: 4.5, richtung: 0, aniso: 3, unten: true });
  for (const [x, z] of [[-0.78, -2.18], [0.88, -2.18], [-0.78, -1.32], [0.88, -1.32]]) kiste([x - 0.03, 0, z - 0.03], [x + 0.03, 0.72, z + 0.03], tisch, { dichte: 4, richtung: Math.PI / 2 });
  // Stühle
  const stuhl = (x, z, dreh) => {
    const s = holz([0.24, 0.17, 0.12], 7), sx = dreh ? -1 : 1;
    kiste([x - 0.22, 0.44, z - 0.22], [x + 0.22, 0.48, z + 0.22], s, { dichte: 3.4 });
    kiste([x - 0.22, 0.48, z + sx * 0.2 - 0.02], [x + 0.22, 0.92, z + sx * 0.2 + 0.02], s, { dichte: 3.4 });
    for (const [a, b] of [[-0.19, -0.19], [0.19, -0.19], [-0.19, 0.19], [0.19, 0.19]]) kiste([x + a - 0.02, 0, z + b - 0.02], [x + a + 0.02, 0.44, z + b + 0.02], s, { dichte: 5 });
  };
  stuhl(-0.25, -0.95, false); stuhl(0.5, -2.62, true);
  // Auf dem Tisch: Tasse, Kladde, Kanal-Karte
  kiste([0.32, 0.78, -1.62], [0.41, 0.87, -1.53], () => [0.9, 0.89, 0.85], { dichte: 9 });
  kiste([-0.55, 0.78, -1.95], [-0.05, 0.795, -1.55], () => [0.86, 0.82, 0.7], { dichte: 6, richtung: 0 });
  kiste([-0.5, 0.795, -1.92], [-0.1, 0.81, -1.6], () => [0.2, 0.24, 0.32], { dichte: 6, richtung: 0 });
  // Karte des Kanals an der linken Wand (gerahmt)
  flaeche([RAUM.x0 + 0.02, 1.15, -2.6], [0, 0, 1.3], [0, 0.85, 0], [1, 0, 0], (p, a, b) => {
    if (a < 0.04 || a > 1.26 || b < 0.04 || b > 0.81) return [0.12, 0.1, 0.09];
    const kanal = Math.abs(b - 0.42 - Math.sin(a * 2.6) * 0.12) < 0.05, land = fbm(a * 4, b * 4) > 0.5;
    return kanal ? [0.32, 0.5, 0.58] : land ? [0.8, 0.74, 0.58] : [0.72, 0.78, 0.74];
  }, { dichte: 3.2 });
  // Pendelleuchte: Schirm (Emaille, dunkelgrün), leuchtender Rand, Lichthof aus großen, schwachen Splats
  for (let i = 0; i < 2000; i++) {
    const w = zufall() * Math.PI * 2, h = zufall(), r = 0.06 + h * 0.2, y = LAMPE[1] + 0.2 - h * 0.17;
    const p = [LAMPE[0] + Math.cos(w) * r, y, LAMPE[2] + Math.sin(w) * r], n = [Math.cos(w) * 0.7, 0.7, Math.sin(w) * 0.7];
    const L = licht(p, n), innen = i % 2 === 0;
    if (innen) { p[0] = LAMPE[0] + Math.cos(w) * (r - 0.012); p[2] = LAMPE[2] + Math.sin(w) * (r - 0.012); p[1] -= 0.006; }
    const rgb = innen ? [1, 0.84 - h * 0.1, 0.58 - h * 0.12] : [ton(0.14 * L[0] + 0.03), ton(0.26 * L[1] + 0.04), ton(0.22 * L[2] + 0.04)];
    splat(p, rgb, 0.92, [-Math.sin(w), 0, Math.cos(w)], [Math.cos(w) * 0.6, -0.8, Math.sin(w) * 0.6], n, 0.018, 0.022, 0.003);
  }
  for (let i = 0; i < 80; i++) { const w = zufall() * Math.PI * 2, r = zufall() * 0.12; splat([LAMPE[0] + Math.cos(w) * r, LAMPE[1] + 0.03, LAMPE[2] + Math.sin(w) * r], [1, 0.95, 0.82], 0.95, [1, 0, 0], [0, 0, 1], [0, 1, 0], 0.03, 0.03, 0.01); }
  for (let i = 0; i < 26; i++) { const r = 0.25 + zufall() * 0.6; splat([LAMPE[0] + (zufall() - 0.5) * 0.3, LAMPE[1] - 0.05 - zufall() * 0.4, LAMPE[2] + (zufall() - 0.5) * 0.3], [1, 0.72, 0.42], 0.035, [1, 0, 0], [0, 1, 0], [0, 0, 1], r, r * 0.8, r); }
  for (let i = 0; i < 260; i++) { const y = LAMPE[1] + 0.2 + zufall() * (RAUM.y1 - LAMPE[1] - 0.2); splat([LAMPE[0], y, LAMPE[2]], [0.08, 0.08, 0.08], 0.9, [1, 0, 0], [0, 0, 1], [0, 1, 0], 0.004, 0.004, 0.01); }
  // Zimmerpflanze in der linken Ecke: Topf + viele längliche Blatt-Splats
  for (let i = 0; i < 900; i++) {
    const w = zufall() * Math.PI * 2, h = zufall() * 0.42, r = 0.2 + h * 0.12, p = [-2.25 + Math.cos(w) * r, h, -3.45 + Math.sin(w) * r], n = [Math.cos(w), 0, Math.sin(w)];
    const L = licht(p, n); splat(p, [ton(0.55 * L[0]), ton(0.3 * L[1]), ton(0.2 * L[2])], 0.9, [-Math.sin(w), 0, Math.cos(w)], [0, 1, 0], n, 0.02, 0.025, 0.003);
  }
  for (let i = 0; i < 4200; i++) {
    const zweig = Math.floor(zufall() * 14), wz = hash(zweig, 1) * Math.PI * 2, hz = 0.6 + hash(zweig, 2) * 1.0, t = Math.pow(zufall(), 0.7);
    const p = [-2.25 + Math.cos(wz) * t * 0.55 + (zufall() - 0.5) * 0.18, 0.42 + t * hz + (zufall() - 0.5) * 0.12, -3.45 + Math.sin(wz) * t * 0.5 + (zufall() - 0.5) * 0.18];
    const n = [zufall() - 0.5, 0.8, zufall() - 0.5], ln = Math.hypot(...n); n.forEach((v, k) => n[k] = v / ln);
    const L = licht(p, n), g = 0.75 + zufall() * 0.5;
    const blatt = [Math.cos(wz) * 0.5 + (zufall() - 0.5), 0.6 + zufall() * 0.4, Math.sin(wz) * 0.5 + (zufall() - 0.5)], lb = Math.hypot(...blatt); blatt.forEach((v, k) => blatt[k] = v / lb);
    const quer = [blatt[1] * n[2] - blatt[2] * n[1], blatt[2] * n[0] - blatt[0] * n[2], blatt[0] * n[1] - blatt[1] * n[0]], lq = Math.hypot(...quer) || 1; quer.forEach((v, k) => quer[k] = v / lq);
    splat(p, [ton(0.14 * g * L[0]), ton(0.34 * g * L[1]), ton(0.16 * g * L[2])], 0.85, blatt, quer, n, 0.05, 0.016, 0.003);
  }
  // Blick durchs Fenster: Dämmerhimmel, Kanal, anderes Ufer mit Lichtern, Schiff. Weit hinten = echte Parallaxe
  const zH = -11;
  const nbx = Math.round(Math.sqrt(dichte * 15 * 2)), nby = Math.round(nbx / 2);
  for (let i = 0; i < nbx * nby; i++) {
    const x = -6.5 + (i % nbx + zufall()) / nbx * 13, y = -1.2 + (Math.floor(i / nbx) + zufall()) / nby * 6.8, h = (y + 1.2) / 6.8;
    let rgb;
    const ufer = 1.25 + fbm(x * 0.4, 1) * 0.35 + (Math.abs(x - 2.6) < 0.5 ? 0.55 : 0);
    if (y < 0.9) { const s = 0.5 + 0.5 * fbm(x * 2, y * 18); rgb = [0.1 + 0.18 * s * h, 0.13 + 0.22 * s * h, 0.2 + 0.3 * s * h]; }
    else if (y < ufer) { rgb = [0.05, 0.06, 0.08]; if (hash(Math.floor(x * 5), Math.floor(y * 9)) > 0.93) rgb = [1, 0.78, 0.45]; }
    else { const t = Math.min(1, (y - 1.2) / 4.5); rgb = [0.92 - 0.62 * t, 0.62 - 0.3 * t, 0.5 - 0.06 * t]; rgb = rgb.map(c => c * (1 - 0.4 * t) + 0.04); }
    const s = (0.45 + zufall() * 0.3) * 13 / nbx;
    splat([x, y, zH], rgb, -0.95, [1, 0, 0], [0, 1, 0], [0, 0, 1], s * 1.6, s, 0.01);
  }
  // Schiff mit Positionslicht auf dem Kanal (vor dem Ufer)
  for (let i = 0; i < 700; i++) { const x = -2.6 + zufall() * 3.4, y = 0.62 + zufall() * (x > 0.2 ? 0.75 : 0.32); splat([x, y, -9.5], y > 1.1 ? [0.75, 0.7, 0.62] : [0.07, 0.08, 0.1], -0.95, [1, 0, 0], [0, 1, 0], [0, 0, 1], 0.05, 0.035, 0.01); }
  for (const [x, y, c] of [[0.8, 1.42, [1, 0.3, 0.2]], [-2.5, 1.0, [0.4, 1, 0.5]], [0.5, 1.2, [1, 0.88, 0.6]], [0.65, 1.2, [1, 0.88, 0.6]]]) for (let i = 0; i < 6; i++) splat([x, y, -9.45], c, -0.6, [1, 0, 0], [0, 1, 0], [0, 0, 1], 0.06 + i * 0.03, 0.06 + i * 0.03, 0.01);
  // Glas: kaum sichtbarer Schimmer
  for (let i = 0; i < 500; i++) { const x = fb.x0 + zufall() * (fb.x1 - fb.x0), y = fb.y0 + zufall() * (fb.y1 - fb.y0); splat([x, y, fb.z - 0.02], [0.75, 0.82, 0.9], 0.04, [1, 0, 0], [0, 1, 0], [0, 0, 1], 0.12, 0.08, 0.004); }
  // Floater: typische Scan-Artefakte am Rand des erfassten Bereichs
  for (let i = 0; i < 90; i++) { const p = [(zufall() - 0.5) * 5, 0.2 + zufall() * 2.4, -0.4 + zufall() * 1.4]; const s = 0.008 + zufall() * 0.022; const g = 0.35 + zufall() * 0.3; splat(p, [g, g * 0.95, g * 0.9], 0.12 + zufall() * 0.2, [1, 0, 0], [0, 1, 0], [0, 0, 1], s, s * 0.7, s * 0.5); }
  return { P: new Float32Array(P), F: new Float32Array(F), C: new Float32Array(C), n: P.length / 4 };
}
// Dichte so wählen, dass die Splatzahl etwa N_ZIEL erreicht (die Fläche des Raums ist fest)
const DICHTE = N_ZIEL / 182;
const SZENE = erzeugeSzene(DICHTE);
document.querySelector('[data-anzahl]').textContent = zahl(SZENE.n);

/* ---------- Kurve (cubic-bezier, Newton-Iteration) ---------- */
function bezier([x1, y1, x2, y2]) {
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx, cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
  const X = t => ((ax * t + bx) * t + cx) * t, Y = t => ((ay * t + by) * t + cy) * t, dX = t => (3 * ax * t + 2 * bx) * t + cx;
  return x => { if (x <= 0) return 0; if (x >= 1) return 1; let t = x; for (let i = 0; i < 6; i++) { const d = dX(t); if (Math.abs(d) < 1e-6) break; t -= (X(t) - x) / d; } return Y(t); };
}
const kurve = bezier(W.kurve);

function zeigeWerte() {
  const r = reduziert();
  const teile = [`<span>splats <b>${zahl(SZENE.n)}</b></span>`, `<span>sortierung <b>tiefe 16 bit, bei bewegung</b></span>`];
  if (modus === 'punkte') teile.push(`<span>punkt <b>${String(W.punktPx).replace('.', ',')} px, isotrop</b></span>`);
  else teile.push(`<span>quad <b>±${W.sigma} σ</b></span>`);
  if (r) teile.push(`<span>wechsel <b>blende ${W.blendeMs} ms</b></span>`, `<span>kamera <b>fest</b></span>`);
  else {
    teile.push(`<span>fahrt <b>${zahl(W.fahrtMs)} ms</b></span>`, `<span>kurve <b>cubic-bezier(${W.kurve.join(', ')})</b></span>`);
    if (modus === 'reveal') teile.push(`<span>reveal <b>${zahl(W.revealMs)} ms · kante ${String(W.revealRand).replace('.', ',')} m</b></span>`);
  }
  werteEl.innerHTML = teile.join('');
}
function zeigeStand() {
  standKnoepfe.forEach((k, i) => k.setAttribute('aria-pressed', String(i === stand)));
  zaehler.innerHTML = `${String(stand + 1).padStart(2, '0')} <span>/ 03</span>`;
}

/* ---------- Renderer ---------- */
let renderer = null;
try { if (document.createElement('canvas').getContext('webgl2')) renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
const api = renderer ? mitWebGL() : ohneWebGL();

/* ---------- Bedienung ---------- */
let schleife = [], vorfuehrend = false;
function stoppeVorfuehrung() { schleife.forEach(clearTimeout); schleife = []; vorfuehrend = false; html.classList.remove('vorfuehrung'); zeiger.classList.remove('drueckt'); api.zeigerZiehen(null); }
function setzeStand(i, ohneAnsage) { stand = i; zeigeStand(); api.fliege(i); if (!ohneAnsage) ansage.textContent = `Standpunkt ${STAND[i].name}: ${STAND[i].text}`; }
function setzeModus(m) {
  modus = m; modusKnoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === m)));
  zeigeWerte(); api.modus();
  ansage.textContent = { fahrt: 'Variante Kamerafahrt.', reveal: 'Variante Reveal: Der Raum baut sich von der Leuchte aus auf.', punkte: 'Variante Punkte: Splats als gleich große Punkte.' }[m];
}
standKnoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeStand(+k.dataset.stand); }));
modusKnoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.modus); }));
addEventListener('keydown', e => {
  if (e.target.closest && e.target.closest('input, textarea')) return;
  if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { if (!buehne.matches(':hover') && !buehne.contains(document.activeElement)) return; stoppeVorfuehrung(); setzeStand((stand + (e.key === 'ArrowRight' ? 1 : 2)) % 3); }
});
buehne.addEventListener('pointerdown', e => { if (e.isTrusted && vorfuehrend && !e.target.closest('a, button')) stoppeVorfuehrung(); });

function zeigerNach(x, y) { zeiger.style.transform = `translate(${x}px, ${y}px)`; }
function neustart() { stoppeVorfuehrung(); stand = 0; zeigeStand(); api.neustart(); }
function vorfuehren() {
  stoppeVorfuehrung();
  vorfuehrend = true; html.classList.add('vorfuehrung');
  const b = buehne.getBoundingClientRect();
  let schritt = 0;
  stand = 0; zeigeStand(); api.neustart();
  zeigerNach(b.width * 0.5, b.height * 0.58);
  const takt = () => {
    if (schritt > 0) setzeStand(schritt % 3, true);
    // zwischen den Flügen zieht der gedachte Zeiger einmal quer und schwenkt den Blick
    const start = schritt === 0 ? W.revealMs + 200 : W.fahrtMs + 150;
    schleife.push(setTimeout(() => { zeiger.classList.add('drueckt'); api.zeigerZiehen([0, 0]); }, start));
    schleife.push(setTimeout(() => { zeigerNach(b.width * 0.36, b.height * 0.55); api.zeigerZiehen([-1, 0.15]); }, start + 60));
    schleife.push(setTimeout(() => { zeigerNach(b.width * 0.6, b.height * 0.6); api.zeigerZiehen([0.7, -0.1]); }, start + 1300));
    schleife.push(setTimeout(() => { zeiger.classList.remove('drueckt'); api.zeigerZiehen(null); }, start + 2300));
    schritt++;
    schleife.push(setTimeout(takt, schritt === 1 ? W.revealMs + W.takt - 1200 : W.takt + 1200));
  };
  takt();
}
function pause() { schleife.forEach(clearTimeout); schleife = []; api.pause(true); }
function weiter() { api.pause(false); vorfuehren(); }

zeigeWerte(); zeigeStand();
addEventListener('load', () => { if (!html.classList.contains('vorschau')) api.neustart(); });


/* ======================================================================
   WebGL: instanzierte Quads, je Splat 2D-Kovarianz aus der 3D-Kovarianz
   (EWA-Projektion wie im 3DGS-Paper), Sortierung hinten → vorne,
   vormultipliziertes Alpha-Blending
   ====================================================================== */
function mitWebGL() {
  renderer.setPixelRatio(Math.min(devicePixelRatio, istMobil ? 1.5 : 1.75));
  renderer.setClearColor(0x0e0e0e, 1);
  const kamera = new THREE.PerspectiveCamera(52, 1, 0.05, 60);
  const szene = new THREE.Scene();
  const n = SZENE.n, TB = 1024, TH = Math.ceil(n / TB);
  const tex = (quelle, breite) => {
    const a = new Float32Array(TB * TH * 4);
    for (let i = 0; i < n; i++) for (let k = 0; k < 4; k++) a[i * 4 + k] = k < breite ? quelle[i * breite + k] : 0;
    const t = new THREE.DataTexture(a, TB, TH, THREE.RGBAFormat, THREE.FloatType); t.needsUpdate = true; return t;
  };
  const C = SZENE.C, kA = new Float32Array(n * 4), kB = new Float32Array(n * 4);
  for (let i = 0; i < n; i++) { kA.set([C[i * 6], C[i * 6 + 1], C[i * 6 + 2], C[i * 6 + 3]], i * 4); kB.set([C[i * 6 + 4], C[i * 6 + 5], 0, 0], i * 4); }
  const texPos = tex(SZENE.P, 4), texFarbe = tex(SZENE.F, 4), texKovA = tex(kA, 4), texKovB = tex(kB, 4);

  const geo = new THREE.InstancedBufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]), 3));
  geo.setIndex([0, 1, 2, 0, 2, 3]);
  const reihenfolge = new Float32Array(n);
  for (let i = 0; i < n; i++) reihenfolge[i] = i;
  const idxAttr = new THREE.InstancedBufferAttribute(reihenfolge, 1); idxAttr.setUsage(THREE.DynamicDrawUsage);
  geo.setAttribute('idx', idxAttr);
  geo.instanceCount = n;

  const mat = new THREE.ShaderMaterial({
    uniforms: {
      uPos: { value: texPos }, uFarbe: { value: texFarbe }, uKovA: { value: texKovA }, uKovB: { value: texKovB },
      uFokus: { value: new THREE.Vector2(1, 1) }, uViewport: { value: new THREE.Vector2(1, 1) },
      uSigma: { value: W.sigma }, uMaxPx: { value: W.maxRadiusPx },
      uReveal: { value: 99 }, uRand: { value: W.revealRand }, uMitte: { value: new THREE.Vector3(LAMPE[0], 1.2, LAMPE[2]) },
      uPunkte: { value: 0 }, uPunktPx: { value: W.punktPx }, uDeck: { value: 1 },
    },
    vertexShader: `
      precision highp float; precision highp int;
      attribute float idx;
      uniform highp sampler2D uPos, uFarbe, uKovA, uKovB;
      uniform vec2 uFokus, uViewport;
      uniform float uSigma, uMaxPx, uReveal, uRand, uPunkte, uPunktPx;
      uniform vec3 uMitte;
      varying vec4 vFarbe; varying vec2 vQ;
      void main() {
        int i = int(idx + 0.5);
        ivec2 t = ivec2(i % 1024, i / 1024);
        vec4 p = texelFetch(uPos, t, 0);
        vec4 f = texelFetch(uFarbe, t, 0);
        vec4 ka = texelFetch(uKovA, t, 0), kb = texelFetch(uKovB, t, 0);
        vec4 cam = viewMatrix * vec4(p.xyz, 1.0);
        vec4 clip = projectionMatrix * cam;
        float g = 1.2 * clip.w;
        if (cam.z > -0.12 || clip.x < -g || clip.x > g || clip.y < -g || clip.y > g) { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); return; }
        // Außenbereich (Alpha negativ markiert): nur zeichnen, wenn der Sehstrahl durchs Fenster geht
        if (p.w < 0.0) {
          vec3 r = p.xyz - cameraPosition; float tf = (${FENSTER.z.toFixed(2)} - cameraPosition.z) / r.z;
          vec2 h = cameraPosition.xy + r.xy * tf;
          if (h.x < ${(FENSTER.x0 - 0.25).toFixed(2)} || h.x > ${(FENSTER.x1 + 0.25).toFixed(2)} || h.y < ${(FENSTER.y0 - 0.25).toFixed(2)} || h.y > ${(FENSTER.y1 + 0.25).toFixed(2)}) { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); return; }
          p.w = -p.w;
        }
        // Außenbereich erscheint, sobald die Reveal-Kugel das Fenster erreicht
        vec3 rp = p.w > 0.0 && p.z < ${(FENSTER.z - 1).toFixed(2)} ? vec3(clamp(p.x, ${FENSTER.x0.toFixed(2)}, ${FENSTER.x1.toFixed(2)}), clamp(p.y, ${FENSTER.y0.toFixed(2)}, ${FENSTER.y1.toFixed(2)}), ${FENSTER.z.toFixed(2)}) : p.xyz;
        // Reveal: Kugel wächst um die Leuchte, an der Kante wachsen die Splats von 0 auf 1
        float d = distance(rp, uMitte) + f.w * 0.35 + (p.z < ${(FENSTER.z - 1).toFixed(2)} ? 0.6 : 0.0);
        float k = 1.0 - smoothstep(uReveal - uRand, uReveal, d);
        if (k <= 0.001) { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); return; }
        mat3 V = mat3(ka.x, ka.y, ka.z, ka.y, ka.w, kb.x, ka.z, kb.x, kb.y);
        float z = cam.z, z2 = z * z;
        mat3 J = mat3(-uFokus.x / z, 0.0, uFokus.x * cam.x / z2,
                      0.0, -uFokus.y / z, uFokus.y * cam.y / z2,
                      0.0, 0.0, 0.0);
        mat3 T = transpose(mat3(viewMatrix)) * J;
        mat3 c2 = transpose(T) * V * T;
        float a = c2[0][0] + 0.3, b = c2[0][1], c = c2[1][1] + 0.3;   // + 0,3 px²: Tiefpass gegen Aliasing
        float mid = 0.5 * (a + c), rad = length(vec2(0.5 * (a - c), b));
        float l1 = mid + rad, l2 = max(mid - rad, 0.1);
        vec2 e1 = normalize(vec2(b, l1 - a)); if (abs(b) < 1e-7) e1 = a >= c ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
        vec2 e2 = vec2(-e1.y, e1.x);
        float r1 = min(sqrt(l1), uMaxPx / uSigma), r2 = min(sqrt(l2), uMaxPx / uSigma);
        float kante = k * (1.0 - k) * 4.0;
        if (uPunkte > 0.5) { r1 = uPunktPx * (0.8 + f.w * 0.5); r2 = r1; }
        r1 *= k; r2 *= k;
        vec2 off = (position.x * r1 * e1 + position.y * r2 * e2) * uSigma;
        vec2 ndc = clip.xy / clip.w + off * 2.0 / uViewport;
        gl_Position = vec4(ndc, clip.z / clip.w, 1.0);
        vec3 rgb = mix(f.rgb, vec3(1.0, 0.93, 0.82), kante * 0.65);
        float alpha = uPunkte > 0.5 ? min(1.0, p.w * 1.3) : p.w;
        vFarbe = vec4(rgb, alpha);
        vQ = position.xy * uSigma;
      }`,
    fragmentShader: `
      precision highp float;
      uniform float uPunkte, uDeck, uSigma;
      varying vec4 vFarbe; varying vec2 vQ;
      void main() {
        float q = dot(vQ, vQ);
        float lim = uSigma * uSigma;
        if (q > lim) discard;
        float a = uPunkte > 0.5 ? smoothstep(lim, lim * 0.45, q) : exp(-0.5 * q);
        a *= vFarbe.a * uDeck;
        if (a < 1.0 / 255.0) discard;
        gl_FragColor = vec4(vFarbe.rgb * a, a);
      }`,
    transparent: true, depthTest: false, depthWrite: false,
    blending: THREE.CustomBlending, blendEquation: THREE.AddEquation, blendSrc: THREE.OneFactor, blendDst: THREE.OneMinusSrcAlphaFactor,
  });
  const splats = new THREE.Mesh(geo, mat);
  splats.frustumCulled = false;
  szene.add(splats);

  // Tiefensortierung (Zählsortierung über 16-bit-Schlüssel), nur wenn sich die Kamera bewegt hat
  const P = SZENE.P, schluessel = new Uint16Array(n), zaehl = new Uint32Array(65536), letzteBlick = new Float32Array(16);
  function sortiere(force) {
    const m = kamera.matrixWorldInverse.elements;
    let gleich = true; for (let i = 0; i < 16; i++) if (Math.abs(m[i] - letzteBlick[i]) > 1e-4) { gleich = false; break; }
    if (gleich && !force) return;
    letzteBlick.set(m);
    const a = m[2], b = m[6], c = m[10], d = m[14];
    let min = Infinity, max = -Infinity;
    const tiefe = sortiere.tiefe || (sortiere.tiefe = new Float32Array(n));
    for (let i = 0; i < n; i++) { const z = a * P[i * 4] + b * P[i * 4 + 1] + c * P[i * 4 + 2] + d; tiefe[i] = z; if (z < min) min = z; if (z > max) max = z; }
    const s = 65535 / Math.max(max - min, 1e-6);
    zaehl.fill(0);
    for (let i = 0; i < n; i++) { const k = ((tiefe[i] - min) * s) | 0; schluessel[i] = k; zaehl[k]++; }
    // hinten (sehr negatives z) zuerst
    let summe = 0; for (let k = 0; k < 65536; k++) { const z = zaehl[k]; zaehl[k] = summe; summe += z; }
    for (let i = 0; i < n; i++) reihenfolge[zaehl[schluessel[i]]++] = i;
    idxAttr.needsUpdate = true;
  }

  // Kamera: Standpunkte, Flug mit der Rezeptkurve, Blick schwenken per Ziehen
  const v3 = a => new THREE.Vector3(...a);
  let von = { pos: v3(STAND[0].pos), ziel: v3(STAND[0].ziel) }, nach = { pos: v3(STAND[0].pos), ziel: v3(STAND[0].ziel) };
  let flugStart = -1e9, revealStart = -1e9, pausiert = false, gestartet = false, zeit = 0, letzt = performance.now();
  let gier = 0, nick = 0, gierZiel = 0, nickZiel = 0, ziehtAb = null, kunstZug = null;
  const aktPos = new THREE.Vector3(), aktZiel = new THREE.Vector3();
  function aktuell(jetzt) {
    const t = reduziert() ? 1 : kurve(Math.min(1, (jetzt - flugStart) / W.fahrtMs));
    aktPos.lerpVectors(von.pos, nach.pos, t); aktZiel.lerpVectors(von.ziel, nach.ziel, t);
    // Bogen statt Gerade: in der Mitte des Flugs leicht anheben
    aktPos.y += Math.sin(Math.PI * t) * 0.12 * von.pos.distanceTo(nach.pos) / 3;
  }

  buehne.addEventListener('pointerdown', e => {
    if (e.target.closest('a, button')) return;
    ziehtAb = [e.clientX, e.clientY, gierZiel, nickZiel]; buehne.classList.add('zieht'); hinweis.classList.remove('an');
  });
  addEventListener('pointermove', e => {
    if (!ziehtAb) return;
    const Z = W.ziehen;
    gierZiel = THREE.MathUtils.clamp(ziehtAb[2] + (e.clientX - ziehtAb[0]) * Z.empfindlich, -Z.gier, Z.gier);
    if (e.pointerType !== 'touch') nickZiel = THREE.MathUtils.clamp(ziehtAb[3] + (e.clientY - ziehtAb[1]) * Z.empfindlich, -Z.nick, Z.nick);
  });
  const loslassen = () => { ziehtAb = null; buehne.classList.remove('zieht'); };
  addEventListener('pointerup', loslassen); addEventListener('pointercancel', loslassen);

  function layout() {
    const b = buehne.clientWidth, h = buehne.clientHeight;
    renderer.setSize(b, h, false);
    kamera.aspect = b / h;
    kamera.fov = b / h < 0.8 ? 72 : 52;   // hochkant: weiter, damit der Raum lesbar bleibt
    kamera.updateProjectionMatrix();
    const pr = renderer.getPixelRatio(), vw = b * pr, vh = h * pr;
    mat.uniforms.uViewport.value.set(vw, vh);
    mat.uniforms.uFokus.value.set(kamera.projectionMatrix.elements[0] * vw / 2, kamera.projectionMatrix.elements[5] * vh / 2);
    mat.uniforms.uPunktPx.value = W.punktPx * pr;
    sortiere(true);
  }
  addEventListener('resize', layout);
  layout();

  const blick = new THREE.Vector3(), seit = new THREE.Vector3(), oben = new THREE.Vector3(0, 1, 0);
  function frame(jetzt) {
    requestAnimationFrame(frame);
    const dt = Math.min((jetzt - letzt) / 1000, 1 / 15); letzt = jetzt;
    if (pausiert) return;
    const red = reduziert();
    zeit += dt;
    if (kunstZug) { gierZiel = kunstZug[0] * W.ziehen.gier * 0.8; nickZiel = kunstZug[1] * W.ziehen.nick; }
    const f = red ? 1 : 1 - Math.exp(-dt * W.ziehen.daempfung);
    gier += (gierZiel - gier) * f; nick += (nickZiel - nick) * f;
    aktuell(jetzt);
    // ruhige Eigenbewegung (nur Kamerafahrt, nicht reduziert)
    const atmen = (modus === 'fahrt' && !red) ? W.atmen.amp : 0;
    const ph = zeit * W.atmen.hz * Math.PI * 2;
    kamera.position.set(aktPos.x + Math.sin(ph) * atmen, aktPos.y + Math.sin(ph * 0.7) * atmen * 0.5, aktPos.z + Math.cos(ph * 0.8) * atmen * 0.6);
    // Blick um Gier/Nick schwenken
    blick.subVectors(aktZiel, aktPos);
    const lb = blick.length();
    blick.applyAxisAngle(oben, gier);
    seit.crossVectors(blick, oben).normalize();
    blick.applyAxisAngle(seit, nick);
    kamera.lookAt(kamera.position.x + blick.x, kamera.position.y + blick.y, kamera.position.z + blick.z);
    kamera.updateMatrixWorld();
    // Reveal
    const rt = (jetzt - revealStart) / W.revealMs;
    mat.uniforms.uReveal.value = red ? 99 : (rt >= 1 ? 99 : kurve(Math.max(0, rt)) * W.revealRadius);
    mat.uniforms.uPunkte.value = modus === 'punkte' ? 1 : 0;
    sortiere(false);
    renderer.render(szene, kamera);
    void lb;
  }
  function start() { if (gestartet) return; gestartet = true; html.classList.add('gl-bereit'); requestAnimationFrame(t => { letzt = t; frame(t); }); }

  let blendeTimer = 0;
  return {
    neustart() {
      von = { pos: v3(STAND[0].pos), ziel: v3(STAND[0].ziel) }; nach = { pos: v3(STAND[0].pos), ziel: v3(STAND[0].ziel) };
      flugStart = -1e9; gier = gierZiel = nick = nickZiel = 0;
      revealStart = reduziert() ? -1e9 : performance.now();
      start();
      if (!vorfuehrend && !reduziert()) setTimeout(() => { if (!ziehtAb && !vorfuehrend) hinweis.classList.add('an'); setTimeout(() => hinweis.classList.remove('an'), 2600); }, W.revealMs);
    },
    fliege(i) {
      const jetzt = performance.now();
      if (reduziert()) {
        // Feste Standpunkte: kurz ausblenden, umsetzen, einblenden
        canvas.classList.add('blende'); clearTimeout(blendeTimer);
        blendeTimer = setTimeout(() => { von = nach = { pos: v3(STAND[i].pos), ziel: v3(STAND[i].ziel) }; gier = gierZiel = nick = nickZiel = 0; canvas.classList.remove('blende'); }, W.blendeMs);
        return;
      }
      aktuell(jetzt);
      von = { pos: aktPos.clone(), ziel: aktZiel.clone() };
      nach = { pos: v3(STAND[i].pos), ziel: v3(STAND[i].ziel) };
      // Schwenk mitnehmen und während des Flugs auflösen
      flugStart = jetzt; gierZiel = 0; nickZiel = 0;
      if (modus === 'reveal') revealStart = jetzt;
    },
    modus() { if (modus === 'reveal' && !reduziert()) revealStart = performance.now(); },
    pause(an) { pausiert = an; if (!an) letzt = performance.now(); },
    zeigerZiehen(z) { kunstZug = z; if (!z) { gierZiel = 0; nickZiel = 0; } },
  };
}

/* ======================================================================
   Ersatz ohne WebGL2: Standbild aus einer Auswahl der Splats (Canvas 2D),
   von Standpunkt 1 aus gezeichnet
   ====================================================================== */
function ohneWebGL() {
  html.classList.add('ohne-webgl');
  const g = ersatz.getContext('2d');
  function male() {
    const b = buehne.clientWidth, h = buehne.clientHeight, dpr = Math.min(devicePixelRatio, 2);
    ersatz.width = b * dpr; ersatz.height = h * dpr; g.setTransform(dpr, 0, 0, dpr, 0, 0);
    g.fillStyle = '#0e0e0e'; g.fillRect(0, 0, b, h);
    const s = STAND[stand], cam = new THREE.PerspectiveCamera(b / h < 0.8 ? 72 : 52, b / h, 0.05, 60);
    cam.position.set(...s.pos); cam.lookAt(...s.ziel); cam.updateMatrixWorld();
    const v = new THREE.Vector3(), liste = [];
    for (let i = 0; i < SZENE.n; i += 3) {
      v.set(SZENE.P[i * 4], SZENE.P[i * 4 + 1], SZENE.P[i * 4 + 2]).applyMatrix4(cam.matrixWorldInverse);
      if (v.z > -0.2) continue; const z = v.z; v.applyMatrix4(cam.projectionMatrix);
      if (Math.abs(v.x) > 1.1 || Math.abs(v.y) > 1.1) continue; liste.push([z, (v.x + 1) * b / 2, (1 - v.y) * h / 2, i]);
    }
    liste.sort((a, c) => a[0] - c[0]);
    for (const [z, x, y, i] of liste) {
      const F = SZENE.F; g.globalAlpha = Math.abs(SZENE.P[i * 4 + 3]) * 0.8;
      g.fillStyle = `rgb(${F[i * 4] * 255 | 0},${F[i * 4 + 1] * 255 | 0},${F[i * 4 + 2] * 255 | 0})`;
      const r = Math.max(1, 24 / -z); g.fillRect(x - r, y - r, r * 2, r * 2);
    }
    g.globalAlpha = 1;
  }
  addEventListener('resize', male);
  return { neustart() { male(); }, fliege() { male(); }, modus() {}, pause() {}, zeigerZiehen() {} };
}
</script>
</body>
</html>