Zum Inhalt
← 3D & WebGL

Shader- und Dissolve-Übergang (Noise, Displacement, Pixel)

Projekt-Slider eines Markenateliers: Bilder verbrennen mit Glutkante, fließen per Displacement mit RGB-Shift oder zerfallen in Pixelblöcke, alles in einem Shader.

Alte und neue Ansicht liegen als Texturen vor: Bilder und Videos direkt, ganze Szenen über Render-Targets. Ein Vollbild-Quad mit Fragment-Shader mischt beide anhand von uProgress (0 bis 1). Die Mischung ist kein linearer Fade, sondern eine Maske: Noise mit smoothstep() oder step(), ein diagonaler Verlauf oder Pixelblöcke. Dazu kommen Displacement (UV-Verschiebung) und RGB-Shift, die zur Mitte des Übergangs am stärksten sind (parabola über progress). GSAP oder ein eigener Tween treibt die Uniform.

Der Übergang wird zum Material: Das Bild verbrennt, zerfließt oder zerfällt. Mit DOM und CSS ist das nicht erreichbar, deshalb wirkt es sofort hochwertig und handwerklich stark. Farbe der Glut, Maskenform und Rhythmus lassen sich markenspezifisch einstellen.

Wann einsetzen

Bei Vollbild-Medienwechseln: Hero- und Projekt-Slider, Galerien, Portfolios mit Video, Detailwechsel in Portfolios. Besonders auf Seiten, die ohnehin eine WebGL-Ebene haben. Die Pixel-Variante passt zu Tech-, Gaming- und Kreativseiten mit Retro-Ästhetik.

Wann nicht

Auf textlastigen oder sachlichen Seiten ohne WebGL, weil der Aufwand nicht im Verhältnis steht. Bei Bildern mit viel Text, weil Verzerrung die Lesbarkeit zerstört. Wenn das Performance-Budget knapp ist oder kein Fallback gebaut wird. Bei ruhigen Luxus- und Editorial-Marken wirkt es zu laut, und bei häufiger Navigation nerven lange Übergänge.

Varianten

  • Noise-Dissolve mit Glutkante: step bzw. smoothstep gegen Noise, die Kante leuchtet wie Brennen.
  • Displacement mit chromatischer Aberration: Eine Noise- oder Graustufentextur verschiebt die UVs, die Farbkanäle laufen auseinander.
  • Pixel-Mosaik mit Block-Reveal: Die Pixelgröße wächst zur Mitte und schrumpft wieder, Blöcke wechseln per step() gegen Zufall (uNoisePixelSize).
  • Radiale Welle oder Kreis-Reveal ab Klickposition.
  • Tinte, Spirale, Dithering oder Partikel (Interlude-Shader).
  • Pixel-Sortierung entlang einer Hilbert-Kurve nach Helligkeit (Codrops Demo 20).
  • Datamosh-Glitch über ein Feedback-Render-Target mit Bewegungsvektoren.
  • Composite Rendering: DOM-Ansichten werden in eine gemeinsame Szene gerendert und dort überblendet, der Canvas bleibt über Seitenwechsel bestehen (Barba, Astro ClientRouter).
  • Ohne WebGL: Kachelraster per GSAP oder Canvas2D (drawImage mit imageSmoothingEnabled=false).

Umsetzung

Three.js oder OGL mit einem einzigen Vollbild-Quad, dessen Vertex-Shader direkt im Clip-Space ausgibt (gl_Position = vec4(position.xy, 0.0, 1.0)). Uniforms: uProgress, uFrom, uTo, Bild- und Canvasgröße (für object-fit cover im Shader), dazu Stärken wie uDisplace oder uRGBShift. Kern der Noise-Variante: float m = smoothstep(p − w, p + w, noise); vec2 d = uv + (noise − 0.5) * uDisplace * parabola(p, 2.0); mix(texture(uFrom, d), texture(uTo, d), m). Die globale Zeit kann linear oder mit power2.inOut laufen, die Form entsteht pro Pixel. Für ganze Seiten werden beide Ansichten in Render-Targets gerendert und Passes in einem Composite-Shader gebündelt. Texturen vorladen und auf Mobilgeräten auf 2048 px begrenzen, Kontextverlust abfangen, Render-Loop nur während des Übergangs laufen lassen.

Robust umsetzen

Telefon. Pixelratio auf 1 bis 1,5 begrenzen, Render-Targets in halber Auflösung. Kürzere Dauer, bei der Pixel-Variante größere Zellen. Ein einzelner Vollbild-Pass ist meist unproblematisch. Auf schwachen Geräten auf Crossfade oder Clip-Path-Wipe zurückfallen.

Ohne Bewegung. Kein Displacement, kein RGB-Shift, keine Pixel. Nur eine weiche Überblendung von höchstens 300 ms oder ein harter Schnitt.

Barrierefrei. Der Canvas ist aria-hidden, Bilder und Texte bleiben echt im DOM. Bedienung wie bei einem normalen Slider: Knöpfe, Pfeiltasten, Ansage des neuen Projekts per aria-live. Nach einem Seitenwechsel den Fokus korrekt setzen. Nicht mehr als drei Blitze pro Sekunde (WCAG 2.3.1), Pixelblöcke also nur einmal wechseln lassen und nicht flackern.

Code

<!DOCTYPE html>
<!--
  Shader- und Dissolve-Übergang (Noise, Displacement, Pixel)
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/shader-uebergang-dissolve/
  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>Shader- und Dissolve-Übergang · 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-normal.woff2') format('woff2'); font-weight: 400; font-style: normal; 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 {
  --grund: #0d0d0c; --flaeche: #161614; --text: #f1eee7; --leise: rgba(241, 238, 231, .56); --linie: rgba(241, 238, 231, .13);
  --glut: #ff6a1f;
  --kurve: cubic-bezier(0.65, 0, 0.35, 1);   /* power2.inOut, wie uProgress im Shader */
  --rand: clamp(20px, 3.9vw, 56px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1.4 '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; }

/* ---------- Bühne ---------- */
.buehne { position: relative; min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; isolation: isolate; }

.kopf { display: flex; justify-content: space-between; align-items: center; padding: clamp(18px, 2.6vw, 32px) var(--rand); }
.marke { font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
.marke span { font: italic 400 19px/1 'Instrument Serif', serif; color: var(--leise); margin-left: 5px; letter-spacing: 0; }
.kopf nav { display: flex; gap: 28px; }
.kopf nav a { color: var(--leise); text-decoration: none; transition: color .15s linear; }
.kopf nav a:hover, .kopf nav a:focus-visible, .kopf nav a[aria-current] { color: var(--text); }

.inhalt { display: grid; grid-template-columns: minmax(270px, 1fr) minmax(0, 2.15fr); gap: clamp(28px, 4vw, 64px); align-items: center; padding: 0 var(--rand); min-height: 0; }

/* Linke Spalte: Projektangaben */
.info { display: grid; gap: 0; align-content: center; }
.zaehler { display: flex; gap: 14px; align-items: center; margin-bottom: 26px; }
.zaehler b { color: var(--text); font-weight: 500; font-variant-numeric: tabular-nums; }
.zaehler i { width: 32px; height: 1px; background: var(--linie); display: block; }
.maske { overflow: hidden; padding-bottom: .08em; }
.maske-titel { display: flex; align-items: flex-end; min-height: calc(clamp(46px, 5.6vw, 92px) * 1.88 + .08em); }
h1 { font-family: 'Instrument Serif', serif; font-weight: 400; font-size: clamp(46px, 5.6vw, 92px); line-height: .94; letter-spacing: -.025em; text-wrap: balance; }
h1 em { font-style: italic; }
.art { margin-top: 18px; color: var(--text); }
.art span { color: var(--leise); }
.text { margin-top: 18px; max-width: 34ch; font-size: 15px; line-height: 1.55; color: var(--leise); min-height: 4.65em; }
.wechsel { will-change: transform, opacity; }

.navi { display: flex; align-items: center; gap: 18px; margin-top: 34px; }
.pfeile { display: flex; gap: 8px; }
.pfeile button { width: 46px; height: 46px; border-radius: 50%; border: 1px solid var(--linie); background: none; cursor: pointer; display: grid; place-items: center; transition: border-color .15s linear, background-color .15s linear; }
.pfeile button:hover, .pfeile button:focus-visible { border-color: var(--leise); }
.pfeile button:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.pfeile button:active { background: rgba(241, 238, 231, .08); }
.pfeile svg { width: 16px; height: 16px; }
.punkte { display: flex; gap: 6px; list-style: none; padding: 0; }
.punkte button { width: 22px; height: 22px; padding: 0; border: 0; background: none; cursor: pointer; display: grid; place-items: center; }
.punkte button::before { content: ''; width: 100%; height: 2px; background: var(--linie); transition: background-color .15s linear; }
.punkte button[aria-current='true']::before { background: var(--text); }
.punkte button:hover::before, .punkte button:focus-visible::before { background: var(--leise); }
.punkte button:focus-visible { outline: 1px solid var(--leise); outline-offset: 2px; }

/* Rechte Spalte: Bildrahmen mit Canvas */
.bildspalte { display: grid; gap: 12px; min-width: 0; }
.rahmen { position: relative; width: 100%; aspect-ratio: 3 / 2; max-height: calc(100svh - 230px); justify-self: end; overflow: hidden; background: var(--flaeche); border-radius: 3px; cursor: pointer; touch-action: pan-y; outline: none; }
.rahmen:focus-visible { box-shadow: 0 0 0 1px var(--leise); }
.rahmen canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .6s var(--kurve); }
.gl-bereit .rahmen canvas { opacity: 1; }
.rahmen picture, .rahmen img { position: absolute; inset: 0; width: 100%; height: 100%; }
.rahmen img { object-fit: cover; opacity: 0; transition: opacity .6s var(--kurve); }
.rahmen picture.an img { opacity: 1; }
.gl-bereit .rahmen picture img { opacity: 0; transition: none; }
.unterzeile { display: flex; justify-content: space-between; gap: 16px; }
.unterzeile b { color: var(--text); font-weight: 500; font-variant-numeric: tabular-nums; }
.fortschritt { display: flex; align-items: center; gap: 10px; }
.fortschritt span { position: relative; width: 84px; height: 1px; background: var(--linie); overflow: hidden; }
.fortschritt span::after { content: ''; position: absolute; inset: 0; background: var(--glut); transform-origin: 0 50%; transform: scaleX(var(--p, 0)); }

.fuss { display: flex; justify-content: space-between; align-items: end; gap: 24px; padding: clamp(18px, 2.6vw, 30px) var(--rand); }
.modi { display: flex; gap: 6px; flex-wrap: wrap; }
.modi button { letter-spacing: inherit; text-transform: inherit; font: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: border-color .15s linear, color .15s linear; }
.modi button:hover, .modi button:focus-visible { color: var(--text); border-color: var(--leise); }
.modi button:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; max-width: 640px; text-align: right; }
.werte b { color: var(--text); font-weight: 500; }

/* Gedachter Zeiger in der Vorführung */
.zeiger { position: fixed; z-index: 5; left: 0; top: 0; width: 30px; height: 30px; margin: -15px 0 0 -15px; border: 1px solid rgba(241, 238, 231, .85); border-radius: 50%; pointer-events: none; display: none; transition: transform .9s var(--kurve), scale .15s linear, background-color .15s linear; }
.zeiger.drueckt { scale: .72; background: rgba(241, 238, 231, .22); }
.vorfuehrung .zeiger { display: block; }

/* ---------- Unter der Bühne ---------- */
.mehr { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(20px, 4vw, 56px); padding: clamp(56px, 8vw, 112px) var(--rand); border-top: 1px solid var(--linie); }
.mehr h2 { font: italic 400 34px/1 'Instrument Serif', serif; margin: 10px 0 12px; letter-spacing: -.01em; }
.mehr p:not(.mono) { color: var(--leise); font-size: 15px; line-height: 1.55; max-width: 36ch; }

/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .mehr, .vorschau .kopf nav, .vorschau .text, .vorschau .werte { display: none; }
.vorschau .buehne { height: 100vh; min-height: 0; }
.vorschau .inhalt { grid-template-columns: minmax(0, .82fr) minmax(0, 2fr); gap: 36px; }
.vorschau .zaehler span, .vorschau .zaehler i, .vorschau .punkte { display: none; }
.vorschau h1 { font-size: 46px; }
.vorschau .maske-titel { min-height: 92px; }
.vorschau .art span { display: none; }
.vorschau .rahmen { max-height: calc(100vh - 200px); }

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

@media (max-width: 1100px) {
  .werte span:nth-child(n+4) { display: none; }
}
@media (max-width: 860px) and (min-width: 761px) { .inhalt { grid-template-columns: 1fr 1.6fr; } }
@media (max-width: 760px) {
  .buehne { min-height: 0; grid-template-rows: auto auto auto; }
  .kopf nav { display: none; }
  .inhalt { grid-template-columns: 1fr; gap: 24px; }
  .bildspalte { order: -1; }
  .rahmen { aspect-ratio: 4 / 5; max-height: none; }
  .zaehler { margin-bottom: 14px; }
  h1 { font-size: clamp(42px, 12.5vw, 60px); }
  .maske-titel { min-height: 0; }
  .fortschritt span { display: none; }
  .text { font-size: 14px; min-height: 0; }
  .navi { margin-top: 24px; }
  .fuss { flex-direction: column; align-items: stretch; gap: 14px; padding-top: 30px; }
  .werte { justify-content: flex-start; text-align: left; }
  .mehr { grid-template-columns: 1fr; }
}
@media (max-height: 760px) and (min-width: 761px) { .text { display: none; } }
@media (hover: none) {
  .rahmen { cursor: default; }
  .pfeile button { width: 48px; height: 48px; }
}
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
  .rahmen:focus-visible { outline: 2px solid Highlight; }
}
</style>
</head>
<body>
<section class="buehne" data-buehne aria-labelledby="titel">
  <header class="kopf">
    <p class="marke">Ostwind<span>Markenatelier</span></p>
    <nav class="mono" aria-label="Hauptmenü"><a href="#arbeiten" aria-current="page">Arbeiten</a><a href="#atelier">Atelier</a><a href="#kontakt">Kontakt</a></nav>
  </header>

  <div class="inhalt">
    <div class="info">
      <p class="zaehler mono"><span>Ausgewählte Arbeiten</span><i></i><b data-nr>01</b> / 06</p>
      <div class="maske maske-titel"><h1 id="titel" class="wechsel" data-titel>Röst&shy;kontor</h1></div>
      <div class="maske"><p class="art mono wechsel" data-art>Wortbildmarke, Verpackung <span>· Kaffeerösterei · 2025</span></p></div>
      <div class="maske"><p class="text wechsel" data-text>Eine Bohne im Kreis, darunter breite Versalien. Das Zeichen hält auf der Tüte, am Röster und auf dem Kaffeebecher.</p></div>
      <div class="navi">
        <div class="pfeile">
          <button type="button" data-zurueck aria-label="Vorheriges Projekt"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" aria-hidden="true"><path d="M13 8H3M7 4 3 8l4 4"/></svg></button>
          <button type="button" data-vor aria-label="Nächstes Projekt"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4"/></svg></button>
        </div>
        <ol class="punkte" data-punkte aria-label="Projekt wählen"></ol>
      </div>
    </div>

    <div class="bildspalte" id="arbeiten">
      <div class="rahmen" data-rahmen tabindex="0" role="group" aria-roledescription="Bildwechsler" aria-label="Projektbilder. Klick, Tippen oder Pfeiltasten wechseln das Projekt.">
        <picture data-bild><source media="(max-width: 760px)" srcset="bilder/marke-roestkontor-hoch.jpg"><img src="bilder/marke-roestkontor.jpg" width="1200" height="800" alt="Wortbildmarke Röstkontor: Kaffeebohne im Kreis über cremefarbenen Versalien auf Ziegelrot"></picture>
        <picture data-bild><source media="(max-width: 760px)" srcset="bilder/marke-krume-hoch.jpg"><img src="bilder/marke-krume.jpg" width="1200" height="800" alt="Wortmarke krume: fette dunkelbraune Kleinbuchstaben und ein Brotlaib auf Sonnengelb"></picture>
        <picture data-bild><source media="(max-width: 760px)" srcset="bilder/marke-eselsohr-hoch.jpg"><img src="bilder/marke-eselsohr.jpg" width="1200" height="800" alt="Wortbildmarke Eselsohr, Buchhandlung und Lesecafé: Blatt mit rosa Eselsohr neben kursiver Schrift auf Anthrazit"></picture>
        <picture data-bild><source media="(max-width: 760px)" srcset="bilder/typo-plakat-hoch.jpg"><img src="bilder/typo-plakat.jpg" width="1200" height="800" alt="Ausstellungsplakat LICHT: schwarze Riesenversalien und eine orange Scheibe auf Neongelb"></picture>
        <picture data-bild><source media="(max-width: 760px)" srcset="bilder/flakon-hoch.jpg"><img src="bilder/flakon.jpg" width="1200" height="800" alt="Bernsteinfarbener Parfumflakon auf weißem Podest vor kräftigem Blau"></picture>
        <picture data-bild><source media="(max-width: 760px)" srcset="bilder/magazin-doppelseite-hoch.jpg"><img src="bilder/magazin-doppelseite.jpg" width="1200" height="800" alt="Aufgeschlagenes Magazin: Keramik-Stillleben auf Orange, daneben die Überschrift Die Ruhe der Dinge"></picture>
        <canvas data-canvas aria-hidden="true"></canvas>
      </div>
      <div class="unterzeile mono">
        <span data-abb>Abb. 01 · Bohne im Kreis</span>
        <span class="fortschritt" aria-hidden="true">uProgress <b data-p>0.00</b><span data-balken></span></span>
      </div>
    </div>
  </div>

  <div class="fuss">
    <div class="modi mono" role="group" aria-label="Übergang wählen" data-modi>
      <button type="button" data-modus="glut" aria-pressed="true">Noise · Glutkante</button>
      <button type="button" data-modus="verdraengen" aria-pressed="false">Displacement · RGB</button>
      <button type="button" data-modus="pixel" aria-pressed="false">Pixel · Blöcke</button>
    </div>
    <p class="werte mono" data-werte></p>
  </div>
  <p class="nur-sr" aria-live="polite" data-ansage></p>
  <div class="zeiger" data-zeiger aria-hidden="true"></div>
</section>

<section class="mehr" id="atelier">
  <div><p class="mono">01 · Zeichen</p><h2>Wenig Striche</h2><p>Wir zeichnen so lange weg, bis nur bleibt, was auch auf einem Stempel, einer Tasse und in 16 Pixeln trägt.</p></div>
  <div><p class="mono">02 · Werkstatt</p><h2>Vor Ort</h2><p>Bevor wir skizzieren, stehen wir einen Tag hinter dem Tresen, auf der Bühne oder in der Werkstatt unserer Kunden.</p></div>
  <div id="kontakt"><p class="mono">03 · Anfragen</p><h2>Ein Gespräch</h2><p>Neue Projekte nehmen wir ab Februar an. Schreiben Sie uns zwei Sätze über Ihr Vorhaben, den Rest klären wir am Telefon.</p></div>
</section>

<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const $ = s => document.querySelector(s);
const buehne = $('[data-buehne]'), rahmen = $('[data-rahmen]'), canvas = $('[data-canvas]');
const bilder = [...document.querySelectorAll('[data-bild]')];
const imgs = bilder.map(b => b.querySelector('img'));
const titelEl = $('[data-titel]'), artEl = $('[data-art]'), textEl = $('[data-text]');
const nrEl = $('[data-nr]'), abbEl = $('[data-abb]'), pEl = $('[data-p]'), balken = $('[data-balken]');
const werteEl = $('[data-werte]'), ansage = $('[data-ansage]'), zeiger = $('[data-zeiger]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];

/* ---------- Inhalte ---------- */
const PROJEKTE = [
  { titel: 'Röst&shy;kontor', art: 'Wortbildmarke, Verpackung', ort: 'Kaffeerösterei', jahr: 2025, abb: 'Bohne im Kreis', text: 'Eine Bohne im Kreis, darunter breite Versalien. Das Zeichen hält auf der Tüte, am Röster und auf dem Kaffeebecher.' },
  { titel: '<em>krume</em>', art: 'Wortmarke', ort: 'Backstube', jahr: 2024, abb: 'Wortmarke', text: 'Fette, runde Kleinbuchstaben wie frisch aus dem Ofen. Dazu ein Laib als Zeichen für die Brottüte und das Schild über der Tür.' },
  { titel: 'Esels<em>ohr</em>', art: 'Logo, Leitsystem', ort: 'Buchhandlung', jahr: 2025, abb: 'Signet', text: 'Ein Blatt mit umgeknickter Ecke und eine schmale Kursive. Seit 1987 Buchhandlung, jetzt auch Lesecafé mit eigener Karte.' },
  { titel: '<em>Licht</em>, Werkschau', art: 'Plakat, Kampagne', ort: 'Ausstellung', jahr: 2026, abb: 'Plakat', text: 'Neongelb, eine orange Scheibe und Versalien, die über den Rand laufen. Das Plakat muss an der Litfaßsäule aus 30 Metern wirken.' },
  { titel: 'Ocker <em>04</em>', art: 'Packaging, Key Visual', ort: 'Duftlabor', jahr: 2025, abb: 'Flakon', text: 'Ein bernsteinfarbener Flakon auf hellem Podest, davor kräftiges Blau. Das Motiv trägt Etikett, Schachtel und Schaufenster.' },
  { titel: 'Gefüge, <em>Heft 06</em>', art: 'Editorial Design', ort: 'Magazin', jahr: 2024, abb: 'Doppelseite', text: 'Ein Heft über Dinge, die bleiben. Große Stillleben links, rechts eine ruhige Antiqua mit viel Luft und einem Initial in Rost.' },
];

/* ---------- Werte (meta.json → messwerte: progress 0 → 1 per Tween, Formgebung im Shader per smoothstep/step,
   Displacement 0,1–0,3 UV, parabola(progress, 2) mit Spitze bei 50 %, Dauer 1200–2600 ms, Kurve power2.inOut) ---------- */
const MODI = {
  glut: {
    nr: 0, dauer: 1800,
    werte: [['Kurve', 'power2.inOut'], ['Dauer', '1800 ms'], ['Maske', 'fbm 5 Okt. × 3.2 + 35 % Diagonale'], ['Kante', '0.1'], ['Glut', '#ff6a1f'], ['Warp', '0.03 × parabola(p, 2)']],
  },
  verdraengen: {
    nr: 1, dauer: 1400,
    werte: [['Kurve', 'power2.inOut'], ['Dauer', '1400 ms'], ['Displacement', '0.2 UV × (0.6 p + 0.4 Maske)'], ['Maske', 'Diagonale + 45 % Noise, Breite 0.1'], ['RGB-Shift', '0.008 × parabola(p, 2)'], ['Warp-Noise', 'fbm × 2.4']],
  },
  pixel: {
    nr: 2, dauer: 1200,
    werte: [['Kurve', 'power2.inOut'], ['Dauer', '1200 ms'], ['Pixel', '1 → 48 px, 8er-Stufen × parabola(p, 2)'], ['Blöcke', '40 px, step(rand, p)'], ['Ordnung', '40 % Diagonale']],
  },
};
const REDUZIERT = { nr: 3, dauer: 240, werte: [['Reduziert', 'Überblendung 240 ms linear, ohne Verzerrung']] };
const W = {
  kante: 0.1, glut: new THREE.Color('#ff6a1f'), warp: 0.03, rauschSkala: 3.2,
  displacement: 0.2, rgb: 0.008, maskeBreite: 0.1,
  pixelMax: 48, block: 40,
};
const power2InOut = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;   // = cubic-bezier(0.65, 0, 0.35, 1)

let modus = 'glut';
let aktiv = 0, laeuft = false, wartend = null;
const aktuellerModus = () => reduziert() ? REDUZIERT : MODI[modus];

/* ---------- Shader ---------- */
const vertex = /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;

const fragment = /* glsl */`
precision highp float;
varying vec2 vUv;
uniform sampler2D uFrom, uTo;
uniform vec2 uFromRes, uToRes, uRes;
uniform float uProgress, uMode, uDir, uSeed, uDpr;
uniform float uEdge, uWarp, uNoiseScale, uDisp, uRgb, uBand, uPixMax, uBlock;
uniform vec3 uGlow;

float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.03 + 17.1; a *= 0.5; } return v; }
float parabola(float x, float k) { return pow(4.0 * x * (1.0 - x), k); }
vec2 cover(vec2 uv, vec2 img) {
  float rs = uRes.x / uRes.y, ri = img.x / img.y;
  vec2 s = rs < ri ? vec2(rs / ri, 1.0) : vec2(1.0, ri / rs);
  return (uv - 0.5) * s + 0.5;
}
vec3 von(vec2 uv)  { return texture2D(uFrom, clamp(cover(uv, uFromRes), 0.001, 0.999)).rgb; }
vec3 nach(vec2 uv) { return texture2D(uTo,   clamp(cover(uv, uToRes),   0.001, 0.999)).rgb; }

void main() {
  vec2 uv = vUv;
  float p = uProgress;
  float par = parabola(p, 2.0);
  vec2 nuv = (uv - 0.5) * vec2(uRes.x / uRes.y, 1.0);
  /* Diagonale in Leserichtung: vor = von links unten nach rechts oben, zurück = umgekehrt */
  float diag = dot(uDir > 0.0 ? uv : 1.0 - uv, vec2(0.62, 0.38));
  vec3 col;

  if (uMode < 0.5) {
    /* Noise-Dissolve mit Glutkante */
    float n = smoothstep(0.18, 0.82, fbm(nuv * uNoiseScale + uSeed));
    n = mix(n, diag, 0.35);
    float w = uEdge;
    float e = p * (1.0 + 3.0 * w) - 1.5 * w;
    float d = n - e;                                   /* d < 0: neue Ansicht */
    vec2 off = (vec2(fbm(nuv * 2.0 + uSeed + 3.1), fbm(nuv * 2.0 + uSeed + 8.7)) - 0.5) * 2.0 * uWarp * par;
    vec3 a = von(uv + off), b = nach(uv - off);
    float verkohlt = smoothstep(w * 0.9, 0.0, d);
    a = mix(a, a * vec3(0.16, 0.08, 0.05), verkohlt * 0.9);
    float m = 1.0 - smoothstep(-0.003, 0.003, d);
    col = mix(a, b, m);
    float an = smoothstep(0.0, 0.05, p) * smoothstep(1.0, 0.95, p);
    float glut = exp(-pow(d / (w * 0.16), 2.0));
    float kern = exp(-pow(d / (w * 0.035), 2.0));
    col += (uGlow * glut * 1.1 + vec3(1.0, 0.82, 0.55) * kern * 0.7) * an;
  } else if (uMode < 1.5) {
    /* Displacement-Map mit chromatischer Aberration */
    vec2 dv = (vec2(fbm(nuv * 2.4 + uSeed + 5.2), fbm(nuv * 2.4 + uSeed + 11.3)) - 0.5) * 2.0;
    float t = mix(diag, smoothstep(0.2, 0.8, fbm(nuv * 1.6 + uSeed)), 0.45);
    float s = uBand;
    float e = p * (1.0 + 2.0 * s) - s;
    float m = smoothstep(t - s, t + s, e);
    /* alte Ansicht fließt weg, neue fließt ein: global über p, an der Front zusätzlich über m */
    vec2 ua = uv + dv * uDisp * (0.6 * p + 0.4 * m);
    vec2 ub = uv - dv * uDisp * (0.6 * (1.0 - p) + 0.4 * (1.0 - m));
    vec2 rs = vec2(uRgb * par * uDir, uRgb * par * 0.35);
    vec3 a = vec3(von(ua + rs).r, von(ua).g, von(ua - rs).b);
    vec3 b = vec3(nach(ub + rs).r, nach(ub).g, nach(ub - rs).b);
    col = mix(a, b, m);
  } else if (uMode < 2.5) {
    /* Pixel-Mosaik grob → fein, Block-Reveal per step() gegen Zufall */
    vec2 px = uv * uRes;
    float sz = max(1.0, floor(par * uPixMax / 8.0) * 8.0) * uDpr;
    vec2 q = sz > 1.5 ? (floor(px / sz) + 0.5) * sz / uRes : uv;
    float bl = uBlock * uDpr;
    vec2 zelle = floor(px / bl);
    vec2 mitte = (zelle + 0.5) * bl / uRes;
    float t = mix(hash(zelle + uSeed), dot(uDir > 0.0 ? mitte : 1.0 - mitte, vec2(0.62, 0.38)), 0.4);
    float m = step(t, p);
    col = mix(von(q), nach(q), m);
  } else {
    /* Reduzierte Bewegung: reiner Mix */
    col = mix(von(uv), nach(uv), p);
  }
  gl_FragColor = vec4(col, 1.0);
}`;

/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null, szene, kamera, material;
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; }
}
const texturen = imgs.map(img => {
  const t = new THREE.Texture(img);
  t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = false;
  t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
  return t;
});
const bereit = img => img.complete && img.naturalWidth > 0;
const groesse = i => new THREE.Vector2(imgs[i].naturalWidth || 1200, imgs[i].naturalHeight || 800);

if (renderer) {
  renderer.setClearColor(0x161614, 1);
  szene = new THREE.Scene();
  kamera = new THREE.Camera();
  material = new THREE.ShaderMaterial({
    vertexShader: vertex, fragmentShader: fragment, depthTest: false, depthWrite: false,
    uniforms: {
      uFrom: { value: texturen[0] }, uTo: { value: texturen[1] },
      uFromRes: { value: groesse(0) }, uToRes: { value: groesse(1) }, uRes: { value: new THREE.Vector2(1, 1) },
      uProgress: { value: 0 }, uMode: { value: 0 }, uDir: { value: 1 }, uSeed: { value: 0 }, uDpr: { value: 1 },
      uEdge: { value: W.kante }, uWarp: { value: W.warp }, uNoiseScale: { value: W.rauschSkala }, uGlow: { value: W.glut },
      uDisp: { value: W.displacement }, uRgb: { value: W.rgb }, uBand: { value: W.maskeBreite },
      uPixMax: { value: W.pixelMax }, uBlock: { value: W.block },
    },
  });
  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
  quad.frustumCulled = false;
  szene.add(quad);
  canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); renderer = null; buehne.classList.remove('gl-bereit'); zeigeDom(aktiv); });
}
const U = () => material.uniforms;

function groesseAnpassen() {
  if (!renderer) return;
  const dpr = Math.min(devicePixelRatio || 1, beruehrung ? 1.5 : 2);
  renderer.setPixelRatio(dpr);
  renderer.setSize(rahmen.clientWidth, rahmen.clientHeight, false);
  const v = new THREE.Vector2(); renderer.getDrawingBufferSize(v);
  U().uRes.value.copy(v); U().uDpr.value = dpr;
  zeichnen();
}
function zeichnen() { if (renderer) renderer.render(szene, kamera); }

function texturAuffrischen(i) {
  if (!renderer || !bereit(imgs[i])) return;
  texturen[i].image = imgs[i]; texturen[i].needsUpdate = true;
  if (U().uFrom.value === texturen[i]) U().uFromRes.value.copy(groesse(i));
  if (U().uTo.value === texturen[i]) U().uToRes.value.copy(groesse(i));
  if (i === aktiv && !buehne.classList.contains('gl-bereit')) {
    U().uFrom.value = texturen[i]; U().uFromRes.value.copy(groesse(i)); U().uProgress.value = 0;
    groesseAnpassen();
    buehne.classList.add('gl-bereit');
  }
  zeichnen();
}
imgs.forEach((img, i) => { img.addEventListener('load', () => texturAuffrischen(i)); if (bereit(img)) texturAuffrischen(i); });
if (renderer) new ResizeObserver(groesseAnpassen).observe(rahmen);

/* ---------- DOM ohne WebGL: Bilder überblenden ---------- */
function zeigeDom(i) {
  bilder.forEach((b, k) => { b.classList.toggle('an', k === i); imgs[k].setAttribute('aria-hidden', k === i ? 'false' : 'true'); });
}
zeigeDom(0);

/* ---------- Texte, Punkte, Werte ---------- */
const punkteEl = $('[data-punkte]');
const punkte = PROJEKTE.map((pr, i) => {
  const li = document.createElement('li'), b = document.createElement('button');
  b.type = 'button'; b.setAttribute('aria-label', `Projekt ${i + 1}: ${pr.titel.replace(/<[^>]+>|&shy;/g, '')}`);
  b.addEventListener('click', () => { stopVorfuehrung(); gehe(i); });
  li.append(b); punkteEl.append(li); return b;
});
const nr = i => String(i + 1).padStart(2, '0');

function texteSetzen(i) {
  const pr = PROJEKTE[i];
  titelEl.innerHTML = pr.titel;
  artEl.innerHTML = `${pr.art} <span>· ${pr.ort} · ${pr.jahr}</span>`;
  textEl.textContent = pr.text;
  nrEl.textContent = nr(i);
  abbEl.textContent = `Abb. ${nr(i)} · ${pr.abb}`;
  punkte.forEach((b, k) => b.setAttribute('aria-current', k === i ? 'true' : 'false'));
}
function texteWechseln(i, dauer, dir) {
  const els = [titelEl, artEl, textEl];
  if (reduziert()) {
    els.forEach(el => el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200, easing: 'linear' }));
    texteSetzen(i); return;
  }
  const raus = Math.round(dauer * 0.3), rein = Math.round(dauer * 0.6);
  els.forEach((el, k) => {
    el.animate([{ transform: 'translateY(0)', opacity: 1 }, { transform: `translateY(${-60 * dir}%)`, opacity: 0 }],
      { duration: raus, delay: k * 40, easing: 'cubic-bezier(0.65, 0, 0.35, 1)', fill: 'forwards' });
  });
  setTimeout(() => {
    texteSetzen(i);
    els.forEach((el, k) => {
      el.animate([{ transform: `translateY(${70 * dir}%)`, opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }],
        { duration: rein, delay: k * 60, easing: 'cubic-bezier(0.65, 0, 0.35, 1)', fill: 'both' });
    });
  }, raus + 80);
}
function werteZeigen() {
  const m = aktuellerModus();
  werteEl.innerHTML = m.werte.map(([k, v]) => `<span>${k} <b>${v}</b></span>`).join('');
  knoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.modus === modus ? 'true' : 'false'));
}
function fortschritt(p) { pEl.textContent = p.toFixed(2); balken.style.setProperty('--p', p.toFixed(3)); }

/* ---------- Übergang ---------- */
let raf = 0;
function gehe(ziel, dir) {
  ziel = (ziel + PROJEKTE.length) % PROJEKTE.length;
  if (ziel === aktiv && !laeuft) return;
  if (laeuft) { wartend = { ziel, dir }; return; }
  dir = dir || (ziel > aktiv ? 1 : -1);
  const m = aktuellerModus();
  const von = aktiv;
  aktiv = ziel;
  ansage.textContent = `Projekt ${ziel + 1} von ${PROJEKTE.length}: ${PROJEKTE[ziel].titel.replace(/<[^>]+>|&shy;/g, '')}`;
  texteWechseln(ziel, m.dauer, dir);

  if (!renderer || !buehne.classList.contains('gl-bereit')) { zeigeDom(ziel); return; }
  zeigeDom(ziel);
  const u = U();
  u.uFrom.value = texturen[von]; u.uFromRes.value.copy(groesse(von));
  u.uTo.value = texturen[ziel]; u.uToRes.value.copy(groesse(ziel));
  u.uMode.value = m.nr; u.uDir.value = dir; u.uSeed.value = Math.random() * 40;
  laeuft = true;
  const start = performance.now();
  cancelAnimationFrame(raf);
  const schritt = jetzt => {
    const t = Math.min(1, (jetzt - start) / m.dauer);
    const p = m === REDUZIERT ? t : power2InOut(t);
    u.uProgress.value = p; fortschritt(p);
    zeichnen();
    if (t < 1) { raf = requestAnimationFrame(schritt); return; }
    /* Ende: neue Ansicht wird Ausgangsbild, Render-Loop ruht */
    u.uFrom.value = texturen[ziel]; u.uFromRes.value.copy(groesse(ziel)); u.uProgress.value = 0;
    zeichnen(); fortschritt(0);
    laeuft = false;
    if (wartend) { const w = wartend; wartend = null; gehe(w.ziel, w.dir); }
  };
  raf = requestAnimationFrame(schritt);
}
const vor = () => gehe(aktiv + 1, 1);
const zurueck = () => gehe(aktiv - 1, -1);

/* ---------- Bedienung ---------- */
$('[data-vor]').addEventListener('click', () => { stopVorfuehrung(); vor(); });
$('[data-zurueck]').addEventListener('click', () => { stopVorfuehrung(); zurueck(); });
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); modus = b.dataset.modus; werteZeigen(); vor(); }));
rahmen.addEventListener('keydown', e => {
  if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); stopVorfuehrung(); vor(); }
  if (e.key === 'ArrowLeft') { e.preventDefault(); stopVorfuehrung(); zurueck(); }
});
/* Klick oder Tippen: weiter. Wischen waagerecht: vor oder zurück. Senkrecht scrollt die Seite (touch-action: pan-y). */
let druck = null;
rahmen.addEventListener('pointerdown', e => { druck = { x: e.clientX, y: e.clientY }; });
rahmen.addEventListener('pointerup', e => {
  if (!druck) return;
  const dx = e.clientX - druck.x, dy = e.clientY - druck.y; druck = null;
  if (Math.abs(dy) > 40 && Math.abs(dy) > Math.abs(dx)) return;
  stopVorfuehrung();
  if (dx < -40) vor(); else if (dx > 40) zurueck(); else vor();
});
rahmen.addEventListener('pointercancel', () => { druck = null; });
new MutationObserver(werteZeigen).observe(html, { attributes: true, attributeFilter: ['class'] });

/* ---------- Vorführung: gedachter Zeiger klickt weiter und wechselt die Variante ---------- */
let zeitgeber = [], vorfuehrt = false, schritte = 0;
const spaeter = (fn, ms) => zeitgeber.push(setTimeout(fn, ms));
function zeigerZu(el) {
  const r = el.getBoundingClientRect();
  zeiger.style.transform = `translate(${r.left + r.width / 2}px, ${r.top + r.height / 2}px)`;
}
function drueck() { zeiger.classList.add('drueckt'); spaeter(() => zeiger.classList.remove('drueckt'), 160); }
function stopVorfuehrung() {
  if (!vorfuehrt) return;
  zeitgeber.forEach(clearTimeout); zeitgeber = []; vorfuehrt = false;
  html.classList.remove('vorfuehrung');
}
function runde() {
  const folge = ['glut', 'verdraengen', 'pixel'];
  /* je Variante zwei Wechsel, dann zur nächsten Variante */
  if (schritte > 0 && schritte % 2 === 0) {
    const naechster = folge[(folge.indexOf(modus) + 1) % folge.length];
    const knopf = knoepfe.find(b => b.dataset.modus === naechster);
    zeigerZu(knopf);
    spaeter(() => { drueck(); modus = naechster; werteZeigen(); }, 950);
    spaeter(klickVor, 1500);
  } else klickVor();
}
function klickVor() {
  const knopf = $('[data-vor]');
  zeigerZu(knopf);
  spaeter(() => { drueck(); vor(); schritte++; }, 950);
  spaeter(runde, 950 + aktuellerModus().dauer + 900);
}
function vorfuehren() {
  stopVorfuehrung();
  vorfuehrt = true; schritte = 0;
  html.classList.add('vorfuehrung');
  zeigerZu(rahmen);
  spaeter(runde, 1200);
}
function neustart() {
  stopVorfuehrung();
  cancelAnimationFrame(raf); laeuft = false; wartend = null;
  aktiv = 0; texteSetzen(0); zeigeDom(0); fortschritt(0);
  if (renderer && material) { U().uFrom.value = texturen[0]; U().uFromRes.value.copy(groesse(0)); U().uProgress.value = 0; zeichnen(); }
  setTimeout(vor, 400);
}
function pause() { stopVorfuehrung(); }
function weiter() { vorfuehren(); }

texteSetzen(0); werteZeigen(); fortschritt(0);

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