Zum Inhalt
← 3D & WebGL

ASCII-, Dither- und Pixel-Stil (Render und Hover)

Ein 3D-Knoten läuft durch einen eigenen Shader-Pass und erscheint als ASCII mit CRT-Look, als Bayer-Dither in Markenfarben oder als Pixelraster; unter dem Zeiger scheint in 5vw-Kacheln das Original durch.

Creative-Coding-Studio, Label, Tech-Kultur: ein eigenes 3D-Objekt wird live zu ASCII, Dither oder Pixeln, unter dem Zeiger scheint das Original durch.

Eine 3D-Szene, ein Video oder ein Bild wird zuerst normal in ein Render-Target gerendert. Ein Nachbearbeitungs-Pass teilt das Bild dann in Zellen und übersetzt jede Zelle in eine grobe Form: ASCII-Zeichen (Zellen 8 bis 16 px), Dither-Punkte (1 bis 4 px) oder Pixelblöcke. Beim Dithering wird die Helligkeit jedes Pixels mit einer Bayer-Matrix (4×4 oder 8×8) verglichen. Ergebnis ist 1-Bit oder eine kleine Palette. Varianten sind Floyd-Steinberg (vorberechnet) und Blue Noise. Beim ASCII-Stil bestimmt die Helligkeit der Zelle das Zeichen aus einem Glyphen-Atlas. Shape-aware ASCII wählt das Zeichen zusätzlich nach der Kantenrichtung in der Zelle. Die Maus verändert Zellgröße oder Schwelle oder lässt das Original durchscheinen. Ein CRT-Zusatz bringt Scanlines, Wölbung und Phosphor-Glühen.

Hochwertiges 3D trifft auf Retro-Computer-Ästhetik. Der Stil reduziert Details auf eine starke grafische Handschrift und kaschiert einfache Modelle. Er wirkt technisch und handgemacht zugleich und verbindet das Terminal von früher mit heutiger KI- und Daten-Ästhetik. Retro-Digitales (Dithering, 8-Bit, Terminal) ist 2025/26 ein wachsendes Motiv, etwa in Codrops-Experimenten.

Wann einsetzen

Tech- und KI-Marken mit Haltung, Dev-Tools, Creative-Coding-Studios, Musik, Gaming, Entwickler-Portfolios mit Nerd-Charme. Gut als Hero-Hintergrund, für 404-Seiten und als Hover-Zustand von Projektbildern.

Wann nicht

Wenn ein Produkt fotorealistisch gezeigt werden muss. Bei warmen, klassischen oder organischen Marken. Hinter Fließtext. Auf kleinen Flächen, auf denen Zeichen unlesbar werden. Bei zu feinen Zellen auf Mobilgeräten (Flimmern, Moiré) und bei Rastern aus Hunderten DOM-Elementen (Leistung). Video-Variante: nicht für Filme, deren Inhalt man erkennen muss (Produktdemo, Erklärvideo), und nie mit Ton im Autoplay.

Varianten

  • Bayer-Dither 1-Bit
  • Dither mit Markenpalette
  • ASCII nach Helligkeit
  • Shape-aware ASCII: Zeichen nach Kantenrichtung, z. B. ein Logo als ziehbarer 3D-Körper (Codrops 09/2026)
  • CRT- und Monitor-Look mit Scanlines, Vignette, Wölbung und Aberration (Midjourney)
  • Dither im 3D-Raum: Wolken mit Bayer-Dither in 3 px Zellen (Good Growth)
  • Zeichenfeld unter Spotlight-Maske, per WebGL mit Glyphen-Atlas (Codrops AnimatedCodeBackground)
  • Canvas-ASCII eines Videos mit Cursor-Verwirbelung
  • Pixel-Spur: 5vw-Kacheln leuchten unter dem Zeiger auf und verschwinden nach 300 ms (Olivier Larose)
  • Bild pixelt beim Laden oder Hover in Stufen scharf (Codrops ImagePixelLoading)
  • Pixel-Glitch mit Clip-Path-Streifen und image-rendering: pixelated (Codrops GlitchPerspective)
  • Raster-Kollision: Kacheln reagieren auf die Nähe des Zeigers (Motion)

Umsetzung

Szene in ein WebGLRenderTarget rendern, dann ein Vollbild-Quad mit eigenem Shader zeichnen (oder einen eigenen Effekt in EffectComposer bzw. pmndrs postprocessing). Zellen über gl_FragCoord an Device-Pixeln ausrichten, sonst flimmert es beim Scrollen. Bei 3D den Dither im Screen-Space stabil halten. Die Bayer-Matrix steht als Konstante oder Formel im Shader.

ASCII: Zeichen nach Tinte sortiert in eine Atlas-Textur zeichnen. Pro Zelle die Szene einmal in der Zellmitte abtasten, Index = floor(Helligkeit · n). Weil jede Zelle nur einen Messpunkt braucht, reicht ein Render-Target in halber Auflösung. Die Cursor-Distanz kann Index, Farbe oder die Mischung mit dem Original steuern.

Pixel-Spur: per Canvas oder kleiner Datentextur statt DOM. Ein Float32Array hält einen Alpha-Wert je Kachel. pointermove setzt die Kachel unter dem Zeiger auf 1; die Strecke dazwischen abtasten, damit bei schnellen Bewegungen keine Lücken bleiben. Pro Frame linear um dt/300 ms abbauen.

Bild-Pixelation: Bild in ein kleines Offscreen-Canvas (Breite ÷ Zellgröße) zeichnen und mit imageSmoothingEnabled = false hochskalieren. Die Zellgröße in Stufen ändern, z. B. 24 → 12 → 6 → 3 → 1. Fallstrick: Ein DOM-Raster mit Hunderten Elementen und mouseenter ist einfach, aber teuer. DPR berücksichtigen.

Robust umsetzen

Telefon. Größere Zellen. Ohne Hover leuchten zufällige Kacheln als ruhige Hintergrundbewegung auf, beim Wischen folgt die Spur dem Finger. Bei schwacher GPU die Bildrate drosseln oder ein Standbild zeigen. Projektbilder pixeln beim Hereinscrollen scharf.

Ohne Bewegung. Szene einfrieren, der Stil bleibt als Standbild. Keine Spur, keine Stufen beim Wechsel, keine CRT-Einschaltanimation. Bilder sofort scharf.

Barrierefrei. Inhalte nie nur als ASCII- oder Pixelbild zeigen: Texte bleiben echtes HTML, die Canvas-Fläche ist aria-hidden. Flimmern vermeiden (Photosensitivität), besonders bei feinen Zellen und Glitch-Effekten. Kontrast von Text über aktiven Kacheln prüfen, denn mix-blend-mode: difference kann unlesbare Mischfarben erzeugen.

Code

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

:root {
  --tinte: #0c0b0a; --papier: #efe9dc; --signal: #ff5b24; --tief: #3a1608;
  --leise: rgba(239, 233, 220, .58); --linie: rgba(239, 233, 220, .16);
  --kurve: cubic-bezier(0.5, 1, 0.89, 1);   /* easeOutQuad, UI 150 ms */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--tinte); color: var(--papier); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
a { color: inherit; }

/* ---------- Bühne ---------- */
.held { position: relative; height: 100svh; min-height: 560px; overflow: hidden; isolation: isolate; }
.held canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: -1; }
.ersatz { display: none; position: absolute; inset: 0; z-index: -1;
  background:
    repeating-conic-gradient(var(--tinte) 0 25%, transparent 0 50%) 0 0 / 6px 6px,
    radial-gradient(42% 52% at 66% 46%, var(--signal), var(--tief) 55%, var(--tinte) 80%); }
.ohne-webgl .ersatz { display: block; }
.ohne-webgl .held canvas { display: none; }

.kopf { position: absolute; inset: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 24px; padding: clamp(18px, 2.6vw, 32px) clamp(20px, 4vw, 56px); }
.marke { font: 600 13px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .22em; color: var(--papier); text-decoration: none; }
.marke b { color: var(--signal); font-weight: 600; }
.kopf nav { display: flex; gap: clamp(16px, 2.4vw, 36px); }
.kopf nav a { text-decoration: none; color: var(--leise); transition: color .15s var(--kurve); }
.kopf nav a:hover, .kopf nav a:focus-visible { color: var(--papier); }

.text { position: absolute; left: clamp(20px, 4vw, 56px); bottom: clamp(96px, 13vh, 140px); max-width: min(46vw, 640px); }
.text .mono { margin-bottom: 22px; display: flex; gap: 10px; align-items: center; }
.text .mono::before { content: ''; width: 8px; height: 8px; background: var(--signal); }
h1 { font-size: clamp(40px, 5.6vw, 88px); font-weight: 500; line-height: .96; letter-spacing: -.045em; }
h1 em { font: italic 400 1.08em/0 'Instrument Serif', serif; letter-spacing: -.02em; color: var(--signal); }
.text p.lauf { margin-top: 24px; max-width: 30em; font-size: clamp(15px, 1.15vw, 17px); line-height: 1.5; color: rgba(239, 233, 220, .76); }

.fuss { position: absolute; inset: auto 0 0; display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; padding: 0 clamp(20px, 4vw, 56px) clamp(18px, 2.6vw, 30px); }
.werte { display: grid; gap: 4px; max-width: 62ch; font-size: 10px; letter-spacing: .1em; }
.werte b { color: var(--papier); font-weight: 500; }

.schalter { display: flex; padding: 3px; border: 1px solid var(--linie); background: rgba(12, 11, 10, .62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); flex: none; }
.schalter button { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); background: none; border: 0; padding: 10px 14px; cursor: pointer; transition: color .15s var(--kurve), background-color .15s var(--kurve); }
.schalter button:hover { color: var(--papier); }
.schalter button[aria-pressed="true"] { background: var(--papier); color: var(--tinte); }
.schalter button:focus-visible { outline: 1px solid var(--signal); outline-offset: 2px; }

/* Pult: Quelle wählen (Variante) */
.pult-reihe { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
/* Pause-Knopf (WCAG 2.2.2): hält Szene, Video und Spur an */
.halt { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--papier); background: rgba(12, 11, 10, .62); border: 1px solid var(--linie); cursor: pointer; transition: border-color .15s var(--kurve); }
.halt:hover { border-color: rgba(239, 233, 220, .5); }
.halt:focus-visible { outline: 1px solid var(--signal); outline-offset: 2px; }
.halt svg { width: 12px; height: 12px; }
.halt .i-weiter, .halt[aria-pressed="true"] .i-halt { display: none; }
.halt[aria-pressed="true"] .i-weiter { display: block; }
.reduziert .halt, .vorschau .halt { display: none; }
/* Variante video: Kontrastschicht hinter dem Text, Ersatzbild ohne WebGL */
.v-video .held::after { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(90deg, rgba(12, 11, 10, .9) 0%, rgba(12, 11, 10, .72) 34%, rgba(12, 11, 10, 0) 62%), linear-gradient(0deg, rgba(12, 11, 10, .85) 0%, rgba(12, 11, 10, 0) 26%); }
.v-video .ersatz { background: repeating-conic-gradient(var(--tinte) 0 25%, transparent 0 50%) 0 0 / 6px 6px, url('bilder/zitronen-tisch-poster.jpg') center / cover; }

.film { position: absolute; left: 0; top: 0; width: 2px; height: 2px; opacity: 0; pointer-events: none; z-index: -2; }

/* gedachter Zeiger der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }

/* ---------- Arbeiten: Bilder pixeln beim Hover in Stufen scharf ---------- */
.arbeiten { padding: clamp(64px, 9vw, 128px) clamp(20px, 4vw, 56px) clamp(64px, 8vw, 112px); border-top: 1px solid var(--linie); }
.arbeiten header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: clamp(24px, 3vw, 40px); }
.arbeiten h2 { font-size: clamp(28px, 3.2vw, 48px); font-weight: 500; letter-spacing: -.035em; line-height: 1; }
.raster { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(14px, 1.6vw, 24px); list-style: none; padding: 0; }
.karte a { display: block; text-decoration: none; outline: none; }
.karte .bild { position: relative; aspect-ratio: 4 / 5; overflow: hidden; background: #1a1816; }
.karte img, .karte canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.karte canvas { image-rendering: pixelated; }
.karte canvas.weg { visibility: hidden; }
.karte .zeile { display: grid; grid-template-columns: 2.6em 1fr auto; gap: 12px; align-items: baseline; padding-top: 14px; border-top: 1px solid var(--linie); margin-top: 14px; }
.karte h3 { font-size: clamp(18px, 1.6vw, 22px); font-weight: 500; letter-spacing: -.02em; }
.karte h3 small { display: block; margin-top: 4px; font-size: 14px; font-weight: 400; color: var(--leise); letter-spacing: 0; }
.karte a:focus-visible h3 { text-decoration: underline; text-underline-offset: .15em; }

/* ---------- Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .arbeiten, .vorschau .werte, .vorschau .kopf nav, .vorschau .text p.lauf { display: none; }
.vorschau .text { bottom: 108px; max-width: 52vw; }
/* Video: eine Werte-Zeile mehr, der Text rückt darüber */
.v-video .text { bottom: clamp(116px, 15vh, 160px); }
.vorschau.v-video .text, .vorschau .v-video .text { bottom: 108px; }

/* ---------- Telefon ---------- */
@media (max-width: 760px) {
  .held { min-height: 640px; }
  .kopf nav { display: none; }
  .text { left: 20px; right: 20px; bottom: 132px; max-width: none; }
  h1 { font-size: clamp(36px, 10.4vw, 52px); }
  .text p.lauf { font-size: 15px; margin-top: 16px; }
  .fuss { flex-direction: column-reverse; align-items: stretch; gap: 14px; }
  .schalter { align-self: flex-start; }
  .werte { font-size: 9.5px; }
  .werte span:nth-child(n+2) { display: none; }
  .raster { grid-template-columns: 1fr; gap: 40px; }
}
@media (max-width: 760px) {
  .v-video .held::after { background: linear-gradient(0deg, rgba(12, 11, 10, .94) 0%, rgba(12, 11, 10, .8) 48%, rgba(12, 11, 10, 0) 72%); }
  .kopf > span.mono { display: none; }
}
@media (hover: none) { .schalter button { padding: 12px 16px; } }
@media (forced-colors: active) { .halt { border: 1px solid ButtonText; } }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
</style>
</head>
<body>
<section class="held" aria-labelledby="titel">
  <canvas data-szene aria-hidden="true"></canvas>
  <div class="ersatz" aria-hidden="true"></div>
  <header class="kopf">
    <a class="marke" href="#">RAUSCH<b>/</b>WERK</a>
    <nav class="mono" aria-label="Hauptnavigation"><a href="#arbeiten">Arbeiten</a><a href="#">Studio</a><a href="#">Labor</a><a href="#">Kontakt</a></nav>
    <span class="mono" aria-hidden="true">51.33° N · 12.33° O</span>
    <button type="button" class="halt" data-halt aria-pressed="false" aria-label="Bewegung anhalten"><svg class="i-halt" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1h3v10H2zM7 1h3v10H7z" fill="currentColor"/></svg><svg class="i-weiter" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1l9 5-9 5z" fill="currentColor"/></svg>Anhalten</button>
  </header>
  <div class="text">
    <p class="mono"><span data-welt-text="szene">Studio für Creative Coding · Leipzig</span><span data-welt-text="video" hidden>Kampagnenfilm · Feinkost · Herbst 2026</span></p>
    <h1 id="titel"><span data-welt-text="szene">Code, Klang und <em>Raster</em>.</span><span data-welt-text="video" hidden>Film, Zeichen für <em>Zeichen</em>.</span></h1>
    <p class="lauf"><span data-welt-text="szene">Rauschwerk entwickelt interaktive Installationen, Websites und Klangwerkzeuge für Kulturhäuser und Labels. Seit 2017 in einer alten Druckerei in Plagwitz.</span><span data-welt-text="video" hidden>Für eine Leipziger Feinkostmarke läuft der Clip live durch denselben Pass: Jedes Bild wird in Echtzeit zu Zeichen, Raster oder Pixeln. Unter dem Zeiger erscheint das Original.</span></p>
  </div>
  <div class="fuss">
    <p class="mono werte" data-werte aria-live="polite"></p>
    <div class="pult-reihe">
    <div class="schalter" role="group" aria-label="Variante: Quelle">
      <button type="button" data-variante="szene" aria-pressed="true">3D-Szene</button>
      <button type="button" data-variante="video" aria-pressed="false">Video</button>
    </div>
    <div class="schalter" role="group" aria-label="Darstellung">
      <button type="button" data-modus="ascii" aria-pressed="true">ASCII</button>
      <button type="button" data-modus="dither" aria-pressed="false">Dither</button>
      <button type="button" data-modus="pixel" aria-pressed="false">Pixel</button>
    </div>
    </div>
  </div>
</section>

<section class="arbeiten" id="arbeiten" aria-labelledby="arbeiten-titel">
  <header><h2 id="arbeiten-titel">Laufende Arbeiten</h2><p class="mono">(03) · 2024–2026</p></header>
  <ul class="raster">
    <li class="karte"><a href="#licht"><div class="bild"><img src="bilder/typo-plakat-hoch.jpg" alt="Plakat in Neongelb mit großem schwarzem LICHT und orangem Kreis, darunter kursiv: und was es übrig lässt" width="800" height="1000"><canvas aria-hidden="true"></canvas></div>
      <div class="zeile"><span class="mono">01</span><h3>Licht, und was es übrig lässt<small>Interaktive Ausstellung, Halle 4</small></h3><span class="mono">2026</span></div></a></li>
    <li class="karte"><a href="#fernklang"><div class="bild"><img src="bilder/huegel-nebel-hoch.jpg" alt="Hügelketten im Morgennebel in gestaffelten Rosa- und Violetttönen, tief stehende Sonne" width="800" height="1000"><canvas aria-hidden="true"></canvas></div>
      <div class="zeile"><span class="mono">02</span><h3>Fernklang<small>Generative Klanglandschaft</small></h3><span class="mono">2025</span></div></a></li>
    <li class="karte"><a href="#flakon"><div class="bild"><img src="bilder/flakon-hoch.jpg" alt="Bernsteinfarbener Parfumflakon auf weißem Podest vor kräftigem Blau, daneben eine weiße Kugel" width="800" height="1000"><canvas aria-hidden="true"></canvas></div>
      <div class="zeile"><span class="mono">03</span><h3>Flakon 03<small>Produktbühne in WebGL</small></h3><span class="mono">2024</span></div></a></li>
  </ul>
</section>

<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#efe9dc" stroke="#0c0b0a" stroke-width="1.2" stroke-linejoin="round"/></svg>

<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), $$ = s => [...document.querySelectorAll(s)];
const held = $('.held'), canvas = $('[data-szene]'), zeiger = $('[data-zeiger]');
const schmal = () => innerWidth < 760;

/* ---------- Werte (Token) ---------- */
// Messwerte: Kachelspur 5vw / 20 Spalten, 300 ms Abklingen (Larose); CRT nach Midjourney
// (Scanlines .4, Vignette .7, Wölbung .1 über 3000 ms easeOutQuad, Aberration .005·e^(−t/.75 s));
// Dither-Zelle 3 px nach Good Growth; Maus-Parallaxe mit Lerp .02 (Good Growth).
const W = {
  ascii:  { zelle: [10, 16], zelleMobil: [12, 19] },
  dither: { zelle: 3, zelleMobil: 4 },
  pixel:  { zelle: 12, zelleMobil: 16, toene: 6, fuge: 1 },
  spur:   { spalten: 20, spaltenMobil: 8, abklingen: 300 },
  crt:    { scanlines: 0.4, vignette: 0.7, woelbung: 0.1, woelbungMs: 3000, aberration: 0.005, tau: 0.75 },
  stufen: { faktoren: [4, 2, 1], ms: 150 },
  lerp: 0.02,
};
const GLYPHEN = ' .:-=+*#%@';
const FARBEN = { tinte: '#0c0b0a', tief: '#3a1608', rost: '#8a2a0c', signal: '#ff5b24', sand: '#f2b48c', papier: '#efe9dc' };
let modus = 'ascii', variante = 'szene';

function zeigeWerte() {
  const m = schmal();
  const z = {
    ascii: `Zelle <b>${(m ? W.ascii.zelleMobil : W.ascii.zelle).join(' × ')} px</b> · <b>${GLYPHEN.length} Glyphen</b> nach Tinte · CRT: Scanlines <b>.4</b> · Vignette <b>.7</b> · Wölbung <b>.1 / 3000 ms easeOutQuad</b> · Aberration <b>.005·e^(−t/.75)</b>`,
    dither: `Bayer <b>4 × 4</b> · Zelle <b>${m ? W.dither.zelleMobil : W.dither.zelle} px</b> · Palette <b>4 Töne</b> ${['tinte', 'tief', 'signal', 'papier'].map(k => FARBEN[k]).join(' ')}`,
    pixel: `Zelle <b>${m ? W.pixel.zelleMobil : W.pixel.zelle} px</b> · Rampe <b>6 Töne</b> ohne Dither · Fuge <b>1 px</b>`,
  }[modus];
  const spur = reduziert()
    ? 'Reduzierte Bewegung: <b>Szene steht, keine Spur, Wechsel ohne Stufen</b>'
    : `Spur: Kachel <b>${m ? 'Breite ÷ 8' : '5vw (20 Spalten)'}</b> · <b>300 ms linear</b> · Wechsel in Stufen <b>4 → 2 → 1 × 150 ms</b>`;
  const quelle = variante === 'video'
    ? `<span>Quelle: <b>Video 1280 × 720, 25 fps, 4,9 s Schleife</b>${reduziert() ? ' · <b>Standbild bei 1,2 s</b>' : ''}</span>`
    : '';
  $('[data-werte]').innerHTML = `<span>${z}</span>${quelle}<span>${spur}</span>`;
}

/* ---------- Renderer ---------- */
let renderer = null;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
if (!renderer || !renderer.getContext()) { document.body.classList.add('ohne-webgl'); renderer = null; }

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 60);
camera.position.set(0, 0, 9);
const knoten = new THREE.Mesh(
  new THREE.TorusKnotGeometry(1, 0.36, 180, 22, 2, 3),
  new THREE.MeshStandardMaterial({ color: '#e6ddcc', roughness: 0.3, metalness: 0.15 })
);
const ring = new THREE.Mesh(new THREE.TorusGeometry(2.05, 0.035, 8, 120), new THREE.MeshStandardMaterial({ color: '#ff5b24', roughness: 0.5, emissive: '#ff5b24', emissiveIntensity: 0.6 }));
ring.rotation.x = Math.PI * 0.42;
const gruppe = new THREE.Group(); gruppe.add(knoten, ring); scene.add(gruppe);
const licht1 = new THREE.DirectionalLight('#ffd6b8', 2.3); licht1.position.set(-4, 5, 6);
const licht2 = new THREE.DirectionalLight('#7fd6ff', 1.8); licht2.position.set(5, -2, -4);
const licht3 = new THREE.DirectionalLight('#ff5b24', 1.1); licht3.position.set(4, -4, 3);
scene.add(licht1, licht2, licht3, new THREE.AmbientLight('#ffffff', 0.03));

let RT_SKALA = 0.5;
const rt = new THREE.WebGLRenderTarget(4, 4, { type: THREE.UnsignedByteType, depthBuffer: true });

/* Glyphen-Atlas: Zeichen nach Tinte sortiert in eine Textur */
async function atlasBauen() {
  try { await document.fonts.load('500 48px "Geist Mono"'); } catch (e) {}
  const tw = 40, th = 64, n = GLYPHEN.length;
  const c = document.createElement('canvas'); c.width = tw * n; c.height = th;
  const x = c.getContext('2d', { willReadFrequently: true });
  x.font = '500 50px "Geist Mono", ui-monospace, monospace'; x.textAlign = 'center'; x.textBaseline = 'middle';
  // Tinte messen und sortieren, damit die Rampe wirklich von hell nach dunkel läuft
  const tinte = [...GLYPHEN].map(g => {
    x.clearRect(0, 0, tw, th); x.fillStyle = '#fff'; x.fillText(g, tw / 2, th / 2 + 2);
    const d = x.getImageData(0, 0, tw, th).data; let s = 0; for (let i = 3; i < d.length; i += 4) s += d[i];
    return { g, s };
  }).sort((a, b) => a.s - b.s);
  x.clearRect(0, 0, c.width, c.height); x.fillStyle = '#fff';
  tinte.forEach((t, i) => x.fillText(t.g, tw * i + tw / 2, th / 2 + 2));
  const tex = new THREE.CanvasTexture(c);
  tex.minFilter = tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false;
  return tex;
}

/* Kachelspur als kleine Datentextur (cols × rows) */
const spur = { cols: 20, rows: 12, tile: 72, a: new Float32Array(1), daten: new Uint8Array(4), tex: null };
function spurGroesse() {
  spur.cols = schmal() ? W.spur.spaltenMobil : W.spur.spalten;
  spur.tile = innerWidth / spur.cols;
  spur.rows = Math.ceil(held.clientHeight / spur.tile);
  spur.a = new Float32Array(spur.cols * spur.rows);
  spur.daten = new Uint8Array(spur.cols * spur.rows * 4);
  if (spur.tex) spur.tex.dispose();
  spur.tex = new THREE.DataTexture(spur.daten, spur.cols, spur.rows, THREE.RGBAFormat, THREE.UnsignedByteType);
  spur.tex.minFilter = spur.tex.magFilter = THREE.NearestFilter; spur.tex.needsUpdate = true;
  if (post) { post.uniforms.tSpur.value = spur.tex; post.uniforms.uKacheln.value.set(spur.cols, spur.rows); post.uniforms.uKachel.value = spur.tile; }
}

const vert = `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const frag = `
precision highp float;
uniform sampler2D tSzene, tAtlas, tSpur, tVideo;
uniform float uVideo;
uniform vec2 uCover, uMitte;
uniform vec2 uRes, uKacheln, uFokus, uZelleA;
uniform float uDpr, uModus, uFaktor, uKachel, uGlyphen, uZelleD, uZelleP, uWoelbung, uAberr, uScan, uVign;
uniform vec3 cTinte, cTief, cSignal, cPapier, cRost, cSand;

vec3 srgb(vec3 c) { return pow(max(c, 0.0), vec3(1.0 / 2.2)); }
// Szene plus Hintergrundglühen (das Glühen gibt dem Dither eine Fläche)
vec3 szene(vec2 uv) {
  if (uVideo > 0.5) return texture2D(tVideo, (uv - 0.5) * uCover + uMitte).rgb;   // Variante video: Filmbild, cover
  vec4 s = texture2D(tSzene, uv);
  vec2 d = (uv - uFokus) * vec2(uRes.x / uRes.y, 1.0);
  float g = smoothstep(0.62, 0.0, length(d));
  vec3 bg = mix(vec3(0.0), vec3(0.32, 0.07, 0.02), g * g) + vec3(0.06, 0.012, 0.0) * g;
  return mix(bg, srgb(s.rgb), s.a);
}
float lum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
// Bayer 4×4 rechnerisch (statt Tabelle): Werte 0..15 / 16
float bayer2(vec2 a) { a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
float bayer4(vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a) + 1.0 / 32.0; }
vec3 rampe(float t) {   // Markenpalette: Tinte, Tief, Signal, Papier
  if (t < 1.0) return cTinte; if (t < 2.0) return cTief; if (t < 3.0) return cSignal; return cPapier;
}
void main() {
  vec2 fc = gl_FragCoord.xy / uDpr;          // CSS-Pixel, an Device-Pixel ausgerichtet
  vec2 uv = fc / uRes;
  vec2 cc = uv - 0.5;
  bool crt = uModus < 0.5;
  if (crt) {                                  // Wölbung wie ein Röhrenmonitor
    uv = uv + cc * dot(cc, cc) * uWoelbung;
    if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) { gl_FragColor = vec4(cTinte, 1.0); return; }
    fc = uv * uRes;
  }
  vec3 orig = szene(uv);
  vec3 col;
  if (crt) {
    vec2 zelle = uZelleA * uFaktor;
    vec2 c = floor(fc / zelle);
    vec2 m = (c + 0.5) * zelle / uRes;
    vec3 s = vec3(szene(m + vec2(uAberr, 0.0)).r, szene(m).g, szene(m - vec2(uAberr, 0.0)).b);
    float l = clamp(smoothstep(0.03, 0.85, lum(s)), 0.0, 0.999);
    float idx = floor(l * uGlyphen);
    vec2 lok = fract(fc / zelle);
    float g = texture2D(tAtlas, vec2((idx + lok.x) / uGlyphen, lok.y)).r;
    vec3 ton = mix(cSignal, cPapier, smoothstep(0.45, 0.9, l));
    col = mix(cTinte, ton, g * (0.4 + 0.6 * l));   // Glyphen tragen zusätzlich die Schattierung
  } else if (uModus < 1.5) {
    float z = uZelleD * uFaktor;
    vec2 c = floor(fc / z);
    float l = lum(szene((c + 0.5) * z / uRes));
    float v = clamp(l * 1.15, 0.0, 0.999) * 3.0;
    float stufe = floor(v) + step(bayer4(c), fract(v));
    col = rampe(stufe);
  } else {
    float z = uZelleP * uFaktor;
    vec2 c = floor(fc / z);
    float l = clamp(lum(szene((c + 0.5) * z / uRes)) * 1.1, 0.0, 0.999);
    float t = floor(l * 6.0);                       // 6-Ton-Rampe ohne Dither
    col = t < 1.0 ? cTinte : t < 2.0 ? cTief : t < 3.0 ? cRost : t < 4.0 ? cSignal : t < 5.0 ? cSand : cPapier;
    vec2 lok = fc - c * z;
    if (t > 0.0 && (lok.x < 1.0 || lok.y < 1.0)) col = mix(col, cTinte, 0.6);   // 1 px Fuge
  }
  // Kachelspur: unter dem Zeiger scheint das Original durch
  vec2 oben = vec2(fc.x, uRes.y - fc.y);
  vec2 k = floor(oben / uKachel);
  float a = texture2D(tSpur, (k + 0.5) / uKacheln).r;
  if (a > 0.0) {
    col = mix(col, orig, a);
    vec2 lt = oben - k * uKachel;
    if (min(lt.x, lt.y) < 1.0) col = mix(col, cSignal, a * 0.85);
  }
  if (crt) {
    float sl = 0.5 - 0.5 * cos(fc.y * 6.2831853 / 3.0);
    col = mix(col, cTinte, uScan * sl * 0.5);      // Scanlines .4
    float d = length(cc * 1.4142);
    col = mix(col, cTinte, uVign * d * d * 0.6);   // Vignette .7
  }
  gl_FragColor = vec4(col, 1.0);
}`;

let post = null, postScene = null, postCam = null;
if (renderer) {
  renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
  renderer.setClearColor(0x000000, 0);
  const col = c => new THREE.Color(c);
  post = new THREE.ShaderMaterial({
    vertexShader: vert, fragmentShader: frag, depthTest: false, depthWrite: false,
    uniforms: {
      tSzene: { value: rt.texture }, tAtlas: { value: null }, tSpur: { value: null }, tVideo: { value: null }, uVideo: { value: 0 }, uCover: { value: new THREE.Vector2(1, 1) }, uMitte: { value: new THREE.Vector2(0.5, 0.5) },
      uRes: { value: new THREE.Vector2(1, 1) }, uKacheln: { value: new THREE.Vector2(1, 1) }, uFokus: { value: new THREE.Vector2(0.66, 0.5) },
      uZelleA: { value: new THREE.Vector2(...W.ascii.zelle) }, uDpr: { value: 1 }, uModus: { value: 0 }, uFaktor: { value: 1 },
      uKachel: { value: 72 }, uGlyphen: { value: GLYPHEN.length }, uZelleD: { value: W.dither.zelle }, uZelleP: { value: W.pixel.zelle },
      uWoelbung: { value: 0 }, uAberr: { value: 0 }, uScan: { value: W.crt.scanlines }, uVign: { value: W.crt.vignette },
      cTinte: { value: col(FARBEN.tinte) }, cTief: { value: col(FARBEN.tief) }, cSignal: { value: col(FARBEN.signal) }, cPapier: { value: col(FARBEN.papier) }, cRost: { value: col(FARBEN.rost) }, cSand: { value: col(FARBEN.sand) },
    },
  });
  // Farben 1:1 als sRGB-Werte in den Shader (kein Farbraum-Umrechnen)
  ['cTinte', 'cTief', 'cRost', 'cSignal', 'cSand', 'cPapier'].forEach(k => { const h = FARBEN[k.slice(1).toLowerCase()]; const n = parseInt(h.slice(1), 16); post.uniforms[k].value.setRGB((n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255, THREE.LinearSRGBColorSpace); });
  postScene = new THREE.Scene(); postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  postScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), post));
}

/* ---------- Größe und Lage ---------- */
const fokus = new THREE.Vector3();
function groesse() {
  const w = innerWidth, h = held.clientHeight, dpr = Math.min(devicePixelRatio || 1, 2);
  zeigeWerte();
  if (!renderer) return;
  renderer.setPixelRatio(dpr); renderer.setSize(w, h, false);
  rt.setSize(Math.round(w * dpr * RT_SKALA), Math.round(h * dpr * RT_SKALA));   // Szene in halber Auflösung: jede Zelle braucht nur einen Messpunkt
  camera.aspect = w / h; camera.updateProjectionMatrix();
  const halbH = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * camera.position.z, halbB = halbH * camera.aspect;
  if (camera.aspect < 0.9) { gruppe.position.set(0, halbH * 0.34, 0); gruppe.scale.setScalar(Math.min(1, halbB / 2.1)); }
  else if (w <= 1000) { gruppe.position.set(halbB * 0.4, halbH * 0.06, 0); gruppe.scale.setScalar(0.9); }
  else { gruppe.position.set(halbB * 0.36, halbH * 0.04, 0); gruppe.scale.setScalar(1); }
  camera.updateMatrixWorld(); gruppe.updateMatrixWorld();
  fokus.copy(gruppe.position).project(camera);
  const u = post.uniforms;
  u.uRes.value.set(w, h); u.uDpr.value = dpr; u.uFokus.value.set(fokus.x * 0.5 + 0.5, fokus.y * 0.5 + 0.5);
  u.uZelleA.value.set(...(schmal() ? W.ascii.zelleMobil : W.ascii.zelle));
  u.uZelleD.value = schmal() ? W.dither.zelleMobil : W.dither.zelle;
  u.uZelleP.value = schmal() ? W.pixel.zelleMobil : W.pixel.zelle;
  spurGroesse();
  zeichnen(performance.now());
}

/* ---------- Zeiger und Spur ---------- */
const maus = { x: 0, y: 0, drin: false, nx: 0, ny: 0, lx: 0, ly: 0 };
function kachelSetzen(x, y) {
  const c = Math.floor(x / spur.tile), r = Math.floor(y / spur.tile);
  if (c >= 0 && r >= 0 && c < spur.cols && r < spur.rows) spur.a[r * spur.cols + c] = 1;
}
function zeigerBei(x, y, sprung) {
  if (reduziert()) return;
  // Strecke abtasten, damit bei schnellen Bewegungen keine Lücken bleiben
  const dx = x - maus.lx, dy = y - maus.ly, n = sprung ? 1 : Math.min(60, Math.max(1, Math.ceil(Math.hypot(dx, dy) / (spur.tile * 0.4)) || 1));
  for (let i = 1; i <= n; i++) kachelSetzen(maus.lx + dx * i / n, maus.ly + dy * i / n);
  maus.lx = x; maus.ly = y; maus.x = x; maus.y = y; maus.drin = true;
  maus.nx = x / innerWidth * 2 - 1; maus.ny = y / held.clientHeight * 2 - 1;
  starten();
}
held.addEventListener('pointermove', e => { if (vorf) return; const r = held.getBoundingClientRect(); zeigerBei(e.clientX - r.left, e.clientY - r.top, !maus.drin); });
held.addEventListener('pointerleave', () => { if (!vorf) maus.drin = false; });
held.addEventListener('pointerdown', e => { if (vorf) stoppeVorfuehrung(); });

/* ---------- Modus-Wechsel in Stufen ---------- */
let stufenStart = -1, crtStart = -1;
function setzeModus(m, still) {
  modus = m;
  $$('[data-modus]').forEach(b => b.setAttribute('aria-pressed', b.dataset.modus === m));
  zeigeWerte();
  if (!post) return;
  post.uniforms.uModus.value = { ascii: 0, dither: 1, pixel: 2 }[m];
  const t = performance.now();
  stufenStart = reduziert() || still ? -1 : t;
  crtStart = m === 'ascii' && !reduziert() && !still ? t : -1;
  starten(); zeichnen(t);
}
$$('[data-modus]').forEach(b => b.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(b.dataset.modus); }));

/* ---------- Bildschleife ---------- */
let laeuft = false, angehalten = false, letzte = performance.now(), zeit = 0;
const ziel = { x: 0, y: 0 }, kam = { x: 0, y: 0 };
const easeOutQuad = t => 1 - (1 - t) * (1 - t);
function zeichnen(t) {
  if (!renderer || !post.uniforms.tAtlas.value) return;
  const u = post.uniforms;
  // Stufen 4 → 2 → 1, je 150 ms
  if (stufenStart >= 0) {
    const i = Math.floor((t - stufenStart) / W.stufen.ms);
    u.uFaktor.value = W.stufen.faktoren[Math.min(i, 2)];
    if (i >= 2) stufenStart = -1;
  } else u.uFaktor.value = 1;
  // CRT: Wölbung 0 → .1 über 3000 ms easeOutQuad, Aberration .005·e^(−t/.75 s)
  if (modus === 'ascii') {
    if (crtStart >= 0) {
      const s = (t - crtStart) / 1000;
      u.uWoelbung.value = W.crt.woelbung * easeOutQuad(Math.min(1, s * 1000 / W.crt.woelbungMs));
      u.uAberr.value = W.crt.aberration * Math.exp(-s / W.crt.tau);
      if (s * 1000 > W.crt.woelbungMs) crtStart = -1;
    } else { u.uWoelbung.value = W.crt.woelbung; u.uAberr.value = 0; }
  }
  if (variante !== 'video') { renderer.setRenderTarget(rt); renderer.clear(); renderer.render(scene, camera); }   // im Video-Modus entfällt die 3D-Szene
  renderer.setRenderTarget(null); renderer.render(postScene, postCam);
}
function bild(t) {
  if (angehalten || !sichtbar) { laeuft = false; return; }
  const dt = Math.min(64, t - letzte); letzte = t;
  if (!reduziert()) {
    zeit += dt / 1000;
    knoten.rotation.set(zeit * 0.22, zeit * 0.31, 0);
    ring.rotation.z = zeit * 0.4;
    // Parallaxe: Lerp .02 je Bild (auf 60 Hz normiert)
    const k = 1 - Math.pow(1 - W.lerp, dt / 16.667);
    ziel.x = maus.drin ? maus.nx : 0; ziel.y = maus.drin ? maus.ny : 0;
    kam.x += (ziel.x - kam.x) * k; kam.y += (ziel.y - kam.y) * k;
    gruppe.rotation.set(kam.y * 0.35, kam.x * 0.5, 0);
    // Spur: Kachel unter dem Zeiger bleibt an, alle anderen klingen in 300 ms linear ab
    const ab = dt / W.spur.abklingen;
    for (let i = 0; i < spur.a.length; i++) spur.a[i] = Math.max(0, spur.a[i] - ab);
    if (maus.drin) kachelSetzen(maus.x, maus.y);
    if (beruehrung && !vorf) ambient(t);
    for (let i = 0; i < spur.a.length; i++) spur.daten[i * 4] = Math.round(spur.a[i] * 255);
    if (spur.tex) spur.tex.needsUpdate = true;
  }
  zeichnen(t);
  if (reduziert() && stufenStart < 0) { laeuft = false; return; }
  // Schwache GPU (z. B. Software-Rendering): Bildrate drosseln statt alles zu blockieren
  mittel += (dt - mittel) * 0.1;
  if (mittel > 45) setTimeout(() => requestAnimationFrame(bild), 70); else requestAnimationFrame(bild);
}
let mittel = 16;
// Touch ohne Berührung: zufällige Kacheln als ruhige Hintergrundbewegung
let naechsteKachel = 0;
function ambient(t) {
  if (maus.drin || t < naechsteKachel) return;
  naechsteKachel = t + 260;
  spur.a[Math.floor(Math.random() * spur.a.length)] = 1;
}
function starten() { if (renderer && !laeuft && !angehalten && sichtbar) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
// Touch: Spur folgt dem Finger auch während die Seite scrollt
held.addEventListener('touchmove', e => { if (vorf) return; const t = e.touches[0], r = held.getBoundingClientRect(); zeigerBei(t.clientX - r.left, t.clientY - r.top, !maus.drin); }, { passive: true });
held.addEventListener('touchend', () => { maus.drin = false; }, { passive: true });

/* ---------- Vorführung: gedachter Zeiger, Modi wechseln ---------- */
let vorf = null, vorfStart = 0, vorfModus = -1;
const REIHE = ['ascii', 'dither', 'pixel'], RUNDE = 4200;
function vorfuehren() {
  stoppeVorfuehrung(true);
  angehalten = false; setzeHalt(false); filmLaeuft(true);
  html.classList.add('vorfuehrung');
  vorfStart = performance.now(); vorfModus = -1; maus.drin = false;
  const schritt = t => {
    const e = t - vorfStart, i = Math.floor(e / RUNDE) % 3;
    if (i !== vorfModus) { vorfModus = i; setzeModus(REIHE[i]); }
    // Lissajous-Bahn um das Objekt
    const r = held.getBoundingClientRect();
    const fx = (fokus.x * 0.5 + 0.5) * r.width, fy = (1 - (fokus.y * 0.5 + 0.5)) * r.height;
    const rad = Math.min(r.width, r.height) * (schmal() ? 0.3 : 0.26);
    const p = e / 1000;
    const x = fx + Math.sin(p * 1.3) * rad * 1.15, y = fy + Math.sin(p * 2.1 + 0.6) * rad * 0.8;
    zeigerBei(x, y, false);
    zeiger.style.transform = `translate(${x + r.left - 2}px, ${y + r.top - 1}px)`;
    vorf = requestAnimationFrame(schritt);
  };
  vorf = requestAnimationFrame(schritt);
  starten();
}
function stoppeVorfuehrung(still) {
  if (vorf) cancelAnimationFrame(vorf);
  vorf = null;
  if (!still) { html.classList.remove('vorfuehrung'); maus.drin = false; }
}
function pause() { if (vorf) cancelAnimationFrame(vorf); vorf = null; angehalten = true; filmLaeuft(false); setzeHalt(true); }
function weiter() { angehalten = false; setzeHalt(false); filmLaeuft(true); if (html.classList.contains('vorfuehrung')) vorfuehren(); else starten(); }
function neustart() {
  stoppeVorfuehrung(); angehalten = false;
  spur.a.fill(0); zeit = 0; kam.x = kam.y = 0;
  setzeModus('ascii');
}

/* ---------- Varianten (Regel 13): Quelle 3D-Szene oder Video ---------- */
const VARIANTEN = ['szene', 'video'];
let film = null, sichtbar = true;
function filmBereit() {
  if (film) return;
  film = document.createElement('video');
  film.muted = true; film.loop = true; film.playsInline = true; film.preload = 'auto';
  film.setAttribute('muted', ''); film.setAttribute('playsinline', '');
  film.src = 'bilder/zitronen-tisch.mp4';
  film.className = 'film'; film.setAttribute('aria-hidden', 'true'); film.tabIndex = -1;
  held.appendChild(film);   // im DOM, damit auch Safari zuverlässig dekodiert; unsichtbar, gezeichnet wird im Shader
  film.addEventListener('loadedmetadata', () => { deckung(); if (reduziert()) film.currentTime = 1.2; });
  ['loadeddata', 'seeked'].forEach(e => film.addEventListener(e, () => zeichnen(performance.now())));
  if (post) {
    const tex = new THREE.VideoTexture(film);
    tex.minFilter = tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false;
    post.uniforms.tVideo.value = tex;
  }
}
function deckung() {   // object-fit: cover für das Filmbild
  if (!post || !film || !film.videoWidth) return;
  const A = innerWidth / held.clientHeight, V = film.videoWidth / film.videoHeight;
  // Querformat: 1,25-fach näher und Bildmitte bei x .42, damit das Motiv rechts neben dem Text steht
  const zoom = A > 1 ? 0.8 : 1, sx = (A > V ? 1 : A / V) * zoom, sy = (A > V ? V / A : 1) * zoom;
  post.uniforms.uCover.value.set(sx, sy);
  post.uniforms.uMitte.value.set(A > 1 ? Math.max(sx / 2, 0.42) : 0.5, 0.5);
}
function filmLaeuft(an) {
  if (!film) return;
  if (an && variante === 'video' && !reduziert() && !angehalten && sichtbar && !document.hidden) { const p = film.play(); if (p && p.catch) p.catch(() => {}); }
  else film.pause();
}
function setzeVariante(v, url) {
  variante = VARIANTEN.includes(v) ? v : 'szene';
  html.classList.toggle('v-video', variante === 'video');
  $$('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
  $$('[data-welt-text]').forEach(el => { el.hidden = el.dataset.weltText !== variante; });
  if (variante === 'video') filmBereit();
  if (post) post.uniforms.uVideo.value = variante === 'video' ? 1 : 0;
  filmLaeuft(true);
  if (url) { const q = new URLSearchParams(location.search); q.set('v', variante); history.replaceState(null, '', '?' + q.toString().replace(/=(?=&|$)/g, '')); }
  zeigeWerte(); deckung(); starten(); zeichnen(performance.now());
}
$$('[data-variante]').forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.variante, true)));
// Außerhalb des Viewports und bei verstecktem Tab ruht alles
if ('IntersectionObserver' in window) new IntersectionObserver(e => { sichtbar = e[0].isIntersecting; filmLaeuft(sichtbar); if (sichtbar) starten(); }).observe(held);
document.addEventListener('visibilitychange', () => filmLaeuft(!document.hidden));
// Pause-Knopf: dieselbe Pause wie FUNDUS_DEMO.pause
const halt = $('[data-halt]');
function setzeHalt(an) { halt.setAttribute('aria-pressed', String(an)); }
halt.addEventListener('click', () => { if (halt.getAttribute('aria-pressed') === 'true') { stoppeVorfuehrung(); weiter(); } else { stoppeVorfuehrung(); pause(); } });


/* ---------- Arbeiten: Bilder pixeln in Stufen scharf ---------- */
const STUFEN = [24, 12, 6, 3, 1], STUFE_MS = 60;    // 5 Stufen × 60 ms = 300 ms
function karte(li) {
  const img = li.querySelector('img'), cv = li.querySelector('canvas'), x = cv.getContext('2d');
  const klein = document.createElement('canvas'), kx = klein.getContext('2d');
  let stufe = 0, uhr = null;
  function malen(px) {
    const w = cv.clientWidth, h = cv.clientHeight, d = Math.min(devicePixelRatio || 1, 2);
    if (!w || !img.naturalWidth) return;
    if (px <= 1) { cv.classList.add('weg'); return; }
    cv.classList.remove('weg');
    cv.width = Math.round(w * d); cv.height = Math.round(h * d);
    klein.width = Math.max(1, Math.ceil(w / px)); klein.height = Math.max(1, Math.ceil(h / px));
    // object-fit: cover nachbilden
    const s = Math.max(klein.width / img.naturalWidth, klein.height / img.naturalHeight);
    const iw = img.naturalWidth * s, ih = img.naturalHeight * s;
    kx.imageSmoothingEnabled = true;
    kx.drawImage(img, (klein.width - iw) / 2, (klein.height - ih) / 2, iw, ih);
    x.imageSmoothingEnabled = false;
    x.drawImage(klein, 0, 0, klein.width * px * d, klein.height * px * d);
  }
  function gehe(richtung) {
    clearTimeout(uhr);
    if (reduziert()) { stufe = STUFEN.length - 1; malen(1); return; }
    const schritt = () => {
      const n = stufe + richtung;
      if (n < 0 || n >= STUFEN.length) return;
      stufe = n; malen(STUFEN[stufe]);
      uhr = setTimeout(schritt, STUFE_MS);
    };
    uhr = setTimeout(schritt, 0);
  }
  const bereit = () => { if (reduziert()) malen(1); else malen(STUFEN[stufe]); };
  if (img.complete) bereit(); else img.addEventListener('load', bereit);
  addEventListener('resize', bereit);
  const a = li.querySelector('a');
  a.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') gehe(1); });
  a.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') gehe(-1); });
  a.addEventListener('focus', () => gehe(1));
  a.addEventListener('blur', () => gehe(-1));
  a.addEventListener('click', e => e.preventDefault());
  // Touch: beim Hereinscrollen einmal scharf pixeln
  if (beruehrung && 'IntersectionObserver' in window) {
    const io = new IntersectionObserver(es => es.forEach(en => { if (en.isIntersecting) { gehe(1); io.disconnect(); } }), { threshold: 0.55 });
    io.observe(li);
  }
}
$$('.karte').forEach(karte);

/* ---------- Start ---------- */
addEventListener('resize', () => { groesse(); deckung(); });
setzeVariante(new URLSearchParams(location.search).get('v'));
if (renderer) {
  groesse();
  post.uniforms.tAtlas.value = await atlasBauen();
  setzeModus('ascii', reduziert());
  starten();
} else zeigeWerte();
</script>
</body>
</html>