3D- und Shader-Typografie
Ein Wort wird zum Material: In WebGL gerendert, wölbt, verdrillt oder schmilzt es unter dem Zeiger, der echte Text bleibt darunter im DOM.
- Der Text wird in WebGL gezeichnet: als SDF- oder MSDF-Schrift (troika-three-text, MSDF-Atlas) oder als Textur aus einem Canvas.
- Ein Vertex- oder Fragment-Shader verformt ihn: verdrillen um die eigene Achse, wölben unter dem Cursor, Wellen, verflüssigen, Rauschen.
- Die Parameter hängen an Cursor, Scroll oder Zeit.
- Der echte HTML-Text bleibt als Ebene darunter, für Screenreader, Auswahl und Suchmaschinen.
Die Schrift hört auf, Fläche zu sein, und wird zum Material: Gummi, Flüssigkeit, Glas, Creme. Das unterscheidet sich deutlich von allem, was DOM und CSS können, und bleibt im Gedächtnis. Es passt zum Trend Tactile Maximalism mit skulpturaler Typografie.
Wann einsetzen
Ein einzelnes Hero-Wort als Signatur. Portfolios von Kreativ-Entwicklern, Kampagnen, Musik und Kultur. Auch bei Marken mit sinnlichem Produkt (Eis, Schokolade, Getränke), wenn das Wort die Eigenschaft des Produkts zeigt.
Wann nicht
Lesetext und alles, was Information trägt. Projekte ohne WebGL-Erfahrung oder Budget. Seiten, bei denen Ladezeit kritisch ist. Nie mehrere solcher Wörter auf einem Bildschirm.
Varianten
- Verdrillen in 3D: Text dreht sich um die Längsachse (Codrops: Twisting and rotating text in 3D with Three.js and shaders)
- Wölbung unter dem Cursor (Codrops, React Three Fiber)
- Verflüssigen: eine Fluid-Simulation verzerrt die Schrift unter dem Cursor (Unseen Studio)
- Tippbarer 3D-Text (Codrops, Ksenia Kondrashova)
- Flüssig- oder Glas-Typo mit WebGPU und TSL
- Leichtere Alternative ohne WebGL: Motion-Type mit SVG und GSAP (Codrops, Yuri Artiukh)
Umsetzung
Three.js mit troika-three-text (SDF), MSDF-Atlas oder Canvas-Textur auf einer fein unterteilten Ebene. Verdrillen im Vertex-Shader: pos.yz = rotate(pos.yz, uTwist * pos.x). Wölbung: d = Abstand zur Cursor-UV, pos.z += smoothstep(r, 0., d) * strength. Normalen nach der Verformung neu berechnen, sonst fehlt das Licht.
Fallstricke: Für Atlas oder Canvas-Textur erst nach document.fonts.load zeichnen, sonst landet die Ersatzschrift in der Textur. Schriftlizenz für die Umwandlung in einen Atlas prüfen. Pixel-Verhältnis auf höchstens 2 begrenzen. Das WebGL-Wort genau über das Rechteck des HTML-Worts legen, damit Layout und Fallback identisch sind.
Robust umsetzen
Telefon. Einfachere Shader, weniger Geometrie und kleinere Canvas-Auflösung. Ohne Hover braucht es eigenes Verhalten: Wischen, Tippen oder ein leiser Selbstlauf. Alternativ ein Standbild oder kurzes Video.
Ohne Bewegung. Standbild der Typo oder der HTML-Text ohne Verformung.
Barrierefrei. Canvas mit aria-hidden, der echte Text bleibt im DOM. Keine Information nur im WebGL. Das HTML-Wort ist der Fallback ohne WebGL und nach Kontextverlust.
Code
<!DOCTYPE html>
<!--
3D- und Shader-Typografie
Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/kinetic-3d-shader-type/
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>3D- und Shader-Typografie · 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: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
/* Gleiche Datei, Achse SOFT 100 fest im @font-face: so übernimmt auch Canvas 2D (Textur für WebGL) die weiche Form */
@font-face { font-family: 'Fraunces Weich'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: block; font-variation-settings: "SOFT" 100, "WONK" 0; }
@font-face { font-family: 'Commissioner'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/commissioner:vf@latest/latin-full-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 {
--papier: #f0ece0; --tinte: #353839; --tinte-leise: rgba(53, 56, 57, .76); --tinte-linie: rgba(53, 56, 57, .14);
--wald: #293e3a; --wald-tief: #1f302d; --salbei: #7f937a; --senf: #eac16e; --rose: #e1a894;
--creme: #f3ead6; --creme-leise: rgba(243, 234, 214, .62); --creme-linie: rgba(243, 234, 214, .16);
--front: #f4e7c5; --seite: #c79a52; /* Sorte: setzt das Skript je Variante */
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out */
--rand: clamp(20px, 3.4vw, 52px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--wald); color: var(--creme); font-family: 'Commissioner', system-ui, sans-serif; font-size: 100%; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
button, a { font: inherit; color: inherit; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Versal-Labels der Seite: Commissioner mit FLAR als Brücke zur Serif (Rezept), 0.75rem / 0.15em */
.rubrik { font-size: .75rem; font-weight: 500; letter-spacing: .15em; text-transform: uppercase; font-variation-settings: "FLAR" 80; }
/* Bedienelemente der Demo (Fundus-Ebene): Geist Mono */
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
/* ---------- Bühne ---------- */
.buehne { position: relative; min-height: 100svh; display: grid; grid-template-rows: auto auto 1fr auto auto; isolation: isolate; overflow: hidden;
background: radial-gradient(120% 70% at 50% 46%, #335049 0%, var(--wald) 52%, var(--wald-tief) 100%); }
.buehne > * { position: relative; z-index: 2; }
.gl { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; display: block; pointer-events: none; }
.kopf { display: flex; align-items: center; gap: 28px; padding: clamp(16px, 2.2vw, 28px) var(--rand); }
.marke { display: flex; align-items: baseline; gap: 10px; text-decoration: none; }
.marke b { font: 600 26px/1 'Fraunces Weich', Georgia, serif; letter-spacing: -.02em; }
.marke span { color: var(--creme-leise); }
.kopf nav { display: flex; gap: 26px; margin-left: auto; }
.kopf nav a { text-decoration: none; color: var(--creme-leise); font-size: 15px; transition: color .2s var(--kurve); }
.kopf nav a:hover, .kopf nav a:focus-visible { color: var(--creme); }
.status { display: inline-flex; align-items: center; gap: 9px; height: 34px; padding: 0 14px; border-radius: 999px; background: rgba(243, 234, 214, .08); border: 1px solid var(--creme-linie); font-size: 14px; white-space: nowrap; }
.status i { width: 7px; height: 7px; border-radius: 50%; background: var(--senf); box-shadow: 0 0 0 4px rgba(234, 193, 110, .18); }
.status.zu i { background: var(--rose); box-shadow: 0 0 0 4px rgba(225, 168, 148, .16); }
.oben { display: flex; justify-content: space-between; gap: 20px; padding: 0 var(--rand); color: var(--creme-leise); }
.oben b { color: var(--creme); font-weight: 500; }
.mitte { display: grid; place-items: center; padding: 0 var(--rand); min-height: 0; touch-action: pan-y; }
h1 { font-weight: 400; margin: 0; line-height: 1; }
.wort { display: inline-block; white-space: nowrap; font: 600 clamp(80px, 19vw, 330px)/1.1 'Fraunces Weich', Georgia, serif; letter-spacing: -.02em; color: var(--front);
text-shadow: 1px 1px 0 var(--seite), 2px 2px 0 var(--seite), 3px 3px 0 var(--seite), 4px 4px 0 var(--seite), 5px 5px 0 var(--seite), 6px 6px 0 var(--seite), 7px 8px 18px rgba(10, 20, 18, .35); }
.wort { transition-property: none; } /* wird per JS eingepasst: keine Übergänge, sonst misst getComputedStyle den alten Wert */
.gl-an .wort { color: transparent; text-shadow: none; } /* bleibt als echter Text im DOM (Auswahl, Vorlesen), WebGL zeichnet darüber */
.hinweis { margin-top: 18px; color: var(--creme-leise); text-align: center; transition: opacity .4s var(--kurve); }
.angefasst .hinweis { opacity: 0; }
.unten { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr) auto; gap: clamp(20px, 3vw, 48px); align-items: end; padding: clamp(14px, 2vw, 24px) var(--rand) clamp(14px, 1.8vw, 22px); }
.sorte .rubrik { color: var(--creme-leise); }
.sorte h2 { font: 500 clamp(22px, 1.9vw, 28px)/1.15 'Fraunces', Georgia, serif; font-variation-settings: "SOFT" 80; letter-spacing: -.01em; margin: 8px 0 4px; }
.sorte h2 em { font-style: normal; color: var(--front); transition: color .6s var(--kurve); }
.sorte p { color: var(--creme-leise); font-size: 15px; line-height: 1.5; }
.claim { font-size: 17px; line-height: 1.55; color: var(--creme-leise); max-width: 42ch; }
.claim b { color: var(--creme); font-weight: 500; }
.aktion { display: flex; gap: 10px; }
.knopf { display: inline-flex; align-items: center; gap: 10px; height: 46px; padding: 0 20px; border-radius: 999px; text-decoration: none; font-size: 15px; font-weight: 500; white-space: nowrap;
border: 1px solid var(--creme-linie); transition: background-color .2s var(--kurve), border-color .2s var(--kurve), color .2s var(--kurve), transform .2s var(--kurve); }
.knopf:hover, .knopf:focus-visible { border-color: var(--creme-leise); }
.knopf:focus-visible { outline: 2px solid var(--creme); outline-offset: 3px; }
.besuch .knopf:focus-visible { outline-color: var(--tinte); }
.knopf:active { transform: scale(.97); }
.knopf.voll { background: var(--senf); border-color: var(--senf); color: #2a2a22; }
.knopf.voll:hover, .knopf.voll:focus-visible { background: #f1cf87; }
.fuss { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 14px var(--rand) clamp(14px, 1.8vw, 22px); border-top: 1px solid var(--creme-linie); color: var(--creme-leise); }
.modi { display: flex; gap: 6px; flex-wrap: wrap; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--creme-leise); background: none; border: 1px solid var(--creme-linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer;
transition: border-color .15s linear, color .15s linear, background-color .15s linear; }
.modi button:hover, .modi button:focus-visible { color: var(--creme); border-color: var(--creme-leise); }
.modi button:focus-visible, .anhalten:focus-visible { outline: 2px solid var(--creme); outline-offset: 3px; }
/* Zugang (WCAG 2.2.2): Wort und Neigung bewegen sich auch ohne Zeiger, deshalb ein Knopf zum Anhalten */
.links { flex: none; display: flex; gap: 6px; align-items: center; }
.anhalten { flex: none; }
@media (max-width: 760px) { .links { display: grid; } .links > * { grid-area: 1 / 1; } .anhalten { justify-self: end; align-self: end; } } /* Knopf rechts in der letzten, kurzen Reihe */
.anhalten { width: 30px; height: 30px; display: grid; place-items: center; padding: 0; color: var(--creme-leise); background: none; border: 1px solid var(--creme-linie); border-radius: 999px; cursor: pointer; transition: border-color .15s linear, color .15s linear; }
.anhalten:hover { color: var(--creme); border-color: var(--creme-leise); }
.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 { display: none; }
.modi button[aria-pressed='true'] { background: var(--creme); color: var(--wald); border-color: var(--creme); }
.modi button.drueckt { background: rgba(243, 234, 214, .7); }
.vorfuehrung .modi button { transition: none; } /* programmatischer Klick: kein Zwischenzustand bei wenigen Bildern pro Sekunde */
.rechts { display: grid; gap: 5px; justify-items: end; text-align: right; }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 3px 16px; max-width: 820px; }
.werte b, .messung b { color: var(--creme); font-weight: 500; font-variant-numeric: tabular-nums; }
.messung { display: flex; gap: 16px; }
/* Gedachter Zeiger in der Vorführung */
.zeiger { position: fixed; z-index: 9; left: 0; top: 0; width: 28px; height: 28px; margin: -14px 0 0 -14px; border: 1.5px solid var(--creme); border-radius: 50%; pointer-events: none; display: none; transition: scale .15s linear, background-color .15s linear; }
.zeiger.drueckt { scale: .7; background: rgba(243, 234, 214, .45); }
.vorfuehrung .zeiger { display: block; }
/* ---------- Unter der Bühne: Theke und Besuch (Papier) ---------- */
.theke { background: var(--papier); color: var(--tinte); display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: clamp(32px, 6vw, 96px); padding: clamp(56px, 8vw, 112px) var(--rand); }
.theke .rubrik { color: var(--tinte-leise); }
.theke h2 { font: 500 clamp(36px, 1.6rem + 2.4vw, 60px)/1.02 'Fraunces', Georgia, serif; font-variation-settings: "SOFT" 70; letter-spacing: -.01em; margin: 12px 0 28px; }
.karte { list-style: none; padding: 0; border-top: 1px solid var(--tinte-linie); }
.karte li { display: grid; grid-template-columns: 1fr auto; gap: 2px 24px; padding: 16px 0; border-bottom: 1px solid var(--tinte-linie); }
.karte b { font-size: 1.125rem; font-weight: 500; }
.karte sup { font-size: .7em; color: var(--tinte-leise); margin-left: 3px; letter-spacing: .04em; }
.karte span { grid-column: 1; color: var(--tinte-leise); font-size: .9375rem; line-height: 1.5; }
.karte data { grid-row: 1 / span 2; grid-column: 2; align-self: center; font-variant-numeric: tabular-nums; font-size: 1.0625rem; }
.legende { margin-top: 16px; color: var(--tinte-leise); font-size: .875rem; }
.besuch { align-self: start; background: #e7e1d1; border-radius: 4px; padding: clamp(24px, 3vw, 40px); display: grid; gap: 22px; }
.besuch .rubrik { margin-bottom: 10px; }
.besuch address { font-style: normal; font-size: 1.0625rem; line-height: 1.55; }
.zeiten { width: 100%; border-collapse: collapse; font-size: 1rem; font-variant-numeric: tabular-nums; }
.zeiten td { padding: 7px 0; border-bottom: 1px solid var(--tinte-linie); }
.zeiten td + td { text-align: right; }
.zeiten tr.heute td { font-weight: 600; }
.besuch .aktion { flex-wrap: wrap; }
.besuch .knopf { border-color: var(--tinte-linie); }
.besuch .knopf:hover { border-color: var(--tinte-leise); }
.abspann { background: var(--wald-tief); color: var(--creme-leise); display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; padding: 28px var(--rand) 96px; font-size: 14px; }
.abspann b { color: var(--creme); font-weight: 500; }
/* Mobil: Leiste unten fest (Rezept: Karte / Route / Anrufen) */
.leiste { display: none; }
/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .theke, .vorschau .abspann, .vorschau .leiste, .vorschau .kopf nav, .vorschau .claim, .vorschau .messung, .vorschau .hinweis { display: none !important; }
.vorschau .buehne { height: 100vh; min-height: 0; }
.vorschau .unten { grid-template-columns: 1fr auto; }
.vorschau .rechts { display: none; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
.reduziert .messung, .kein-gl .messung { display: none; }
@media (max-width: 1180px) { .werte span:nth-child(n+5) { display: none; } .kopf nav { display: none; } }
@media (max-width: 980px) and (min-width: 761px) { .unten { grid-template-columns: 1fr auto; } .claim { display: none; } }
@media (max-width: 760px) {
.kopf { gap: 12px; }
.marke span { display: none; }
.status { margin-left: auto; height: 32px; font-size: 13px; padding: 0 12px; }
.oben span:last-child { display: none; }
.buehne { min-height: 0; }
.mitte { padding-block: 56px 40px; }
.unten { grid-template-columns: 1fr; gap: 16px; }
.claim { font-size: 15px; }
.aktion .knopf:not(.voll) { display: none; }
.fuss { flex-direction: column; align-items: stretch; gap: 10px; }
.rechts { justify-items: start; text-align: left; }
.werte { justify-content: flex-start; }
.werte span:nth-child(n+4) { display: none; }
.messung { display: none; }
.theke { grid-template-columns: 1fr; }
.leiste { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; z-index: 8; left: 0; right: 0; bottom: 0; height: 64px; background: var(--papier); color: var(--tinte); border-top: 1px solid var(--tinte-linie); }
.leiste a { display: grid; place-items: center; text-decoration: none; font-size: 13px; font-weight: 500; }
.leiste a:first-child { background: var(--senf); }
}
@media (max-height: 760px) and (min-width: 761px) { .claim { display: none; } .unten { grid-template-columns: 1fr auto; } }
@media (hover: hover) { .nur-touch { display: none; } }
@media (hover: none) {
.nur-maus { display: none; }
.modi button { height: 36px; padding: 0 14px; }
}
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
.gl-an .wort { color: CanvasText; } /* Wort als echter Text statt nur WebGL */
.gl { display: none; }
}
</style>
</head>
<body>
<section class="buehne" aria-labelledby="titel" data-buehne>
<header class="kopf">
<a class="marke" href="#titel"><b>Kugelwerk</b><span class="rubrik">Eismanufaktur · Ulm</span></a>
<nav aria-label="Hauptmenü"><a href="#theke">Sorten</a><a href="#theke">Manufaktur</a><a href="#theke">Eistorten</a><a href="#besuch">Besuch</a></nav>
<p class="status" data-status><i></i><span>Geöffnet bis 21 Uhr</span></p>
</header>
<p class="oben rubrik"><span>Sorte der Woche · <b data-kw>KW 44</b></span><span>Fischergasse 21 · 89073 Ulm</span></p>
<div class="mitte" data-mitte>
<div>
<h1 id="titel"><span class="nur-sr">Kugelwerk, Eismanufaktur im Fischerviertel Ulm: </span><span class="wort" data-wort>cremig.</span></h1>
<p class="hinweis mono" aria-hidden="true"><span class="nur-maus">Mit der Maus über das Wort fahren</span><span class="nur-touch">Über das Wort wischen oder tippen</span></p>
</div>
</div>
<div class="unten">
<div class="sorte" aria-live="polite">
<p class="rubrik" data-sorte-nr>Heute frisch · Nr. 1</p>
<h2 data-sorte-name><em>Bourbon-Vanille</em> mit Alb-Sahne</h2>
<p data-sorte-text>Ganze Schoten, über Nacht in der Milch gezogen.</p>
</div>
<p class="claim">Jeden Morgen <b>in kleinen Chargen gerührt</b>, ohne Fertigpasten und ohne Pulver. Was abends übrig bleibt, wird am nächsten Tag zu Milchshake.</p>
<div class="aktion">
<a class="knopf" href="#theke">Zur Theke</a>
<a class="knopf voll" href="#besuch">Eistorte bestellen</a>
</div>
</div>
<div class="fuss">
<div class="links">
<div class="modi mono" role="group" aria-label="Verformung wählen" data-modi>
<button type="button" data-modus="woelbung" aria-pressed="true">Wölbung · Cursor</button>
<button type="button" data-modus="twist" aria-pressed="false">Verdrillen · Twist</button>
<button type="button" data-modus="schmelzen" aria-pressed="false">Schmelzen · Spur</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>
<div class="rechts mono">
<p class="werte" data-werte></p>
<p class="messung" aria-hidden="true" data-messung></p>
</div>
</div>
<canvas class="gl" data-gl aria-hidden="true"></canvas>
</section>
<section class="theke" id="theke" aria-labelledby="theke-titel">
<div>
<p class="rubrik">Heute in der Theke · Kugel 2,20 €</p>
<h2 id="theke-titel">Zwölf Sorten, jeden Tag neu gerührt.</h2>
<ul class="karte">
<li><b>Bourbon-Vanille<sup>a</sup></b><span>Ganze Schoten, Sahne von der Alb</span><data value="2.20">2,20</data></li>
<li><b>Erdbeere-Vanille-Swirl<sup>a</sup></b><span>Erdbeeren aus dem Donautal, durch die Vanille gezogen</span><data value="2.20">2,20</data></li>
<li><b>Aprikose & Albhonig</b><span>Sorbet, vegan, mit Thymian aus dem eigenen Kasten</span><data value="2.20">2,20</data></li>
<li><b>Haselnuss geröstet<sup>a, c</sup></b><span>Langsam geröstet, grob gemahlen</span><data value="2.40">2,40</data></li>
<li><b>Schokolade 72 %<sup>a</sup></b><span>Kuvertüre aus einer kleinen Rösterei, eine Prise Salz</span><data value="2.20">2,20</data></li>
<li><b>Joghurt-Holunder<sup>a</sup></b><span>Holunderblüten vom Kuhberg, Joghurt aus der Region</span><data value="2.20">2,20</data></li>
</ul>
<p class="legende">a Milch · b Ei · c Schalenfrüchte. Waffel 0,50 €, Becher ohne Aufpreis.</p>
</div>
<aside class="besuch" id="besuch" aria-labelledby="besuch-titel">
<div>
<p class="rubrik" id="besuch-titel">Besuch</p>
<address>Kugelwerk Eismanufaktur<br>Fischergasse 21, 89073 Ulm<br>Zwei Minuten vom Schiefen Haus</address>
</div>
<table class="zeiten" data-zeiten>
<tr data-tage="2,3,4"><td>Dienstag bis Donnerstag</td><td>12 – 20 Uhr</td></tr>
<tr data-tage="5,6"><td>Freitag und Samstag</td><td>12 – 22 Uhr</td></tr>
<tr data-tage="0"><td>Sonntag</td><td>11 – 21 Uhr</td></tr>
<tr data-tage="1"><td>Montag</td><td>Ruhetag</td></tr>
</table>
<div class="aktion">
<a class="knopf" href="#besuch">Route planen</a>
<a class="knopf" href="#besuch">Anrufen</a>
</div>
</aside>
</section>
<footer class="abspann">
<p><b>Kugelwerk</b> Eismanufaktur · Fischergasse 21 · 89073 Ulm</p>
<p>Eistorten mit drei Tagen Vorlauf · Impressum · Datenschutz</p>
</footer>
<nav class="leiste" aria-label="Schnellzugriff"><a href="#besuch">Torte</a><a href="#theke">Sorten</a><a href="#besuch">Route</a><a href="#besuch">Anrufen</a></nav>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
<p class="nur-sr" aria-live="polite" data-ansage></p>
<script>
/* Öffnungszeiten: Status aus den echten Zeiten berechnen (Rezept), unabhängig von WebGL */
(() => {
const ZEITEN = { 0: [11, 21], 1: null, 2: [12, 20], 3: [12, 20], 4: [12, 20], 5: [12, 22], 6: [12, 22] };
const jetzt = new Date(), tag = jetzt.getDay(), h = jetzt.getHours() + jetzt.getMinutes() / 60, z = ZEITEN[tag];
const el = document.querySelector('[data-status]'), txt = el.querySelector('span');
if (z && h >= z[0] && h < z[1]) txt.textContent = `Geöffnet bis ${z[1]} Uhr`;
else {
el.classList.add('zu');
if (z && h < z[0]) txt.textContent = `Heute ab ${z[0]} Uhr`;
else { let n = (tag + 1) % 7; while (!ZEITEN[n]) n = (n + 1) % 7; txt.textContent = `${['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][n]} ab ${ZEITEN[n][0]} Uhr`; }
}
document.querySelectorAll('[data-tage]').forEach(tr => tr.classList.toggle('heute', tr.dataset.tage.split(',').includes(String(tag))));
const d = new Date(Date.UTC(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate())), wt = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - wt);
const kw = Math.ceil(((d - new Date(Date.UTC(d.getUTCFullYear(), 0, 1))) / 864e5 + 1) / 7);
document.querySelector('[data-kw]').textContent = 'KW ' + kw;
})();
</script>
<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';
THREE.ColorManagement.enabled = false; // Hausfarben 1:1 als sRGB in den Shader, keine Linear-Umrechnung
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const $ = s => document.querySelector(s);
const buehne = $('[data-buehne]'), mitte = $('[data-mitte]'), wortEl = $('[data-wort]'), canvas = $('[data-gl]');
const zeiger = $('[data-zeiger]'), ansage = $('[data-ansage]'), werteEl = $('[data-werte]'), messEl = $('[data-messung]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];
/* ---------- Werte. Gemessen (meta.json → messwerte): Unseen mouseRadius 0.2; DPR ≤ 2 und Bulge-Formel
smoothstep(r, 0, d) * strength aus dem Steckbrief. Kurve: expo.out (Fundus-Standard), Lerp 0.1 / Frame. ---------- */
const W = {
radius: 0.2, // Wölbung: Radius in Anteilen der Wortbreite (Unseen mouseRadius .2)
staerke: 0.55, // Wölbung: max. Hub nach vorn = 0.55 × Höhe der Textebene
druck: 0.06, // Wölbung: seitliches Nachgeben (Gummi)
twist: Math.PI * 0.4, // Verdrillen: 72° über die volle Wortbreite (±36° an den Enden) bei Zeiger am Rand; mehr zeigt die Ebenen hochkant
welle: 0.3, // Verdrillen: Grundwelle in rad, läuft immer
welleTempo: 1.2, // rad/s
hitzeRadius: 0.12, // Schmelzen: Breite der Wärmespur (Anteil Wortbreite)
hitzeAufbau: 2.2, // pro Sekunde unter dem Zeiger
halbwert: 1400, // ms, bis eine Spur halb erkaltet ist
tropfen: 1.0, // max. Fließweg nach unten = 1.0 × Höhe (am Plattenrand, Exponent 1.8)
spalten: 128, // Auflösung der Hitze-Textur (wie Unseen Fluid resolution 128)
lerp: 0.1, ein: 900, aus: 1600, einroll: 1400,
schichten: 10, tiefe: 0.1, // Extrusion: 10 Ebenen auf 0.1 em (Touch/Vorschau 7)
neigung: [0.1, 0.16], // Parallaxe der ganzen Gruppe (rad, x / y)
dpr: 2, raster: [160, 32], fov: 30,
};
const MODI = {
woelbung: { nr: 0, wort: 'cremig.', front: '#f4e7c5', seite: '#c79a52', name: 'Wölbung',
sorte: ['Heute frisch · Nr. 1', '<em>Bourbon-Vanille</em> mit Alb-Sahne', 'Ganze Schoten, über Nacht in der Milch gezogen.'],
werte: [['Radius', '0.2 × Breite'], ['Hub', '0.55 × Höhe'], ['Ein / Aus', '900 / 1600 ms expo.out'], ['Lerp', '0.1 / Frame'], ['Extrusion', '10 Ebenen · 0.1 em'], ['DPR', '≤ 2']] },
twist: { nr: 1, wort: 'gedreht.', front: '#f3c2b1', seite: '#b5645a', name: 'Verdrillen',
sorte: ['Heute frisch · Nr. 2', '<em>Erdbeere-Vanille</em>, verdreht', 'Erdbeeren aus dem Donautal, durch die Vanille gezogen.'],
werte: [['Twist', '72° über die Breite'], ['Welle', '0.3 rad · 1.2 rad/s'], ['Zeiger', 'Lerp 0.1 / Frame'], ['Ein / Aus', '900 / 1600 ms expo.out'], ['Extrusion', '10 Ebenen · 0.1 em'], ['DPR', '≤ 2']] },
schmelzen: { nr: 2, wort: 'schmilzt.', front: '#efc56f', seite: '#a8702a', name: 'Schmelzen',
sorte: ['Heute frisch · Nr. 3', '<em>Aprikose</em> und Albhonig', 'Sorbet, vegan, mit Thymian aus dem eigenen Kasten.'],
werte: [['Spur', '128 Spalten · 0.12 × Breite'], ['Aufbau', '2.2 / s'], ['Halbwert', '1400 ms'], ['Fließweg', '1.0 × Höhe'], ['Extrusion', '10 Ebenen · 0.1 em'], ['DPR', '≤ 2']] },
};
const FOLGE = ['woelbung', 'twist', 'schmelzen'];
let modus = 'woelbung';
const expoOut = t => t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
/* ---------- Shader ---------- */
const vertex = /* glsl */`
uniform vec2 uGroesse, uMaus;
uniform float uZ, uHover, uRadius, uStaerke, uDruck;
uniform float uTwist, uWelle, uWelleTempo, uZeit;
uniform float uTropfen, uEin;
uniform vec3 uGewicht; // Wölbung, Verdrillen, Schmelzen
uniform sampler2D uHitze;
varying vec2 vUv;
varying vec3 vN, vPos;
vec3 verformen(vec2 uv) {
float B = uGroesse.x, H = uGroesse.y;
vec3 p = vec3((uv - 0.5) * uGroesse, uZ);
/* Wölbung: pos.z += smoothstep(r, 0, d) * strength */
vec2 d = (uv - uMaus) * vec2(1.0, H / B);
float f = smoothstep(uRadius, 0.0, length(d)) * uHover * uGewicht.x;
p.z += f * uStaerke * H;
p.xy += (uv - uMaus) * uGroesse * f * uDruck;
/* Schmelzen: Hitze je Spalte zieht den unteren Teil nach unten */
float h = texture2D(uHitze, vec2(uv.x, 0.5)).r * uGewicht.z;
float n = 0.5 + 0.5 * sin(uv.x * 41.0 + 1.3) * sin(uv.x * 13.0 + 0.4);
float spalte = 0.35 + 0.65 * n * n * n; /* einzelne Tropfen laufen weiter als der Rest */
float unten = pow(1.0 - uv.y, 1.8);
p.y -= h * spalte * unten * H * uTropfen;
p.x += sin(uv.y * 8.0 + uZeit * 2.2) * h * unten * 0.01 * B;
p.z += h * (1.0 - unten) * 0.12 * H;
/* Verdrillen um die Längsachse: Winkel wächst mit x (rotate(pos.yz, uTwist * x)) */
float xn = uv.x - 0.5;
float a = (uTwist * xn + uWelle * sin(xn * 6.2832 - uZeit * uWelleTempo)) * uGewicht.y;
float c = cos(a), s = sin(a);
p.yz = vec2(c * p.y - s * p.z, s * p.y + c * p.z);
/* Einrollen beim Sortenwechsel: Spalten klappen von links nach rechts hoch (expo.out je Spalte) */
float pr = clamp(uEin * 1.5 - uv.x * 0.5, 0.0, 1.0);
float e = pr >= 1.0 ? 1.0 : 1.0 - pow(2.0, -10.0 * pr);
float r = (1.0 - e) * -1.5708;
float y0 = -0.32 * H; // Drehachse etwa auf der Grundlinie
vec2 q = vec2(p.y - y0, p.z);
p.y = y0 + cos(r) * q.x - sin(r) * q.y;
p.z = sin(r) * q.x + cos(r) * q.y;
return p;
}
void main() {
vUv = uv;
vec3 p = verformen(uv);
vec2 schritt = 1.0 / vec2(${W.raster[0]}.0, ${W.raster[1]}.0);
vec3 px = verformen(uv + vec2(schritt.x, 0.0)) - p;
vec3 py = verformen(uv + vec2(0.0, schritt.y)) - p;
vN = normalize(normalMatrix * cross(px, py));
vec4 welt = modelMatrix * vec4(p, 1.0);
vPos = welt.xyz;
gl_Position = projectionMatrix * viewMatrix * welt;
}`;
const fragment = /* glsl */`
precision highp float;
uniform sampler2D uText;
uniform vec3 uFront, uSeite;
uniform float uSchicht, uSchatten;
varying vec2 vUv;
varying vec3 vN, vPos;
void main() {
if (uSchatten > 0.5) {
float b = texture2D(uText, vUv + vec2(0.0, 0.03), 4.0).r;
gl_FragColor = vec4(0.04, 0.09, 0.08, b * 0.32);
return;
}
float a = texture2D(uText, vUv).r;
float w = fwidth(a) * 0.7 + 1e-4;
float al = smoothstep(0.5 - w, 0.5 + w, a);
if (al < 0.01) discard;
vec3 n = normalize(vN);
if (!gl_FrontFacing) n = -n;
vec3 L = normalize(vec3(-0.45, 0.62, 0.64));
float diff = dot(n, L) - L.z; /* 0 bei flacher Ebene: Hausfarbe bleibt exakt */
float front = 1.0 - step(0.001, uSchicht);
vec3 basis = mix(uSeite * (1.0 - 0.38 * uSchicht), uFront, front);
/* Schattenseite wird warm (Richtung Karamell der Sorte) statt grau: wirkt wie Eis, nicht wie Metall */
float sch = clamp(-diff / 0.6, 0.0, 1.0);
vec3 warm = mix(basis, basis * uSeite * 1.08, 0.9);
vec3 col = mix(basis, warm, sch) + basis * 0.3 * max(diff, 0.0);
vec3 V = normalize(cameraPosition - vPos);
vec3 Hh = normalize(L + V);
float glanz = pow(max(dot(n, Hh), 0.0), 18.0) - pow(max(Hh.z, 0.0), 18.0);
col += max(glanz, 0.0) * 0.2 * front;
gl_FragColor = vec4(col, al);
}`;
/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null, szene, kamera, gruppe, textTex, hitzeTex, hitzeDaten;
const U = {};
const hitze = new Float32Array(W.spalten);
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
if (!renderer) html.classList.add('kein-gl');
const schichten = beruehrung || html.classList.contains('vorschau') ? 7 : W.schichten;
if (renderer) {
renderer.setClearColor(0x000000, 0);
szene = new THREE.Scene();
kamera = new THREE.PerspectiveCamera(W.fov, 1, 10, 20000);
gruppe = new THREE.Group();
szene.add(gruppe);
const textCanvas = document.createElement('canvas');
textTex = new THREE.CanvasTexture(textCanvas);
textTex.minFilter = THREE.LinearMipmapLinearFilter; textTex.magFilter = THREE.LinearFilter;
textTex.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
hitzeDaten = new Uint8Array(W.spalten * 4);
hitzeTex = new THREE.DataTexture(hitzeDaten, W.spalten, 1, THREE.RGBAFormat);
hitzeTex.magFilter = THREE.LinearFilter; hitzeTex.minFilter = THREE.LinearFilter; hitzeTex.needsUpdate = true;
Object.assign(U, {
uText: { value: textTex }, uHitze: { value: hitzeTex },
uGroesse: { value: new THREE.Vector2(1, 1) }, uMaus: { value: new THREE.Vector2(.5, .5) },
uHover: { value: 0 }, uRadius: { value: W.radius }, uStaerke: { value: W.staerke }, uDruck: { value: W.druck },
uTwist: { value: 0 }, uWelle: { value: W.welle }, uWelleTempo: { value: W.welleTempo }, uZeit: { value: 0 },
uTropfen: { value: W.tropfen }, uEin: { value: 1 }, uGewicht: { value: new THREE.Vector3(1, 0, 0) },
uFront: { value: new THREE.Color(MODI.woelbung.front) }, uSeite: { value: new THREE.Color(MODI.woelbung.seite) },
});
const geo = new THREE.PlaneGeometry(1, 1, W.raster[0], W.raster[1]);
const ebene = (i, schatten) => {
const m = new THREE.ShaderMaterial({
vertexShader: vertex, fragmentShader: fragment, transparent: true, depthTest: !schatten, depthWrite: !schatten, side: THREE.DoubleSide,
uniforms: { ...U, uZ: { value: 0 }, uSchicht: { value: schatten ? 1 : i / (schichten - 1) }, uSchatten: { value: schatten ? 1 : 0 } },
});
const mesh = new THREE.Mesh(geo, m);
mesh.frustumCulled = false;
mesh.renderOrder = schatten ? 0 : schichten - i; // von hinten nach vorn
gruppe.add(mesh);
return mesh;
};
gruppe.userData.schatten = ebene(0, true);
gruppe.userData.ebenen = Array.from({ length: schichten }, (_, i) => ebene(i, false));
canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); renderer = null; html.classList.remove('gl-an'); html.classList.add('kein-gl'); });
}
/* ---------- Layout: DOM-Wort ist die Quelle, die Textebene folgt seinem Rechteck ---------- */
let rahmen = { x: 0, y: 0, b: 1, h: 1, fs: 100 }, buehneR = { b: 1, h: 1, l: 0, t: 0 }, schriftBereit = false;
function wortEinpassen() {
wortEl.style.fontSize = '100px';
const breite100 = wortEl.getBoundingClientRect().width;
const m = mitte.getBoundingClientRect(), rand = parseFloat(getComputedStyle(mitte).paddingLeft) || 0;
const verfuegbar = m.width - rand * 2;
const fs = Math.max(48, Math.min(verfuegbar * 0.94 / breite100 * 100, m.height * 0.62, 330));
wortEl.style.fontSize = fs.toFixed(1) + 'px';
}
function textZeichnen() {
if (!renderer) return;
const fs = rahmen.fs, dpr = renderer.getPixelRatio();
const k = Math.min(dpr * 1.25, 4096 / rahmen.b);
const c = textTex.image, ctx = c.getContext('2d');
const nb = Math.round(rahmen.b * k), nh = Math.round(rahmen.h * k);
if (nb !== c.width || nh !== c.height) { c.width = nb; c.height = nh; textTex.dispose(); } // texStorage: neue Größe braucht neue Textur
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, c.width, c.height);
ctx.fillStyle = '#fff';
ctx.font = `600 ${fs * k}px "Fraunces Weich"`;
ctx.letterSpacing = `${-0.02 * fs * k}px`;
ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
const mt = ctx.measureText(MODI[modus].wort);
const asc = mt.fontBoundingBoxAscent, desc = mt.fontBoundingBoxDescent;
ctx.fillText(MODI[modus].wort, c.width / 2, c.height / 2 + (asc - desc) / 2);
textTex.needsUpdate = true;
}
function layout() {
wortEinpassen();
const br = buehne.getBoundingClientRect(), wr = wortEl.getBoundingClientRect(), fs = parseFloat(wortEl.style.fontSize);
buehneR = { b: br.width, h: br.height, l: br.left, t: br.top };
const b = wr.width + fs * 0.24, h = fs * 1.34;
rahmen = { x: wr.left - br.left + wr.width / 2, y: wr.top - br.top + wr.height / 2, b, h, fs };
if (!renderer) return;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, html.classList.contains('vorschau') ? 1.5 : W.dpr));
renderer.setSize(br.width, br.height, false);
kamera.aspect = br.width / br.height;
kamera.position.set(0, 0, (br.height / 2) / Math.tan(THREE.MathUtils.degToRad(W.fov / 2)));
kamera.updateProjectionMatrix();
gruppe.position.set(rahmen.x - br.width / 2, br.height / 2 - rahmen.y, 0);
U.uGroesse.value.set(b, h);
const tiefe = fs * W.tiefe;
gruppe.userData.ebenen.forEach((m, i) => { m.material.uniforms.uZ.value = -tiefe * i / (schichten - 1); });
gruppe.userData.schatten.material.uniforms.uZ.value = -tiefe - fs * 0.18;
if (schriftBereit) textZeichnen();
}
new ResizeObserver(() => layout()).observe(buehne);
/* ---------- Zeiger: echte Maus, Finger oder gedachter Zeiger der Vorführung ---------- */
const zeigerPos = { x: 0, y: 0, da: false, quelle: '' };
let fingerZuletzt = -1e9;
function zeigerSetzen(e) {
const r = buehne.getBoundingClientRect();
zeigerPos.x = e.clientX - r.left; zeigerPos.y = e.clientY - r.top; zeigerPos.da = true; zeigerPos.quelle = e.pointerType;
}
buehne.addEventListener('pointermove', e => {
if (e.pointerType === 'touch') { fingerZuletzt = performance.now(); }
else if (vorfuehrt) stopVorfuehrung();
if (!vorfuehrt) zeigerSetzen(e);
}, { passive: true });
buehne.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch') return;
if (vorfuehrt) stopVorfuehrung();
fingerZuletzt = performance.now(); zeigerSetzen(e);
puls = performance.now() + 700; // Tippen: kurzer Impuls an der Tippstelle
}, { passive: true });
buehne.addEventListener('pointerleave', e => { if (!vorfuehrt && e.pointerType !== 'touch') zeigerPos.da = false; });
let puls = 0;
/* ---------- Modus ---------- */
function werteZeigen() {
const m = MODI[modus];
werteEl.innerHTML = reduziert() || !renderer
? '<span>Reduziert <b>Standbild · HTML-Text · CSS-Extrusion 6 px</b></span>'
: m.werte.map(([a, b]) => `<span>${a} <b>${b}</b></span>`).join('');
knoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.modus === modus ? 'true' : 'false'));
}
let einStart = -1e9;
function setzeModus(neu, still) {
modus = neu;
const m = MODI[neu];
wortEl.textContent = m.wort;
html.style.setProperty('--front', m.front); html.style.setProperty('--seite', m.seite);
$('[data-sorte-nr]').textContent = m.sorte[0];
$('[data-sorte-name]').innerHTML = m.sorte[1];
$('[data-sorte-text]').textContent = m.sorte[2];
werteZeigen();
layout();
if (renderer) {
U.uFront.value.set(m.front); U.uSeite.value.set(m.seite);
U.uGewicht.value.set(neu === 'woelbung' ? 1 : 0, neu === 'twist' ? 1 : 0, neu === 'schmelzen' ? 1 : 0);
hitze.fill(0); twist = 0; hover = 0; hoverZiel = 0;
einStart = reduziert() ? -1e9 : performance.now();
}
if (!still) ansage.textContent = `Verformung: ${m.name}`;
}
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeModus(b.dataset.modus); }));
function glPruefen() {
const an = !!renderer && !reduziert() && schriftBereit;
html.classList.toggle('gl-an', an);
canvas.style.display = an ? '' : 'none';
return an;
}
new MutationObserver(() => { werteZeigen(); glPruefen(); layout(); }).observe(html, { attributes: true, attributeFilter: ['class'] });
/* ---------- Schleife ---------- */
let hover = 0, hoverVon = 0, hoverZiel = 0, hoverStart = 0, twist = 0, letzte = performance.now(), sichtbar = true;
const maus = new THREE.Vector2(.5, .5), mausZiel = new THREE.Vector2(.5, .5), neig = new THREE.Vector2();
new IntersectionObserver(([e]) => { sichtbar = e.isIntersecting; }).observe(buehne);
/* Touch ohne Eingabe: das Wort spielt leise von selbst (gedachter Finger, ohne Ring) */
function selbstlauf(jetzt) {
const s = jetzt / 1000;
if (modus === 'schmelzen') return { u: 0.5 + 0.42 * Math.sin(s * 0.7), v: 0.5 };
if (modus === 'twist') return { u: 0.5 + 0.6 * Math.sin(s * 0.9), v: 0.5 };
return { u: 0.5 + 0.36 * Math.sin(s * 0.8), v: 0.5 + 0.2 * Math.sin(s * 1.6 + .6) };
}
function bild(jetzt) {
requestAnimationFrame(bild);
const rohMs = jetzt - letzte, dtMs = Math.min(64, rohMs); letzte = jetzt;
const dt = dtMs / 16.667;
if (vorfuehrt) vorfuehrSchritt(jetzt);
const tAnim = angehalten ? haltBei : jetzt - zeitVersatz; /* angehalten: Eigenbewegung steht, Zeiger wirkt weiter */
if (!glPruefen() || !sichtbar) return;
const a = 1 - Math.pow(1 - W.lerp, dt);
/* Zeiger → UV der Textebene */
let u, v, drin;
const selbst = beruehrung && !vorfuehrt && jetzt - fingerZuletzt > 3500;
if (selbst) { ({ u, v } = selbstlauf(tAnim)); drin = true; }
else if (zeigerPos.da) {
u = (zeigerPos.x - (rahmen.x - rahmen.b / 2)) / rahmen.b;
v = 1 - (zeigerPos.y - (rahmen.y - rahmen.h / 2)) / rahmen.h;
drin = u > -0.15 && u < 1.15 && v > -0.6 && v < 1.6;
if (zeigerPos.quelle === 'touch' && jetzt - fingerZuletzt > 1600 && puls < jetzt) drin = false;
} else { u = .5; v = .5; drin = false; }
const ziel = drin || puls > jetzt ? 1 : 0;
if (ziel !== hoverZiel) { hoverZiel = ziel; hoverVon = hover; hoverStart = jetzt; }
hover = hoverVon + (hoverZiel - hoverVon) * expoOut(Math.min(1, (jetzt - hoverStart) / (hoverZiel ? W.ein : W.aus)));
if (drin) { mausZiel.set(u, v); if (hover < 0.04) maus.copy(mausZiel); }
if (drin && !selbst && !buehne.classList.contains('angefasst')) buehne.classList.add('angefasst');
maus.lerp(mausZiel, a);
/* Verdrillen: Zeiger-x steuert den Twist */
const tz = modus === 'twist' && drin ? Math.max(-1, Math.min(1, (mausZiel.x - 0.5) * 2)) : 0;
twist += (tz - twist) * a;
/* Schmelzen: Hitze unter dem Zeiger aufbauen, sonst abkühlen (Halbwert 1400 ms) */
if (modus === 'schmelzen') {
const dtMs = Math.min(250, rohMs); // echte Zeit: Spur und Abkühlen bleiben auch bei wenigen Bildern pro Sekunde gleich schnell
const abk = Math.pow(0.5, dtMs / W.halbwert);
const unterZeiger = drin && maus.y > -0.3 && maus.y < 1.3;
for (let i = 0; i < W.spalten; i++) {
const x = (i + 0.5) / W.spalten;
const nah = unterZeiger ? Math.max(0, 1 - Math.abs(x - maus.x) / W.hitzeRadius) : 0;
hitze[i] = Math.min(1, hitze[i] * abk + nah * nah * (3 - 2 * nah) * W.hitzeAufbau * dtMs / 1000);
hitzeDaten[i * 4] = hitze[i] * 255;
}
hitzeTex.needsUpdate = true;
}
/* Parallaxe der ganzen Gruppe zeigt die Extrusion */
const nz = drin ? new THREE.Vector2((maus.x - 0.5), (maus.y - 0.5)) : new THREE.Vector2(Math.sin(tAnim / 2600) * 0.12, Math.cos(tAnim / 3100) * 0.08);
neig.lerp(nz, a * 0.6);
gruppe.rotation.set(-neig.y * W.neigung[0] * 2, neig.x * W.neigung[1] * 2, 0);
U.uMaus.value.copy(maus);
U.uHover.value = hover;
U.uTwist.value = twist * W.twist;
U.uZeit.value = tAnim / 1000;
U.uEin.value = Math.min(1, (jetzt - einStart) / W.einroll);
if (messEl) {
const hmax = modus === 'schmelzen' ? Math.max(...hitze) : 0;
messEl.innerHTML = `<span>uHover <b>${hover.toFixed(2)}</b></span><span>uTwist <b>${(twist * 72).toFixed(0).padStart(3, ' ')}°</b></span><span>Hitze <b>${hmax.toFixed(2)}</b></span>`;
}
renderer.render(szene, kamera);
}
/* ---------- Vorführung: gedachter Zeiger, je Variante ein Segment ---------- */
const SEGMENT = 7000;
let vorfuehrt = false, vStart = 0, vSegment = -1, vDrueck = -1;
const glatt = t => t * t * (3 - 2 * t);
function mitteVon(el) { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }
function wortPunkt(u, v) { return { x: buehneR.l + rahmen.x + (u - 0.5) * rahmen.b, y: buehneR.t + rahmen.y - (v - 0.5) * rahmen.h }; }
function bahn(m, s) {
if (m === 'woelbung') { const w = s * Math.PI * 2 * 0.3; return wortPunkt(0.5 - 0.36 * Math.cos(w), 0.52 + 0.2 * Math.sin(w * 2 + .4)); }
if (m === 'twist') { const w = s * Math.PI * 2 * 0.28; return wortPunkt(0.5 - 0.52 * Math.cos(w), 0.5 + 0.08 * Math.sin(w * 2)); }
return wortPunkt(0.08 + 0.84 * glatt(Math.min(1, s / 3.4)), 0.56 + 0.06 * Math.sin(s * 3));
}
function vorfuehrSchritt(jetzt) {
const z = Math.max(0, jetzt - vStart), seg = Math.floor(z / SEGMENT) % FOLGE.length, s = (z % SEGMENT) / 1000;
if (seg !== vSegment) { vSegment = seg; setzeModus(FOLGE[seg], true); }
const m = FOLGE[seg], naechster = knoepfe[(seg + 1) % FOLGE.length];
let p;
if (s < 0.9) { const k = mitteVon(knoepfe[seg]), b = bahn(m, 0), e = glatt(s / 0.9); p = { x: k.x + (b.x - k.x) * e, y: k.y + (b.y - k.y) * e }; }
else if (s < 5.9) p = bahn(m, s - 0.9);
else {
const b = bahn(m, 5.0), k = mitteVon(naechster), e = glatt(Math.min(1, (s - 5.9) / 0.8));
p = { x: b.x + (k.x - b.x) * e, y: b.y + (k.y - b.y) * e };
const nr = Math.floor(z / SEGMENT);
if (s > 6.75 && vDrueck !== nr) {
vDrueck = nr; zeiger.classList.add('drueckt'); naechster.classList.add('drueckt');
setTimeout(() => { zeiger.classList.remove('drueckt'); knoepfe.forEach(b => b.classList.remove('drueckt')); }, 160);
}
}
zeigerPos.x = p.x - buehneR.l; zeigerPos.y = p.y - buehneR.t; zeigerPos.da = true; zeigerPos.quelle = 'vorfuehrung';
zeiger.style.transform = `translate(${p.x}px, ${p.y}px)`;
}
function stopVorfuehrung() {
if (!vorfuehrt) return;
vorfuehrt = false; html.classList.remove('vorfuehrung'); zeigerPos.da = false;
}
function vorfuehren() {
if (!html.classList.contains('vorschau')) scrollTo(0, 0);
vorfuehrt = true; vStart = performance.now(); vSegment = -1; vDrueck = -1;
html.classList.add('vorfuehrung');
}
function neustart() {
stopVorfuehrung();
setzeModus('woelbung', true);
/* einmal abspielen: Einrollen, danach ein kurzer Wölbungs-Impuls in der Wortmitte */
mausZiel.set(.42, .55); maus.copy(mausZiel);
zeigerPos.da = false; puls = performance.now() + W.einroll + 700;
}
function pause() { stopVorfuehrung(); anhalten(true); }
function weiter() { anhalten(false); vorfuehren(); }
/* Knopf „Bewegung anhalten“: gleiche Pause wie FUNDUS_DEMO.pause(); Fortsetzen ohne Sprung */
let angehalten = false, haltBei = 0, zeitVersatz = 0;
const anhaltenKnopf = $('[data-anhalten]');
function anhalten(an) {
if (an && !angehalten) { haltBei = performance.now() - zeitVersatz; angehalten = true; }
else if (!an && angehalten) { zeitVersatz = performance.now() - haltBei; angehalten = false; }
anhaltenKnopf.setAttribute('aria-pressed', angehalten ? 'true' : 'false');
}
anhaltenKnopf.addEventListener('click', () => { if (angehalten) anhalten(false); else pause(); });
/* Start: erst zeichnen, wenn die weiche Fraunces geladen ist (sonst landet die Ersatzschrift in der Textur) */
layout(); werteZeigen();
document.fonts.load('600 100px "Fraunces Weich"').catch(() => {}).then(() => document.fonts.ready).then(() => {
schriftBereit = true; layout(); textZeichnen();
einStart = reduziert() ? -1e9 : performance.now();
});
requestAnimationFrame(bild);
</script>
</body>
</html>