WebGPU mit Three.js-TSL
Reinraum-Simulation eines Dresdner SaaS-Tools: Compute-Partikel umströmen Anlagen und eine Person; ohne WebGPU läuft dieselbe Physik sichtbar markiert im WebGL2-Rückfall.
Three.js bringt mit three/webgpu einen WebGPURenderer, der ohne WebGPU automatisch auf WebGL2 zurückfällt. Shader schreibt man in TSL, einem Node-Graph in JavaScript, der zu WGSL oder GLSL kompiliert. Compute-Shader rechnen Partikel, Simulationen und Instanz-Animationen auf der GPU und schreiben in Storage-Buffer statt in Ping-Pong-Texturen. Typische Einsätze 2026: Partikelspuren, zerfallende 3D-Buchstaben, Relighting von Bildern über Tiefenkarten, musikreaktive Flüssigkeiten und scroll-gesteuerte Szenen.
Millionen Partikel oder echte Simulationen laufen mit stabiler Bildrate. Mit WebGL war das nicht wirtschaftlich. Ein Codepfad bedient beide Backends.
Wann einsetzen
Neue 3D-Projekte mit Simulations- oder Partikelanteil.
Wann nicht
Wenn eine einfache GLSL-Shaderfläche reicht. Wenn das Projekt an Bibliotheken hängt, die nur mit WebGL laufen, etwa manche Postprocessing-Pakete.
Varianten
- WebGPURenderer mit automatischem WebGL2-Rückfall
- Postprocessing in TSL
- Compute-Partikel in Storage-Buffern
- Bild-Relighting mit Tiefenkarten
Umsetzung
import * as THREE from 'three/webgpu', danach await renderer.init(). navigator.gpu zeigt, ob WebGPU da ist; sonst rendert der Renderer mit WebGL2 (shader.se erzwingt das per forceWebGL, dazu dpr [1, 1.5] und antialias aus). Compute vor dem Rendern mit renderer.compute() ausführen. Shader beim Start vorwärmen, sonst ruckelt das erste Bild. Nicht alle Addons und Postprocessing-Effekte sind TSL-kompatibel. Die Demo nutzt einen rohen WGSL-Compute-Shader und als Rückfall three r186 mit GLSL-Ping-Pong, weil im Fundus nur der WebGL-Build von three liegt. Die Physik ist in beiden Pfaden dieselbe; die TSL-Fassung des Kernels steht im Inspektor.
Robust umsetzen
Telefon. WebGPU läuft in Chrome für Android, auf iOS hängt es von der Version ab. Den Rückfall deshalb immer testen und auf Telefonen weniger Partikel rechnen.
Ohne Bewegung. Keine laufende Simulation. Die Strömung einmal einschwingen lassen und als Standbild zeigen.
Barrierefrei. Die Zeichenfläche braucht eine Textbeschreibung (role img mit aria-label). Aktiver Renderer und Messwerte stehen zusätzlich als Text daneben. Bedienung über echte Buttons und Tabs, nicht nur über Zeigerbewegung.
Code
<!DOCTYPE html>
<!--
WebGPU mit Three.js-TSL
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/webgpu-tsl-compute/
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>WebGPU mit Three.js-TSL · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<style>
@font-face { font-family: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
--grund: #08090a; --panel: #111213; --karte: #161718; --text: #f7f8f8; --zwei: #d0d6e0; --leise: #8a8f98; --linie: #242728;
--marke: #5aa9ff;
--kurve: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* ease-out-quad, 160 ms */
--rand: clamp(16px, 3vw, 40px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Geist', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; min-height: 100svh; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
.mono { font-family: 'Geist Mono', ui-monospace, monospace; font-size: 12px; letter-spacing: .02em; }
.ziffern { font-variant-numeric: tabular-nums; }
/* ---------- Kopf ---------- */
.kopf { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; height: 64px; padding: 0 var(--rand); }
.logo { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; letter-spacing: -.01em; }
.logo svg { width: 22px; height: 22px; }
.kopf nav { display: flex; gap: 28px; font-size: 14px; color: var(--zwei); }
.kopf nav a { transition: color .16s var(--kurve); }
.kopf nav a:hover { color: var(--text); }
.kopf .aktion { display: flex; gap: 10px; align-items: center; font-size: 14px; }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 14px; border-radius: 8px; font-size: 14px; font-weight: 500; transition: background-color .16s var(--kurve), border-color .16s var(--kurve); }
.knopf.primaer { background: var(--marke); color: #04121f; }
.knopf.primaer:hover { background: #7bbaff; }
.knopf.zweit { border: 1px solid var(--linie); color: var(--zwei); }
.knopf.zweit:hover { border-color: #3a3e42; color: var(--text); }
/* ---------- Hero ---------- */
.held { max-width: 1200px; margin: 0 auto; padding: clamp(40px, 6vw, 76px) var(--rand) 0; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 40px; align-items: end; }
.hinweis { display: inline-flex; gap: 10px; align-items: center; height: 28px; padding: 0 12px 0 6px; border: 1px solid var(--linie); border-radius: 999px; color: var(--zwei); font-size: 13px; margin-bottom: 22px; }
.hinweis b { font: 500 11px/20px 'Geist Mono', monospace; background: rgba(90, 169, 255, .14); color: var(--marke); padding: 0 8px; border-radius: 999px; }
h1 { font-size: clamp(2.5rem, 1.6rem + 3.6vw, 4.5rem); font-weight: 590; letter-spacing: -.022em; line-height: 1; max-width: 13ch; }
.unterzeile { font-size: 1.0625rem; line-height: 1.6; color: var(--leise); letter-spacing: -.006em; max-width: 46ch; }
.unterzeile b { color: var(--zwei); font-weight: 500; }
.ctas { display: flex; gap: 10px; margin-top: 22px; }
.ctas .knopf { height: 40px; padding: 0 18px; }
/* ---------- Produkt-Fenster ---------- */
.fenster { max-width: 1200px; margin: clamp(32px, 4vw, 52px) auto 0; padding: 0 var(--rand); }
.app { border: 1px solid var(--linie); border-radius: 12px; background: var(--panel); overflow: hidden; box-shadow: 0 30px 80px rgba(0, 0, 0, .5), 0 0 0 1px rgba(255, 255, 255, .02) inset; }
.leiste { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 44px; padding: 0 14px; border-bottom: 1px solid var(--linie); font-size: 13px; color: var(--leise); }
.pfad { display: flex; gap: 8px; align-items: center; white-space: nowrap; overflow: hidden; }
.pfad b { color: var(--text); font-weight: 500; }
.reiter { display: flex; gap: 2px; padding: 2px; background: var(--grund); border: 1px solid var(--linie); border-radius: 8px; position: relative; }
.reiter button { position: relative; z-index: 1; height: 26px; padding: 0 12px; border-radius: 6px; font-size: 12.5px; color: var(--leise); transition: color .16s var(--kurve); }
.reiter button[aria-selected='true'] { color: var(--text); }
.reiter .gleiter { position: absolute; z-index: 0; top: 2px; bottom: 2px; left: 0; border-radius: 6px; background: var(--karte); box-shadow: 0 0 0 1px var(--linie) inset; transition: translate .18s var(--kurve), width .18s var(--kurve); }
.backend { display: flex; align-items: center; gap: 8px; }
.leiste-rechts { display: flex; align-items: center; gap: 12px; }
.punkt { width: 7px; height: 7px; border-radius: 50%; background: #59d499; box-shadow: 0 0 0 3px rgba(89, 212, 153, .15); }
.fallback .punkt { background: #ffc533; box-shadow: 0 0 0 3px rgba(255, 197, 51, .15); }
/* Pause-Knopf (WCAG 2.2.2): hält die Simulation an wie FUNDUS_DEMO.pause() */
.halt { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 9px; border: 1px solid var(--linie); border-radius: 6px; font-size: 12.5px; color: var(--zwei); transition: color .16s var(--kurve), border-color .16s var(--kurve); }
.halt:hover { color: var(--text); border-color: #3a3e42; }
.halt svg { width: 9px; height: 9px; flex: none; }
.halt .spiel, .halt[aria-pressed='true'] .pause { display: none; }
.halt[aria-pressed='true'] .spiel { display: block; }
.vorschau .halt, .reduziert .halt { display: none; }
.koerper { display: grid; grid-template-columns: 196px minmax(0, 1fr) 248px; height: clamp(400px, 52vh, 540px); }
.seite { border-right: 1px solid var(--linie); padding: 12px 8px; font-size: 13px; color: var(--zwei); display: grid; align-content: start; gap: 2px; }
.seite h3, .inspektor h3 { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); padding: 10px 8px 8px; }
.ebene { display: flex; align-items: center; gap: 9px; height: 30px; padding: 0 8px; border-radius: 6px; transition: background-color .16s var(--kurve); }
.ebene:hover { background: var(--karte); }
.ebene i { width: 14px; height: 14px; border-radius: 3px; border: 1px solid #3a3e42; display: block; flex: none; }
.ebene.an i { background: var(--zwei); border-color: var(--zwei); }
.ebene span { margin-left: auto; color: var(--leise); font: 11px 'Geist Mono', monospace; }
.ebene.person-live span { color: #59d499; }
.ansicht { position: relative; overflow: hidden; background: radial-gradient(120% 90% at 50% 0%, #121417 0%, #0b0c0d 70%); touch-action: pan-y; cursor: ew-resize; }
.ansicht canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ansicht canvas[hidden] { display: none; }
.decke { position: absolute; left: 0; right: 0; top: 0; height: 6%; background: repeating-linear-gradient(90deg, #1b1d20 0 calc(12.5% - 2px), #0b0c0d calc(12.5% - 2px) 12.5%); border-bottom: 1px solid #2c3034; }
.decke::after { content: ''; position: absolute; inset: 35% 1% 35%; background: repeating-linear-gradient(90deg, rgba(208, 214, 224, .18) 0 1px, transparent 1px 6px); }
.boden { position: absolute; left: 0; right: 0; bottom: 0; height: 10%; border-top: 1px solid #2c3034; background: radial-gradient(circle at 3px 3px, #2a2e33 1.2px, transparent 1.6px) 0 0 / 9px 9px, #0d0e10; }
.anlage { position: absolute; border: 1px solid #2f3337; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #1a1c1f, #121315); display: grid; align-content: start; padding: 10px; gap: 4px; }
.anlage b { font: 500 11px/1.2 'Geist Mono', monospace; color: var(--zwei); }
.anlage span { font: 11px/1.2 'Geist Mono', monospace; color: var(--leise); }
.anlage::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: 14%; height: 2px; background: repeating-linear-gradient(90deg, #3a3e42 0 8px, transparent 8px 12px); }
.person { position: absolute; bottom: 10%; width: 4.6%; min-width: 20px; height: 26%; translate: -50% 0; pointer-events: none; }
.person svg { width: 100%; height: 100%; display: block; overflow: visible; }
.person em { position: absolute; left: 50%; top: -22px; translate: -50% 0; white-space: nowrap; font: 500 10.5px/1 'Geist Mono', monospace; color: #04121f; background: var(--marke); padding: 4px 6px; border-radius: 4px; font-style: normal; }
.messe { position: absolute; right: 10px; top: calc(6% + 10px); display: grid; gap: 3px; justify-items: end; font: 11px/1.3 'Geist Mono', monospace; color: var(--leise); pointer-events: none; padding: 7px 9px; border: 1px solid var(--linie); border-radius: 6px; background: rgba(11, 12, 13, .78); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
.rueckfall { position: absolute; left: 10px; top: calc(6% + 10px); display: none; align-items: flex-start; gap: 9px; max-width: calc(100% - 20px); white-space: nowrap; padding: 6px 10px 6px 8px; border: 1px solid rgba(255, 197, 51, .28); border-radius: 6px; background: rgba(11, 12, 13, .82); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font: 11px/1.35 'Geist Mono', monospace; color: var(--zwei); pointer-events: none; }
.rueckfall i { flex: none; margin-top: 4px; width: 7px; height: 7px; border-radius: 50%; background: #ffc533; box-shadow: 0 0 0 3px rgba(255, 197, 51, .15); }
.rueckfall b { color: var(--text); font-weight: 500; }
.fallback .rueckfall { display: flex; }
.messe b { color: var(--text); font-weight: 500; }
.leer { position: absolute; inset: 0; display: none; place-items: center; text-align: center; padding: 24px; color: var(--leise); font-size: 14px; }
.ohne-gpu .leer { display: grid; }
.inspektor { border-left: 1px solid var(--linie); padding: 0 12px 12px; font-size: 13px; overflow: hidden; display: grid; align-content: start; }
.zeile { display: flex; justify-content: space-between; gap: 10px; height: 30px; align-items: center; color: var(--leise); }
.zeile b { color: var(--text); font: 500 12px 'Geist Mono', monospace; }
.segment { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; padding: 2px; background: var(--grund); border: 1px solid var(--linie); border-radius: 8px; margin: 2px 0 6px; }
.segment button { height: 26px; border-radius: 6px; font: 500 11.5px 'Geist Mono', monospace; color: var(--leise); transition: background-color .16s var(--kurve), color .16s var(--kurve); }
.segment button[aria-pressed='true'] { background: var(--karte); color: var(--text); box-shadow: 0 0 0 1px var(--linie) inset; }
.segment button:disabled { opacity: .4; cursor: not-allowed; }
.code { margin-top: 6px; border: 1px solid var(--linie); border-radius: 8px; background: var(--grund); padding: 10px 10px 8px; font: 10.5px/1.6 'Geist Mono', monospace; color: var(--leise); white-space: pre; overflow: hidden; }
.code .k { color: #c49cff; } .code .f { color: var(--marke); } .code .s { color: #59d499; } .code .c { color: #777c83; }
.code-kopf { display: flex; justify-content: space-between; color: var(--leise); font: 11px 'Geist Mono', monospace; padding: 12px 0 0; }
.status { display: flex; justify-content: space-between; gap: 16px; height: 30px; align-items: center; padding: 0 14px; border-top: 1px solid var(--linie); font: 11px 'Geist Mono', monospace; color: var(--leise); white-space: nowrap; overflow: hidden; }
.status b { color: var(--zwei); font-weight: 500; }
.fakten { max-width: 1200px; margin: 0 auto; padding: 28px var(--rand) 64px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
.fakten p { font-size: 15px; line-height: 1.55; color: var(--leise); max-width: 34ch; }
.fakten b { display: block; color: var(--text); font-weight: 500; margin-bottom: 4px; }
/* ---------- Vorschau: nur das Produkt-Fenster ---------- */
.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .held, .vorschau .fakten { display: none; }
.vorschau .fenster { margin: 0; padding: 14px; max-width: none; }
.vorschau .koerper { height: calc(100vh - 28px - 44px - 30px - 2px); grid-template-columns: minmax(0, 1fr); }
.vorschau .seite, .vorschau .inspektor { display: none; }
.vorschau .rueckfall { display: none; } /* Rückfall steht in der Vorschau schon oben rechts und in der Statuszeile */
/* ---------- Reduziert ---------- */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
/* ---------- Schmal ---------- */
@media (max-width: 1100px) { .koerper { grid-template-columns: 0 minmax(0, 1fr) 236px; } .seite { display: none; } }
@media (max-width: 860px) {
.kopf nav, .kopf .aktion .zweit { display: none; }
.held { grid-template-columns: 1fr; gap: 18px; }
.koerper { grid-template-columns: 1fr; height: auto; }
.ansicht { aspect-ratio: 4 / 4.6; }
.inspektor { display: none; }
.pfad span, .pfad i { display: none; }
.leiste { padding: 0 8px; }
.reiter button { padding: 0 8px; }
.backend .text-lang { display: none; }
.status .lang { display: none; }
.fakten { grid-template-columns: 1fr; }
.anlage span { display: none; }
.anlage { padding: 6px; }
.messe { display: none; }
}
@media (max-width: 560px) {
.rueckfall { white-space: normal; }
.status { height: auto; min-height: 30px; padding-block: 6px; flex-wrap: wrap; row-gap: 2px; white-space: normal; }
}
@media (max-width: 480px) { .halt { padding: 0 7px; } .leiste-rechts { gap: 8px; } .backend .text, .pfad, .halt-text { display: none; } .leiste { justify-content: center; position: relative; } .leiste-rechts { position: absolute; right: 10px; } }
@media (max-width: 360px) { .leiste { justify-content: space-between; padding: 0 8px; } .leiste-rechts { position: static; } }
@media (hover: none) { .ansicht { cursor: default; } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<a class="logo" href="#"><svg viewBox="0 0 22 22" fill="none" aria-hidden="true"><rect x=".5" y=".5" width="21" height="21" rx="6" stroke="#3a3e42"/><path d="M6 6v10M11 6v10M16 6v10" stroke="#8a8f98" stroke-width="1.4"/><path d="M4 11c3 0 4-2 7-2s4 2 7 2" stroke="#5aa9ff" stroke-width="1.6"/></svg>Querstrom</a>
<nav aria-label="Hauptmenü"><a href="#">Produkt</a><a href="#">Reinräume</a><a href="#">Preise</a><a href="#">Changelog</a><a href="#">Doku</a></nav>
<div class="aktion"><a class="knopf zweit" href="#">Anmelden</a><a class="knopf primaer" href="#">Kostenlos testen</a></div>
</header>
<section class="held" aria-labelledby="titel">
<div>
<p class="hinweis"><b>4.0</b>Solver läuft jetzt auf der GPU im Browser</p>
<h1 id="titel">Reinraumluft sehen, bevor sie fließt.</h1>
</div>
<div>
<p class="unterzeile">Querstrom simuliert Zuluft, Wirbel und Partikel im Reinraum <b>direkt im Browser</b>: Anlagen setzen, Personen bewegen, Ergebnis sofort sehen. Für Fabs, Pharma und Labore.</p>
<div class="ctas"><a class="knopf primaer" href="#">Kostenlos testen</a><a class="knopf zweit" href="#">Demo vereinbaren</a></div>
</div>
</section>
<div class="fenster">
<div class="app" role="region" aria-label="Querstrom-Oberfläche mit laufender Simulation">
<div class="leiste">
<p class="pfad"><span>Fab 2</span><i aria-hidden="true">/</i><span>Bay C</span><i aria-hidden="true">/</i><b>Lithografie, Schnitt A</b></p>
<div class="reiter" role="tablist" aria-label="Darstellung" data-reiter>
<span class="gleiter" data-gleiter aria-hidden="true"></span>
<button type="button" role="tab" aria-selected="true" data-modus="0">Partikel</button>
<button type="button" role="tab" aria-selected="false" data-modus="1">Tempo</button>
<button type="button" role="tab" aria-selected="false" data-modus="2">Stromlinien</button>
</div>
<div class="leiste-rechts"><p class="backend" data-backend><i class="punkt"></i><span class="text" data-backend-text>startet …</span></p><button type="button" class="halt" data-halt aria-pressed="false" aria-label="Bewegung anhalten"><svg class="pause" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1h3v10H2zM7 1h3v10H7z" fill="currentColor"/></svg><svg class="spiel" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1l9 5-9 5z" fill="currentColor"/></svg><span class="halt-text">Anhalten</span></button></div>
</div>
<div class="koerper">
<aside class="seite" aria-label="Ebenen">
<h3>Ebenen</h3>
<p class="ebene an"><i></i>Deckenfilter<span>FFU 8</span></p>
<p class="ebene an"><i></i>Belichter L1<span>2,1 m</span></p>
<p class="ebene an"><i></i>Belichter L2<span>2,4 m</span></p>
<p class="ebene an person-live" data-ebene-person><i></i>Person<span data-person-v>steht</span></p>
<p class="ebene an"><i></i>Doppelboden<span>Abluft</span></p>
<h3>Szenarien</h3>
<p class="ebene"><i></i>Tür offen</p>
<p class="ebene"><i></i>Wartung L2</p>
</aside>
<div class="ansicht" data-ansicht aria-label="Schnitt durch den Reinraum: Zuluft von oben, zwei Anlagen, eine Person. Zeiger oder Finger bewegt die Person." role="img">
<canvas data-gpu hidden aria-hidden="true"></canvas>
<canvas data-gl hidden aria-hidden="true"></canvas>
<div class="decke" aria-hidden="true"></div>
<div class="boden" aria-hidden="true"></div>
<div class="anlage" style="left:12%;top:50%;width:22%;height:40%" aria-hidden="true"><b>Belichter L1</b><span>Abwärme 3,2 kW</span></div>
<div class="anlage" style="left:62%;top:44%;width:24%;height:46%" aria-hidden="true"><b>Belichter L2</b><span>Abwärme 4,0 kW</span></div>
<div class="person" data-person style="left:48%" aria-hidden="true"><em data-person-label>Person · 0,0 m/s</em>
<svg viewBox="0 0 40 120" preserveAspectRatio="none"><path d="M20 2c6 0 9 4 9 10 0 4-1 7-3 9l7 4c3 2 4 5 4 9v38c0 3-2 5-5 5l-1 37c0 3-2 5-5 5h-1l-2-38h-6l-2 38h-1c-3 0-5-2-5-5L8 77c-3 0-5-2-5-5V34c0-4 1-7 4-9l7-4c-2-2-3-5-3-9 0-6 3-10 9-10z" fill="#d0d6e0"/><rect x="13" y="8" width="14" height="7" rx="3" fill="#08090a"/></svg>
</div>
<div class="messe" aria-hidden="true"><span>Zuluft <b>0,45 m/s</b></span><span>Partikel <b class="ziffern" data-n>–</b></span><span>Bild <b class="ziffern" data-ms>–</b></span></div>
<p class="rueckfall" data-rueckfall><i aria-hidden="true"></i><span data-rueckfall-text></span></p>
<p class="leer">Die Simulation braucht WebGPU oder WebGL2. Dieses Gerät bietet beides nicht, darum steht hier nur der Aufbau.</p>
</div>
<aside class="inspektor" aria-label="Simulation">
<h3>Renderer</h3>
<div class="segment" role="group" aria-label="Backend wählen">
<button type="button" data-wahl="webgpu" aria-pressed="false">WebGPU</button>
<button type="button" data-wahl="webgl" aria-pressed="false">WebGL2</button>
</div>
<p class="zeile">Kernel <b data-kernel>–</b></p>
<p class="zeile">Workgroup <b>64</b></p>
<p class="zeile">dpr <b>[1, 1.5]</b></p>
<p class="zeile">antialias <b>aus</b></p>
<p class="code-kopf"><span>derselbe Kernel in TSL</span><span>three r186</span></p>
<pre class="code" aria-label="So sieht der Kernel in Three.js-TSL aus"><span class="k">const</span> ort = <span class="f">instancedArray</span>(n, <span class="s">'vec2'</span>);
<span class="k">const</span> tempo = <span class="f">instancedArray</span>(n, <span class="s">'vec2'</span>);
<span class="k">const</span> schritt = <span class="f">Fn</span>(() => {
<span class="k">const</span> p = ort.<span class="f">element</span>(instanceIndex);
<span class="k">const</span> v = tempo.<span class="f">element</span>(instanceIndex);
v.<span class="f">assign</span>(<span class="f">mix</span>(v, <span class="f">feld</span>(p), k));
p.<span class="f">addAssign</span>(v.<span class="f">mul</span>(dt));
<span class="f">If</span>(p.y.<span class="f">greaterThan</span>(<span class="s">0.9</span>), () =>
p.<span class="f">assign</span>(<span class="f">oben</span>()));
})().<span class="f">compute</span>(n);
<span class="c">// vor jedem Bild</span>
renderer.<span class="f">compute</span>(schritt);</pre>
</aside>
</div>
<p class="status"><span>Querstrom Solver 4.0 · <b data-status-backend>–</b></span><span class="lang">Zuluft <b>0,45 m/s</b> · Echtzeit · Annäherung <b>1 − e<sup>−7·dt</sup></b></span><span><b class="ziffern" data-fps>–</b></span></p>
</div>
</div>
<section class="fakten">
<p><b>Bis zu 262.144 Partikel bei 60 Bildern pro Sekunde.</b>Ein Compute-Shader rechnet jedes Teilchen einzeln, Storage-Buffer statt Ping-Pong-Texturen.</p>
<p><b>WebGPU zuerst, WebGL2 als Rückfall.</b>Ein Codepfad in TSL, kompiliert zu WGSL oder GLSL. Ältere Geräte rechnen mit weniger Partikeln.</p>
<p><b>Rechnet lokal.</b>Grundrisse und Anlagendaten verlassen den Browser nicht. Hosting und Support in Dresden.</p>
</section>
<script type="module">
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const schmal = () => innerWidth <= 860;
const ansicht = document.querySelector('[data-ansicht]');
const cGpu = document.querySelector('[data-gpu]'), cGl = document.querySelector('[data-gl]');
const personEl = document.querySelector('[data-person]'), personLabel = document.querySelector('[data-person-label]'), personV = document.querySelector('[data-person-v]');
const reiter = [...document.querySelectorAll('[data-modus]')], gleiter = document.querySelector('[data-gleiter]');
const wahl = [...document.querySelectorAll('[data-wahl]')];
const $ = s => document.querySelector(s);
/* ---------- Werte (shader.se: dpr [1, 1.5], antialias aus, WebGL-Fallback per forceWebGL; Bruno Simon: high-performance) ---------- */
const W = {
zuluft: 0.1286, // Raumhöhen je s: 0,45 m/s bei 3,5 m Raum = Echtzeit
annaeherung: 7, // Tempo folgt dem Feld mit 1 − e^(−7·dt)
wirbel: 0.05, personWirbel: 0.12,
n: { webgpu: beruehrung || schmal() ? 65536 : 262144, webgl: beruehrung || schmal() ? 160 * 160 : 256 * 256 },
dpr: [1, 1.5],
personLerp: 0.1,
vorwaermen: 150,
};
let modus = 0, backend = null, laeuft = null, angehalten = false, sichtbar = true;
/* ---------- Reiter mit gleitendem Indikator (180 ms) ---------- */
function setzeModus(m) {
modus = m;
reiter.forEach(r => r.setAttribute('aria-selected', String(+r.dataset.modus === m)));
const r = reiter[m];
gleiter.style.width = r.offsetWidth + 'px'; gleiter.style.translate = (r.offsetLeft - 2) + 'px 0';
if (laeuft) laeuft.einBild();
}
reiter.forEach(r => r.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(+r.dataset.modus); }));
/* ---------- Gemeinsame Physik: in WGSL und GLSL dieselbe Formel ---------- */
const WGSL_FELD = /* wgsl */`
fn hash(p: vec2f) -> f32 { return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453); }
fn rausch(p: vec2f) -> f32 {
let i = floor(p); let f = fract(p); let w = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2f(1.0, 0.0)), w.x), mix(hash(i + vec2f(0.0, 1.0)), hash(i + vec2f(1.0, 1.0)), w.x), w.y);
}
fn wirbel(p: vec2f) -> vec2f {
let e = 0.05;
return vec2f(rausch(p + vec2f(0.0, e)) - rausch(p - vec2f(0.0, e)), rausch(p - vec2f(e, 0.0)) - rausch(p + vec2f(e, 0.0))) / (2.0 * e);
}
fn kasten(p: vec2f, a: vec2f, b: vec2f) -> f32 {
let c = (a + b) * 0.5; let h = (b - a) * 0.5; let q = abs(p - c) - h;
return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0);
}
fn szene(p: vec2f) -> f32 {
let a = u.aspekt; let q = vec2f(p.x * a, p.y);
var d = kasten(q, vec2f(0.12 * a, 0.5), vec2f(0.34 * a, 0.97));
d = min(d, kasten(q, vec2f(0.62 * a, 0.44), vec2f(0.86 * a, 0.97)));
d = min(d, kasten(q, vec2f(u.person.x * a - 0.016, 0.67), vec2f(u.person.x * a + 0.016, 0.97)) - 0.012);
return d;
}
fn feld(p: vec2f, id: f32) -> vec2f {
let a = vec2f(u.aspekt, 1.0);
var v = vec2f(0.0, u.zuluft) + wirbel(p * a * 5.0 + vec2f(0.0, -u.zeit * 0.35)) * u.zuluft * ${W.wirbel};
let d = szene(p); let e = 0.003;
let g = vec2f(szene(p + vec2f(e, 0.0)) - szene(p - vec2f(e, 0.0)), szene(p + vec2f(0.0, e)) - szene(p - vec2f(0.0, e)));
let n = g / max(length(g), 1e-5);
let w = 1.0 - smoothstep(0.0, 0.045, d);
let vn = dot(v, n);
if (vn < 0.0) {
var t = vec2f(-n.y, n.x);
var s = dot(t, v);
if (abs(s) < 0.25 * length(v)) { s = hash(vec2f(id, 3.7)) - 0.5; }
if (s < 0.0) { t = -t; }
v = v - n * vn * w + t * (-vn) * w * 1.15;
}
// Nachlauf der Person: Wirbel hinter dem Körper, stärker, wenn sie geht
let dp = (p - vec2f(u.person.x, 0.8)) * a;
let nah = exp(-dot(dp, dp) * 40.0);
v += wirbel(p * a * 13.0 + vec2f(u.zeit * 1.4, u.zeit * 0.6)) * u.zuluft * (0.35 + abs(u.personV) * 9.0) * nah * ${W.personWirbel};
v.x += u.personV * nah * 0.8;
return v;
}`;
const GLSL_FELD = /* glsl */`
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float rausch(vec2 p) {
vec2 i = floor(p), f = fract(p), w = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), w.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), w.x), w.y);
}
vec2 wirbel(vec2 p) {
float e = 0.05;
return vec2(rausch(p + vec2(0.0, e)) - rausch(p - vec2(0.0, e)), rausch(p - vec2(e, 0.0)) - rausch(p + vec2(e, 0.0))) / (2.0 * e);
}
float kasten(vec2 p, vec2 a, vec2 b) {
vec2 c = (a + b) * 0.5, h = (b - a) * 0.5, q = abs(p - c) - h;
return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0);
}
float szene(vec2 p) {
float a = u_aspekt; vec2 q = vec2(p.x * a, p.y);
float d = kasten(q, vec2(0.12 * a, 0.5), vec2(0.34 * a, 0.97));
d = min(d, kasten(q, vec2(0.62 * a, 0.44), vec2(0.86 * a, 0.97)));
d = min(d, kasten(q, vec2(u_person.x * a - 0.016, 0.67), vec2(u_person.x * a + 0.016, 0.97)) - 0.012);
return d;
}
vec2 feld(vec2 p, float id) {
vec2 a = vec2(u_aspekt, 1.0);
vec2 v = vec2(0.0, u_zuluft) + wirbel(p * a * 5.0 + vec2(0.0, -u_zeit * 0.35)) * u_zuluft * ${W.wirbel};
float d = szene(p), e = 0.003;
vec2 g = vec2(szene(p + vec2(e, 0.0)) - szene(p - vec2(e, 0.0)), szene(p + vec2(0.0, e)) - szene(p - vec2(0.0, e)));
vec2 n = g / max(length(g), 1e-5);
float w = 1.0 - smoothstep(0.0, 0.045, d);
float vn = dot(v, n);
if (vn < 0.0) {
vec2 t = vec2(-n.y, n.x);
float s = dot(t, v);
if (abs(s) < 0.25 * length(v)) s = hash(vec2(id, 3.7)) - 0.5;
if (s < 0.0) t = -t;
v = v - n * vn * w + t * (-vn) * w * 1.15;
}
vec2 dp = (p - vec2(u_person.x, 0.8)) * a;
float nah = exp(-dot(dp, dp) * 40.0);
v += wirbel(p * a * 13.0 + vec2(u_zeit * 1.4, u_zeit * 0.6)) * u_zuluft * (0.35 + abs(u_personV) * 9.0) * nah * ${W.personWirbel};
v.x += u_personV * nah * 0.8;
return v;
}`;
/* Startverteilung: gleichmäßig im freien Raum, nie in Anlagen oder Person (sonst würden alle im ersten Schritt gleichzeitig oben neu starten und als Band durch den Raum wandern) */
function startDaten(N) {
const d = new Float32Array(N * 4);
const belegt = (x, y) => (x > 0.12 && x < 0.34 && y > 0.5) || (x > 0.62 && x < 0.86 && y > 0.44) || (Math.abs(x - 0.48) < 0.03 && y > 0.64);
for (let i = 0; i < N; i++) {
let x, y; do { x = Math.random(); y = 0.06 + Math.random() * 0.84; } while (belegt(x, y));
d[i * 4] = x; d[i * 4 + 1] = y; d[i * 4 + 3] = W.zuluft;
}
return d;
}
/* ---------- Pfad A: WebGPU, Compute-Shader auf Storage-Buffer ---------- */
const WGSL_U = `struct U { zeit: f32, dt: f32, zuluft: f32, aspekt: f32, person: vec2f, personV: f32, n: f32, modus: f32, a: f32, b: f32, c: f32, };`;
async function starteGpu(N) {
if (!navigator.gpu) return null;
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }).catch(() => null);
if (!adapter) return null;
const device = await adapter.requestDevice().catch(() => null);
if (!device) return null;
const ctx = cGpu.getContext('webgpu');
if (!ctx) return null;
const format = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({ device, format, alphaMode: 'premultiplied' });
const start = startDaten(N);
const teilchen = device.createBuffer({ size: N * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(teilchen, 0, start);
const uni = device.createBuffer({ size: 48, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const rechnen = device.createShaderModule({ code: `${WGSL_U}
@group(0) @binding(0) var<storage, read_write> T: array<vec4f>;
@group(0) @binding(1) var<uniform> u: U;
${WGSL_FELD}
@compute @workgroup_size(64) fn schritt(@builtin(global_invocation_id) gid: vec3u) {
let i = gid.x;
if (f32(i) >= u.n) { return; }
var t = T[i];
let id = f32(i);
var vel = mix(t.zw, feld(t.xy, id), 1.0 - exp(-u.dt * ${W.annaeherung}.0));
var pos = t.xy + vel * u.dt;
if (pos.y > 0.9 || szene(pos) < -0.002 || pos.x < 0.0 || pos.x > 1.0) {
pos = vec2f(hash(vec2f(id * 0.0131, u.zeit)), 0.06 + 0.03 * hash(vec2f(u.zeit, id * 0.0713)));
vel = vec2f(0.0, u.zuluft);
}
T[i] = vec4f(pos, vel);
}` });
const malen = device.createShaderModule({ code: `${WGSL_U}
@group(0) @binding(0) var<storage, read> T: array<vec4f>;
@group(0) @binding(1) var<uniform> u: U;
struct VO { @builtin(position) pos: vec4f, @location(0) farbe: vec4f, };
// Tempo-Skala: gestaut dunkel, freie Zuluft grau, beschleunigt oder seitlich abgelenkt in Markenblau
fn skala(s: f32, q: f32) -> vec3f {
let langsam = vec3f(0.2, 0.24, 0.32); let normal = vec3f(0.62, 0.66, 0.72); let schnell = vec3f(0.35, 0.66, 1.0);
return mix(mix(langsam, normal, smoothstep(0.4, 0.95, s)), schnell, clamp(smoothstep(0.15, 0.6, q) + smoothstep(1.03, 1.15, s), 0.0, 1.0));
}
@vertex fn vs(@builtin(vertex_index) vi: u32) -> VO {
var i = vi; var ende = 0.0;
if (u.modus > 1.5) { i = vi / 2u; ende = f32(vi % 2u); }
let t = T[i];
let p = t.xy - t.zw * ende * 0.22;
let s = length(t.zw) / u.zuluft;
var o: VO;
o.pos = vec4f(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
var c = vec3f(0.84, 0.88, 0.95); var al = 0.3;
if (u.modus > 0.5) { c = skala(s, abs(t.z) / max(length(t.zw), 1e-5)); al = 0.42; }
al *= 0.45 + 1.1 * fract(sin(f32(i) * 12.9898) * 43758.5453);
if (u.modus > 1.5) { al = 0.16 * (1.0 - ende); }
o.farbe = vec4f(c * al, al);
return o;
}
@fragment fn fs(i: VO) -> @location(0) vec4f { return i.farbe; }` });
const cp = device.createComputePipeline({ layout: 'auto', compute: { module: rechnen, entryPoint: 'schritt' } });
const blend = { color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }, alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' } };
const rp = topo => device.createRenderPipeline({ layout: 'auto', vertex: { module: malen, entryPoint: 'vs' }, fragment: { module: malen, entryPoint: 'fs', targets: [{ format, blend }] }, primitive: { topology: topo } });
const punkte = rp('point-list'), linien = rp('line-list');
const bgC = device.createBindGroup({ layout: cp.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: teilchen } }, { binding: 1, resource: { buffer: uni } }] });
const bgP = device.createBindGroup({ layout: punkte.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: teilchen } }, { binding: 1, resource: { buffer: uni } }] });
const bgL = device.createBindGroup({ layout: linien.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: teilchen } }, { binding: 1, resource: { buffer: uni } }] });
const u = new Float32Array(12);
return {
name: 'webgpu', n: N, kernel: 'WGSL compute',
groesse(b, h, dpr) { cGpu.width = Math.round(b * dpr); cGpu.height = Math.round(h * dpr); },
schritt(z, rechne) {
u.set([z.zeit, z.dt, W.zuluft, z.aspekt, z.person, 0.8, z.personV, N, modus, 0, 0, 0]);
device.queue.writeBuffer(uni, 0, u);
const enc = device.createCommandEncoder();
if (rechne) { const p = enc.beginComputePass(); p.setPipeline(cp); p.setBindGroup(0, bgC); p.dispatchWorkgroups(Math.ceil(N / 64)); p.end(); }
const r = enc.beginRenderPass({ colorAttachments: [{ view: ctx.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store', clearValue: { r: 0, g: 0, b: 0, a: 0 } }] });
if (modus > 1.5) { r.setPipeline(linien); r.setBindGroup(0, bgL); r.draw(N * 2); } else { r.setPipeline(punkte); r.setBindGroup(0, bgP); r.draw(N); }
r.end();
device.queue.submit([enc.finish()]);
},
weg() { device.destroy(); },
};
}
/* ---------- Pfad B: WebGL2 mit three.js, Fragment-Ping-Pong in Float-Texturen ---------- */
async function starteGl(N) {
const probe = document.createElement('canvas');
if (!probe.getContext('webgl2')) return null;
const THREE = await import('three'); // nur laden, wenn der Rückfall gebraucht wird
let renderer;
try { renderer = new THREE.WebGLRenderer({ canvas: cGl, antialias: false, alpha: true, powerPreference: 'high-performance' }); } catch (e) { return null; }
const S = Math.round(Math.sqrt(N));
const typ = renderer.extensions.has('EXT_color_buffer_float') ? THREE.FloatType : THREE.HalfFloatType;
const start = startDaten(S * S);
const startTex = new THREE.DataTexture(start, S, S, THREE.RGBAFormat, THREE.FloatType); startTex.needsUpdate = true;
const ziel = () => new THREE.WebGLRenderTarget(S, S, { type: typ, format: THREE.RGBAFormat, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, depthBuffer: false });
let A = ziel(), B = ziel();
const uniGl = { u_zeit: { value: 0 }, u_dt: { value: 0.016 }, u_zuluft: { value: W.zuluft }, u_aspekt: { value: 1 }, u_person: { value: new THREE.Vector2(0.5, 0.8) }, u_personV: { value: 0 }, u_modus: { value: 0 }, tZustand: { value: startTex } };
const kopf = `precision highp float; uniform float u_zeit, u_dt, u_zuluft, u_aspekt, u_personV, u_modus; uniform vec2 u_person; uniform sampler2D tZustand;`;
const rechnen = new THREE.ShaderMaterial({ uniforms: uniGl, vertexShader: 'void main(){ gl_Position = vec4(position.xy, 0.0, 1.0); }', fragmentShader: `${kopf}
${GLSL_FELD}
void main() {
vec2 ref = gl_FragCoord.xy / ${S}.0;
vec4 t = texture2D(tZustand, ref);
float id = floor(gl_FragCoord.y) * ${S}.0 + floor(gl_FragCoord.x);
vec2 vel = mix(t.zw, feld(t.xy, id), 1.0 - exp(-u_dt * ${W.annaeherung}.0));
vec2 pos = t.xy + vel * u_dt;
if (pos.y > 0.9 || szene(pos) < -0.002 || pos.x < 0.0 || pos.x > 1.0) {
pos = vec2(hash(vec2(id * 0.0131, u_zeit)), 0.06 + 0.03 * hash(vec2(u_zeit, id * 0.0713)));
vel = vec2(0.0, u_zuluft);
}
gl_FragColor = vec4(pos, vel);
}`, depthTest: false, depthWrite: false });
const quadSzene = new THREE.Scene(); quadSzene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), rechnen));
const kamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// Erstes Bild: Startdaten in A kopieren
renderer.setRenderTarget(A); renderer.render(quadSzene, kamera); renderer.setRenderTarget(null);
uniGl.tZustand.value = A.texture;
const ref = new Float32Array(S * S * 2), ref2 = new Float32Array(S * S * 4), ende = new Float32Array(S * S * 2);
for (let i = 0; i < S * S; i++) { const x = (i % S + 0.5) / S, y = (Math.floor(i / S) + 0.5) / S; ref[i * 2] = x; ref[i * 2 + 1] = y; ref2.set([x, y, x, y], i * 4); ende[i * 2 + 1] = 1; }
const gP = new THREE.BufferGeometry(); gP.setAttribute('position', new THREE.BufferAttribute(new Float32Array(S * S * 3), 3)); gP.setAttribute('ref', new THREE.BufferAttribute(ref, 2)); gP.setAttribute('ende', new THREE.BufferAttribute(new Float32Array(S * S), 1));
const gL = new THREE.BufferGeometry(); gL.setAttribute('position', new THREE.BufferAttribute(new Float32Array(S * S * 6), 3)); gL.setAttribute('ref', new THREE.BufferAttribute(ref2, 2)); gL.setAttribute('ende', new THREE.BufferAttribute(ende, 1));
const malVs = `uniform sampler2D tZustand; uniform float u_zuluft, u_modus; attribute vec2 ref; attribute float ende; varying vec4 vFarbe;
vec3 skala(float s, float q) { vec3 langsam = vec3(0.2, 0.24, 0.32), normal = vec3(0.62, 0.66, 0.72), schnell = vec3(0.35, 0.66, 1.0);
return mix(mix(langsam, normal, smoothstep(0.4, 0.95, s)), schnell, clamp(smoothstep(0.15, 0.6, q) + smoothstep(1.03, 1.15, s), 0.0, 1.0)); }
void main() {
vec4 t = texture2D(tZustand, ref);
vec2 p = t.xy - t.zw * ende * 0.22;
float s = length(t.zw) / u_zuluft;
gl_Position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
gl_PointSize = 1.0;
vec3 c = vec3(0.84, 0.88, 0.95); float al = 0.3;
if (u_modus > 0.5) { c = skala(s, abs(t.z) / max(length(t.zw), 1e-5)); al = 0.42; }
al *= 0.45 + 1.1 * fract(sin(dot(ref, vec2(12.9898, 78.233))) * 43758.5453);
if (u_modus > 1.5) { al = 0.16 * (1.0 - ende); }
vFarbe = vec4(c * al, al);
}`;
const malFs = `precision highp float; varying vec4 vFarbe; void main(){ gl_FragColor = vFarbe; }`;
const malMat = () => new THREE.ShaderMaterial({ uniforms: uniGl, vertexShader: malVs, fragmentShader: malFs, transparent: true, depthTest: false, depthWrite: false, blending: THREE.CustomBlending, blendSrc: THREE.OneFactor, blendDst: THREE.OneFactor, premultipliedAlpha: true });
const pts = new THREE.Points(gP, malMat()), lin = new THREE.LineSegments(gL, malMat());
pts.frustumCulled = lin.frustumCulled = false;
const bildSzene = new THREE.Scene(); bildSzene.add(pts, lin);
renderer.setClearColor(0x000000, 0);
return {
name: 'webgl', n: S * S, kernel: 'GLSL Ping-Pong',
groesse(b, h, dpr) { renderer.setPixelRatio(dpr); renderer.setSize(b, h, false); },
schritt(z, rechne) {
Object.assign(uniGl.u_zeit, { value: z.zeit }); uniGl.u_dt.value = z.dt; uniGl.u_aspekt.value = z.aspekt;
uniGl.u_person.value.set(z.person, 0.8); uniGl.u_personV.value = z.personV; uniGl.u_modus.value = modus;
if (rechne) {
uniGl.tZustand.value = A.texture;
renderer.setRenderTarget(B); renderer.render(quadSzene, kamera); renderer.setRenderTarget(null);
[A, B] = [B, A];
}
uniGl.tZustand.value = A.texture;
pts.visible = modus < 1.5; lin.visible = modus > 1.5;
renderer.render(bildSzene, kamera);
},
weg() { renderer.dispose(); },
};
}
/* ---------- Steuerung: Backend wählen, Größe, Bildschleife ---------- */
const Z = { zeit: 0, dt: 1 / 60, aspekt: 1, person: 0.48, personZiel: 0.48, personV: 0, uhr: 0 };
let zeigerDa = false, gpuMoeglich = false;
let B = 1, H = 1;
function groesse() {
const r = ansicht.getBoundingClientRect(); B = r.width; H = r.height;
Z.aspekt = B / H;
const dpr = Math.min(Math.max(devicePixelRatio || 1, W.dpr[0]), W.dpr[1]);
if (laeuft) { laeuft.groesse(B, H, dpr); laeuft.einBild(); }
}
new ResizeObserver(groesse).observe(ansicht);
function zeigeBackend(b) {
const fb = b && b.name === 'webgl';
html.classList.toggle('fallback', fb);
if (fb) $('[data-rueckfall-text]').innerHTML = gpuMoeglich
? `<b>WebGL2 gewählt</b><br>Gleiche Physik als Ping-Pong · ${b.n.toLocaleString('de-DE')} Partikel`
: `<b>WebGPU nicht verfügbar</b><br>WebGL2-Rückfall · ${b.n.toLocaleString('de-DE')} statt ${W.n.webgpu.toLocaleString('de-DE')} Partikel`;
$('[data-backend]').classList.toggle('fallback', fb);
$('[data-backend-text]').innerHTML = !b ? 'keine GPU' : fb ? (gpuMoeglich ? 'WebGL2' : 'WebGL2 <span class="text-lang">· Fallback aktiv</span>') : 'WebGPU <span class="text-lang">· Compute</span>';
$('[data-kernel]').textContent = b ? b.kernel : '–';
$('[data-status-backend]').textContent = !b ? 'ohne GPU' : fb ? `WebGL2${gpuMoeglich ? '' : ' (WebGPU nicht verfügbar)'}` : 'WebGPU';
$('[data-n]').textContent = b ? b.n.toLocaleString('de-DE') : '–';
wahl.forEach(w => { w.setAttribute('aria-pressed', String(!!b && w.dataset.wahl === b.name)); });
const g = wahl.find(w => w.dataset.wahl === 'webgpu');
g.disabled = !gpuMoeglich; g.title = gpuMoeglich ? '' : 'In diesem Browser nicht verfügbar';
}
async function waehle(name) {
if (laeuft && laeuft.name === name) return;
if (laeuft) { laeuft.weg(); laeuft = null; }
let b = null;
if (name === 'webgpu') b = await starteGpu(W.n.webgpu).catch(() => null);
if (!b) b = await starteGl(W.n.webgl).catch(e => { console.warn(e); return null; });
cGpu.hidden = !(b && b.name === 'webgpu'); cGl.hidden = !(b && b.name === 'webgl');
if (b) {
b.einBild = () => { if (!rennt) b.schritt(Z, false); };
laeuft = b;
groesse();
// Vorwärmen: Shader kompilieren und Strömung einschwingen lassen, bevor das erste Bild zählt
// 150 Schritte à 1/20 s = 7,5 s Simulationszeit: einmal durch den Raum, danach ist die Verteilung im Gleichgewicht
for (let i = 0; i < W.vorwaermen; i++) { Z.zeit += 1 / 20; Z.dt = 1 / 20; b.schritt(Z, true); }
if (reduziert()) setzeModus(2);
} else html.classList.add('ohne-gpu');
zeigeBackend(b);
anstossen();
}
wahl.forEach(w => w.addEventListener('click', () => waehle(w.dataset.wahl)));
/* Zeiger: die Person geht dorthin (nur waagerecht, sie steht auf dem Doppelboden) */
const lokalX = e => { const r = ansicht.getBoundingClientRect(); return Math.min(0.97, Math.max(0.03, (e.clientX - r.left) / r.width)); };
ansicht.addEventListener('pointermove', e => { if (html.classList.contains('vorfuehrung')) return; if (e.pointerType === 'mouse' || e.buttons) { zeigerDa = true; Z.personZiel = lokalX(e); anstossen(); } });
ansicht.addEventListener('pointerdown', e => { stoppeVorfuehrung(); zeigerDa = true; Z.personZiel = lokalX(e); anstossen(); });
ansicht.addEventListener('pointerleave', () => { zeigerDa = false; });
addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') zeigerDa = false; });
/* Pause-Knopf: eigener Zustand, damit Rahmen-pause/weiter die Wahl nicht überschreiben */
let nutzerHalt = false;
const haltKnopf = $('[data-halt]');
haltKnopf.addEventListener('click', () => {
nutzerHalt = !nutzerHalt;
haltKnopf.setAttribute('aria-pressed', String(nutzerHalt));
if (nutzerHalt) clearInterval(vUhr); else if (html.classList.contains('vorfuehrung')) vorfuehren(); else anstossen();
});
let rennt = false, letzte = 0, msMittel = 16.7, letzteAnzeige = 0, laufweg = 0;
function anstossen() { if (!rennt && laeuft && sichtbar && !angehalten && !nutzerHalt && !reduziert()) { rennt = true; letzte = performance.now(); requestAnimationFrame(bild); } }
function bild(t) {
if (!laeuft || !sichtbar || angehalten || nutzerHalt || reduziert()) { rennt = false; if (laeuft) laeuft.einBild(); return; }
const ms = Math.min(100, t - letzte); letzte = t;
const dt = Math.min(1 / 30, ms / 1000);
msMittel += (ms - msMittel) * 0.05;
Z.zeit += dt; Z.dt = dt;
// Ohne Zeiger geht die Person selbst zwischen den Anlagen hin und her
if (!zeigerDa) { laufweg += dt; Z.personZiel = 0.48 + Math.sin(laufweg * 0.45) * 0.1; }
const alt = Z.person;
Z.person += (Z.personZiel - Z.person) * (1 - Math.pow(1 - W.personLerp, dt * 60));
Z.personV = (Z.person - alt) / Math.max(dt, 1e-3) * 0.25; // Raumbreiten je s, für den Nachlauf
personEl.style.left = (Z.person * 100).toFixed(2) + '%';
laeuft.schritt(Z, true);
if (t - letzteAnzeige > 250) {
letzteAnzeige = t;
const mps = Math.abs(Z.personV / 0.25) * 9; // 9 m Raumbreite
const txt = mps < 0.05 ? 'steht' : mps.toFixed(1).replace('.', ',') + ' m/s';
personLabel.textContent = 'Person · ' + (mps < 0.05 ? '0,0 m/s' : txt); personV.textContent = txt;
$('[data-ms]').textContent = msMittel.toFixed(1).replace('.', ',') + ' ms';
$('[data-fps]').textContent = Math.round(1000 / msMittel) + ' fps · ' + laeuft.n.toLocaleString('de-DE') + ' Partikel';
}
requestAnimationFrame(bild);
}
new IntersectionObserver(([e]) => { sichtbar = e.isIntersecting; anstossen(); }).observe(ansicht);
/* ---------- Vorführung: Person geht, Darstellungen wechseln ---------- */
let vUhr = null;
function stoppeVorfuehrung() { if (!html.classList.contains('vorfuehrung')) return; clearInterval(vUhr); vUhr = null; html.classList.remove('vorfuehrung'); }
function vorfuehren() {
clearInterval(vUhr); angehalten = false; html.classList.add('vorfuehrung'); zeigerDa = false;
let i = 0; setzeModus(0);
vUhr = setInterval(() => { i = (i + 1) % 3; setzeModus(i); }, 4200);
anstossen();
}
function pause() { angehalten = true; clearInterval(vUhr); }
function weiter() { angehalten = false; if (html.classList.contains('vorfuehrung')) vorfuehren(); else anstossen(); }
function neustart() { stoppeVorfuehrung(); angehalten = false; zeigerDa = false; setzeModus(reduziert() ? 2 : 0); anstossen(); }
/* Start: WebGPU zuerst, sonst WebGL2 (wie WebGPURenderer mit forceWebGL) */
gpuMoeglich = !!(navigator.gpu && await navigator.gpu.requestAdapter().catch(() => null));
setzeModus(0);
await waehle(gpuMoeglich ? 'webgpu' : 'webgl');
</script>
<script>/* Fundus-API sofort bereitstellen, auch während das Modul noch startet */
window.FUNDUS_DEMO = window.FUNDUS_DEMO || (function () { var w = []; function f(n) { return function () { var D = window.FUNDUS_DEMO; if (D && D !== platz && D[n]) D[n](); else w.push(n); }; } var platz = { neustart: f('neustart'), vorfuehren: f('vorfuehren'), pause: f('pause'), weiter: f('weiter') }; var t = setInterval(function () { var D = window.FUNDUS_DEMO; if (D !== platz) { clearInterval(t); w.splice(0).forEach(function (n) { D[n](); }); } }, 100); return platz; })();
</script>
</body>
</html>