Typografie in WebGL
Die Überschrift bleibt echter HTML-Text, WebGL legt SDF-Glyphen darüber: Sie verwürfeln per Atlas-Offset, laufen als Welle durch den Raum oder zerfallen in 3D.
Schrift wird als Distanzfeld (SDF oder MSDF) in einen Texturatlas gerendert und in WebGL als eine Fläche pro Glyphe gezeichnet. Jede Glyphe bekommt Attribute wie Index und Zufallswert, damit der Vertex-Shader sie einzeln bewegen kann: wellen, explodieren, verwürfeln. Ein Scramble tauscht nur den Texturoffset im Atlas, ohne DOM-Änderung und ohne Style-Neuberechnung. Der echte HTML-Text bleibt die Layout-Quelle, die WebGL-Glyphen liegen exakt darüber.
Typografie wird Material. Sie kann brechen, schmelzen, sich in Glas spiegeln und auf die 3D-Szene reagieren. Das Distanzfeld bleibt in jeder Größe und Perspektive scharf. Kontur und Gewicht lassen sich im Shader stufenlos ändern.
Wann einsetzen
Headlines auf immersiven Seiten, Intro-Sequenzen und Text hinter Glasobjekten. Am besten dort, wo ohnehin eine WebGL-Szene läuft.
Wann nicht
Fließtext und lange Lesestrecken. Seiten ohne sonstiges WebGL, denn dort kostet der Effekt viel Ladezeit für einen einzigen Moment.
Varianten
- Scramble per Atlas-Offset (Igloo Inc)
- Welle oder Verformung im Vertex-Shader
- Explodierende 3D-Buchstaben
- HTML-Text responsiv in WebGL gespiegelt
- Text als Partikel
Umsetzung
In Produktion troika-three-text (SDF-Layout im Worker, so bei Unseen Studio und 14islands) oder ein MSDF-Atlas mit eigenem Shader (median der drei Kanäle, fwidth-Kantenglättung, Outline-Breite wie bei Igloo). Ohne Bibliothek: Glyphen in Canvas 2D rastern und per Distanztransformation zu einem einkanaligen SDF-Atlas machen. Den DOM-Text mit color: transparent verstecken, aber lesbar und auswählbar lassen. Zeichenpositionen per Range.getClientRects aus dem Layout lesen und bei Größenänderung neu setzen. Schriftlizenz für die Atlas-Erzeugung prüfen.
Robust umsetzen
Telefon. Weniger Glyphen-Effekte und kürzere Flugbahnen. Ohne WebGL bleibt der DOM-Text sichtbar.
Ohne Bewegung. Kein WebGL, der HTML-Text blendet kurz ein.
Barrierefrei. Echter HTML-Text muss existieren: für Screenreader, Übersetzung, Suche und Auswahl. Das Canvas bekommt aria-hidden.
Code
<!DOCTYPE html>
<!--
Typografie in WebGL
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/webgl-text-sdf/
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>Typografie in WebGL · 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: 'Host Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/host-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 300 800; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; 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; }
/* Rezept „Stille Visitenkarte“: Graustufen, eine Farbe nur für Fokus, Bewegung mit easeOutQuart */
:root {
--grund: #fcfcfc; --text: #0b0b09; --leise: #646464; --linie: #e8e8e8; --flaeche: #f0f0f0;
--fokus: #2f5bea;
--kurve: cubic-bezier(0.165, 0.84, 0.44, 1); /* easeOutQuart, wie im Shader */
--seite: 1072px;
--rand: clamp(16px, 4vw, 48px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Host Grotesk', system-ui, sans-serif; font-weight: 400; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--fokus); outline-offset: 3px; border-radius: 2px; }
.mono { font: 500 11px/1.5 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.rahmen { width: min(100%, calc(var(--seite) + 2 * var(--rand))); margin: 0 auto; padding: 0 var(--rand); }
/* ---------- Bühne ---------- */
.buehne { position: relative; min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; isolation: isolate; }
.gl { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; display: block; }
.kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding-top: clamp(20px, 3vw, 32px); padding-bottom: 12px; font-size: 15px; position: relative; z-index: 2; }
.marke { font-weight: 500; letter-spacing: -.005em; }
.marke span { color: var(--leise); font-weight: 400; margin-left: 10px; }
.kopf nav { display: flex; gap: clamp(14px, 2.2vw, 28px); }
.kopf nav a { color: var(--leise); transition: color 120ms var(--kurve); }
.kopf nav a:hover, .kopf nav a:focus-visible { color: var(--text); }
.satz { align-self: center; padding-top: clamp(24px, 6vh, 72px); padding-bottom: clamp(28px, 7vh, 84px); position: relative; z-index: 0; }
.satz .mono { margin-bottom: clamp(18px, 2.6vw, 28px); display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; }
.satz .mono i { width: 28px; height: 1px; background: var(--linie); }
h1 {
font-weight: 500; font-size: clamp(2.15rem, 5.9vw, 5.25rem); line-height: 1.06; letter-spacing: -.028em;
max-width: 12.6em; text-wrap: balance; cursor: default;
}
h1 em { font: italic 400 1.06em/1 'Newsreader', Georgia, serif; font-variation-settings: 'opsz' 72; letter-spacing: -.01em; }
/* Die Zeile bleibt echter Text: lesbar, auswählbar, übersetzbar. WebGL zeichnet nur darüber. */
.js h1 { opacity: 0; }
.satz-bereit h1 { opacity: 1; transition: opacity 320ms var(--kurve); }
.gl-an h1 { color: transparent; transition: none; }
.gl-an h1::selection, .gl-an h1 em::selection { color: var(--text); background: rgba(47, 91, 234, .14); }
/* Kontrastmodus: echte Schrift statt WebGL-Zeichnung, Knöpfe mit Rahmen */
@media (forced-colors: active) {
.gl-an h1 { color: CanvasText; }
.gl { visibility: hidden; }
button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
.lead { max-width: 34em; margin-top: clamp(22px, 3vw, 34px); font-size: 1rem; line-height: 1.6; color: var(--leise); text-wrap: pretty; }
.lead em { font: italic 400 1.05em/1 'Newsreader', Georgia, serif; color: var(--text); }
.fuss { position: relative; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 12px 32px; flex-wrap: wrap; padding-top: 16px; padding-bottom: clamp(18px, 2.6vw, 28px); }
.fuss::before { content: ''; position: absolute; top: 0; left: var(--rand); right: var(--rand); height: 1px; background: var(--linie); }
.modi { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.modi .mono:first-child { margin-right: 8px; }
.modi button { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); padding: 8px 10px; border-radius: 999px; transition: color 120ms var(--kurve), background-color 120ms var(--kurve); }
.modi button:hover { color: var(--text); background: var(--flaeche); }
.modi button[aria-pressed='true'] { color: var(--grund); background: var(--text); }
.hinweis { margin-left: auto; }
.hinweis .tipp { display: none; }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px 16px; max-width: 560px; }
.werte b { color: var(--text); font-weight: 500; font-variant-numeric: tabular-nums; }
/* gedachter Zeiger der Vorführung */
.zeiger { position: absolute; left: 0; top: 0; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; border: 1px solid var(--text); background: rgba(252, 252, 252, .6); z-index: 3; pointer-events: none; opacity: 0; transition: opacity 160ms var(--kurve); }
.zeiger.an { opacity: 1; }
/* ---------- Arbeiten (unter der Bühne) ---------- */
.arbeiten { padding-top: clamp(48px, 8vw, 96px); padding-bottom: clamp(56px, 9vw, 112px); }
.arbeiten > div { max-width: 640px; }
.arbeiten h2, .kontakt h2 { font-size: 1rem; font-weight: 500; color: var(--leise); margin-bottom: 14px; }
.liste { list-style: none; padding: 0; }
.liste li { display: grid; grid-template-columns: 1fr auto; gap: 4px 24px; padding: 14px 0; border-top: 1px solid var(--linie); font-size: 1rem; line-height: 1.45; }
.liste li b { font-weight: 400; }
.liste li span { color: var(--leise); grid-column: 1; }
.liste time { font-variant-numeric: tabular-nums; color: var(--leise); text-align: right; grid-row: 1 / span 2; grid-column: 2; }
.liste em { font: italic 400 1.05em/1 'Newsreader', Georgia, serif; }
.kontakt { margin-top: clamp(40px, 6vw, 64px); }
.kontakt p { font-size: 1rem; line-height: 1.6; }
.kopieren { display: inline-flex; gap: 10px; align-items: baseline; padding: 6px 10px; margin-left: -10px; border-radius: 6px; transition: background-color 120ms var(--kurve), transform 120ms var(--kurve); }
.kopieren:hover { background: var(--flaeche); }
.kopieren:active { transform: scale(.98); }
.kopieren .mono { min-width: 8ch; text-align: left; }
/* ---------- Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .arbeiten, .vorschau .lead, .vorschau .kopf nav { display: none; }
.vorschau .buehne { height: 100vh; min-height: 0; grid-template-columns: minmax(0, 1fr); }
.vorschau h1 { font-size: 80px; }
.vorschau .satz { padding-top: 0; }
.vorschau .fuss { flex-wrap: nowrap; }
.vorschau .modi { flex: none; }
.vorschau .werte { flex: 1 1 auto; min-width: 0; max-width: none; flex-wrap: nowrap; white-space: nowrap; overflow: hidden; justify-content: flex-start; -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent); mask-image: linear-gradient(90deg, #000 85%, transparent); }
/* ---------- Reduziert: kein WebGL, Text blendet nur ein ---------- */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
:root.reduziert .satz-bereit h1 { transition: opacity 320ms linear !important; }
:root.reduziert .modi button:not([aria-pressed='true']) { opacity: .45; }
@media (max-width: 760px) {
.marke span { display: none; }
.fuss { flex-direction: column; align-items: flex-start; }
.werte { justify-content: flex-start; }
.werte span:nth-child(n+4) { display: none; }
.hinweis { flex-basis: 100%; margin-left: 0; }
}
@media (hover: none) {
.hinweis .maus { display: none; }
.hinweis .tipp { display: inline; }
}
</style>
</head>
<body>
<main>
<section class="buehne" data-buehne aria-label="Einstieg">
<header class="kopf rahmen">
<a class="marke" href="#">Hedda Tjarks<span>Interaction-Design, Oldenburg</span></a>
<nav aria-label="Hauptnavigation"><a href="#arbeiten">Arbeiten</a><a href="#">Notizen</a><a href="#kontakt">Kontakt</a></nav>
</header>
<div class="satz rahmen">
<p class="mono kicker" aria-hidden="true"><span>Notiz 07</span><i></i><span>Schrift als Material</span><span class="hinweis"><span class="maus">Zeiger über die Zeile · Klick spielt ab</span><span class="tipp">Wort antippen</span></span></p>
<h1 data-zeile>Hedda Tjarks gestaltet Oberflächen, die <em>antworten</em>.</h1>
<p class="lead">Seit 2014 entwerfe ich Bedienoberflächen für Messgeräte, Archive und kleine Werkzeuge. Gründlich, in ruhigem Tempo und <em>meistens</em> zusammen mit den Menschen, die sie später benutzen.</p>
</div>
<div class="fuss rahmen">
<div class="modi" role="group" aria-label="Variante">
<span class="mono">Variante</span>
<button type="button" data-modus="scramble" aria-pressed="true">Scramble</button>
<button type="button" data-modus="welle" aria-pressed="false">Welle</button>
<button type="button" data-modus="zerfall" aria-pressed="false">Zerfall</button>
</div>
<p class="mono werte" data-werte aria-live="off"></p>
</div>
<canvas class="gl" data-gl aria-hidden="true"></canvas>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
</section>
<section class="arbeiten rahmen" id="arbeiten">
<div>
<h2>Ausgewählte Arbeiten</h2>
<ul class="liste">
<li><b>Messoberfläche für Bodenproben</b><span>Agrarlabor Nordwest, Rastede</span><time datetime="2026">2026</time></li>
<li><b>Ausleihterminal für eine Stadtteilbibliothek</b><span>Gestaltung und Prototyp, mit der Bibliothek Kreyenbrück</span><time datetime="2025">2025</time></li>
<li><b>Fahrplan-Anzeige für die <em>Huntefähre</em></b><span>Leitsystem in drei Sprachen</span><time datetime="2024">2024</time></li>
<li><b>Notizwerkzeug für Restauratorinnen</b><span>Eigenes Projekt, offen dokumentiert</span><time datetime="2022">2022</time></li>
</ul>
<div class="kontakt" id="kontakt">
<h2>Kontakt</h2>
<p>Frei ab Februar 2027. Am liebsten per Mail:</p>
<button type="button" class="kopieren" data-kopieren="post@heddatjarks.de"><span>post@heddatjarks.de</span><span class="mono" data-kopiert>Kopieren</span></button>
</div>
</div>
</section>
</main>
<p class="nur-sr" aria-live="polite" data-ansage></p>
<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 $ = s => document.querySelector(s);
const buehne = $('[data-buehne]'), zeile = $('[data-zeile]'), canvas = $('[data-gl]');
const zeigerEl = $('[data-zeiger]'), werteEl = $('[data-werte]'), ansage = $('[data-ansage]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];
/* ---------- Werte. Messwerte: Glyphen-Stagger 10–30 ms (beobachtet), fwidth-Kantenglättung, Outline über SDF-Band.
Bewegung aus dem Rezept: easeOutQuart cubic-bezier(0.165, 0.84, 0.44, 1). ---------- */
const W = {
atlasPx: 96, spread: 12, atlas: 1024, // SDF-Atlas: Schriftgröße, Distanzbereich (px), Kantenlänge
stagger: 20, // ms pro Glyphe
takt: 40, scramble: 360, einrasten: 240, // Scramble: Glyphenwechsel alle 40 ms, 360 ms je Glyphe, 240 ms Einrasten
streifen: 240, // Zeiger streift Glyphe: 240 ms Scramble
kontur: 2.2, // halbe Konturbreite in Atlas-px (bei 96 px Schriftgröße)
welleY: 12, welleZ: 56, welleK: 0.38, welleTempo: 2.4, radius: 150, wulst: 110, gewicht: 0.05, lerp: 0.1, ueberblenden: 480,
zerfallAus: 900, halten: 500, zerfallEin: 1200, weite: 640, tiefe: 900,
};
const SCRAMBLE_ZEICHEN = 'abdeghkmnorsuxz02345789#'; // Geist Mono: gleiche Breite, technischer Kontrast zur Grotesk
const MODI = {
scramble: { name: 'Scramble per Atlas-Offset', werte: () => [['Stagger', `${W.stagger} ms`], ['Takt', `${W.takt} ms`], ['Scramble', `${W.scramble} ms`], ['Einrasten', `${W.einrasten} ms`], ['Kurve', 'easeOutQuart'], ['Kontur', 'SDF-Band']] },
welle: { name: 'Welle im Vertex-Shader', werte: () => [['Amplitude', `${W.welleY} px · z ${W.welleZ} px`], ['Phase', `${W.welleK} rad/Glyphe`], ['Tempo', `${W.welleTempo} rad/s`], ['Radius', `${W.radius} px`], ['Gewicht', `+${W.gewicht} SDF`], ['Lerp', `${W.lerp}`]] },
zerfall: { name: 'Zerfall in 3D', werte: () => [['Aus', `${W.zerfallAus} ms`], ['Halten', `${W.halten} ms`], ['Ein', `${W.zerfallEin} ms`], ['Stagger', `${W.stagger} ms`], ['Kurve', 'easeOutQuart'], ['Weite', `${W.weite} px · z ${W.tiefe} px`]] },
};
const quartOut = t => 1 - Math.pow(1 - Math.min(Math.max(t, 0), 1), 4);
let modus = 'scramble', vorfuehrt = false, pausiert = false;
const jetzt = () => performance.now();
/* ---------- Werte-Anzeige und Modus ---------- */
function werteZeigen() {
werteEl.innerHTML = reduziert() || !glAn
? `<span>Reduziert <b>HTML-Text, Einblenden 320 ms</b></span>`
: MODI[modus].werte().map(([a, b]) => `<span>${a} <b>${b}</b></span>`).join('');
knoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.modus === modus ? 'true' : 'false'));
}
/* ---------- SDF-Atlas zur Laufzeit: Glyphen in Canvas 2D rastern, dann euklidische Distanztransformation ---------- */
const INF = 1e20;
function edt1d(f, d, v, z, n) {
v[0] = 0; z[0] = -INF; z[1] = INF; let k = 0;
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] = INF;
}
k = 0;
for (let q = 0; q < n; q++) { while (z[k + 1] < q) k++; d[q] = (q - v[k]) * (q - v[k]) + f[v[k]]; }
}
function edt(grid, 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] = grid[y * w + x];
edt1d(f, d, v, z, h);
for (let y = 0; y < h; y++) grid[y * w + x] = d[y];
}
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) f[x] = grid[y * w + x];
edt1d(f, d, v, z, w);
for (let x = 0; x < w; x++) grid[y * w + x] = d[x];
}
}
function baueAtlas(eintraege) {
const S = W.atlasPx, R = W.spread, N = W.atlas;
const data = new Uint8Array(N * N);
const c = document.createElement('canvas'), ctx = c.getContext('2d', { willReadFrequently: true });
let x = 0, y = 0, zeileH = 0;
const glyphen = new Map();
for (const { schluessel, zeichen, font } of eintraege) {
ctx.font = font;
const m = ctx.measureText(zeichen);
const links = Math.ceil(Math.max(0, m.actualBoundingBoxLeft)) + R;
const rechts = Math.ceil(Math.max(m.width, m.actualBoundingBoxRight)) + R;
const oben = Math.ceil(m.fontBoundingBoxAscent) + R, unten = Math.ceil(m.fontBoundingBoxDescent) + R;
const w = links + rechts, h = oben + unten;
if (x + w > N) { x = 0; y += zeileH + 2; zeileH = 0; }
if (y + h > N) break;
c.width = w; c.height = h;
ctx.font = font; ctx.fillStyle = '#000'; ctx.textBaseline = 'alphabetic';
ctx.clearRect(0, 0, w, h); ctx.fillText(zeichen, links, oben);
const a = ctx.getImageData(0, 0, w, h).data;
const aussen = new Float64Array(w * h), innen = new Float64Array(w * h);
for (let i = 0; i < w * h; i++) {
const al = a[i * 4 + 3] / 255;
if (al >= 1) { aussen[i] = 0; innen[i] = INF; }
else if (al <= 0) { aussen[i] = INF; innen[i] = 0; }
else { const o = Math.max(0, 0.5 - al), n = Math.max(0, al - 0.5); aussen[i] = o * o; innen[i] = n * n; }
}
edt(aussen, w, h); edt(innen, w, h);
for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
const k = j * w + i, dist = Math.sqrt(aussen[k]) - Math.sqrt(innen[k]);
data[(y + j) * N + (x + i)] = Math.round(255 * Math.min(1, Math.max(0, 0.5 - dist / (2 * R))));
}
glyphen.set(schluessel, { stiftX: x + links, grundY: y + oben, vor: m.width, box: [x, y, x + w, y + h], links, oben, w, h });
x += w + 2; zeileH = Math.max(zeileH, h);
}
const tex = new THREE.DataTexture(data, N, N, THREE.RedFormat, THREE.UnsignedByteType);
tex.flipY = false; tex.unpackAlignment = 1;
tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false;
tex.needsUpdate = true;
return { tex, glyphen, belegt: y + zeileH };
}
/* ---------- Shader ---------- */
const vertex = /* glsl */`
precision highp float;
attribute vec4 aBox; // Quad in CSS-px relativ zur Bühne: x, y, b, h
attribute vec2 aStift; // Stift/Grundlinie in CSS-px
attribute vec4 aAtlas; // Stift x, Grundlinie y, Vorschub, Maßstab (CSS-px je Atlas-px)
attribute vec4 aZelle; // Zelle im Atlas (x0, y0, x1, y1)
attribute float aIdx, aStreif;
attribute vec4 aZufall;
uniform vec2 uBuehne, uZeiger;
uniform float uZeit, uStagger, uScrStart, uScrDauer, uEinrasten, uStreifDauer, uTakt, uN;
uniform float uWelle, uWelleY, uWelleZ, uWelleK, uWelleTempo, uRadius, uWulst, uGewicht, uZeigerAn;
uniform float uZAus, uZEin, uZDauerAus, uZDauerEin, uWeite, uTiefe;
varying vec2 vLokal;
varying float vFuell, vScr, vWahl, vAlpha, vGewicht, vVor;
varying vec4 vZelle;
varying vec3 vStift;
float quart(float t) { t = clamp(t, 0.0, 1.0); return 1.0 - pow(1.0 - t, 4.0); }
vec3 drehe(vec3 p, vec3 a, float w) { return p * cos(w) + cross(a, p) * sin(w) + a * dot(a, p) * (1.0 - cos(w)); }
void main() {
vec2 ecke = position.xy + 0.5; // 0..1
vec2 px = aBox.xy + vec2(ecke.x, 1.0 - ecke.y) * aBox.zw;
vLokal = (px - aStift) / aAtlas.w; // Atlas-px relativ zu Stift/Grundlinie
vZelle = aZelle; vStift = vec3(aAtlas.xy, aAtlas.z); vVor = aAtlas.z;
/* Scramble: Einstieg gestaffelt, dazu „gestreifte“ Glyphen unter dem Zeiger */
float tS = uZeit - uScrStart - aIdx * uStagger;
float tT = uZeit - aStreif;
float scr = (tS >= 0.0 && tS < uScrDauer) || (tT >= 0.0 && tT < uStreifDauer) ? 1.0 : 0.0;
float seit = min(tS - uScrDauer, tT >= 0.0 ? tT - uStreifDauer : 1e9);
float fuell = scr > 0.5 ? 0.0 : quart(seit / uEinrasten);
vAlpha = tS < 0.0 ? 0.0 : 1.0;
vScr = scr;
vWahl = mod(floor(aZufall.x * 24.0 + uZeit / uTakt), 24.0);
vec2 mitte = aBox.xy + aBox.zw * 0.5;
vec3 lokal = vec3((ecke.x - 0.5) * aBox.z, (ecke.y - 0.5) * aBox.w, 0.0);
vec3 versatz = vec3(0.0);
/* Welle: Phase pro Glyphe, Wulst und Gewicht am Zeiger */
float d = distance(mitte, uZeiger);
float g = exp(-pow(d / uRadius, 2.0)) * uZeigerAn;
float ph = aIdx * uWelleK - uZeit * 0.001 * uWelleTempo;
versatz.y += sin(ph) * uWelleY * uWelle;
versatz.z += (sin(ph) * uWelleZ + g * uWulst) * uWelle;
lokal = drehe(lokal, vec3(1.0, 0.0, 0.0), cos(ph) * 0.42 * uWelle);
vGewicht = g * uGewicht * uWelle;
/* Zerfall: Glyphen fliegen in den Raum und rasten gestaffelt wieder ein */
float aus = quart((uZeit - uZAus - aIdx * uStagger) / uZDauerAus);
float ein = quart((uZeit - uZEin - aIdx * uStagger) / uZDauerEin);
float p = aus * (1.0 - ein);
vec3 achse = normalize(vec3(aZufall.y - 0.5, aZufall.z - 0.5, aZufall.w - 0.5) + 1e-4);
lokal = drehe(lokal, achse, (aZufall.w - 0.5) * 7.5 * p);
versatz += vec3((aZufall.x - 0.5) * 2.0 * uWeite, (aZufall.y - 0.5) * 1.3 * uWeite, (aZufall.z - 0.35) * uTiefe) * p;
fuell *= 1.0 - smoothstep(0.08, 0.5, p);
vFuell = fuell;
vec3 welt = vec3(mitte.x - uBuehne.x * 0.5, uBuehne.y * 0.5 - mitte.y, 0.0) + lokal + versatz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(welt, 1.0);
}`;
const fragment = /* glsl */`
precision highp float;
uniform sampler2D uAtlas;
uniform vec4 uScr[24], uScrBox[24];
uniform float uKontur, uSpread, uAtlasN;
uniform vec3 uTinte, uGrau;
varying vec2 vLokal;
varying float vFuell, vScr, vWahl, vAlpha, vGewicht, vVor;
varying vec4 vZelle;
varying vec3 vStift;
float probe(vec2 at, vec4 box) {
if (at.x < box.x || at.y < box.y || at.x > box.z || at.y > box.w) return 0.0;
return texture2D(uAtlas, at / uAtlasN).r;
}
void main() {
float sd;
if (vScr > 0.5) {
/* Scramble: nur der Texturoffset wechselt, die Geometrie bleibt */
int j = int(vWahl + 0.5);
vec4 s = uScr[j];
float f = clamp(vVor * 1.1 / s.z, 0.62, 1.0); // schmale Lücke: Ersatzglyphe gleichmäßig verkleinern
vec2 q = (vLokal - vec2(vVor * 0.5, 0.0)) / f + vec2(s.z * 0.5, 0.0);
sd = probe(s.xy + q, uScrBox[j]);
} else {
sd = probe(vStift.xy + vLokal, vZelle);
}
sd += vGewicht;
float w = max(fwidth(sd) * 0.75, 1e-4); // fwidth-Kantenglättung
float fuell = smoothstep(0.5 - w, 0.5 + w, sd);
float k = uKontur / (2.0 * uSpread);
float kontur = 1.0 - smoothstep(k - w, k + w, abs(sd - 0.5));
float a = mix(kontur, fuell, vFuell) * vAlpha;
vec3 farbe = mix(uGrau, uTinte, vFuell);
if (a < 0.003) discard;
gl_FragColor = vec4(farbe, a);
}`;
/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null, szene, kamera, mesh, material, geo, atlas = null, glAn = false;
const probeCanvas = document.createElement('canvas');
if (probeCanvas.getContext('webgl2')) {
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
if (renderer) {
renderer.setClearColor(0x000000, 0);
szene = new THREE.Scene();
kamera = new THREE.PerspectiveCamera(30, 1, 1, 8000);
canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); renderer = null; glPruefen(); });
}
/* Glyphen aus dem echten HTML-Text: jedes Zeichen bekommt seine Position per Range aus dem Layout */
let glyphen = [];
function zeichenSammeln() {
const liste = [];
const lauf = (knoten, kursiv) => {
for (const k of knoten.childNodes) {
if (k.nodeType === 3) for (let i = 0; i < k.data.length; i++) { if (k.data[i].trim()) liste.push({ knoten: k, i, z: k.data[i], kursiv }); }
else if (k.nodeType === 1) lauf(k, kursiv || k.tagName === 'EM');
}
};
lauf(zeile, false);
return liste;
}
const FONT = { gerade: `500 ${W.atlasPx}px "Host Grotesk"`, kursiv: `italic 400 ${W.atlasPx}px "Newsreader"`, mono: `500 ${W.atlasPx}px "Geist Mono"` };
const schluessel = (z, kursiv) => (kursiv ? 'k' : 'g') + z;
function aufbauen() {
if (!renderer) return false;
const roh = zeichenSammeln();
const eintraege = new Map();
roh.forEach(r => eintraege.set(schluessel(r.z, r.kursiv), { schluessel: schluessel(r.z, r.kursiv), zeichen: r.z, font: r.kursiv ? FONT.kursiv : FONT.gerade }));
[...SCRAMBLE_ZEICHEN].forEach(z => eintraege.set('s' + z, { schluessel: 's' + z, zeichen: z, font: FONT.mono }));
atlas = baueAtlas([...eintraege.values()]);
glyphen = roh.map((r, idx) => ({ ...r, idx, a: atlas.glyphen.get(schluessel(r.z, r.kursiv)), streif: -1e9, zufall: [Math.random(), Math.random(), Math.random(), Math.random()] }));
if (glyphen.some(g => !g.a)) return false;
const n = glyphen.length;
geo = new THREE.InstancedBufferGeometry();
const basis = new THREE.PlaneGeometry(1, 1);
geo.index = basis.index; geo.setAttribute('position', basis.attributes.position);
geo.instanceCount = n;
const attr = (name, k) => geo.setAttribute(name, new THREE.InstancedBufferAttribute(new Float32Array(n * k), k));
attr('aBox', 4); attr('aStift', 2); attr('aAtlas', 4); attr('aZelle', 4); attr('aIdx', 1); attr('aStreif', 1); attr('aZufall', 4);
glyphen.forEach((g, i) => {
geo.attributes.aZelle.setXYZW(i, ...g.a.box);
geo.attributes.aIdx.setX(i, i);
geo.attributes.aZufall.setXYZW(i, ...g.zufall);
geo.attributes.aStreif.setX(i, -1e9);
});
const scr = [], scrBox = [];
[...SCRAMBLE_ZEICHEN].forEach(z => { const a = atlas.glyphen.get('s' + z); scr.push(new THREE.Vector4(a.stiftX, a.grundY, a.vor, 0)); scrBox.push(new THREE.Vector4(...a.box)); });
material = new THREE.ShaderMaterial({
vertexShader: vertex, fragmentShader: fragment, transparent: true, depthTest: false, depthWrite: false, side: THREE.DoubleSide,
uniforms: {
uAtlas: { value: atlas.tex }, uAtlasN: { value: W.atlas }, uSpread: { value: W.spread }, uKontur: { value: W.kontur },
uScr: { value: scr }, uScrBox: { value: scrBox },
uTinte: { value: new THREE.Vector3(11 / 255, 11 / 255, 9 / 255) }, uGrau: { value: new THREE.Vector3(100 / 255, 100 / 255, 100 / 255) },
uBuehne: { value: new THREE.Vector2(1, 1) }, uZeiger: { value: new THREE.Vector2(-9999, -9999) }, uZeigerAn: { value: 0 },
uZeit: { value: 0 }, uStagger: { value: W.stagger }, uScrStart: { value: -1e9 }, uScrDauer: { value: W.scramble }, uEinrasten: { value: W.einrasten },
uStreifDauer: { value: W.streifen }, uTakt: { value: W.takt }, uN: { value: n },
uWelle: { value: 0 }, uWelleY: { value: W.welleY }, uWelleZ: { value: W.welleZ }, uWelleK: { value: W.welleK }, uWelleTempo: { value: W.welleTempo },
uRadius: { value: W.radius }, uWulst: { value: W.wulst }, uGewicht: { value: W.gewicht },
uZAus: { value: -1e9 }, uZEin: { value: -1e9 }, uZDauerAus: { value: W.zerfallAus }, uZDauerEin: { value: W.zerfallEin }, uWeite: { value: W.weite }, uTiefe: { value: W.tiefe },
},
});
mesh = new THREE.Mesh(geo, material);
mesh.frustumCulled = false;
szene.add(mesh);
return true;
}
/* Layout: Positionen aus dem DOM lesen (bei Größenänderung und nach dem Laden der Schriften) */
let bW = 1, bH = 1, bRect = null;
const range = document.createRange();
let lageAlt = '';
function lage() { const z = zeile.getBoundingClientRect(), b = buehne.getBoundingClientRect(); return [z.left - b.left, z.top - b.top, z.width, b.width, b.height].map(Math.round).join(); }
function layout() {
if (!renderer || !mesh) return;
lageAlt = lage();
bRect = buehne.getBoundingClientRect();
bW = buehne.clientWidth; bH = buehne.clientHeight;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
renderer.setSize(bW, bH, false);
kamera.aspect = bW / bH;
kamera.position.set(0, 0, (bH / 2) / Math.tan(THREE.MathUtils.degToRad(15)));
kamera.near = 1; kamera.far = kamera.position.z + 4000;
kamera.updateProjectionMatrix();
material.uniforms.uBuehne.value.set(bW, bH);
material.uniforms.uWeite.value = W.weite * Math.min(1, Math.max(0.45, bW / 1100)); /* schmale Bühne: kürzere Flugbahnen */
const S = W.atlasPx, R = W.spread;
const groesse = { gerade: parseFloat(getComputedStyle(zeile).fontSize), kursiv: parseFloat(getComputedStyle(zeile.querySelector('em')).fontSize) };
/* Range-Rechteck eines Zeichens = Inhaltsbereich der Schrift: Oberkante + Ascent = Grundlinie */
const metrik = {};
const c = document.createElement('canvas').getContext('2d');
for (const art of ['gerade', 'kursiv']) { c.font = FONT[art]; metrik[art] = c.measureText('H').fontBoundingBoxAscent; }
glyphen.forEach((g, i) => {
range.setStart(g.knoten, g.i); range.setEnd(g.knoten, g.i + 1);
const rs = [...range.getClientRects()].filter(r => r.width > 0);
const r = rs[rs.length - 1] || range.getBoundingClientRect();
const art = g.kursiv ? 'kursiv' : 'gerade';
const m = groesse[art] / S; // CSS-px je Atlas-px
const stiftX = r.left - bRect.left, grund = r.top - bRect.top + metrik[art] * m;
let w = g.a.w * m, x = stiftX - g.a.links * m;
const minW = (S * 0.9 + 2 * R) * m; // Platz für breitere Scramble-Glyphen
if (w < minW) { x -= (minW - w) / 2; w = minW; }
g.mitte = [stiftX + g.a.vor * m / 2, grund - S * 0.35 * m];
g.halb = [g.a.vor * m / 2 + 4, S * 0.45 * m];
geo.attributes.aBox.setXYZW(i, x, grund - g.a.oben * m, w, g.a.h * m);
geo.attributes.aStift.setXY(i, stiftX, grund);
geo.attributes.aAtlas.setXYZW(i, g.a.stiftX, g.a.grundY, g.a.vor, m);
});
['aBox', 'aStift', 'aAtlas'].forEach(a => geo.attributes[a].needsUpdate = true);
}
/* ---------- Zustand ---------- */
const zeiger = { x: -9999, y: -9999, da: false };
let welle = 0, welleZiel = 0, zeigerAn = 0;
function startScramble(t = jetzt()) { if (material) material.uniforms.uScrStart.value = t; }
function startZerfall(t = jetzt()) {
if (!material) return;
const u = material.uniforms;
if (t < u.uZEin.value + W.zerfallEin + glyphen.length * W.stagger && t > u.uZAus.value) return; // läuft noch
u.uZAus.value = t;
u.uZEin.value = t + W.zerfallAus + W.halten;
}
function streifen(x, y) {
if (!material || modus !== 'scramble') return;
const t = jetzt();
glyphen.forEach((g, i) => {
if (Math.abs(x - g.mitte[0]) < g.halb[0] && Math.abs(y - g.mitte[1]) < g.halb[1]) {
[-1, 0, 1].forEach(o => {
const n = glyphen[i + o];
if (!n || t - n.streif < W.streifen + W.einrasten) return;
n.streif = t + Math.abs(o) * W.stagger;
geo.attributes.aStreif.setX(i + o, n.streif);
geo.attributes.aStreif.needsUpdate = true;
});
}
});
}
function wortStreifen(x, y) { // Touch: ganzes Wort unter dem Finger
if (!material) return;
const t = jetzt();
const treffer = glyphen.find(g => Math.abs(x - g.mitte[0]) < g.halb[0] + 6 && Math.abs(y - g.mitte[1]) < g.halb[1] + 10);
if (!treffer) return false;
let a = treffer.idx, b = treffer.idx;
const gleichesWort = (p, q) => p && q && p.knoten === q.knoten && Math.abs(p.i - q.i) === 1;
while (gleichesWort(glyphen[a - 1], glyphen[a])) a--;
while (gleichesWort(glyphen[b + 1], glyphen[b])) b++;
for (let i = a; i <= b; i++) { glyphen[i].streif = t + (i - a) * W.stagger; geo.attributes.aStreif.setX(i, glyphen[i].streif); }
geo.attributes.aStreif.needsUpdate = true;
return true;
}
function setzeModus(m, still) {
modus = m;
welleZiel = m === 'welle' ? 1 : 0;
if (m === 'scramble') startScramble();
if (m === 'zerfall') startZerfall(jetzt() + 120);
werteZeigen();
if (!still) ansage.textContent = `Variante: ${MODI[m].name}`;
}
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeModus(b.dataset.modus); }));
/* ---------- Eingaben ---------- */
function lokal(e) { const r = buehne.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }
buehne.addEventListener('pointermove', e => {
if (e.pointerType === 'touch' && modus !== 'welle') return;
if (vorfuehrt && e.pointerType !== 'touch') stopVorfuehrung();
[zeiger.x, zeiger.y] = lokal(e); zeiger.da = true;
streifen(zeiger.x, zeiger.y);
}, { passive: true });
buehne.addEventListener('pointerleave', () => { if (!vorfuehrt) zeiger.da = false; });
zeile.addEventListener('click', e => {
if (!glAn) return;
stopVorfuehrung();
const [x, y] = lokal(e);
if (modus === 'scramble') { if (!(e.pointerType === 'touch' || beruehrung) || !wortStreifen(x, y)) startScramble(); }
else if (modus === 'zerfall') startZerfall();
});
buehne.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch' || modus !== 'welle') return;
[zeiger.x, zeiger.y] = lokal(e); zeiger.da = true;
});
buehne.addEventListener('pointerup', e => { if (e.pointerType === 'touch' && !vorfuehrt) zeiger.da = false; });
/* ---------- Schleife ---------- */
let letzter = jetzt(), sichtbar = true;
new IntersectionObserver(e => { sichtbar = e[0].isIntersecting; }).observe(buehne); /* außer Sicht: nicht rendern */
function bild(t) {
requestAnimationFrame(bild);
if (!glAn || pausiert || !sichtbar) { letzter = t; return; }
const dt = Math.min(64, t - letzter); letzter = t;
const k = 1 - Math.pow(1 - W.lerp, dt / 16.67);
const u = material.uniforms;
welle += (welleZiel - welle) * Math.min(1, dt / W.ueberblenden * 3.2);
zeigerAn += ((zeiger.da && modus === 'welle' ? 1 : 0) - zeigerAn) * k;
if (zeiger.da) {
const z = u.uZeiger.value;
if (z.x < -9000) z.set(zeiger.x, zeiger.y);
z.x += (zeiger.x - z.x) * k; z.y += (zeiger.y - z.y) * k;
}
if (lage() !== lageAlt) layout(); /* DOM bleibt Layout-Quelle: verschiebt sich die Zeile, folgen die Glyphen */
u.uWelle.value = welle; u.uZeigerAn.value = zeigerAn; u.uZeit.value = t;
renderer.render(szene, kamera);
}
/* ---------- Ein/Aus: WebGL nur ohne reduzierte Bewegung ---------- */
let bereit = false;
function glPruefen() {
const an = !!renderer && bereit && !reduziert();
const war = glAn;
glAn = an;
if (html.classList.contains('gl-an') !== an) html.classList.toggle('gl-an', an); /* nur bei Änderung, sonst feuert der Observer erneut */
if (!html.classList.contains('satz-bereit')) html.classList.add('satz-bereit');
canvas.style.visibility = an ? 'visible' : 'hidden';
werteZeigen();
if (an && !war) { layout(); startScramble(jetzt() + 80); }
}
new MutationObserver(() => { if (entschieden) glPruefen(); }).observe(html, { attributes: true, attributeFilter: ['class'] });
let rz;
addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(() => { layout(); }, 60); });
async function start() {
try {
await Promise.race([
Promise.all([document.fonts.load(FONT.gerade, 'Hedda'), document.fonts.load(FONT.kursiv, 'antworten'), document.fonts.load(FONT.mono, 'abc012'), document.fonts.ready]),
new Promise(r => setTimeout(r, 2500)),
]);
} catch (e) { /* Systemschrift */ }
if (renderer) { try { bereit = aufbauen(); } catch (e) { bereit = false; } }
entschieden = true;
glPruefen();
requestAnimationFrame(bild);
}
let entschieden = false;
const startFertig = start();
/* ---------- Uhr und Kopieren (Rezept: jede Interaktion bekommt einen Zustand) ---------- */
document.querySelectorAll('[data-kopieren]').forEach(b => b.addEventListener('click', async () => {
const z = b.querySelector('[data-kopiert]');
try { await navigator.clipboard.writeText(b.dataset.kopieren); z.textContent = 'Kopiert'; } catch (e) { z.textContent = 'Markieren'; }
setTimeout(() => z.textContent = 'Kopieren', 1600);
}));
/* ---------- Vorführung: Einstieg, gedachter Zeiger streift die Zeile, Welle, Zerfall ---------- */
let timer = [], zeigerLauf = null;
const nach = (ms, f) => timer.push(setTimeout(f, ms));
function zeigerFahrt(dauer, bahn) {
cancelAnimationFrame(zeigerLauf);
const t0 = jetzt();
zeigerEl.classList.add('an');
const schritt = () => {
const p = Math.min(1, (jetzt() - t0) / dauer);
const [x, y] = bahn(p);
zeiger.x = x; zeiger.y = y; zeiger.da = true;
zeigerEl.style.transform = `translate(${x}px, ${y}px)`;
streifen(x, y);
if (p < 1) zeigerLauf = requestAnimationFrame(schritt);
else zeigerEl.classList.remove('an');
};
schritt();
}
function zeilenBahn() {
/* fährt die Zeilen der Überschrift entlang, wie ein lesender Blick */
const zeilen = [];
glyphen.forEach(g => { const z = zeilen.find(l => Math.abs(l.y - g.mitte[1]) < 8); if (z) { z.x0 = Math.min(z.x0, g.mitte[0]); z.x1 = Math.max(z.x1, g.mitte[0]); } else zeilen.push({ y: g.mitte[1], x0: g.mitte[0], x1: g.mitte[0] }); });
return p => {
const f = p * zeilen.length, i = Math.min(zeilen.length - 1, Math.floor(f)), q = f - i, l = zeilen[i];
return [l.x0 + (l.x1 - l.x0) * q, l.y];
};
}
function stopVorfuehrung() {
if (!vorfuehrt) return;
vorfuehrt = false; html.classList.remove('vorfuehrung');
timer.forEach(clearTimeout); timer = [];
cancelAnimationFrame(zeigerLauf); zeigerEl.classList.remove('an');
zeiger.da = false;
}
function neustart() {
stopVorfuehrung();
if (material) { material.uniforms.uZAus.value = -1e9; material.uniforms.uZEin.value = -1e9; }
setzeModus('scramble', true);
}
function vorfuehren() {
stopVorfuehrung();
pausiert = false;
if (!entschieden) { startFertig.then(vorfuehren); return; }
vorfuehrt = true; html.classList.add('vorfuehrung');
if (!glAn) { setzeModus('scramble', true); return; }
const dauerEinstieg = glyphen.length * W.stagger + W.scramble + W.einrasten;
setzeModus('scramble', true);
nach(dauerEinstieg + 500, () => zeigerFahrt(2600, zeilenBahn()));
nach(dauerEinstieg + 3400, () => {
setzeModus('welle', true);
const r = zeile.getBoundingClientRect(), b = buehne.getBoundingClientRect();
const cx = r.left - b.left + r.width * 0.5, cy = r.top - b.top + r.height * 0.5;
zeigerFahrt(3800, p => [cx + Math.sin(p * Math.PI * 2) * r.width * 0.42, cy + Math.sin(p * Math.PI * 4) * r.height * 0.28]);
});
nach(dauerEinstieg + 7600, () => { zeiger.da = false; setzeModus('zerfall', true); });
nach(dauerEinstieg + 7600 + 120 + W.zerfallAus + W.halten + W.zerfallEin + glyphen.length * W.stagger + 700, () => { if (vorfuehrt) vorfuehren(); });
}
function pause() { stopVorfuehrung(); pausiert = true; }
function weiter() { pausiert = false; vorfuehren(); }
</script>
</body>
</html>