Zum Inhalt
← Glas & Material

Flüssigmetall / Chrom

Der Schriftzug eines Rostocker Motion-Studios fließt als flüssiges Chrom, wird zum 3D-Tropfen oder glüht in der Signalfarbe.

Ein Logo oder Schriftzug wirkt wie aus flüssigem Chrom gegossen. In 2D dient die Form als Maske. Aus ihrem Distanzfeld berechnet ein Shader eine Wölbung und daraus eine Pseudo-Normale. Mit dieser Normalen tastet er eine Umgebung aus Studio-Lichtstreifen ab, das ergibt die Chromreflexe. Eine zeit- oder cursorabhängige Verzerrung lässt die Reflexe fließen. In 3D trägt eine weiche, sich verformende Geometrie ein Material mit metalness 1 und roughness 0 bis 0.15 vor einer Environment-Map.

Chrom ist das Material von Retrofuturismus und Y2K. In hoher Renderqualität wirkt es wertig und laut zugleich. Ein Schriftzug aus Metall prägt sich sofort ein, weil er bei jeder Bewegung anders spiegelt.

Wann einsetzen

Logo-Animation, Musik, Mode, Tech-Launch, Event, Studio-Hero. Ein Akzent pro Seite.

Wann nicht

Seriöse B2B-Umfelder, in denen es verspielt wirkt. Große Flächen oder Hintergründe. Feine Schriftschnitte: Dünne Striche bieten keinen Platz für eine Wölbung.

Varianten

  • 2D-Shader auf Logo oder Schriftzug (Paper Shaders: Liquid metal)
  • 3D-Tropfen mit Chrom-Material vor einer Environment-Map
  • Verwandte Logo-Effekte auf derselben Wölbung: Wärmebild (Heatmap) oder Gem Smoke

Umsetzung

2D: Distanzfeld aus der Logo-Maske, per Jump-Flood auf der GPU, als vorab gebackene Textur oder per Distanztransformation auf der CPU. Die Normale ist der Gradient der Höhe. Weichgezeichnete Höhe nimmt die Grate an der Mittelachse, dann wirkt die Form flüssig statt facettiert. Die Kante aus dem Distanzfeld glätten. 3D: MeshPhysicalMaterial mit metalness 1, roughness 0.08, clearcoat 1 und ior 2.4 (Trionn). Eine Environment-Map ist Pflicht, sonst bleibt Metall schwarz. Ein eigenes Lichtstudio per PMREMGenerator.fromScene erspart den HDR-Download. Die Verformung läuft im Vertex-Shader mit Simplex-Rauschen, die Normale wird aus Nachbarpunkten neu berechnet.

Robust umsetzen

Telefon. pixelRatio höchstens 1.5, gröbere Geometrie. Hover durch Tippen und Ziehen ersetzen. Auf schwachen GPUs Video oder Standbild zeigen.

Ohne Bewegung. Standbild der verchromten Form. Kein Fließen, keine Welle, Wechsel ohne Animation.

Barrierefrei. Der Schriftzug bleibt echter Text im HTML, transparent über dem Canvas, oder bekommt ein aria-label. Canvas mit aria-hidden. Bedienelemente als Buttons mit aria-pressed.

Code

<!DOCTYPE html>
<!--
  Flüssigmetall / Chrom
  Fundus · Glas & Material · https://cecodesign.de/lab/fundus/glas-material/fluessigmetall-chrom/
  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>Flüssigmetall / Chrom · 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: 'TikTok Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/tiktok-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 300 900; font-stretch: 75% 150%; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  --grund: #0b0b0b; --hell: #f2f2f2; --text: #ffffff; --meta: #8a8a8a; --linie: rgba(255, 255, 255, .14);
  --signal: #ff5a1f;                         /* einzige Signalfarbe: CTA und Glut */
  --kurve: cubic-bezier(0.16, 1, 0.3, 1);    /* expo.out: Guss 1200 ms, UI 200 ms */
  --rand: clamp(18px, 3.4vw, 48px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'TikTok Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
.mono { font: 400 12px/1.35 'DM Mono', ui-monospace, monospace; letter-spacing: .04em; text-transform: uppercase; color: var(--meta); }

/* ---------- Bühne ---------- */
.buehne { position: relative; min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; grid-template-columns: minmax(0, 1fr); overflow: hidden; isolation: isolate; }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; display: block; pointer-events: none; }
.kopf, .mitte, .fuss { position: relative; z-index: 2; padding-inline: var(--rand); }

.kopf { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-block: clamp(16px, 2.4vw, 30px); }
.zeichen { font-weight: 800; font-stretch: 75%; font-size: 21px; letter-spacing: -.02em; text-transform: uppercase; color: var(--text); text-decoration: none; }
.zeichen small { font: 400 12px/1 'DM Mono', monospace; letter-spacing: .04em; color: var(--meta); margin-left: 10px; vertical-align: 3px; }
.kopf nav { display: flex; gap: clamp(18px, 2.6vw, 40px); }
.kopf nav a { color: var(--meta); text-decoration: none; transition: color .2s var(--kurve); }
.kopf nav a:hover, .kopf nav a:focus-visible { color: var(--text); }
.anfrage { display: inline-flex; align-items: center; gap: 10px; height: 38px; padding: 0 16px 0 14px; border-radius: 999px; background: var(--signal); color: var(--grund); font: 600 14px/1 'TikTok Sans', sans-serif; text-decoration: none; transition: background-color .2s var(--kurve); }
.anfrage::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--grund); }
.anfrage:hover, .anfrage:focus-visible { background: var(--hell); }

.mitte { min-width: 0; display: grid; align-content: center; justify-items: start; gap: clamp(10px, 1.4vw, 18px); padding-block: 2vh; }
.ueber { display: flex; gap: 10px 28px; flex-wrap: wrap; }
.ueber b { color: var(--hell); font-weight: 400; }
/* Wortmarke: eine Familie in zwei Breiten; wdth 75, wght 800, Laufweite −0,03em, Zeilenhöhe 0,9 */
.wortmarke { font-weight: 800; font-stretch: 75%; line-height: .9; letter-spacing: -.03em; text-transform: uppercase; font-size: clamp(3.5rem, 21vw, 22rem); white-space: nowrap; }
.wortmarke .wm { display: inline-block; }
.wortmarke .z { display: inline; }
.wortmarke.zweizeilig .z { display: block; }
.wortmarke .t { color: var(--hell); -webkit-text-stroke: 1px transparent; transition: -webkit-text-stroke-color .4s var(--kurve), color .4s var(--kurve); }

.fuss { display: flex; justify-content: space-between; align-items: end; gap: 24px 40px; padding-block: 0 clamp(18px, 2.8vw, 36px); }
.satz { max-width: 38ch; }
.satz p { font-size: 18px; line-height: 1.4; color: var(--hell); }
.satz p span { color: var(--meta); }
.steuer { display: grid; justify-items: end; gap: 12px; }
.modi { display: flex; gap: 6px; }
.reihe { display: flex; gap: 6px; align-items: center; }
/* Zugang (WCAG 2.2.2): das Metall fließt dauerhaft, also ein Knopf zum Anhalten */
.anhalten { width: 32px; height: 32px; flex: none; display: grid; place-items: center; padding: 0; color: var(--meta); background: rgba(11, 11, 11, .5); border: 1px solid var(--linie); border-radius: 999px; cursor: pointer; transition: color .2s var(--kurve), border-color .2s var(--kurve); }
.anhalten:hover, .anhalten:focus-visible { color: var(--text); border-color: rgba(255, 255, 255, .4); }
.anhalten svg { width: 10px; height: 10px; fill: currentColor; }
.anhalten .abspielen, .anhalten[aria-pressed='true'] .pause { display: none; }
.anhalten[aria-pressed='true'] .abspielen { display: block; }
.vorschau .anhalten, .reduziert .anhalten, .ohne-webgl .anhalten { display: none; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--meta); background: rgba(11, 11, 11, .5); border: 1px solid var(--linie); border-radius: 999px; height: 32px; padding: 0 14px; cursor: pointer; transition: color .2s var(--kurve), border-color .2s var(--kurve); }
.modi button:hover, .modi button:focus-visible { color: var(--text); border-color: rgba(255, 255, 255, .4); }
.modi button[aria-pressed='true'] { background: var(--hell); color: var(--grund); border-color: var(--hell); }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; max-width: 520px; font-size: 11px; }
.werte b { color: var(--hell); font-weight: 500; }

/* Gedachter Zeiger in der Vorführung */
.zeiger { position: absolute; z-index: 3; left: 0; top: 0; width: 28px; height: 28px; margin: -14px 0 0 -14px; border: 1px solid rgba(255, 255, 255, .85); border-radius: 50%; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }

/* WebGL malt die Wortmarke nach; der Text bleibt echtes HTML (lesbar, markierbar) */
.gl .wortmarke .t { color: transparent; }
.gl [data-modus='tropfen'] .wortmarke .t { -webkit-text-stroke-color: rgba(242, 242, 242, .34); }

/* Ohne WebGL: CSS-Chrom als Standbild */
.ersatz-tropfen { display: none; }
.ohne-webgl .wortmarke .t { color: transparent; background: linear-gradient(180deg, #ffffff 0%, #d4d6dc 30%, #6d6f76 47%, #0d0d10 50%, #3a3b40 58%, #c9cbd0 78%, #f4f4f6 88%, #8d8f95 100%); -webkit-background-clip: text; background-clip: text; }
.ohne-webgl [data-modus='glut'] .wortmarke .t { background-image: linear-gradient(180deg, #fff3e6 0%, #ffc79a 28%, #ff5a1f 52%, #5a1203 78%, #ff5a1f 100%); }
.ohne-webgl [data-modus='tropfen'] .wortmarke .t { background: none; -webkit-text-stroke-color: rgba(242, 242, 242, .34); }
.ohne-webgl [data-modus='tropfen'] .ersatz-tropfen { display: block; position: absolute; z-index: 1; left: 50%; top: 50%; width: min(38vw, 46vh); aspect-ratio: 1; translate: -50% -50%; border-radius: 50%; background: radial-gradient(circle at 34% 28%, #ffffff 0 4%, #c9cbd0 12%, #4a4b50 38%, #0d0d10 52%, #6d6f76 70%, #e6e7ea 86%, #7a7c82 100%); box-shadow: 0 40px 90px rgba(0, 0, 0, .6); }

/* ---------- Unter der Bühne ---------- */
.felder { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--linie); }
.felder > div { padding: clamp(36px, 5vw, 72px) var(--rand); border-right: 1px solid var(--linie); }
.felder > div:last-child { border-right: 0; }
.felder h2 { font-weight: 600; font-size: clamp(30px, 3.6vw, 52px); line-height: .95; letter-spacing: -.02em; margin: 18px 0 14px; }
.felder p:not(.mono) { color: var(--meta); font-size: 17px; line-height: 1.45; max-width: 32ch; }
.schluss { background: var(--hell); color: var(--grund); padding: clamp(56px, 8vw, 120px) var(--rand) clamp(28px, 4vw, 48px); }
.schluss h2 { font-weight: 800; font-stretch: 75%; font-size: clamp(3rem, 9vw, 9.5rem); line-height: .9; letter-spacing: -.03em; text-transform: uppercase; max-width: 12ch; }
.schluss .zeile { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: end; gap: 20px; margin-top: clamp(28px, 4vw, 56px); }
.schluss .mono { color: #555; }
.schluss .anfrage { height: 48px; padding: 0 22px 0 18px; font-size: 16px; }

.vorschau body { overflow: hidden; }
.vorschau .felder, .vorschau .schluss, .vorschau .kopf nav, .vorschau .satz span { display: none; }
.vorschau .buehne { height: 100vh; }

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

@media (max-width: 860px) {
  .kopf nav { display: none; }
  .fuss { flex-direction: column; align-items: stretch; }
  .steuer { justify-items: start; }
  .werte { justify-content: flex-start; }
  .felder { grid-template-columns: 1fr; }
  .felder > div { border-right: 0; border-bottom: 1px solid var(--linie); }
}
@media (max-width: 640px) {
  .zeichen small { display: none; }
  .anfrage { height: 34px; font-size: 13px; }
  .satz p { font-size: 16px; }
  .ohne-webgl [data-modus='tropfen'] .ersatz-tropfen { width: 62vw; }
}
@media (max-height: 640px) and (min-width: 861px) { .satz span { display: none; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
  .wortmarke .t, .gl .wortmarke .t, .ohne-webgl .wortmarke .t { color: CanvasText; background: none; -webkit-text-stroke-color: transparent; }   /* Wortmarke als echter Text statt WebGL/Verlauf */
  .buehne canvas { display: none; }
}
</style>
</head>
<body>
<section class="buehne" data-buehne data-modus="chrom" aria-labelledby="titel">
  <header class="kopf">
    <a class="zeichen" href="#start">Kaltguss<small>Rostock</small></a>
    <nav class="mono" aria-label="Hauptmenü"><a href="#arbeiten">Arbeiten</a><a href="#studio">Studio</a><a href="#showreel">Showreel 26</a></nav>
    <a class="anfrage" href="#kontakt">Projekt anfragen</a>
  </header>

  <div class="mitte">
    <p class="ueber mono"><span><b>Studio für Marke und Bewegung</b></span><span>Stadthafen, Speicher 4</span></p>
    <h1 class="wortmarke" id="titel" data-wortmarke><span class="wm"><span class="z"><span class="t">Kalt</span></span><span class="z"><span class="t">guss</span></span></span></h1>
  </div>

  <div class="fuss">
    <div class="satz">
      <p>Wir gießen Marken in Bewegung. <span>Zeichen, Showreels und Launch-Kampagnen für Musik, Mode und Messe, gemacht an der Warnow.</span></p>
    </div>
    <div class="steuer">
      <div class="reihe">
      <div class="modi mono" role="group" aria-label="Variante wählen" data-modi>
        <button type="button" data-wahl="chrom" aria-pressed="true">Chrom</button>
        <button type="button" data-wahl="tropfen" aria-pressed="false">Tropfen 3D</button>
        <button type="button" data-wahl="glut" aria-pressed="false">Glut</button>
      </div>
      <button class="anhalten" type="button" data-anhalten aria-pressed="false" aria-label="Bewegung anhalten" title="Bewegung anhalten"><svg class="pause" viewBox="0 0 10 10" aria-hidden="true"><rect x="1.5" y="1" width="2.4" height="8" rx=".6"/><rect x="6.1" y="1" width="2.4" height="8" rx=".6"/></svg><svg class="abspielen" viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 1.2v7.6L9 5z"/></svg></button>
      </div>
      <p class="werte mono" data-werte aria-live="polite"></p>
    </div>
  </div>

  <div class="ersatz-tropfen" aria-hidden="true"></div>
  <canvas data-canvas aria-hidden="true"></canvas>
  <div class="zeiger" data-zeiger aria-hidden="true"></div>
</section>

<section class="felder" id="arbeiten">
  <div><p class="mono">01 · Zeichen</p><h2>Logos, die sich bewegen</h2><p>Vom Standbild bis zur Animation auf der Bühnenwand. Ein Zeichen, das in jedem Format dieselbe Haltung hat.</p></div>
  <div><p class="mono">02 · Showreel</p><h2>Neunzig Sekunden Haltung</h2><p>Schnitt, Typo und Ton aus einer Hand. Für Labels, Festivals und Studios, die zeigen wollen, wie sie arbeiten.</p></div>
  <div><p class="mono">03 · Launch</p><h2>Ein Abend, ein Auftritt</h2><p>Kampagnen für Produktstarts und Messen. Laut am Tag selbst, ruhig und brauchbar in den Monaten danach.</p></div>
</section>
<section class="schluss" id="kontakt">
  <h2>Habt ihr ein Zeichen, das glänzen soll?</h2>
  <div class="zeile"><p class="mono">Kaltguss · Stadthafen Rostock · Mo bis Fr, 9 bis 18 Uhr</p><a class="anfrage" href="#kontakt">Gespräch vereinbaren</a></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 buehne = document.querySelector('[data-buehne]');
const canvas = document.querySelector('[data-canvas]');
const wortmarke = document.querySelector('[data-wortmarke]');
const zeiger = document.querySelector('[data-zeiger]');
const werteEl = document.querySelector('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-wahl]')];

/* Werte: Material nach Trionn (meta.json → messwerte), Bewegung nach Rezept (expo.out, 1200 ms) */
const GUSS_MS = 1200, AUS_MS = 400, VERSATZ_MS = 150;
const expoOut = t => t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
const LERP = 0.08;                       // Zeiger-Folgen je Bild bei 60 fps
const STANDZEIT = 2.4;                   // Standbild bei reduzierter Bewegung
const METALL = { metalness: 1, roughness: 0.08, clearcoat: 1, ior: 2.4 };
let modus = 'chrom';

function zeigeWerte() {
  const r = reduziert();
  const w = {
    chrom: [['SDF', '2× EDT'], ['Wölbung', 'Kreisprofil'], ['Umgebung', 'Lichtstreifen'], ['Dispersion', '0.022'], ['Fluss', r ? 'aus' : '0.6 rad/s'], ['Guss', r ? 'sofort' : '1200 ms expo.out']],
    tropfen: [['metalness', 1], ['roughness', 0.08], ['clearcoat', 1], ['ior', 2.4], ['Verformung', r ? 'still' : '0.14 · simplex'], ['Umgebung', 'PMREM']],
    glut: [['SDF', '2× EDT'], ['Glut', '#ff5a1f'], ['Schein', '24 px'], ['Fluss', r ? 'aus' : '0.25/s'], ['Wechsel', r ? 'sofort' : '1200 ms expo.out']],
  }[modus];
  werteEl.innerHTML = w.map(([k, v]) => `<span>${k} <b>${v}</b></span>`).join('');
}

/* ---------- Wortmarke auf Breite setzen (fit to width) ----------
   Gemessen wird mit Canvas-measureText in derselben Schrift: deterministisch, unabhängig vom Reflow.
   Dieselbe Messung legt fest, wo die Maske die Buchstaben malt. */
const LAUFWEITE = -0.03, ZEILENHOEHE = 0.9;
const messC = document.createElement('canvas').getContext('2d');
function setzeFont(c, fs) {
  c.font = `800 ${fs}px "TikTok Sans", system-ui, sans-serif`;
  if ('fontStretch' in c) c.fontStretch = 'condensed';   // wdth 75
  c.letterSpacing = (LAUFWEITE * fs).toFixed(3) + 'px';
}
let zeilen = ['KALTGUSS'], schriftPx = 100;
function passeSchrift() {
  const B = buehne.clientWidth, H = innerHeight;
  const zwei = B <= 640;
  wortmarke.classList.toggle('zweizeilig', zwei);
  zeilen = zwei ? ['KALT', 'GUSS'] : ['KALTGUSS'];
  const verf = (B - 2 * parseFloat(getComputedStyle(wortmarke.parentElement).paddingLeft)) * 0.995;
  setzeFont(messC, 100);
  const breite = Math.max(...zeilen.map(z => messC.measureText(z).width));
  schriftPx = Math.min(100 * verf / breite, zwei ? H * 0.25 : H * 0.5);
  wortmarke.style.fontSize = schriftPx.toFixed(2) + 'px';
}

/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, premultipliedAlpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }

let neustart = () => {}, vorfuehren = () => {}, pause = () => {}, weiter = () => {}, stoppeVorfuehrung = () => {};
let setzeModus = m => { modus = m; buehne.dataset.modus = m; knopfZustand(); zeigeWerte(); };
function knopfZustand() { knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.wahl === modus))); }
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.wahl); }));

passeSchrift();

if (!renderer) {
  html.classList.add('ohne-webgl');
  addEventListener('resize', passeSchrift);
  document.fonts.ready.then(passeSchrift);
  zeigeWerte();
  let uhr = null;
  const REIHE = ['chrom', 'tropfen', 'glut'];
  vorfuehren = () => { clearInterval(uhr); let i = 0; setzeModus('chrom'); uhr = setInterval(() => setzeModus(REIHE[++i % 3]), 4000); };
  pause = () => clearInterval(uhr);
  weiter = vorfuehren;
  neustart = () => { clearInterval(uhr); setzeModus('chrom'); };
} else {
  html.classList.add('gl');
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.0;
  renderer.autoClear = false;
  renderer.setClearColor(0x000000, 0);

  /* ======================================================================
     Variante 1 und 3: 2D-Shader auf der Wortmarke
     Maske → vorzeichenbehaftete Distanz (2× EDT) → Höhe (Kreisprofil, weichgezeichnet)
     → Pseudo-Normale → Lichtstreifen-Umgebung abtasten.
     ====================================================================== */
  const flachSzene = new THREE.Scene();
  const flachKamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const SDR = 64;   // Distanzbereich in Maskenpixeln (±64), gespeichert im Alpha-Kanal
  const feldTextur = new THREE.DataTexture(new Uint8Array(4), 1, 1, THREE.RGBAFormat);
  const U = {
    uFeld: { value: feldTextur }, uGroesse: { value: new THREE.Vector2(1, 1) }, uSkal: { value: 1 }, uDpr: { value: 1 },
    uSDR: { value: SDR }, uMaxIn: { value: 20 }, uZeit: { value: STANDZEIT }, uGuss: { value: 0 }, uGlut: { value: 0 },
    uZeiger: { value: new THREE.Vector2(0, 0) }, uOrt: { value: new THREE.Vector2(-999, -999) }, uDruck: { value: 0 },
  };
  const flachMat = new THREE.ShaderMaterial({
    uniforms: U, blending: THREE.NoBlending, depthTest: false, depthWrite: false,
    vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
    fragmentShader: `
precision highp float;
uniform sampler2D uFeld; uniform vec2 uGroesse; uniform float uSkal, uDpr, uSDR, uMaxIn, uZeit, uGuss, uGlut, uDruck;
uniform vec2 uZeiger, uOrt;
varying vec2 vUv;

float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float rauschen(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y); }

/* Studio-Umgebung als 1D-Profil: Boden mit Lichtkante, schwarze Horizontlinie, heller Himmel mit Lichtband */
const int N = 12;
const float TK[12] = float[12](-1.2, -0.62, -0.44, -0.30, -0.10, -0.03, 0.05, 0.14, 0.34, 0.50, 0.62, 1.2);
const float VK[12] = float[12]( 0.10, 0.34, 0.95, 0.40, 0.16, 0.02, 0.96, 0.78, 0.46, 1.00, 0.58, 0.90);
float studio(float t){
  float v = VK[0];
  for (int i = 0; i < N - 1; i++) v = mix(v, VK[i + 1], smoothstep(TK[i], TK[i + 1], t));
  return v;
}
vec3 glutFarbe(float x){
  x = clamp(x, 0.0, 1.0);
  vec3 c = mix(vec3(0.03, 0.01, 0.0), vec3(0.42, 0.07, 0.01), smoothstep(0.0, 0.3, x));
  c = mix(c, vec3(1.0, 0.353, 0.122), smoothstep(0.28, 0.62, x));
  c = mix(c, vec3(1.0, 0.80, 0.62), smoothstep(0.66, 0.88, x));
  return mix(c, vec3(1.0, 0.98, 0.95), smoothstep(0.9, 1.0, x));
}

void main(){
  vec2 st = vec2(vUv.x, 1.0 - vUv.y);
  vec2 p = st * uGroesse;                       // CSS-Pixel, y nach unten
  vec4 f = texture(uFeld, st);
  float sd = (f.a - 0.5) * 2.0 * uSDR;          // Maskenpixel, innen positiv
  float h = f.b;
  vec3 n = normalize(vec3(f.rg * 2.0 - 1.0, 1.0));

  float schmelze = 1.0 - uGuss;
  // Flüssige Oberfläche: Normale wandert langsam
  vec2 w = vec2(rauschen(p * 0.009 + vec2(uZeit * 0.30, 0.0)), rauschen(p * 0.009 + vec2(7.3, -uZeit * 0.30))) - 0.5;
  n.xy += w * (0.22 + 0.5 * schmelze) * smoothstep(0.0, 0.25, h);
  // Welle um den Zeiger
  vec2 dv = p - uOrt; float d = length(dv);
  float welle = sin(d * 0.05 - uZeit * 6.0) * exp(-d * d / (2.0 * 150.0 * 150.0));
  n.xy += (dv / max(d, 1.0)) * welle * 0.45 * uDruck * smoothstep(0.0, 0.2, h);
  n = normalize(n);

  // Spiegelung bei Blick von vorn
  vec3 r = reflect(vec3(0.0, 0.0, -1.0), n);
  vec2 lichtRichtung = normalize(vec2(uZeiger.x * 0.55, 1.0));
  float t = dot(r.xy, lichtRichtung) * 0.85 + abs(r.x) * 0.18
          + (0.5 - st.y) * 0.55 + uZeiger.y * 0.18
          + 0.10 * sin(p.x * 0.0045 + uZeit * 0.6)
          + (0.16 + 0.4 * schmelze) * (rauschen(p * vec2(0.0024, 0.004) + vec2(uZeit * 0.08, uZeit * 0.05)) - 0.5);
  float kante = 1.0 - n.z;
  float disp = 0.022 * (0.1 + 1.9 * kante);
  vec3 chrom = vec3(studio(t + disp), studio(t), studio(t - disp));
  chrom *= mix(vec3(1.0, 0.99, 0.98), vec3(0.95, 0.97, 1.0), smoothstep(-0.1, 0.3, t));     // Himmel leicht kühl
  // Glanzpunkt, der dem Zeiger folgt
  vec3 L = normalize(vec3(uZeiger.x * 0.8, -uZeiger.y * 0.6 + 0.35, 0.8));
  chrom += pow(max(dot(n, L), 0.0), 90.0) * 0.6;
  chrom *= 0.82 + 0.18 * smoothstep(0.0, 0.35, h);

  // Variante Glut: dieselbe Wölbung als Wärmebild in der Signalfarbe
  float g = 0.08 + h * 0.8 + 0.55 * (rauschen(p * 0.006 + vec2(0.0, -uZeit * 0.25)) - 0.5) + 0.18 * studio(t) - 0.12 * kante;
  vec3 glut = glutFarbe(g);
  vec3 farbe = mix(chrom, glut, uGlut);

  // Maske mit Kantenglättung aus dem Distanzfeld; beim Guss wächst die Form von der Strichmitte nach außen
  float schwelle = mix(uMaxIn + 1.0, -0.5, uGuss);
  float a = clamp((sd - schwelle) / uSkal * uDpr * 0.7 + 0.5, 0.0, 1.0);
  // Schein um die Form (nur Glut)
  float aussen = max(-sd, 0.0) / uSkal;
  float schein = exp(-aussen / 24.0) * 0.34 * smoothstep(uSDR / uSkal * 0.92, uSDR / uSkal * 0.3, aussen) * uGlut * uGuss;
  vec3 rgb = farbe * a + vec3(1.0, 0.353, 0.122) * schein * (1.0 - a);
  gl_FragColor = vec4(rgb, a + schein * (1.0 - a));
}`,
  });
  const flach = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), flachMat);
  flach.frustumCulled = false;
  flachSzene.add(flach);

  /* Felzenszwalb-Distanztransformation (exakt, linear), quadrierte Abstände */
  function edt1d(f, n, d, v, z) {
    let k = 0; v[0] = 0; z[0] = -1e20; z[1] = 1e20;
    for (let q = 1; q < n; q++) {
      let s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
      while (s <= z[k]) { k--; s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); }
      k++; v[k] = q; z[k] = s; z[k + 1] = 1e20;
    }
    k = 0;
    for (let q = 0; q < n; q++) { while (z[k + 1] < q) k++; const dq = q - v[k]; d[q] = dq * dq + f[v[k]]; }
  }
  function edt(g, w, h) {
    const n = Math.max(w, h), f = new Float64Array(n), d = new Float64Array(n), v = new Int32Array(n), z = new Float64Array(n + 1);
    for (let x = 0; x < w; x++) { for (let y = 0; y < h; y++) f[y] = g[y * w + x]; edt1d(f, h, d, v, z); for (let y = 0; y < h; y++) g[y * w + x] = d[y]; }
    for (let y = 0; y < h; y++) { const o = y * w; for (let x = 0; x < w; x++) f[x] = g[o + x]; edt1d(f, w, d, v, z); for (let x = 0; x < w; x++) g[o + x] = d[x]; }
  }
  function boxBlur(a, w, h, r) {
    const t = new Float32Array(a.length), n = 2 * r + 1;
    for (let y = 0; y < h; y++) { const o = y * w; let s = 0; for (let x = -r; x <= r; x++) s += a[o + Math.min(w - 1, Math.max(0, x))];
      for (let x = 0; x < w; x++) { t[o + x] = s / n; s += a[o + Math.min(w - 1, x + r + 1)] - a[o + Math.max(0, x - r)]; } }
    for (let x = 0; x < w; x++) { let s = 0; for (let y = -r; y <= r; y++) s += t[Math.min(h - 1, Math.max(0, y)) * w + x];
      for (let y = 0; y < h; y++) { a[y * w + x] = s / n; s += t[Math.min(h - 1, y + r + 1) * w + x] - t[Math.max(0, y - r) * w + x]; } }
  }

  const maskLeinwand = document.createElement('canvas');
  let maxInPx = 20;
  function maleMaske() {
    const r = buehne.getBoundingClientRect();
    const s = Math.min(1, 1200 / r.width);     // Maskenauflösung: höchstens 1200 px breit
    const mw = Math.max(2, Math.round(r.width * s)), mh = Math.max(2, Math.round(r.height * s));
    maskLeinwand.width = mw; maskLeinwand.height = mh;
    const c = maskLeinwand.getContext('2d', { willReadFrequently: true });
    c.setTransform(s, 0, 0, s, 0, 0);
    c.fillStyle = '#000'; c.fillRect(0, 0, r.width, r.height);
    // Zeilen an der Stelle des (transparenten) HTML-Textes malen: Grundlinie aus Zeilenhöhe und Schriftmetrik
    const hr = wortmarke.getBoundingClientRect();
    setzeFont(c, schriftPx);
    c.fillStyle = '#fff'; c.textBaseline = 'alphabetic';
    const m = c.measureText('KALTGUSS'), A = m.fontBoundingBoxAscent, D = m.fontBoundingBoxDescent, lh = ZEILENHOEHE * schriftPx;
    zeilen.forEach((z, i) => c.fillText(z, hr.left - r.left, hr.top - r.top + i * lh + (lh - (A + D)) / 2 + A));
    const px = c.getImageData(0, 0, mw, mh).data, N = mw * mh;
    const innen = new Float64Array(N), aussen = new Float64Array(N), maske = new Uint8Array(N);
    for (let i = 0; i < N; i++) { const m = px[i * 4] > 127; maske[i] = m; innen[i] = m ? 1e20 : 0; aussen[i] = m ? 0 : 1e20; }
    edt(innen, mw, mh); edt(aussen, mw, mh);
    const sd = new Float32Array(N); let maxIn = 1;
    for (let i = 0; i < N; i++) {
      if (maske[i]) { sd[i] = Math.sqrt(innen[i]) - 0.5; if (sd[i] > maxIn) maxIn = sd[i]; } else sd[i] = -(Math.sqrt(aussen[i]) - 0.5);
      // Randpixel: Kantenglättung der Canvas-Schrift als Subpixel-Lage der Kante nutzen
      if (Math.abs(sd[i]) < 1) sd[i] = px[i * 4] / 255 - 0.5;
    }
    // Höhe: Kreisprofil über die Strichbreite, dann weichgezeichnet (flüssig statt Facette am Mittelgrat)
    const R = maxIn, hoehe = new Float32Array(N);
    for (let i = 0; i < N; i++) if (sd[i] > 0) { const q = 1 - Math.min(sd[i] / R, 1); hoehe[i] = Math.sqrt(1 - q * q); }
    const rb = Math.max(1, Math.round(R * 0.6));
    boxBlur(hoehe, mw, mh, rb); boxBlur(hoehe, mw, mh, rb); boxBlur(hoehe, mw, mh, rb);
    const K = R * 1.7, daten = new Uint8Array(N * 4);
    for (let y = 0; y < mh; y++) for (let x = 0; x < mw; x++) {
      const i = y * mw + x;
      const dx = (hoehe[i + (x < mw - 1 ? 1 : 0)] - hoehe[i - (x > 0 ? 1 : 0)]) * 0.5;
      const dy = (hoehe[i + (y < mh - 1 ? mw : 0)] - hoehe[i - (y > 0 ? mw : 0)]) * 0.5;
      let nx = -dx * K, ny = dy * K; const l = Math.hypot(nx, ny, 1); nx /= l; ny /= l;
      daten[i * 4] = Math.round((nx * 0.5 + 0.5) * 255);
      daten[i * 4 + 1] = Math.round((ny * 0.5 + 0.5) * 255);
      daten[i * 4 + 2] = Math.round(Math.min(1, hoehe[i]) * 255);
      daten[i * 4 + 3] = Math.round(Math.min(1, Math.max(0, 0.5 + sd[i] / (2 * SDR))) * 255);
    }
    const tex = new THREE.DataTexture(daten, mw, mh, THREE.RGBAFormat);
    tex.minFilter = tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false; tex.needsUpdate = true;
    const alt = U.uFeld.value; U.uFeld.value = tex; alt.dispose();
    U.uSkal.value = s; U.uMaxIn.value = maxIn; maxInPx = maxIn / s;
    maskSignatur = signatur();
  }
  let maskSignatur = '';
  const signatur = () => { const q = wortmarke.getBoundingClientRect(); return [q.left, q.top, q.height, schriftPx, buehne.clientWidth, buehne.clientHeight].map(Math.round).join(','); };

  /* ======================================================================
     Variante 2: 3D-Tropfen mit Chrom-Material (Werte nach Trionn)
     ====================================================================== */
  const tropfenSzene = new THREE.Scene();
  const kamera = new THREE.PerspectiveCamera(30, 1, 0.1, 60);
  kamera.position.set(0, 0, 10);
  const pmrem = new THREE.PMREMGenerator(renderer);
  const studio = new THREE.Scene();
  {
    const lw = document.createElement('canvas'); lw.width = 4; lw.height = 256;
    const c = lw.getContext('2d'), g = c.createLinearGradient(0, 0, 0, 256);
    g.addColorStop(0, '#3a3c44'); g.addColorStop(0.44, '#0c0c0e'); g.addColorStop(0.5, '#000000'); g.addColorStop(0.52, '#b9bbc2'); g.addColorStop(0.7, '#7d7f86'); g.addColorStop(1, '#3a3b40');
    c.fillStyle = g; c.fillRect(0, 0, 4, 256);
    const t = new THREE.CanvasTexture(lw); t.colorSpace = THREE.SRGBColorSpace;
    studio.add(new THREE.Mesh(new THREE.SphereGeometry(20, 32, 16), new THREE.MeshBasicMaterial({ map: t, side: THREE.BackSide })));
    const box = (w, h, farbe, staerke, pos) => {
      const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(farbe).multiplyScalar(staerke), side: THREE.DoubleSide }));
      m.position.set(...pos); m.lookAt(0, 0, 0); studio.add(m);
    };
    box(18, 4, '#ffffff', 4, [0, 11, 0]);           // Deckenlicht
    box(2.4, 16, '#e8eeff', 3.5, [-11, 2, 2]);      // Lichtkante links, kühl
    box(2, 14, '#ffffff', 3, [11, 1, -1]);          // Lichtkante rechts
    box(1.4, 10, '#ffffff', 4.5, [-6, 3, 8]);       // Lichtstreifen vorn links, senkrecht
    box(10, 1.1, '#ffffff', 2.6, [5, 6.5, 7]);      // Lichtstreifen vorn rechts oben, waagerecht
    box(8, 0.8, '#ff5a1f', 3, [4, -2.2, 10]);       // ein Streifen Signalfarbe, knapp unter dem Horizont
    box(22, 0.5, '#ffffff', 2.2, [0, -6.5, 8]);     // Bodenkante: scharfe Linie in der unteren Hälfte
    box(10, 0.35, '#ffffff', 1.6, [-4, -9, 3]);     // zweite, schwächere Bodenlinie
  }
  tropfenSzene.environment = pmrem.fromScene(studio, 0.015).texture;

  const tropfenU = { uZeit: { value: STANDZEIT }, uAmp: { value: 0.14 } };
  const NOISE = `
vec4 permute4(vec4 x){ return mod(((x * 34.0) + 1.0) * x, 289.0); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v){
  const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0); const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
  vec3 i = floor(v + dot(v, C.yyy)); vec3 x0 = v - i + dot(i, C.xxx);
  vec3 g = step(x0.yzx, x0.xyz); vec3 l = 1.0 - g; vec3 i1 = min(g.xyz, l.zxy); vec3 i2 = max(g.xyz, l.zxy);
  vec3 x1 = x0 - i1 + C.xxx; vec3 x2 = x0 - i2 + C.yyy; vec3 x3 = x0 - D.yyy;
  i = mod(i, 289.0);
  vec4 p = permute4(permute4(permute4(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
  float n_ = 1.0 / 7.0; vec3 ns = n_ * D.wyz - D.xzx;
  vec4 j = p - 49.0 * floor(p * ns.z * ns.z); vec4 x_ = floor(j * ns.z); vec4 y_ = floor(j - 7.0 * x_);
  vec4 x = x_ * ns.x + ns.yyyy; vec4 y = y_ * ns.x + ns.yyyy; vec4 h = 1.0 - abs(x) - abs(y);
  vec4 b0 = vec4(x.xy, y.xy); vec4 b1 = vec4(x.zw, y.zw);
  vec4 s0 = floor(b0) * 2.0 + 1.0; vec4 s1 = floor(b1) * 2.0 + 1.0; vec4 sh = -step(h, vec4(0.0));
  vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
  vec3 p0 = vec3(a0.xy, h.x); vec3 p1 = vec3(a0.zw, h.y); vec3 p2 = vec3(a1.xy, h.z); vec3 p3 = vec3(a1.zw, h.w);
  vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
  vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); m = m * m;
  return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
float verformung(vec3 p){
  return uAmp * (0.88 * snoise(p * 0.85 + vec3(0.0, uZeit * 0.32, 0.0)) + 0.12 * snoise(p * 1.7 - vec3(uZeit * 0.22)));
}`;
  const tropfenMat = new THREE.MeshPhysicalMaterial({
    color: 0xf4f4f6, metalness: METALL.metalness, roughness: METALL.roughness, clearcoat: METALL.clearcoat, clearcoatRoughness: 0.04,
    ior: METALL.ior, envMapIntensity: 1.25,
  });
  tropfenMat.onBeforeCompile = sh => {
    Object.assign(sh.uniforms, tropfenU);
    sh.vertexShader = sh.vertexShader
      .replace('#include <common>', '#include <common>\nuniform float uZeit;\nuniform float uAmp;\n' + NOISE + '\n')
      .replace('#include <beginnormal_vertex>', `
vec3 n0 = normalize(position);
vec3 tg = normalize(cross(n0, abs(n0.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
vec3 bt = cross(n0, tg);
vec3 pA = n0 * (1.0 + verformung(n0));
vec3 qB = normalize(n0 + tg * 0.012); vec3 pB = qB * (1.0 + verformung(qB));
vec3 qC = normalize(n0 + bt * 0.012); vec3 pC = qC * (1.0 + verformung(qC));
vec3 objectNormal = normalize(cross(pB - pA, pC - pA));
vec3 verschoben = pA;
#ifdef USE_TANGENT
vec3 objectTangent = tg;
#endif`)
      .replace('#include <begin_vertex>', 'vec3 transformed = verschoben;');
  };
  tropfenMat.customProgramCacheKey = () => 'kaltguss-tropfen';
  const tropfen = new THREE.Mesh(new THREE.IcosahedronGeometry(1, beruehrung ? 20 : 30), tropfenMat);
  tropfenSzene.add(tropfen);
  const perlenMat = new THREE.MeshPhysicalMaterial({ color: 0xf4f4f6, metalness: 1, roughness: 0.08, clearcoat: 1, ior: 2.4, envMapIntensity: 1.25 });
  const perlen = [0.13, 0.08].map(g => { const m = new THREE.Mesh(new THREE.SphereGeometry(g, 32, 16), perlenMat); tropfenSzene.add(m); return m; });

  /* ---------- Maße, Zeiger, Zustand ---------- */
  let B = 1, H = 1, tropfenR = 1, mitte = new THREE.Vector2(0.5, 0.5);
  const zeigerZiel = new THREE.Vector2(), zeigerIst = new THREE.Vector2();
  const ortZiel = new THREE.Vector2(-999, -999), ortIst = new THREE.Vector2(-999, -999);
  let druckZiel = 0, letzterPunkt = null;
  const zustand = { flach: { von: 0, bis: 0, start: 0, dauer: 0, w: 0 }, glut: { von: 0, bis: 0, start: 0, dauer: 0, w: 0 }, tropfen: { von: 0, bis: 0, start: 0, dauer: 0, w: 0 } };
  let zeit = STANDZEIT;

  function groesse() {
    passeSchrift();
    const r = buehne.getBoundingClientRect(); B = r.width; H = r.height;
    const dpr = Math.min(devicePixelRatio || 1, beruehrung ? 1.5 : 2);
    renderer.setPixelRatio(dpr); renderer.setSize(B, H, false);
    U.uGroesse.value.set(B, H); U.uDpr.value = dpr;
    kamera.aspect = B / H; kamera.updateProjectionMatrix();
    const wr = wortmarke.getBoundingClientRect();
    setzeFont(messC, schriftPx);
    const zb = Math.max(...zeilen.map(z => messC.measureText(z).width));
    mitte.set((wr.left + zb / 2 - r.left) / B, (wr.top + wr.height * 0.5 - r.top) / H);
    const proPx = 2 * kamera.position.z * Math.tan(THREE.MathUtils.degToRad(kamera.fov / 2)) / H;
    tropfenR = Math.min(B * 0.3, H * 0.2, wr.height * (B <= 640 ? 0.5 : 0.66)) * proPx;
    maleMaske(); malen();
  }
  function inWelt(ax, ay) {
    const h = 2 * kamera.position.z * Math.tan(THREE.MathUtils.degToRad(kamera.fov / 2));
    return new THREE.Vector3((ax - 0.5) * h * (B / H), -(ay - 0.5) * h, 0);
  }

  function setzeZeiger(cx, cy, aktiv = true) {
    if (reduziert()) return;
    const r = buehne.getBoundingClientRect();
    const x = cx - r.left, y = cy - r.top, jetzt = performance.now();
    if (letzterPunkt && aktiv) {
      const v = Math.hypot(x - letzterPunkt.x, y - letzterPunkt.y) / Math.max(8, jetzt - letzterPunkt.t) * 1000;
      druckZiel = Math.min(1, Math.max(druckZiel, v / 900));
    }
    letzterPunkt = { x, y, t: jetzt };
    zeigerZiel.set(x / B * 2 - 1, y / H * 2 - 1);
    ortZiel.set(x, y);
    if (ortIst.x < -900) ortIst.copy(ortZiel);
    anstossen();
  }
  buehne.addEventListener('pointermove', e => {
    if (html.classList.contains('vorfuehrung')) return;
    if (e.pointerType === 'touch' && e.buttons === 0) return;
    setzeZeiger(e.clientX, e.clientY);
  });
  buehne.addEventListener('pointerdown', e => {
    if (e.target.closest('button, a')) return;
    stoppeVorfuehrung(); letzterPunkt = null; druckZiel = 1;
    setzeZeiger(e.clientX, e.clientY, false);
  });
  buehne.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) { zeigerZiel.set(0, 0); druckZiel = 0; letzterPunkt = null; anstossen(); } });

  /* ---------- Wechsel der Varianten ---------- */
  function blende(z, bis, dauer, verz = 0) { const jetzt = performance.now(); Object.assign(z, { von: z.w, bis, start: jetzt + verz, dauer }); }
  setzeModus = (m, sofort) => {
    const ohne = sofort || reduziert();
    const alt = modus; modus = m;
    buehne.dataset.modus = m; knopfZustand(); zeigeWerte();
    const flachSoll = m === 'tropfen' ? 0 : 1, glutSoll = m === 'glut' ? 1 : 0, tropfenSoll = m === 'tropfen' ? 1 : 0;
    if (ohne) {
      for (const [k, v] of [['flach', flachSoll], ['glut', glutSoll], ['tropfen', tropfenSoll]]) Object.assign(zustand[k], { von: v, bis: v, w: v, dauer: 0, start: 0 });
    } else {
      blende(zustand.flach, flachSoll, flachSoll ? GUSS_MS : AUS_MS, flachSoll && alt === 'tropfen' ? VERSATZ_MS : 0);
      blende(zustand.glut, glutSoll, GUSS_MS);
      blende(zustand.tropfen, tropfenSoll, tropfenSoll ? GUSS_MS : AUS_MS, tropfenSoll ? VERSATZ_MS : 0);
      if (tropfenSoll) tropfen.rotation.y -= 1.2;
    }
    anstossen();
  };

  /* ---------- Bildschleife ---------- */
  let laeuft = false, letzte = performance.now(), sichtbar = true, angehalten = false;
  function anstossen() { if (!laeuft && sichtbar) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
  function bild(t) {
    if (!sichtbar) { laeuft = false; return; }
    const dt = Math.min(64, t - letzte) / 1000; letzte = t;
    const k = 1 - Math.pow(1 - LERP, dt * 60);
    let ruhig = true;
    for (const z of Object.values(zustand)) {
      const p = z.dauer ? THREE.MathUtils.clamp((t - z.start) / z.dauer, 0, 1) : 1;
      z.w = z.von + (z.bis - z.von) * expoOut(p);
      if (p < 1) ruhig = false;
    }
    if (angehalten) { /* angehalten: Metall, Tropfen und Welle stehen, Zustandswechsel laufen zu Ende */ }
    else if (!reduziert()) {
      zeit += dt;
      zeigerIst.lerp(zeigerZiel, k);
      if (ortZiel.x > -900) ortIst.lerp(ortZiel, k * 1.6);
      druckZiel *= Math.pow(0.35, dt);                          // Welle klingt ab
      if (html.classList.contains('vorfuehrung')) druckZiel = Math.max(druckZiel, 0.75);
      U.uDruck.value += (druckZiel - U.uDruck.value) * k;
      tropfen.rotation.y += dt * 0.18;
      ruhig = false;                                            // Metall fließt dauerhaft
    } else { zeit = STANDZEIT; zeigerIst.set(0, 0); U.uDruck.value = 0; }
    malen();
    if (ruhig) { laeuft = false; return; }
    requestAnimationFrame(bild);
  }
  function malen() {
    renderer.clear();
    const fw = zustand.flach.w;
    if (fw > 0.001) {
      U.uZeit.value = zeit; U.uGuss.value = fw; U.uGlut.value = zustand.glut.w;
      U.uZeiger.value.copy(zeigerIst); U.uOrt.value.copy(ortIst);
      renderer.render(flachSzene, flachKamera);
    }
    const tw = zustand.tropfen.w;
    if (tw > 0.001) {
      tropfenU.uZeit.value = zeit;
      tropfenU.uAmp.value = 0.14 + 0.3 * (1 - tw) + 0.08 * U.uDruck.value;
      const ort = inWelt(mitte.x, mitte.y);
      tropfen.position.copy(ort);
      tropfen.position.x += zeigerIst.x * 0.15 * tropfenR; tropfen.position.y -= zeigerIst.y * 0.1 * tropfenR;
      tropfen.rotation.x = 0.25 + zeigerIst.y * 0.35;
      tropfen.rotation.z = zeigerIst.x * -0.2;
      const s = tropfenR * (0.4 + 0.6 * tw) * Math.min(1, tw * 1.8);
      tropfen.scale.setScalar(s);
      perlen.forEach((m, i) => {
        const a = zeit * (i ? -0.42 : 0.3) + i * 2.4, rr = (i ? 1.5 : 1.35) * tropfenR;
        m.position.set(ort.x + Math.cos(a) * rr * 1.2, ort.y + Math.sin(a * 1.3) * rr * 0.3 + (i ? -0.3 : 0.3) * tropfenR, ort.z + Math.sin(a) * rr * 0.6);
        m.scale.setScalar(tropfenR * Math.min(1, Math.max(0, tw * 1.6 - 0.4)));
      });
      renderer.render(tropfenSzene, kamera);
    }
  }

  new IntersectionObserver(([e]) => { sichtbar = e.isIntersecting; if (sichtbar) anstossen(); }).observe(buehne);
  let rz; addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(groesse, 140); });

  /* ---------- Vorführung: gedachter Zeiger, Varianten im Wechsel ---------- */
  const REIHE = ['chrom', 'tropfen', 'glut'], TAKT = 5200;
  let uhr = null, vStart = 0, vZeit = 0, vPausiert = false;
  function vSchritt(t) {
    const s = (t - vStart) / 1000;
    const ax = 0.5 + 0.34 * Math.sin(s * 0.85), ay = mitte.y + 0.14 * Math.sin(s * 1.35 + 0.6);
    const r = buehne.getBoundingClientRect();
    zeiger.style.transform = `translate(${ax * B}px, ${ay * H}px)`;
    letzterPunkt = null; setzeZeiger(r.left + ax * B, r.top + ay * H, false);
    const soll = REIHE[Math.floor(Math.max(0, t - vStart) / TAKT) % REIHE.length];
    if (soll !== modus) setzeModus(soll);
    uhr = requestAnimationFrame(vSchritt);
  }
  vorfuehren = () => {
    cancelAnimationFrame(uhr); clearTimeout(uhr);
    html.classList.add('vorfuehrung'); vPausiert = false;
    if (angehalten) { angehalten = false; anhaltenZeigen(); anstossen(); }
    vStart = performance.now();
    if (reduziert()) {
      setzeModus('chrom', true);
      const takt = () => { setzeModus(REIHE[Math.floor((performance.now() - vStart) / 4000) % 3], true); uhr = setTimeout(takt, 250); };
      takt(); return;
    }
    neuGiessen(); uhr = requestAnimationFrame(vSchritt);
  };
  pause = () => { cancelAnimationFrame(uhr); clearTimeout(uhr); uhr = null; if (html.classList.contains('vorfuehrung')) { vPausiert = true; vZeit = performance.now() - vStart; } angehalten = true; anhaltenZeigen(); };
  /* Knopf „Bewegung anhalten“: dieselbe Pause wie FUNDUS_DEMO.pause() */
  const anhaltenKnopf = document.querySelector('[data-anhalten]');
  function anhaltenZeigen() { anhaltenKnopf.setAttribute('aria-pressed', angehalten ? 'true' : 'false'); }
  anhaltenKnopf.addEventListener('click', () => { if (!angehalten) { pause(); return; } angehalten = false; anhaltenZeigen(); letzte = performance.now(); anstossen(); });
  weiter = () => {
    angehalten = false; anhaltenZeigen(); anstossen();
    if (!vPausiert || !html.classList.contains('vorfuehrung')) return;
    vPausiert = false; vStart = performance.now() - vZeit;
    if (reduziert()) { vorfuehren(); return; }
    uhr = requestAnimationFrame(vSchritt);
  };
  stoppeVorfuehrung = () => {
    if (!html.classList.contains('vorfuehrung')) return;
    cancelAnimationFrame(uhr); clearTimeout(uhr); uhr = null; vPausiert = false;
    html.classList.remove('vorfuehrung');
  };
  function neuGiessen() {
    for (const z of Object.values(zustand)) Object.assign(z, { von: 0, bis: 0, w: 0, dauer: 0 });
    modus = 'chrom'; setzeModus('chrom');
  }
  neustart = () => {
    stoppeVorfuehrung();
    if (angehalten) { angehalten = false; anhaltenZeigen(); anstossen(); }
    zeigerZiel.set(0, 0); druckZiel = 0; ortZiel.set(-999, -999); ortIst.set(-999, -999);
    neuGiessen();
  };

  /* Start erst nach dem load-Ereignis: Distanzfeld und Shader-Kompilierung blockieren so das Laden nicht */
  function start() {
    groesse();
    document.fonts.load('condensed 800 100px "TikTok Sans"').then(() => document.fonts.ready).then(groesse);
    // Sicherheitsnetz: Maske neu, falls sich das Layout nach dem Laden noch verschoben hat
    [400, 1200, 2500].forEach(ms => setTimeout(() => { passeSchrift(); if (signatur() !== maskSignatur) groesse(); }, ms));
    if (!html.classList.contains('vorfuehrung')) neustart();
  }
  if (document.readyState === 'complete') requestAnimationFrame(start);
  else addEventListener('load', () => requestAnimationFrame(start), { once: true });
}
zeigeWerte();

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