Zum Inhalt
← 3D & WebGL

Morphende Partikel und Punktwolken

32 768 Punkte fließen per GPU-Federsimulation von Form zu Form, sobald man einen Link berührt.

Startseite eines Studios mit drei bis fünf Bereichen, deren Links die Wolke formen.

Zehntausende bis Millionen Punkte bilden eine Form, zerfließen und setzen sich zu einer neuen zusammen. Jeder Punkt hängt an einer Feder zu seinem Platz in der Zielform. Ein Curl-Noise-Feld lässt die Punkte strömen, der Zeiger stößt sie weg. Die Rechnung läuft auf der GPU: Positionen und Geschwindigkeiten liegen in Float-Texturen und werden jedes Bild neu berechnet. Zielformen werden vorab gesampelt: aus Mesh-Oberflächen, aus Text oder aus Volumendaten.

Die Bewegung wirkt organisch und lebendig. Formen entstehen und zerfallen vor den Augen. Das passt als Bild für Daten, Wandel und Klang. Weil jeder Punkt etwas anders federt, kommt die Form gestaffelt an und nie mechanisch.

Wann einsetzen

Hero-Momente, Navigation mit wenigen Zielen, Logo-Reveal, Themen wie KI, Daten, Akustik oder Musik.

Wann nicht

Als Dauerhintergrund hinter Lesetext. Auf schwachen Geräten ohne Partikelreduktion. Wenn die Seite schnell eine Information liefern muss.

Varianten

  • Link-Ziele: jeder Navigationspunkt hat eine eigene Form (Igloo Inc)
  • Schriftzug: Punkte schreiben Wörter, gesampelt aus Text-Pixeln
  • Spuren: Bild verblasst nur langsam, Punkte ziehen Lichtspuren
  • Chladni: Punkte sammeln sich auf den Knotenlinien einer schwingenden Platte (Resonanzmuster als Zielform)
  • Bild zerstäuben: Ein Foto zerfällt in Partikel und setzt sich wieder zusammen
  • Mesh löst sich in Partikel auf (Dissolve)
  • Punktwolke aus Fotogrammetrie oder 3D-Scan

Umsetzung

WebGL: zwei Float-Rendertargets je Größe (Position, Geschwindigkeit) im Ping-Pong, ein Shader-Durchgang pro Größe und Bild (wie GPUComputationRenderer). Zielformen als DataTexture, je Partikel ein Texel. Darstellung mit THREE.Points, der Vertex-Shader liest die Position aus der Textur. Additives Blending, Tiefe aus. WebGPU: TSL compute() mit Storage-Buffern, damit sind deutlich mehr Partikel möglich. Partikelzahl an das Gerät koppeln. Ersatz ohne Float-Rendertargets: Canvas2D mit wenigen hundert bis tausend Punkten.

Robust umsetzen

Telefon. Partikelzahl stark senken (etwa 4 096 statt 32 768), Punkte größer zeichnen. Hover-Ziele durch Tippen ersetzen.

Ohne Bewegung. Simulation aus. Punkte stehen in der Zielform, der Formwechsel ist eine kurze Überblendung.

Barrierefrei. Links, die die Partikel steuern, bleiben echte Links mit Ziel. Fokus löst denselben Formwechsel aus wie Hover. Das Canvas ist aria-hidden; eine Live-Region sagt an, was die Wolke zeigt.

Code

<!DOCTYPE html>
<!--
  Morphende Partikel und Punktwolken
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/partikel-punktwolke/
  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>Morphende Partikel und Punktwolken · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: '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; }

:root {
  --grund: #0b0a09; --text: #f2ebdf; --leise: rgba(242, 235, 223, .56); --linie: rgba(242, 235, 223, .14);
  --glut: #ff8a4c;
  --kurve: cubic-bezier(0.16, 1, 0.3, 1);   /* expo.out für alle UI-Übergänge */
  --rand: clamp(20px, 4vw, 56px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ---------- Bühne ---------- */
.buehne { position: relative; height: 100svh; min-height: 640px; overflow: hidden; isolation: isolate; touch-action: pan-y;
  background: radial-gradient(52% 70% at 68% 48%, #221b15 0%, #12100d 55%, var(--grund) 100%); }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; display: block; pointer-events: none; }
.buehne [data-canvas] { opacity: 0; transition: opacity 1.2s var(--kurve); }
.gl-bereit .buehne [data-canvas] { opacity: 1; }
.ersatz { display: none; }
.ohne-webgl .ersatz { display: block; transition: opacity .4s var(--kurve); }
.ersatz.weg { opacity: 0; }

.kopf { position: absolute; z-index: 3; inset: 0 0 auto; display: flex; justify-content: space-between; align-items: center; padding: clamp(18px, 3vw, 34px) var(--rand); }
.marke { font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
.marke span { font: italic 400 19px/1 'Instrument Serif', serif; color: var(--leise); margin-left: 6px; letter-spacing: 0; }

.titel { position: absolute; z-index: 2; left: var(--rand); top: 50%; translate: 0 -54%; width: min(44vw, 620px); }
.titel > .mono { margin-bottom: 22px; }
h1 { font-family: 'Instrument Serif', serif; font-weight: 400; font-size: clamp(54px, 6.2vw, 112px); line-height: .92; letter-spacing: -.025em; }
h1 em { color: var(--glut); }

/* Echte Links: Hover/Fokus formt die Wolke, Klick springt zum Abschnitt */
.ziele { margin-top: clamp(28px, 4.5vh, 52px); border-top: 1px solid var(--linie); }
.ziele a { position: relative; display: grid; grid-template-columns: 44px 1fr auto; align-items: baseline; gap: 12px; padding: 14px 0 13px; border-bottom: 1px solid var(--linie); color: var(--leise); text-decoration: none; outline: none; transition: color .15s linear; }
.ziele .wort { font-size: clamp(22px, 2vw, 30px); font-weight: 500; letter-spacing: -.015em; }
.ziele .info { opacity: 0; translate: -8px 0; transition: opacity .8s var(--kurve), translate .8s var(--kurve); }
.ziele a::after { content: ''; position: absolute; left: 0; bottom: -1px; height: 1px; width: 100%; background: var(--text); transform: scaleX(0); transform-origin: left; transition: transform 1.2s var(--kurve); }
.ziele a:hover, .ziele a:focus-visible, .ziele a.aktiv { color: var(--text); }
.ziele a.aktiv .info, .ziele a:focus-visible .info { opacity: 1; translate: 0 0; }
.ziele a.aktiv::after { transform: scaleX(1); }
.ziele a:focus-visible .wort { text-decoration: underline; text-underline-offset: 6px; text-decoration-thickness: 1px; }
.ziele .nr { color: var(--leise); }
.ziele a.aktiv .nr { color: var(--glut); }

/* Legende rechts: was die Wolke gerade zeigt */
.legende { position: absolute; z-index: 2; right: var(--rand); top: 50%; translate: 0 -50%; display: grid; gap: 16px; text-align: right; }
.legende b { display: block; color: var(--text); font-weight: 500; margin-top: 3px; }
.legende .gross { font: italic 400 46px/1 'Instrument Serif', serif; letter-spacing: 0; text-transform: none; color: var(--text); }

.fuss { position: absolute; z-index: 3; inset: auto 0 0; display: flex; justify-content: space-between; align-items: end; gap: 24px; padding: clamp(18px, 3vw, 34px) var(--rand); }
.fuss .text { max-width: 38ch; font-size: 15px; line-height: 1.5; color: var(--leise); }
.steuer { display: grid; justify-items: end; gap: 12px; }
.modi { display: flex; gap: 6px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: rgba(11, 10, 9, .55); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: border-color .15s linear, color .15s linear; }
.modi button:hover { color: var(--text); border-color: var(--leise); }
.modi button:focus-visible { outline: 1px solid var(--text); outline-offset: 2px; }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi { flex-wrap: wrap; justify-content: flex-end; }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; max-width: 560px; }
/* Bewegung anhalten (WCAG 2.2.2): hält Simulation, Drehung und Vorführung an */
.halt { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--text); background: rgba(11, 10, 9, .55); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: border-color .15s linear; }
.halt:hover, .halt[aria-pressed='true'] { border-color: var(--leise); }
.halt:focus-visible { outline: 1px solid var(--text); outline-offset: 2px; }
.vorschau .halt, .reduziert .halt, .ohne-webgl .halt { display: none; }
.werte b { color: var(--text); font-weight: 500; }

/* Gedachter Zeiger in der Vorführung */
.zeiger { position: absolute; z-index: 4; left: 0; top: 0; width: 28px; height: 28px; margin: -14px 0 0 -14px; border: 1px solid rgba(242, 235, 223, .8); border-radius: 50%; pointer-events: none; display: none; transition: transform .9s var(--kurve); }
.vorfuehrung .zeiger { display: block; }
.zeiger.langsam { transition-duration: 1.6s; transition-timing-function: cubic-bezier(.45, 0, .55, 1); }

/* ---------- Unter der Bühne ---------- */
.mehr { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(20px, 4vw, 56px); padding: clamp(56px, 8vw, 112px) var(--rand); border-top: 1px solid var(--linie); }
.mehr h2 { font: italic 400 36px/1 'Instrument Serif', serif; margin: 10px 0 12px; letter-spacing: -.01em; }
.mehr p:not(.mono) { color: var(--leise); font-size: 15px; line-height: 1.55; max-width: 36ch; }
.mehr > div { scroll-margin-top: 40px; }

.vorschau body { overflow: hidden; }
.vorschau .mehr, .vorschau .fuss .text, .vorschau .legende { display: none; }
.vorschau .buehne { height: 100vh; }
.vorschau .fuss { justify-content: flex-end; }

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

@media (max-width: 1180px) { .legende { display: none; } }
@media (max-width: 760px) {
  .buehne { min-height: 760px; background: radial-gradient(90% 46% at 50% 27%, #221b15 0%, #12100d 60%, var(--grund) 100%); }
  .titel { top: auto; bottom: 214px; translate: none; width: auto; right: var(--rand); }
  .titel > .mono { margin-bottom: 12px; }
  .kopf .mono { display: none; }
  h1 { font-size: 14.5vw; }
  .ziele { margin-top: 22px; }
  .ziele a { grid-template-columns: 34px 1fr auto; padding: 11px 0 10px; }
  .ziele .wort { font-size: 21px; }
  .fuss { flex-direction: column; align-items: stretch; gap: 14px; }
  .fuss .text { font-size: 14px; }
  .steuer { justify-items: start; }
  .modi { justify-content: flex-start; }
  /* Telefon: Titel und Fuß im Fluss unter der Wolke, damit mehr Knöpfe oder größere Textabstände nichts überdecken */
  .buehne { height: auto; min-height: max(100svh, 760px); display: flex; flex-direction: column; justify-content: flex-end; gap: 26px; padding-top: 372px; }
  .titel { position: relative; left: auto; right: auto; top: auto; bottom: auto; margin: 0 var(--rand); }
  .fuss { position: relative; inset: auto; padding-top: 0; }
  .werte { justify-content: flex-start; }
  .mehr { grid-template-columns: 1fr; }
}
@media (max-height: 760px) and (min-width: 761px) { .fuss .text { display: none; } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
@media (hover: none) {
  .modi button, .halt { height: 40px; }
  /* Touch: erstes Tippen formt die Wolke, zweites Tippen auf den aktiven Link öffnet ihn */
  .ziele a.aktiv .info::after { content: ' · öffnen'; }
}
</style>
</head>
<body>
<section class="buehne" data-buehne aria-labelledby="titel">
  <canvas class="ersatz" data-ersatz aria-hidden="true"></canvas>
  <canvas data-canvas aria-hidden="true"></canvas>

  <header class="kopf">
    <p class="marke">Nachhall<span>Studio</span></p>
    <p class="mono">Leipzig-Plagwitz · seit 2011</p>
  </header>

  <div class="titel">
    <p class="mono">Raumakustik und Klangdesign</p>
    <h1 id="titel">Wir machen Räume <em>hörbar.</em></h1>
    <nav class="ziele" aria-label="Bereiche" data-ziele>
      <a href="#studio" data-form="0"><span class="nr mono">01</span><span class="wort">Studio</span><span class="info mono">Raumakustik</span></a>
      <a href="#klang" data-form="1"><span class="nr mono">02</span><span class="wort">Klang</span><span class="info mono">Klangdesign</span></a>
      <a href="#archiv" data-form="2"><span class="nr mono">03</span><span class="wort">Archiv</span><span class="info mono">312 Projekte</span></a>
    </nav>
  </div>

  <dl class="legende mono" aria-hidden="true">
    <div><dt>Form</dt><dd class="gross" data-leg-nr>01</dd></div>
    <div><dt>Ziel</dt><dd><b data-leg-name>Kugel, 28 Breitengrade</b></dd></div>
    <div><dt>Punkte</dt><dd><b data-leg-punkte>32 768</b></dd></div>
  </dl>

  <div class="fuss">
    <p class="text">Wir messen Nachhallzeiten, planen Absorber und komponieren Klang für Säle, Studios und Ausstellungen. Jeder Raum klingt anders. Wir machen hörbar, warum.</p>
    <div class="steuer">
      <div class="modi mono" role="group" aria-label="Variante wählen" data-modi>
        <button type="button" data-modus="ziele" aria-pressed="true">Link-Ziele</button>
        <button type="button" data-modus="schrift" aria-pressed="false">Schriftzug</button>
        <button type="button" data-modus="spuren" aria-pressed="false">Spuren</button>
        <button type="button" data-modus="chladni" aria-pressed="false">Chladni</button>
      </div>
      <button type="button" class="halt mono" data-halt aria-pressed="false">Bewegung anhalten</button>
      <p class="werte mono" 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">
  <div id="studio"><p class="mono">01 · Studio</p><h2>Raumakustik</h2><p>Wir messen mit Impulsantwort und Kunstkopf, berechnen Absorberflächen und begleiten den Einbau bis zur Abnahmemessung.</p></div>
  <div id="klang"><p class="mono">02 · Klang</p><h2>Klangdesign</h2><p>Leitsysteme, Ausstellungston und Klanglogos. Komponiert im eigenen Regieraum mit 0,28 Sekunden Nachhall.</p></div>
  <div id="archiv"><p class="mono">03 · Archiv</p><h2>312 Projekte</h2><p>Vom Gewandhaus-Probensaal bis zum Hörsaal in Halle. Jedes Projekt mit Messprotokoll vorher und nachher.</p></div>
</section>

<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 <= 760;
const buehne = document.querySelector('[data-buehne]');
const canvas = document.querySelector('[data-canvas]');
const ersatz = document.querySelector('[data-ersatz]');
const zeiger = document.querySelector('[data-zeiger]');
const werteEl = document.querySelector('[data-werte]');
const ansage = document.querySelector('[data-ansage]');
const links = [...document.querySelectorAll('[data-form]')];
const knoepfe = [...document.querySelectorAll('[data-modus]')];
const leg = { nr: document.querySelector('[data-leg-nr]'), name: document.querySelector('[data-leg-name]'), punkte: document.querySelector('[data-leg-punkte]') };

/* ---------- Werte (meta.json → messwerte: Feder-/Dämpfungssimulation, Morph 1200–2500 ms,
   holtsetio Flow 32 768 Partikel Standard / 4 096 mobil) ---------- */
const W = {
  partikel: { desktop: [256, 128], mobil: [64, 64] },   // Texturgröße = Partikelzahl 32 768 / 4 096
  k: 16,              // Federsteifigkeit zur Zielform in 1/s² (ω0 = 4 rad/s)
  kStreu: [0.6, 1],   // je Partikel: Steifigkeit × 0,6…1, Ankunft wirkt gestaffelt
  c: 4.4,             // Dämpfung in 1/s → Einschwingen 4 / (c/2) ≈ 1,8 s, ζ ≈ 0,55…0,71
  curl: 0.35,         // Curl-Noise-Feld im Ruhezustand (Flimmern um die Form)
  sturm: 3.0,         // Curl-Verstärkung beim Formwechsel
  sturmTau: 0.4,      // Abklingzeit des Sturms in s (e^(−t/τ))
  halt: 0.9,          // Federkraft beim Sturmspitzenwert um 90 % gelockert
  stoss: 9,           // Zeiger-Abstoßung, Beschleunigung in Einheiten/s²
  radius: 0.55,       // Abstoßungsradius in Welteinheiten (Form ≈ 2,6 breit)
  drehung: 0.12,      // Eigendrehung der Wolke in rad/s
  punkt: 15,          // Punktgröße (px bei Abstand 1, Höhe 900 px)
  punktMobil: 1.6,    // Mobil: Punkte × 1,6
  spuren: { curl: 0.9, c: 2.6, nachleuchten: 0.86 },  // Variante Spuren: Bild × 0,86 pro Frame
  chladni: { moden: [[3, 5], [3, 7], [5, 9]], linie: 0.004, rand: 0.05, kipp: -0.38, platte: 2.5 },   // Knotenlinien |f| / |∇f| < 0,004 der Plattenbreite
  wechselMs: 3600,    // Vorführung: Takt je Form
  ueberblendMs: [150, 250], // reduzierte Bewegung: aus- und einblenden
};
const FARBEN = { ruhe: new THREE.Color('#f2e6d2'), mitte: new THREE.Color('#ffb36b'), glut: new THREE.Color('#ff5a2a') };

const FORMEN = [
  { name: 'Kugel, 28 Breitengrade', wort: 'Raum', ansage: 'Studio: Die Punkte formen eine Kugel aus Breitengraden.' },
  { name: 'Welle, 38 Linien', wort: 'Klang', ansage: 'Klang: Die Punkte formen eine wogende Klangwelle.' },
  { name: 'Platte, 46 Rillen', wort: 'Echo', ansage: 'Archiv: Die Punkte formen eine schräg liegende Schallplatte.' },
];
const modusName = i => modus === 'schrift' ? `Schriftzug „${FORMEN[i].wort}“` : modus === 'chladni' ? `Chladni-Figur n ${W.chladni.moden[i][0]} · m ${W.chladni.moden[i][1]}` : FORMEN[i].name;

const MODI = ['ziele', 'schrift', 'spuren', 'chladni'];
let modus = (v => MODI.includes(v) ? v : 'ziele')(new URLSearchParams(location.search).get('v')), form = 0;
const istMobil = beruehrung || schmal();
const [TW, TH] = istMobil ? W.partikel.mobil : W.partikel.desktop;
const N = TW * TH;
const zahl = n => n.toLocaleString('de-DE').replace(/\./g, ' ');
leg.punkte.textContent = zahl(N);

function zeigeWerte() {
  const sp = modus === 'spuren';
  let s;
  if (reduziert()) s = `<span>partikel <b>${zahl(N)}</b></span><span>simulation <b>aus</b></span><span>wechsel <b>blende ${W.ueberblendMs[0]} / ${W.ueberblendMs[1]} ms</b></span>`;
  else s = `<span>partikel <b>${zahl(N)}</b></span><span>feder <b>k ${W.k} · c ${String(sp ? W.spuren.c : W.c).replace('.', ',')}</b></span><span>morph <b>≈ ${sp ? '3,1' : '1,8'} s</b></span><span>curl <b>${String(sp ? W.spuren.curl : W.curl).replace('.', ',')}</b></span>`
    + (sp ? `<span>nachleuchten <b>× 0,86</b></span>` : `<span>sturm <b>τ 0,4 s</b></span>`)
    + (modus === 'schrift' ? `<span>ziel <b>text-pixel</b></span>` : '')
    + (modus === 'chladni' ? `<span>ziel <b>cos(nπx)·cos(mπy) − cos(mπx)·cos(nπy) = 0</b></span><span>moden <b>${W.chladni.moden.map(m => m.join('/')).join(' · ')}</b></span>` : '');
  werteEl.innerHTML = s;
}
function zeigeForm() {
  links.forEach((a, i) => a.classList.toggle('aktiv', i === form));
  leg.nr.textContent = String(form + 1).padStart(2, '0');
  leg.name.textContent = modusName(form);
  const wort = links[form].querySelector('.wort').textContent;
  ansage.textContent = modus === 'schrift' ? `${wort}: Die Punkte schreiben „${FORMEN[form].wort}“.`
    : modus === 'chladni' ? `${wort}: Die Punkte ordnen sich wie Sand auf einer schwingenden Platte zur ${modusName(form)}.` : FORMEN[form].ansage;
}

/* ---------- Zielformen als Positionslisten (x, y, z, frei) ---------- */
const zufall = (s => () => (s = (s * 16807) % 2147483647) / 2147483647)(7);
function kippX(a, w) { const c = Math.cos(w), s = Math.sin(w); for (let i = 0; i < a.length; i += 4) { const y = a[i + 1], z = a[i + 2]; a[i + 1] = y * c - z * s; a[i + 2] = y * s + z * c; } return a; }
function kugel(n) {
  const a = new Float32Array(n * 4), ringe = 28, R = 1.3;
  for (let i = 0; i < n; i++) {
    let th = Math.acos(1 - 2 * zufall());
    if (zufall() < 0.86) th = (Math.round(th / Math.PI * ringe - 0.5) + 0.5) / ringe * Math.PI;   // auf Breitengrad einrasten
    const ph = zufall() * Math.PI * 2, r = R + (zufall() - 0.5) * 0.025;
    a.set([r * Math.sin(th) * Math.cos(ph), r * Math.cos(th), r * Math.sin(th) * Math.sin(ph), 0], i * 4);
  }
  return kippX(a, 0.32);
}
function welle(n) {
  const a = new Float32Array(n * 4), linien = 38;
  for (let i = 0; i < n; i++) {
    const j = Math.floor(zufall() * linien), x = (zufall() - 0.5) * 3.4, z = (j / (linien - 1) - 0.5) * 2.0;
    const y = 0.16 * Math.sin(x * 2.1) * Math.exp(-x * x * 0.3);
    a.set([x, y + (zufall() - 0.5) * 0.01, z, 1], i * 4);
  }
  return kippX(a, 0.55);
}
function platte(n) {
  const a = new Float32Array(n * 4), rillen = 46;
  for (let i = 0; i < n; i++) {
    let r;
    const u = zufall();
    if (u < 0.08) r = 0.06 + Math.sqrt(zufall()) * 0.36;                 // Etikett
    else if (u < 0.11) r = 1.5 + zufall() * 0.012;                      // Rand
    else r = 0.48 + Math.floor(zufall() * rillen) / rillen * 1.0 + zufall() * 0.004;
    const ph = zufall() * Math.PI * 2;
    a.set([r * Math.cos(ph), (zufall() - 0.5) * 0.02, r * Math.sin(ph), 2], i * 4);
  }
  return kippX(a, 0.42);
}
function staub(n) {
  const a = new Float32Array(n * 4);
  for (let i = 0; i < n; i++) {
    const r = 2.6 + zufall() * 3.2, th = Math.acos(1 - 2 * zufall()), ph = zufall() * Math.PI * 2;
    a.set([r * Math.sin(th) * Math.cos(ph), r * Math.cos(th) * 0.7, r * Math.sin(th) * Math.sin(ph), 0], i * 4);
  }
  return a;
}
/* Chladni-Figur: Punkte auf den Knotenlinien von f = cos(nπx)cos(mπy) − cos(mπx)cos(nπy), Abstand |f|/|∇f| (gleich breite Linien) */
function chladni(n, [a, b]) {
  const C = W.chladni, out = new Float32Array(n * 4), P = Math.PI, h = C.platte / 2;
  let i = 0, versuche = 0;
  while (i < n && versuche < n * 400) {
    versuche++;
    let x = zufall(), y = zufall();
    if (zufall() < C.rand) {                               // Plattenrand
      const s = Math.floor(zufall() * 4); if (s < 2) x = s; else y = s - 2;
    } else {
      const f = Math.cos(a * P * x) * Math.cos(b * P * y) - Math.cos(b * P * x) * Math.cos(a * P * y);
      const fx = -a * P * Math.sin(a * P * x) * Math.cos(b * P * y) + b * P * Math.sin(b * P * x) * Math.cos(a * P * y);
      const fy = -b * P * Math.cos(a * P * x) * Math.sin(b * P * y) + a * P * Math.cos(b * P * x) * Math.sin(a * P * y);
      if (Math.abs(f) / Math.max(Math.hypot(fx, fy), 1e-3) > C.linie * (0.4 + zufall())) continue;
    }
    out.set([(x - 0.5) * 2 * h, (y - 0.5) * 2 * h, (zufall() - 0.5) * 0.03, 4], i * 4); i++;
  }
  return kippX(out, C.kipp);
}
async function schrift(n, wort) {
  try { await document.fonts.load('italic 200px "Instrument Serif"'); } catch (e) { /* Systemschrift genügt */ }
  const cw = 1400, ch = 460, c = document.createElement('canvas'); c.width = cw; c.height = ch;
  const g = c.getContext('2d', { willReadFrequently: true });
  g.font = 'italic 360px "Instrument Serif", Georgia, serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = '#fff';
  g.fillText(wort, cw / 2, ch / 2 + 10);
  const breite = g.measureText(wort).width, d = g.getImageData(0, 0, cw, ch).data, px = [];
  for (let y = 0; y < ch; y += 2) for (let x = 0; x < cw; x += 2) if (d[(y * cw + x) * 4 + 3] > 140) px.push(x, y);
  const s = 2.7 / Math.max(breite, 1), a = new Float32Array(n * 4);
  for (let i = 0; i < n; i++) {
    const k = Math.floor(zufall() * (px.length / 2)) * 2;
    a.set([(px[k] - cw / 2 + zufall() * 2) * s, -(px[k + 1] - ch / 2 + zufall() * 2) * s, (zufall() - 0.5) * 0.14, 3], i * 4);
  }
  return a;
}

/* ---------- Renderer: WebGL2 mit Float-Rendertargets, sonst Canvas2D-Ersatz ---------- */
let renderer = null;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2')) {
  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, premultipliedAlpha: true, preserveDrawingBuffer: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
const floatTyp = renderer && (renderer.extensions.has('EXT_color_buffer_float') ? THREE.FloatType : renderer.extensions.has('EXT_color_buffer_half_float') ? THREE.HalfFloatType : null);
if (renderer && !floatTyp) { renderer.dispose(); renderer = null; }

let api;
if (renderer) api = mitWebGL(); else api = ohneWebGL();

/* ---------- Bedienung ---------- */
let schleife = [], vorfuehrend = false;
function stoppeVorfuehrung() { schleife.forEach(clearTimeout); schleife = []; vorfuehrend = false; html.classList.remove('vorfuehrung'); api.zeigerAus(); }
function setzeForm(i, sofort) { if (i === form && !sofort) return; form = i; zeigeForm(); api.morph(); }
function urlSetzen(m) {   // Variante in der URL (?v=), andere Parameter bleiben erhalten
  const u = new URL(location.href);
  m === 'ziele' ? u.searchParams.delete('v') : u.searchParams.set('v', m);
  try { history.replaceState(null, '', u); } catch (e) { /* ohne History */ }
}
function setzeModus(m) { modus = m; knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === m))); zeigeWerte(); zeigeForm(); api.modus(); }
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.modus); urlSetzen(k.dataset.modus); }));
links.forEach(a => {
  const i = +a.dataset.form;
  a.addEventListener('mouseenter', e => { if (!e.isTrusted || beruehrung) return; stoppeVorfuehrung(); setzeForm(i); });
  a.addEventListener('focus', () => { if (!vorfuehrend) setzeForm(i); });
  a.addEventListener('click', e => { stoppeVorfuehrung(); if (beruehrung && form !== i) { e.preventDefault(); setzeForm(i); } });
});
buehne.addEventListener('pointerdown', e => { if (e.isTrusted && vorfuehrend && !e.target.closest('a, button')) stoppeVorfuehrung(); });

function zeigerNach(x, y, langsam) { zeiger.classList.toggle('langsam', !!langsam); zeiger.style.transform = `translate(${x}px, ${y}px)`; }
function linkMitte(i) { const r = links[i].getBoundingClientRect(), b = buehne.getBoundingClientRect(); return [r.left - b.left + Math.min(r.width * 0.32, 150), r.top - b.top + r.height / 2]; }

function neustart() { stoppeVorfuehrung(); form = 0; zeigeForm(); api.neustart(); }
function vorfuehren() {
  stoppeVorfuehrung();
  vorfuehrend = true; html.classList.add('vorfuehrung');
  const b = buehne.getBoundingClientRect();
  const wolke = api.wolkeMitte();
  let schritt = 0;
  const takt = () => {
    const ziel = (schritt === 0) ? 0 : (form + 1) % 3;
    if (schritt === 0) { form = 0; zeigeForm(); api.neustart(); }
    const [lx, ly] = linkMitte(ziel);
    zeigerNach(lx, ly);
    schleife.push(setTimeout(() => { setzeForm(ziel, true); }, schritt === 0 ? 300 : 800));
    // danach fährt der gedachte Zeiger quer durch die Wolke und stößt Punkte weg
    schleife.push(setTimeout(() => { api.zeigerAn(); zeigerNach(wolke[0] - wolke[2] * 0.9, wolke[1] - wolke[2] * 0.25, false); }, 2000));
    schleife.push(setTimeout(() => { zeigerNach(wolke[0] + wolke[2] * 0.8, wolke[1] + wolke[2] * 0.2, true); }, 2300));
    schleife.push(setTimeout(() => { api.zeigerAus(); }, 3400));
    schritt++;
    schleife.push(setTimeout(takt, W.wechselMs));
  };
  zeigerNach(b.width * 0.62, b.height * 0.9);
  takt();
}
function pause() { schleife.forEach(clearTimeout); schleife = []; api.pause(true); }
function weiter() { api.pause(false); vorfuehren(); }
// Knopf „Bewegung anhalten“: dieselbe Pause wie FUNDUS_DEMO.pause(); „Abspielen“ lässt die Wolke weiterlaufen
const haltKnopf = document.querySelector('[data-halt]');
let angehalten = false;
function anhalten(an) {
  angehalten = an;
  haltKnopf.setAttribute('aria-pressed', String(an));
  haltKnopf.textContent = an ? 'Abspielen' : 'Bewegung anhalten';
  if (an) { stoppeVorfuehrung(); pause(); } else api.pause(false);
}
haltKnopf.addEventListener('click', () => anhalten(!angehalten));

knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === modus)));
zeigeWerte(); zeigeForm();
addEventListener('load', () => { if (!html.classList.contains('vorschau')) api.neustart(); });


/* ======================================================================
   WebGL: GPGPU-Simulation per FBO-Ping-Pong (Position und Geschwindigkeit
   in Float-Texturen), Feder zur Zielform + Curl-Noise + Zeiger-Abstoßung
   ====================================================================== */
function mitWebGL() {
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setClearColor(0x000000, 0);
  renderer.autoClear = false;

  const kamera = new THREE.PerspectiveCamera(35, 1, 0.1, 50);
  kamera.position.set(0, 0, 6);
  const szene = new THREE.Scene();
  const gruppe = new THREE.Group();
  szene.add(gruppe);

  // Rechenszene: ein bildschirmfüllendes Dreieckspaar, eine Textur = alle Partikel
  const simKam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const simSzene = new THREE.Scene();
  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2));
  simSzene.add(quad);
  const rtOpt = { type: floatTyp, format: THREE.RGBAFormat, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, depthBuffer: false, stencilBuffer: false, generateMipmaps: false };
  const rt = () => new THREE.WebGLRenderTarget(TW, TH, rtOpt);
  let posA = rt(), posB = rt(), velA = rt(), velB = rt();
  const tex = a => { const t = new THREE.DataTexture(a, TW, TH, THREE.RGBAFormat, THREE.FloatType); t.needsUpdate = true; return t; };

  const zufallTex = (() => { const a = new Float32Array(N * 4); for (let i = 0; i < a.length; i++) a[i] = zufall(); return tex(a); })();
  const nullTex = tex(new Float32Array(N * 4));
  const staubTex = tex(staub(N));
  const zielTex = { ziele: [tex(kugel(N)), tex(welle(N)), tex(platte(N))], schrift: null };
  zielTex.spuren = zielTex.ziele;
  zielTex.chladni = W.chladni.moden.map(m => tex(chladni(N, m)));
  const schriftBereit = Promise.all(FORMEN.map(f => schrift(N, f.wort))).then(l => { zielTex.schrift = l.map(tex); });

  const vert = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }';
  const kopie = new THREE.ShaderMaterial({ uniforms: { uQ: { value: null } }, vertexShader: vert, fragmentShader: 'uniform sampler2D uQ; varying vec2 vUv; void main(){ gl_FragColor = texture2D(uQ, vUv); }' });

  const geschw = new THREE.ShaderMaterial({
    uniforms: { uPos: { value: null }, uVel: { value: null }, uZiel: { value: null }, uZufall: { value: zufallTex }, uZeit: { value: 0 }, uDt: { value: 0.016 },
      uK: { value: W.k }, uC: { value: W.c }, uCurl: { value: W.curl }, uSturm: { value: 0 }, uSturmMax: { value: W.sturm }, uHalt: { value: W.halt }, uWelle: { value: 0 },
      uMaus: { value: new THREE.Vector3(99, 99, 99) }, uMausAn: { value: 0 }, uStoss: { value: W.stoss }, uRadius: { value: W.radius }, uKMin: { value: W.kStreu[0] } },
    vertexShader: vert,
    fragmentShader: `
      uniform sampler2D uPos, uVel, uZiel, uZufall;
      uniform float uZeit, uDt, uK, uC, uCurl, uSturm, uSturmMax, uHalt, uWelle, uMausAn, uStoss, uRadius, uKMin;
      uniform vec3 uMaus;
      varying vec2 vUv;
      // divergenzfreies Feld: Rotation eines Sinus-Potentials, zwei Oktaven
      vec3 curlSin(vec3 p, float t) {
        float f = 1.7, g = 2.3;
        float a = t, b = t * 1.3 + 1.7, c = t * 0.7 + 3.1;
        return vec3(-g * sin(g * p.y + c) - f * cos(f * p.z + c),
                    -g * sin(g * p.z + b) - f * cos(f * p.x + b),
                    -g * sin(g * p.x + a) - f * cos(f * p.y + a));
      }
      vec3 curl(vec3 p, float t) { return (curlSin(p, t) + 0.5 * curlSin(p.yzx * 2.1 + 4.0, t * 1.6)) * 0.35; }
      void main() {
        vec3 p = texture2D(uPos, vUv).xyz;
        vec3 v = texture2D(uVel, vUv).xyz;
        vec4 zi = texture2D(uZiel, vUv);
        vec4 r = texture2D(uZufall, vUv);
        vec3 z = zi.xyz;
        z.y += uWelle * 0.2 * sin(z.x * 2.6 + uZeit * 1.8) * cos(z.z * 2.4 - uZeit * 1.1);
        float k = uK * mix(uKMin, 1.0, r.x);
        vec3 acc = (z - p) * k * (1.0 - uSturm * uHalt) - v * uC * (1.0 - uSturm * 0.5);
        acc += curl(p * 1.1, uZeit * 0.25) * (uCurl + uSturm * uSturmMax * (0.5 + r.y));
        vec3 d = p - uMaus; float dist = length(d);
        acc += d / max(dist, 0.001) * uStoss * uMausAn * (1.0 - smoothstep(0.0, uRadius, dist)) * (0.6 + 0.8 * r.z);
        v += acc * uDt;
        gl_FragColor = vec4(v, 1.0);
      }`,
  });
  const position = new THREE.ShaderMaterial({
    uniforms: { uPos: { value: null }, uVel: { value: null }, uDt: { value: 0.016 } },
    vertexShader: vert,
    fragmentShader: 'uniform sampler2D uPos, uVel; uniform float uDt; varying vec2 vUv; void main(){ vec4 p = texture2D(uPos, vUv); p.xyz += texture2D(uVel, vUv).xyz * uDt; gl_FragColor = p; }',
  });
  function pass(mat, ziel) { quad.material = mat; renderer.setRenderTarget(ziel); renderer.render(simSzene, simKam); }
  function lade(t) { kopie.uniforms.uQ.value = t; pass(kopie, posA); kopie.uniforms.uQ.value = nullTex; pass(kopie, velA); renderer.setRenderTarget(null); }

  // Darstellung: ein Punkt je Texel, Farbe und Größe nach Geschwindigkeit, additiv
  const geo = new THREE.BufferGeometry();
  const ref = new Float32Array(N * 2), zuf = new Float32Array(N), dummy = new Float32Array(N * 3);
  for (let i = 0; i < N; i++) { ref[i * 2] = (i % TW + 0.5) / TW; ref[i * 2 + 1] = (Math.floor(i / TW) + 0.5) / TH; zuf[i] = zufall(); }
  geo.setAttribute('position', new THREE.BufferAttribute(dummy, 3));
  geo.setAttribute('ref', new THREE.BufferAttribute(ref, 2));
  geo.setAttribute('zuf', new THREE.BufferAttribute(zuf, 1));
  const punkteMat = new THREE.ShaderMaterial({
    uniforms: { uPos: { value: null }, uVel: { value: nullTex }, uGroesse: { value: W.punkt }, uPx: { value: 1 }, uDeck: { value: 1 },
      uRuhe: { value: FARBEN.ruhe }, uMitte: { value: FARBEN.mitte }, uGlut: { value: FARBEN.glut } },
    vertexShader: `
      attribute vec2 ref; attribute float zuf;
      uniform sampler2D uPos, uVel; uniform float uGroesse, uPx, uDeck;
      uniform vec3 uRuhe, uMitte, uGlut;
      varying vec3 vFarbe; varying float vA;
      void main() {
        vec3 p = texture2D(uPos, ref).xyz;
        float s = length(texture2D(uVel, ref).xyz);
        vec4 mv = modelViewMatrix * vec4(p, 1.0);
        gl_Position = projectionMatrix * mv;
        float t = smoothstep(0.25, 3.2, s);
        vFarbe = t < 0.5 ? mix(uRuhe, uMitte, t * 2.0) : mix(uMitte, uGlut, t * 2.0 - 1.0);
        gl_PointSize = uGroesse * uPx * (0.5 + zuf) * (1.0 + t * 0.5) / -mv.z;
        vA = (0.35 + 0.4 * zuf) * uDeck * (1.0 + t * 0.6);
      }`,
    fragmentShader: `
      varying vec3 vFarbe; varying float vA;
      void main() {
        float d = length(gl_PointCoord - 0.5);
        if (d > 0.5) discard;
        float a = pow(1.0 - d * 2.0, 1.5) * vA;
        gl_FragColor = vec4(vFarbe, a);
      }`,
    transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending,
  });
  const punkte = new THREE.Points(geo, punkteMat);
  punkte.frustumCulled = false;
  gruppe.add(punkte);

  // Nachleuchten für die Variante Spuren: Bild pro Frame × 0,86 (Farbe und Alpha)
  const blende = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({
    uniforms: { uA: { value: 1 - W.spuren.nachleuchten } }, vertexShader: vert,
    fragmentShader: 'uniform float uA; void main(){ gl_FragColor = vec4(0.0, 0.0, 0.0, uA); }',
    transparent: true, depthTest: false, depthWrite: false,
    blending: THREE.CustomBlending, blendEquation: THREE.AddEquation, blendSrc: THREE.ZeroFactor, blendDst: THREE.OneMinusSrcAlphaFactor,
    blendSrcAlpha: THREE.ZeroFactor, blendDstAlpha: THREE.OneMinusSrcAlphaFactor,
  }));
  const blendSzene = new THREE.Scene(); blendSzene.add(blende);

  // Zeiger → Weltkoordinaten der Gruppe (Ebene durch die Wolkenmitte)
  const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), ebene = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), treffer = new THREE.Vector3();
  let mausZiel = 0, mausAn = 0, echteMaus = null, zeigerAktiv = false, neigX = 0, neigY = 0;
  buehne.addEventListener('pointermove', e => { if (vorfuehrend) return; echteMaus = [e.clientX, e.clientY]; mausZiel = 1; });
  buehne.addEventListener('pointerleave', () => { if (!vorfuehrend) mausZiel = 0; });
  addEventListener('pointerup', e => { if (e.pointerType === 'touch') mausZiel = 0; });

  let sturm = 0, wogen = 0, pausiert = false, deck = 1, ueberblend = null, zeit = 0, letzt = performance.now(), drehY = 0.4;
  let groesse = { b: 1, h: 1 }, mitte = [0, 0, 1];

  function layout() {
    const b = buehne.clientWidth, h = buehne.clientHeight;
    groesse = { b, h };
    renderer.setSize(b, h, false);
    kamera.aspect = b / h; kamera.updateProjectionMatrix();
    const halbH = Math.tan(THREE.MathUtils.degToRad(kamera.fov / 2)) * 6, halbB = halbH * kamera.aspect;
    let s, x, y;
    if (schmal()) { s = Math.min(halbB * 0.72 / 1.6, 0.95); x = 0; y = halbH * (1 - 2 * Math.min(0.27 * h, 200) / h); }   // Wolkenmitte höchstens 200 px unter der Oberkante
    else { s = Math.min(0.85, halbB * 0.33 / 1.3); x = halbB * 0.31; y = 0.04; }
    gruppe.scale.setScalar(s); gruppe.position.set(x, y, 0);
    punkteMat.uniforms.uPx.value = renderer.getPixelRatio() * Math.max(h, 640) / 900 * (istMobil ? W.punktMobil : 1);
    mitte = [(1 + x / halbB) * b / 2, (1 - y / halbH) * h / 2, 1.3 * s / halbB * b / 2];   // Wolkenmitte und Radius in px
    renderer.setRenderTarget(null); renderer.clear();
  }
  addEventListener('resize', layout);
  new ResizeObserver(() => { if (buehne.clientHeight !== groesse.h || buehne.clientWidth !== groesse.b) layout(); }).observe(buehne);
  layout();

  function zielFuer() { return (modus === 'schrift' && zielTex.schrift) ? zielTex.schrift[form] : modus === 'chladni' ? zielTex.chladni[form] : zielTex.ziele[form]; }
  function frame(jetzt) {
    requestAnimationFrame(frame);
    const dt = Math.min((jetzt - letzt) / 1000, 1 / 20); letzt = jetzt;
    if (pausiert) return;
    const red = reduziert();

    // Zeigerposition: echter Zeiger oder gedachter Zeiger der Vorführung
    let pz = null;
    if (zeigerAktiv) { const r = zeiger.getBoundingClientRect(); pz = [r.left + r.width / 2, r.top + r.height / 2]; }
    else if (!vorfuehrend && echteMaus) pz = echteMaus;
    const c = canvas.getBoundingClientRect();
    if (pz) {
      ndc.set((pz[0] - c.left) / c.width * 2 - 1, -((pz[1] - c.top) / c.height) * 2 + 1);
      ray.setFromCamera(ndc, kamera);
      if (ray.ray.intersectPlane(ebene, treffer)) geschw.uniforms.uMaus.value.copy(gruppe.worldToLocal(treffer.clone()));
    }
    const mz = vorfuehrend ? (zeigerAktiv ? 1 : 0) : mausZiel;
    mausAn += (mz - mausAn) * (1 - Math.exp(-dt * 8));

    if (red) {
      // Reduziert: keine Simulation, Punkte stehen in der Zielform, Wechsel als Überblendung
      punkteMat.uniforms.uPos.value = (ueberblend && ueberblend.alt) || zielFuer();
      punkteMat.uniforms.uVel.value = nullTex;
      if (ueberblend) {
        const t = jetzt - ueberblend.start, [aus, ein] = W.ueberblendMs;
        if (t < aus) deck = 1 - t / aus; else { ueberblend.alt = null; deck = Math.min(1, (t - aus) / ein); if (t > aus + ein) ueberblend = null; }
      } else deck = 1;
      punkteMat.uniforms.uDeck.value = deck;
      gruppe.rotation.set(0.08, drehY, 0);
      renderer.setRenderTarget(null); renderer.clear(); renderer.render(szene, kamera);
      return;
    }

    zeit += dt;
    sturm *= Math.exp(-dt / W.sturmTau);
    wogen += ((form === 1 && modus !== 'schrift' && modus !== 'chladni' ? 1 : 0) - wogen) * (1 - Math.exp(-dt * 2));
    const sp = modus === 'spuren';
    const u = geschw.uniforms;
    u.uZiel.value = zielFuer(); u.uZeit.value = zeit; u.uDt.value = dt; u.uSturm.value = sturm; u.uWelle.value = wogen; u.uMausAn.value = mausAn;
    u.uCurl.value = sp ? W.spuren.curl : W.curl; u.uC.value = sp ? W.spuren.c : W.c;
    u.uPos.value = posA.texture; u.uVel.value = velA.texture; pass(geschw, velB); [velA, velB] = [velB, velA];
    position.uniforms.uPos.value = posA.texture; position.uniforms.uVel.value = velA.texture; position.uniforms.uDt.value = dt; pass(position, posB); [posA, posB] = [posB, posA];

    // Wolke dreht sich langsam, neigt sich leicht zum Zeiger; Schriftzug pendelt nur
    if (modus === 'schrift' || modus === 'chladni') { gruppe.rotation.y += (Math.sin(zeit * 0.5) * 0.22 - gruppe.rotation.y) * (1 - Math.exp(-dt * 3)); }
    else gruppe.rotation.y += dt * W.drehung;
    const ny = pz ? ndc.y : 0, nx = pz ? ndc.x : 0;
    neigX += (0.06 - ny * 0.12 - neigX) * (1 - Math.exp(-dt * 3)); neigY += (nx * 0.1 - neigY) * (1 - Math.exp(-dt * 3));
    gruppe.rotation.x = neigX; gruppe.rotation.z = neigY * 0.3;
    drehY = gruppe.rotation.y;

    punkteMat.uniforms.uPos.value = posA.texture; punkteMat.uniforms.uVel.value = velA.texture; punkteMat.uniforms.uDeck.value = 1;
    punkteMat.uniforms.uGroesse.value = W.punkt * (sp ? 0.75 : 1);
    renderer.setRenderTarget(null);
    if (sp) renderer.render(blendSzene, simKam); else renderer.clear();
    renderer.render(szene, kamera);
  }

  let gestartet = false;
  function start() { if (gestartet) return; gestartet = true; html.classList.add('gl-bereit'); requestAnimationFrame(t => { letzt = t; frame(t); }); }

  return {
    neustart() {
      lade(reduziert() ? zielFuer() : staubTex);
      sturm = 0.6; wogen = 0; zeit = 0; gruppe.rotation.set(0.06, 0.4, 0);
      renderer.setRenderTarget(null); renderer.clear();
      start();
    },
    morph() {
      sturm = 1;
      if (reduziert()) ueberblend = { start: performance.now(), alt: punkteMat.uniforms.uPos.value };
      if (modus === 'schrift' && !zielTex.schrift) schriftBereit.then(() => { sturm = 1; });
    },
    modus() {
      sturm = 1; renderer.setRenderTarget(null); renderer.clear();
      if (reduziert()) ueberblend = { start: performance.now(), alt: punkteMat.uniforms.uPos.value };
      if (modus === 'schrift' && !zielTex.schrift) schriftBereit.then(() => { sturm = 1; });
    },
    pause(an) { pausiert = an; if (!an) letzt = performance.now(); },
    zeigerAn() { zeigerAktiv = true; },
    zeigerAus() { zeigerAktiv = false; },
    wolkeMitte() { return mitte; },
  };
}

/* ======================================================================
   Ersatz ohne WebGL2/Float-Texturen: Canvas2D mit 1 400 Punkten,
   Formwechsel als Überblendung
   ====================================================================== */
function ohneWebGL() {
  html.classList.add('ohne-webgl');
  const n = 1400, g = ersatz.getContext('2d');
  const formen = [kugel(n), welle(n), platte(n)], formenC = W.chladni.moden.map(m => chladni(n, m));
  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.clearRect(0, 0, b, h);
    const cx = schmal() ? b / 2 : b * 0.66, cy = schmal() ? Math.min(h * 0.27, 200) : h * 0.5, s = schmal() ? b * 0.27 : Math.min(b * 0.15, h * 0.26);
    const a = (modus === 'chladni' ? formenC : formen)[form], w = modus === 'chladni' ? 0.12 : 0.5, co = Math.cos(w), si = Math.sin(w);
    g.fillStyle = '#f2e6d2';
    for (let i = 0; i < n; i++) {
      const x = a[i * 4], y = a[i * 4 + 1], z = a[i * 4 + 2], xr = x * co - z * si, zr = x * si + z * co, p = 4 / (4 + zr);
      g.globalAlpha = 0.35 + 0.4 * (1 - (zr + 1.5) / 3);
      g.beginPath(); g.arc(cx + xr * s * p, cy - y * s * p, 1.1 * p, 0, Math.PI * 2); g.fill();
    }
  }
  addEventListener('resize', male);
  return {
    neustart() { male(); }, modus() { male(); }, pause() {},
    morph() { ersatz.classList.add('weg'); setTimeout(() => { male(); ersatz.classList.remove('weg'); }, reduziert() ? 0 : 400); },
    zeigerAn() {}, zeigerAus() {},
    wolkeMitte() { return [buehne.clientWidth * 0.66, buehne.clientHeight * 0.5, buehne.clientWidth * 0.15]; },
  };
}
</script>
</body>
</html>