Post-Processing-Filmlook
Eine Echtzeit-Szene am nächtlichen Steg bekommt den Kino-Look: Bloom, Aberration am Rand, Korn, Vignette und Grading in einem Pass, dazu Glitch nur beim Schnitt.
Die Szene rendert in ein Render-Target. Danach läuft eine Effektkette: Bloom nur auf hellen Bereichen, chromatische Aberration zum Bildrand hin, Vignette, Filmkorn, leichte Linsenverzerrung und Farbgrading. Ereignisse lösen weitere Effekte aus, etwa ein Glitch beim Schnitt oder Aberration, die mit der Scrollgeschwindigkeit wächst. Alles läuft in einem kombinierten Pass.
Es hebt Echtzeit-3D vom Look einer Spiele-Engine zum Kino-Look und verbindet die 3D-Szene mit der Korn-Ästhetik der übrigen Seite.
Wann einsetzen
Bei jeder ambitionierten 3D-Szene, dezent dosiert.
Wann nicht
Wenn es übertrieben wird: Dauer-Aberration und starker Bloom wirken billig und anstrengend.
Varianten
- Dezenter Kino-Look
- Aberration nach Geschwindigkeit
- Glitch beim Übergang
- Datamosh
- CRT-Look
Umsetzung
pmndrs/postprocessing mit EffectComposer und EffectPass, der mehrere Effekte in einem Shader vereint. Bloom über luminanceThreshold statt Layer-Auswahl, wo es geht. Auf dem Telefon die Bloom-Auflösung halbieren. Ohne Bibliothek: Szene in ein HalfFloat-Target, Bloom als Dual-Filter-Kette, alles andere in einem Abschluss-Shader.
Robust umsetzen
Telefon. Effekte reduzieren, etwa nur Vignette und Korn oder Bloom in halber Auflösung.
Ohne Bewegung. Keine Glitch-Bursts, kein Flackern, stehendes Korn.
Barrierefrei. Flackern unter 3 Hz halten oder ganz vermeiden (Photosensitivität, WCAG 2.3.1). Glitch-Bursts mit Mindestabstand und ohne Helligkeitsblitze.
Code
<!DOCTYPE html>
<!--
Post-Processing-Filmlook
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/nachbearbeitung-filmlook/
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>Post-Processing-Filmlook · 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 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; }
@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: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
/* Rezept fotograf-film-bild-zuerst, Film-Variante: neutraler dunkler Grund, Text in einem Ton */
--grund: #0e0e0e; --hell: #f2f2f2; --leise: #8a8a8a; --linie: rgba(242, 242, 242, .14);
--kurve: cubic-bezier(0.455, 0.03, 0.515, 0.955); /* easeInOutQuad, gemessen bei Nadav Kander */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--hell); font: 400 1rem/1.5 'Instrument Sans', system-ui, sans-serif; font-stretch: 95%; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; text-decoration: none; }
.mono { font: 400 .75rem/1.45 'Fragment Mono', ui-monospace, monospace; letter-spacing: .02em; font-variant-numeric: tabular-nums; color: var(--leise); }
.mono b { color: var(--hell); font-weight: 400; }
/* ---------- Bühne: das Bild bekommt die ganze Fläche ---------- */
.held { position: relative; height: 100svh; min-height: 600px; overflow: hidden; isolation: isolate; background: #050608; }
.held canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: -2; touch-action: pan-y; }
.ersatz { display: none; position: absolute; inset: 0; z-index: -2;
background:
radial-gradient(2.4% 3.4% at 28% 22%, rgba(236, 240, 255, .9), transparent 70%),
radial-gradient(12% 16% at 28% 22%, rgba(120, 140, 180, .25), transparent 75%),
radial-gradient(1.6% 2.2% at 63% 50%, rgba(255, 196, 120, 1), transparent 70%),
radial-gradient(9% 12% at 63% 50%, rgba(255, 150, 70, .3), transparent 75%),
linear-gradient(90deg, transparent 62.6%, rgba(255, 170, 90, .35) 62.8%, rgba(255, 170, 90, .35) 63.3%, transparent 63.5%) 0 60% / 100% 40% no-repeat,
linear-gradient(180deg, #05070a 0%, #0f1722 48%, #070a0e 52%, #030405 100%); }
.ohne-webgl .ersatz { display: block; }
.ohne-webgl .held canvas { display: none; }
.schleier { position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(0deg, rgba(5, 6, 8, .72) 0%, rgba(5, 6, 8, .2) 26%, transparent 42%); }
/* Interface an den Rand: Ecken wie Nadav Kander und Partizan */
.kopf { position: absolute; inset: 0 0 auto; display: flex; align-items: baseline; justify-content: space-between; gap: 24px; padding: clamp(18px, 2.4vw, 30px) clamp(20px, 3vw, 40px); }
.marke { font: 400 1.5rem/1 'Instrument Serif', serif; letter-spacing: -.01em; }
.marke span { font: 400 .75rem/1 'Fragment Mono', monospace; color: var(--leise); letter-spacing: .02em; margin-left: 12px; }
.kopf nav { display: flex; gap: clamp(16px, 2vw, 28px); font-size: .875rem; }
.kopf nav a { color: rgba(242, 242, 242, .62); transition: color .2s var(--kurve); }
.kopf nav a:hover, .kopf nav a:focus-visible, .kopf nav a[aria-current] { color: var(--hell); }
:focus-visible { outline: 1px solid var(--hell); outline-offset: 4px; }
.bildtext { position: absolute; left: clamp(20px, 3vw, 40px); bottom: clamp(22px, 3vw, 36px); max-width: min(560px, 60vw); }
.bildtext h1 { font: 400 clamp(1.5rem, 3vw, 2.75rem)/1.1 'Instrument Serif', serif; letter-spacing: -.01em; }
.bildtext h1 em { font-style: italic; color: rgba(242, 242, 242, .7); }
.bildtext .zeile { margin-top: 8px; font-size: .875rem; color: rgba(242, 242, 242, .72); }
.bildtext .unterschrift { margin-top: 14px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.blaettern { display: inline-flex; align-items: center; gap: 4px; }
.blaettern button { width: 30px; height: 30px; display: grid; place-items: center; border: 0; background: none; color: var(--leise); cursor: pointer; transition: color .2s var(--kurve); }
.blaettern button:hover { color: var(--hell); }
.blaettern svg { width: 14px; height: 14px; }
.zaehler { min-width: 4.6em; text-align: center; }
.caption { transition: opacity .35s var(--kurve); }
/* ---------- Vergleich Roh / Filmlook ---------- */
.teiler { position: absolute; top: 0; bottom: 0; left: 0; width: 0; z-index: 2; opacity: 0; pointer-events: none; transition: opacity .2s var(--kurve); }
.teiler.an { opacity: 1; pointer-events: auto; }
.teiler::before { content: ''; position: absolute; top: 0; bottom: 0; left: -.5px; width: 1px; background: rgba(242, 242, 242, .75); }
.griff { position: absolute; top: 50%; left: -22px; width: 44px; height: 44px; margin-top: -22px; border-radius: 50%; border: 1px solid rgba(242, 242, 242, .7); background: rgba(14, 14, 14, .5); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); cursor: ew-resize; touch-action: none; display: grid; place-items: center; color: var(--hell); }
.griff svg { width: 18px; height: 18px; }
.teiler .mono { position: absolute; top: 84px; color: var(--hell); white-space: nowrap; }
.teiler .links { right: 14px; }
.teiler .rechts { left: 14px; }
/* ---------- Pult (unten rechts) ---------- */
.pult { position: absolute; right: clamp(20px, 3vw, 40px); bottom: clamp(22px, 3vw, 36px); z-index: 3; display: grid; justify-items: end; gap: 10px; max-width: min(520px, 44vw); padding-top: 22px; }
.werte { text-align: right; font-size: 10.5px; line-height: 1.55; }
.bedienung { display: flex; gap: 8px; }
.schalter { display: flex; padding: 3px; border: 1px solid var(--linie); background: rgba(14, 14, 14, .55); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); border-radius: 999px; }
.schalter button { font: 400 11px/1 'Fragment Mono', ui-monospace, monospace; letter-spacing: .04em; color: var(--leise); background: none; border: 0; border-radius: 999px; padding: 9px 13px; cursor: pointer; transition: color .2s var(--kurve), background-color .2s var(--kurve); }
.schalter button:hover { color: var(--hell); }
.schalter button[aria-pressed="true"] { background: var(--hell); color: var(--grund); }
.reduziert .schalter.halt { display: none; }
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger.an { display: block; }
/* ---------- Arbeiten ---------- */
.arbeiten { padding: clamp(64px, 9vw, 120px) clamp(20px, 3vw, 40px) clamp(56px, 7vw, 96px); display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); gap: clamp(32px, 5vw, 80px); }
.arbeiten > div > p:not(.mono) { margin-top: 18px; max-width: 34ch; color: rgba(242, 242, 242, .72); font-size: .9375rem; }
.liste { list-style: none; padding: 0; border-top: 1px solid var(--linie); }
.liste li { display: grid; grid-template-columns: minmax(0, 1fr) auto 4.5em; align-items: baseline; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--linie); }
.liste h2 { font: 400 clamp(1.5rem, 3vw, 2.25rem)/1.1 'Instrument Serif', serif; letter-spacing: -.01em; }
.liste li > span:last-child { text-align: right; }
.fuss { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; padding: 22px clamp(20px, 3vw, 40px) 30px; border-top: 1px solid var(--linie); }
/* ---------- Vorschau: nur das Bild mit Ecken ---------- */
.vorschau body { overflow: hidden; }
.vorschau .arbeiten, .vorschau .fuss, .vorschau .kopf nav, .vorschau .bildtext .zeile { display: none !important; }
/* ---------- Telefon ---------- */
@media (max-width: 760px) {
.held { min-height: 640px; }
.kopf nav a:not(:first-child) { display: none; }
.marke span { display: none; }
.bildtext { right: 20px; max-width: none; bottom: 26px; }
.bildtext .zeile { font-size: .8125rem; }
.pult { left: 20px; right: 20px; bottom: auto; top: 70px; justify-items: stretch; max-width: none; }
.werte { text-align: left; }
.werte span:nth-child(n+2) { display: none; }
.arbeiten { grid-template-columns: minmax(0, 1fr); }
.liste li { grid-template-columns: minmax(0, 1fr) 4.5em; }
.liste li > span:nth-child(2) { display: none; }
}
@media (hover: none) { .schalter button { padding: 12px 13px; } .blaettern button { width: 44px; height: 44px; } }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="held" aria-labelledby="titel">
<canvas data-szene aria-hidden="true"></canvas>
<div class="ersatz" aria-hidden="true"></div>
<div class="schleier" aria-hidden="true"></div>
<header class="kopf">
<a class="marke" href="#">Kaltlicht<span>Filmproduktion · Potsdam-Babelsberg</span></a>
<nav aria-label="Hauptnavigation"><a href="#arbeiten" aria-current="page">Arbeiten</a><a href="#">Regie</a><a href="#">Previs</a><a href="#">Studio</a><a href="#">Kontakt</a></nav>
</header>
<div class="bildtext">
<h1 id="titel">Nachtfähre <em>Previs, Szene 14</em></h1>
<p class="zeile">Spielfilm in Vorbereitung · Regie Henrike Voss · Kamera Jakob Ilgner</p>
<div class="unterschrift">
<div class="blaettern" role="group" aria-label="Einstellungen">
<button type="button" data-zurueck aria-label="Vorige Einstellung"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M9 2.5 4.5 7 9 11.5" fill="none" stroke="currentColor" stroke-width="1.3"/></svg></button>
<span class="mono zaehler" data-zaehler aria-live="polite">01 / 03</span>
<button type="button" data-vor aria-label="Nächste Einstellung"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M5 2.5 9.5 7 5 11.5" fill="none" stroke="currentColor" stroke-width="1.3"/></svg></button>
</div>
<p class="mono caption" data-caption>Totale · Steg am Griebnitzsee, 23:40 · Echtzeit, three.js</p>
</div>
</div>
<div class="teiler" data-teiler inert>
<span class="mono links">Roh</span><span class="mono rechts">Filmlook</span>
<button class="griff" type="button" role="slider" aria-label="Vergleich Roh und Filmlook" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" data-griff>
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M6.5 4 2 9l4.5 5M11.5 4 16 9l-4.5 5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div class="pult">
<p class="mono werte" data-werte aria-live="polite"></p>
<div class="bedienung">
<div class="schalter" role="group" aria-label="Variante">
<button type="button" data-variante="kino" aria-pressed="true">Kino</button>
<button type="button" data-variante="tempo" aria-pressed="false">Tempo</button>
<button type="button" data-variante="glitch" aria-pressed="false">Schnitt</button>
</div>
<div class="schalter"><button type="button" data-vergleich aria-pressed="false">Vergleich</button></div>
<div class="schalter halt"><button type="button" data-halt aria-pressed="false">Anhalten</button></div>
</div>
</div>
</section>
<section class="arbeiten" id="arbeiten" aria-labelledby="arbeiten-titel">
<div>
<p class="mono" id="arbeiten-titel">Arbeiten · 2023–2027</p>
<p>Wir bauen jedes Set vor dem Dreh als Echtzeit-Szene und graden sie schon so, wie der Film aussehen soll. Regie, Kamera und Szenenbild sehen am selben Bild, wo das Licht hinfällt.</p>
</div>
<ul class="liste">
<li><h2>Nachtfähre</h2><span class="mono">Spielfilm · in Vorbereitung</span><span class="mono">2027</span></li>
<li><h2>Der Winter in Fahrland</h2><span class="mono">Serie · 6 × 45 min</span><span class="mono">2026</span></li>
<li><h2>Rote Stunde</h2><span class="mono">Kurzfilm · 19 min</span><span class="mono">2025</span></li>
<li><h2>Kaltes Wasser, warmes Licht</h2><span class="mono">Dokumentarfilm · 84 min</span><span class="mono">2024</span></li>
</ul>
</section>
<footer class="fuss mono"><span>Kaltlicht Film GmbH · Großbeerenstraße 187 · 14482 Potsdam</span><span>Impressum · Datenschutz · Kontakt</span></footer>
<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="#f2f2f2" stroke="#0e0e0e" 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 teiler = $('[data-teiler]'), griff = $('[data-griff]');
const schmal = () => innerWidth < 760;
const mobil = () => schmal() || beruehrung;
/* ---------- Werte (Token) ----------
Messwerte: Bloom nach Igloo (6 Stufen, Schwelle 0–0.2, Stärke 1, Radius .85),
Aberration 0.001–0.004 uv, Korn 0.03–0.08, Glitch-Bursts 150–400 ms.
Bewegung der Oberfläche nach Rezept: easeInOutQuad, Bildwechsel 350–800 ms. */
const W = {
bloom: { stufen: 6, stufenMobil: 4, schwelle: 0.2, weiche: 0.1, staerke: 1, radius: 0.85 },
aberr: { kino: 0.0015, min: 0.001, max: 0.004, tempoVoll: 2.0 /* px/ms */, abklingen: 0.12 },
verzerrung: 0.05,
vignette: { offset: 0.35, dunkel: 0.55 },
korn: { staerke: 0.05, fps: 24 },
grading: { lift: [0.006, 0.014, 0.024], gain: [1.05, 1.0, 0.94], saettigung: 0.92, belichtung: 1.0 },
glitch: { ms: 300, spitze: 0.35, baender: 18, versatz: 0.06, abstandMin: 3000 },
blende: { ms: 600 }, // Abblende zwischen Einstellungen: 300 ms ab, Schnitt, 300 ms auf
wischer: { ms: 2400 },
kurve: [0.455, 0.03, 0.515, 0.955],
parallax: { lerp: 0.05, kino: 0.08, tempo: 0.32 },
};
const kurve = (() => { const [x1, y1, x2, y2] = W.kurve; return p => { if (p <= 0) return 0; if (p >= 1) return 1; let t = p; for (let i = 0; i < 8; i++) { const x = 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t - p; const d = 3 * (1 - t) * (1 - t) * x1 + 6 * (1 - t) * t * (x2 - x1) + 3 * t * t * (1 - x2); if (Math.abs(d) < 1e-6) break; t -= x / d; } return 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t; }; })();
let variante = 'kino';
/* ---------- Werte-Anzeige ---------- */
const anzeige = { aberr: W.aberr.kino };
function zeigeWerte() {
const m = mobil(), b = W.bloom;
const bloom = `Bloom <b>${m ? b.stufenMobil + ' Stufen, halbe Auflösung' : b.stufen + ' Stufen'}</b> · Schwelle <b>.2</b> · Stärke <b>1</b> · Radius <b>.85</b>`;
const z = {
kino: `${bloom} · Aberration <b>${m ? 'aus' : '.0015 uv am Rand'}</b> · Verzerrung <b>${m ? 'aus' : '.05'}</b>`,
tempo: `Aberration nach Tempo <b>.001 → .004 uv</b> · jetzt <b data-live>${anzeige.aberr.toFixed(4)}</b> · voll ab <b>2 px/ms</b> · Abklingen <b>.12</b> je Bild`,
glitch: `Schnitt mit Glitch-Burst <b>300 ms</b> · Spitze bei <b>35 %</b> · <b>18 Bänder</b> · Versatz <b>≤ .06 uv</b> · Abstand <b>≥ 3 s</b>`,
}[variante];
const film = reduziert()
? 'Reduzierte Bewegung: <b>Kamera steht, Korn steht, kein Burst, Schnitt hart</b>'
: `Korn <b>.05 · ${W.korn.fps} fps</b> · Vignette <b>.55 / .35</b> · ACES · Grading <b>Lift kühl, Gain warm, Sättigung .92</b>`;
$('[data-werte]').innerHTML = `<span>${z}</span><br><span>${film}</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; }
/* ---------- Szene: Steg am Griebnitzsee bei Nacht ---------- */
const scene = new THREE.Scene();
scene.background = new THREE.Color('#05070a');
scene.fog = new THREE.FogExp2('#0a0f16', 0.03);
const camera = new THREE.PerspectiveCamera(34, 1, 0.05, 200);
const zeitU = { value: 0 };
// Himmel: senkrechter Verlauf, am Horizont kühles Restlicht
const himmel = new THREE.Mesh(new THREE.PlaneGeometry(400, 120), new THREE.ShaderMaterial({
fog: false, depthWrite: false,
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `varying vec2 vUv; void main(){ float h = vUv.y;
vec3 c = mix(vec3(0.030, 0.045, 0.070), vec3(0.004, 0.006, 0.010), smoothstep(0.5, 0.85, h));
gl_FragColor = vec4(c, 1.0); }`,
}));
himmel.position.set(0, 0, -95); scene.add(himmel);
// Mond mit Hof: hell genug für den Bloom
const mond = new THREE.Mesh(new THREE.CircleGeometry(1.5, 40), new THREE.MeshBasicMaterial({ color: new THREE.Color(3.2, 3.3, 3.6), fog: false }));
mond.position.set(-24, 12, -88); scene.add(mond);
const hof = new THREE.Mesh(new THREE.PlaneGeometry(26, 26), new THREE.ShaderMaterial({
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false,
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `varying vec2 vUv; void main(){ float d = length(vUv - 0.5) * 2.0; gl_FragColor = vec4(vec3(0.10, 0.12, 0.17) * pow(max(0.0, 1.0 - d), 2.2), 1.0); }`,
}));
hof.position.copy(mond.position).add(new THREE.Vector3(0, 0, 0.5)); scene.add(hof);
// Gegenüberliegendes Ufer: Baumlinie mit Rausch-Kante, im Dunst
const ufer = new THREE.Mesh(new THREE.PlaneGeometry(260, 14), new THREE.ShaderMaterial({
fog: false, transparent: true,
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `varying vec2 vUv;
float h(float x){ return fract(sin(x * 127.1) * 43758.5453); }
float n(float x){ float i = floor(x), f = fract(x); f = f * f * (3.0 - 2.0 * f); return mix(h(i), h(i + 1.0), f); }
void main(){ float x = vUv.x * 260.0;
float kante = 0.24 + n(x * 0.9) * 0.035 + n(x * 2.6) * 0.018 + n(x * 0.08) * 0.07; // Baumkronen, flach wie am Griebnitzsee
float a = 1.0 - smoothstep(kante - 0.01, kante + 0.01, vUv.y);
gl_FragColor = vec4(vec3(0.010, 0.014, 0.020), a); }`,
}));
ufer.position.set(0, 7 - 0.5, -60); scene.add(ufer);
// Lichter am anderen Ufer (Villen, Steg, Bootshaus)
const uferLichter = [];
{
const n = 16, pos = new Float32Array(n * 3), farbe = new Float32Array(n * 3), groesse = new Float32Array(n);
const toene = [[1.0, 0.68, 0.36], [1.0, 0.78, 0.52], [0.72, 0.82, 1.0], [1.0, 0.6, 0.3]];
for (let i = 0; i < n; i++) {
const x = -46 + (i / (n - 1)) * 92 + Math.sin(i * 3.7) * 2.5, y = 0.6 + Math.abs(Math.sin(i * 1.9)) * 2.2, z = -58.5;
pos.set([x, y, z], i * 3);
const f = toene[i % toene.length], k = 2.2 + (i % 3) * 0.8;
farbe.set([f[0] * k, f[1] * k, f[2] * k], i * 3);
groesse[i] = 0.9 + (i % 4) * 0.25;
uferLichter.push({ p: new THREE.Vector3(x, y, z), c: new THREE.Color(f[0], f[1], f[2]).multiplyScalar(0.35) });
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('farbe', new THREE.BufferAttribute(farbe, 3));
g.setAttribute('groesse', new THREE.BufferAttribute(groesse, 1));
const m = new THREE.ShaderMaterial({
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false,
uniforms: { uSkala: { value: 300 } },
vertexShader: `attribute vec3 farbe; attribute float groesse; uniform float uSkala; varying vec3 vF;
void main(){ vF = farbe; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_PointSize = max(2.0, groesse * uSkala / -mv.z); gl_Position = projectionMatrix * mv; }`,
fragmentShader: `varying vec3 vF; void main(){ float d = length(gl_PointCoord - 0.5) * 2.0; gl_FragColor = vec4(vF * smoothstep(1.0, 0.2, d), 1.0); }`,
});
var punkte = new THREE.Points(g, m); scene.add(punkte);
}
// Wasser: dunkel, Spiegelstreifen der Lichter, vom Wind zerrissen
const LAMPE = new THREE.Vector3(0.62, 2.02, -4.1);
const spiegel = [{ p: LAMPE, c: new THREE.Color(1.0, 0.62, 0.3).multiplyScalar(1.6), b: 0.010 }, { p: mond.position, c: new THREE.Color(0.55, 0.6, 0.72), b: 0.006 }]
.concat(uferLichter.map(l => ({ p: l.p, c: l.c, b: 0.0035 })));
const wasser = new THREE.Mesh(new THREE.PlaneGeometry(240, 160), new THREE.ShaderMaterial({
fog: false,
uniforms: { uZeit: zeitU, uKamera: { value: new THREE.Vector3() }, uLp: { value: spiegel.map(s => s.p) }, uLc: { value: spiegel.map(s => s.c) }, uLb: { value: spiegel.map(s => s.b) } },
vertexShader: `varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }`,
fragmentShader: `#define N ${spiegel.length}
uniform float uZeit; uniform vec3 uKamera; uniform vec3 uLp[N]; uniform vec3 uLc[N]; uniform float uLb[N]; varying vec3 vW;
float h(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float n(vec2 p){ vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix(mix(h(i), h(i + vec2(1, 0)), f.x), mix(h(i + vec2(0, 1)), h(i + vec2(1, 1)), f.x), f.y); }
void main(){
vec2 zuP = vW.xz - uKamera.xz; float dP = length(zuP); vec2 r = zuP / dP;
float ferne = smoothstep(4.0, 70.0, dP);
vec3 c = mix(vec3(0.004, 0.006, 0.009), vec3(0.020, 0.030, 0.046), ferne); // Himmel spiegelt am Horizont
float wellen = n(vec2(vW.x * 1.4, vW.z * 7.0 + uZeit * 0.5)) * 0.65 + n(vec2(vW.x * 3.1 - uZeit * 0.2, vW.z * 15.0)) * 0.35;
for (int i = 0; i < N; i++) {
vec2 zuL = uLp[i].xz - uKamera.xz; float dL = length(zuL); vec2 l = zuL / dL;
float quer = r.x * l.y - r.y * l.x;
float breite = uLb[i] * (1.0 + dP * 0.02) + 0.002;
float s = exp(-pow(quer / breite, 2.0)) * step(dP, dL + 2.0) * step(0.0, dot(r, l));
c += uLc[i] * s * smoothstep(0.38, 0.95, wellen) * 1.8 * (0.4 + 0.6 * smoothstep(dL * 0.15, dL, dP));
}
gl_FragColor = vec4(c, 1.0);
}`,
}));
wasser.rotation.x = -Math.PI / 2; wasser.position.z = -40; scene.add(wasser);
// Steg: Bohlen, Längsträger und Pfähle als InstancedMesh (drei Draw Calls)
function holzTextur() {
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
const x = c.getContext('2d');
x.fillStyle = '#5a4434'; x.fillRect(0, 0, 256, 64);
for (let i = 0; i < 46; i++) { x.strokeStyle = `rgba(${Math.random() < .5 ? '30,20,14' : '120,96,74'},${0.08 + Math.random() * 0.14})`; x.lineWidth = 0.6 + Math.random() * 1.6; const y = Math.random() * 64; x.beginPath(); x.moveTo(0, y); x.bezierCurveTo(80, y + Math.random() * 4 - 2, 170, y + Math.random() * 4 - 2, 256, y); x.stroke(); }
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
const holz = new THREE.MeshStandardMaterial({ map: holzTextur(), roughness: 0.82, metalness: 0 });
const steg = new THREE.Group(); scene.add(steg);
const BOHLEN = 44, bohlen = new THREE.InstancedMesh(new THREE.BoxGeometry(1.6, 0.05, 0.19), holz, BOHLEN);
const m4 = new THREE.Matrix4(), q4 = new THREE.Quaternion(), e4 = new THREE.Euler(), s4 = new THREE.Vector3(1, 1, 1), p4 = new THREE.Vector3();
for (let i = 0; i < BOHLEN; i++) {
e4.set((Math.sin(i * 7.3) * 0.01), Math.sin(i * 3.1) * 0.012, Math.sin(i * 5.7) * 0.012);
m4.compose(p4.set(Math.sin(i * 2.3) * 0.02, 0.45 + Math.sin(i * 4.1) * 0.006, 5.2 - i * 0.215), q4.setFromEuler(e4), s4); bohlen.setMatrixAt(i, m4);
}
steg.add(bohlen);
const traeger = new THREE.InstancedMesh(new THREE.BoxGeometry(0.1, 0.16, 9.6), holz, 2);
[-0.62, 0.62].forEach((x, i) => { m4.compose(p4.set(x, 0.34, 0.5), q4.identity(), s4); traeger.setMatrixAt(i, m4); });
steg.add(traeger);
const PFAEHLE = 12, pfaehle = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.075, 0.085, 1.8, 10), new THREE.MeshStandardMaterial({ color: '#2a221c', roughness: 0.9 }), PFAEHLE);
for (let i = 0; i < PFAEHLE; i++) { m4.compose(p4.set(i % 2 ? 0.72 : -0.72, -0.35 + (i % 3) * 0.04, 4.6 - Math.floor(i / 2) * 1.75), q4.identity(), s4); pfaehle.setMatrixAt(i, m4); }
steg.add(pfaehle);
// Laterne am Stegende
const eisen = new THREE.MeshStandardMaterial({ color: '#15171a', roughness: 0.5, metalness: 0.7 });
const mast = new THREE.Mesh(new THREE.BoxGeometry(0.08, 1.75, 0.08), eisen); mast.position.set(0.62, 0.47 + 0.87, -4.1); steg.add(mast);
const kappe = new THREE.Mesh(new THREE.ConeGeometry(0.2, 0.14, 4), eisen); kappe.position.set(0.62, 2.27, -4.1); kappe.rotation.y = Math.PI / 4; steg.add(kappe);
const glasL = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.28, 0.2), new THREE.ShaderMaterial({
fog: false,
vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `varying vec3 vP; void main(){ float k = 1.0 - smoothstep(0.0, 0.16, length(vP.xz) + abs(vP.y) * 0.6);
gl_FragColor = vec4(mix(vec3(1.6, 0.8, 0.32), vec3(7.0, 4.2, 1.9), k), 1.0); }`,
}));
glasL.position.copy(LAMPE); steg.add(glasL);
// Rahmen der Laterne: vier Eckstreben und Bodenplatte
const streben = new THREE.InstancedMesh(new THREE.BoxGeometry(0.022, 0.3, 0.022), eisen, 4);
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b], i) => { m4.compose(p4.set(LAMPE.x + a * 0.105, LAMPE.y, LAMPE.z + b * 0.105), q4.identity(), s4); streben.setMatrixAt(i, m4); });
steg.add(streben);
const platte = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.03, 0.25), eisen); platte.position.set(LAMPE.x, LAMPE.y - 0.16, LAMPE.z); steg.add(platte);
const lampenLicht = new THREE.PointLight('#ffad6b', 6, 0, 2); lampenLicht.position.copy(LAMPE).add(new THREE.Vector3(0, -0.1, 0)); steg.add(lampenLicht);
// Figur, die auf die Fähre wartet: Silhouette im Gegenlicht
const mantel = new THREE.MeshStandardMaterial({ color: '#1b1d22', roughness: 0.9 });
const figur = new THREE.Group();
// Umriss als Drehkörper: Beine, Mantel bis zum Knie, Schultern, Kopf (von hinten lesbar, ein Draw Call)
const umriss = [[0, 0], [0.09, 0], [0.085, 0.06], [0.08, 0.5], [0.19, 0.53], [0.175, 0.9], [0.185, 1.24], [0.2, 1.37], [0.165, 1.45], [0.06, 1.5], [0.06, 1.55], [0.09, 1.58], [0.104, 1.66], [0.09, 1.75], [0.05, 1.79], [0, 1.8]].map(([r, y]) => new THREE.Vector2(r, y));
figur.add(new THREE.Mesh(new THREE.LatheGeometry(umriss, 20), mantel));
figur.position.set(-0.32, 0.47, -3.6); figur.scale.set(1, 1, 0.62); steg.add(figur);
// Ruderboot am Steg
const boot = new THREE.Mesh(new THREE.SphereGeometry(1, 18, 8, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2), new THREE.MeshStandardMaterial({ color: '#2c3b40', roughness: 0.7, side: THREE.DoubleSide }));
boot.scale.set(0.55, 0.3, 1.6); boot.position.set(1.55, 0.18, -2.4); boot.rotation.y = 0.12; scene.add(boot);
// Mondlicht von links hinten, kaltes Fülllicht
const mondLicht = new THREE.DirectionalLight('#8fa6c8', 0.5); mondLicht.position.set(-6, 5, -10); scene.add(mondLicht);
scene.add(new THREE.HemisphereLight('#1b2433', '#05070a', 0.35));
/* ---------- Einstellungen: Totale, Laterne, Wasser ---------- */
const EINSTELLUNGEN = [
{ name: 'Totale', text: 'Totale · Steg am Griebnitzsee, 23:40 · Echtzeit, three.js', pos: [-0.55, 1.2, 5.6], ziel: [0.35, 1.05, -6], fov: 34 },
{ name: 'Laterne', text: 'Halbnah · Laterne, Gegenlicht auf die Figur · Echtzeit', pos: [2.5, 1.05, 0.9], ziel: [0.05, 1.45, -4.4], fov: 30 },
{ name: 'Wasser', text: 'Flach über dem Wasser · Spiegelung, Ufer gegenüber · Echtzeit', pos: [3.6, 0.42, 1.2], ziel: [-0.4, 0.75, -4.6], fov: 34 },
];
let einstellung = 0;
function beschriften() {
$('[data-zaehler]').textContent = `0${einstellung + 1} / 0${EINSTELLUNGEN.length}`;
$('[data-caption]').textContent = EINSTELLUNGEN[einstellung].text;
}
/* ---------- Nachbearbeitung ---------- */
const VS = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const quadScene = new THREE.Scene(), quadCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); quadScene.add(quad);
let software = false, rtSzene = null, runter = [], hoch = [], hell, abw, aufw, post, rtTyp = THREE.UnsignedByteType;
// 1. Schwelle + erste Halbierung (4 Abtastungen)
const FS_HELL = `uniform sampler2D tQ; uniform vec2 uTexel; uniform float uSchwelle, uWeiche; varying vec2 vUv;
void main(){ vec3 c = (texture2D(tQ, vUv + uTexel * vec2(-1., -1.)).rgb + texture2D(tQ, vUv + uTexel * vec2(1., -1.)).rgb
+ texture2D(tQ, vUv + uTexel * vec2(-1., 1.)).rgb + texture2D(tQ, vUv + uTexel * vec2(1., 1.)).rgb) * 0.25;
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
gl_FragColor = vec4(c * smoothstep(uSchwelle, uSchwelle + uWeiche, l), 1.0); }`;
// 2. Dual-Filter abwärts
const FS_AB = `uniform sampler2D tQ; uniform vec2 uTexel; varying vec2 vUv;
void main(){ vec3 s = texture2D(tQ, vUv).rgb * 4.0;
s += texture2D(tQ, vUv - uTexel).rgb + texture2D(tQ, vUv + uTexel).rgb;
s += texture2D(tQ, vUv + vec2(uTexel.x, -uTexel.y)).rgb + texture2D(tQ, vUv - vec2(uTexel.x, -uTexel.y)).rgb;
gl_FragColor = vec4(s / 8.0, 1.0); }`;
// 3. Dual-Filter aufwärts + Stufe mit Gewicht (Radius wie UnrealBloom)
const FS_AUF = `uniform sampler2D tQ, tStufe; uniform vec2 uTexel; uniform float uGewicht; varying vec2 vUv;
void main(){ vec2 o = uTexel;
vec3 s = texture2D(tQ, vUv + vec2(-o.x * 2.0, 0.0)).rgb + texture2D(tQ, vUv + vec2(o.x * 2.0, 0.0)).rgb
+ texture2D(tQ, vUv + vec2(0.0, -o.y * 2.0)).rgb + texture2D(tQ, vUv + vec2(0.0, o.y * 2.0)).rgb;
s += (texture2D(tQ, vUv + vec2(-o.x, o.y)).rgb + texture2D(tQ, vUv + vec2(o.x, o.y)).rgb
+ texture2D(tQ, vUv + vec2(-o.x, -o.y)).rgb + texture2D(tQ, vUv + vec2(o.x, -o.y)).rgb) * 2.0;
gl_FragColor = vec4(s / 12.0 + texture2D(tStufe, vUv).rgb * uGewicht, 1.0); }`;
// 4. Alles in einem Pass: Verzerrung, Glitch, Aberration, Bloom, ACES, Grading, Vignette, Korn
const FS_POST = `
uniform sampler2D tSzene, tBloom; uniform vec2 uRes;
uniform float uAberr, uVerz, uVignOff, uVignDunkel, uKorn, uSeed, uBloom, uGlitch, uGSeed, uSplit, uBelicht, uSatt, uBaender, uVersatz, uSchwarz;
uniform vec3 uLift, uGain;
varying vec2 vUv;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
vec3 aces(vec3 x){ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }
vec3 srgb(vec3 c){ return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c)); }
vec3 hdr(vec2 uv){ return texture2D(tSzene, uv).rgb + texture2D(tBloom, uv).rgb * uBloom; }
void main(){
vec2 uv = vUv;
if (uv.x < uSplit) { gl_FragColor = vec4(srgb(clamp(texture2D(tSzene, uv).rgb, 0.0, 1.0)), 1.0); return; } // Roh: so rendert three ohne Nachbearbeitung
vec2 c = uv - 0.5;
// leichte Tonnenverzerrung, Ecken bleiben im Bild
vec2 q = 0.5 + c * (1.0 + uVerz * dot(c, c)) / (1.0 + uVerz * 0.5);
// Glitch: Bänder springen seitlich, feine Zeilen zittern (keine Helligkeitsblitze)
if (uGlitch > 0.001) {
float band = floor(uv.y * uBaender);
float an = step(1.0 - uGlitch * 0.75, hash(vec2(band, uGSeed)));
q.x += an * (hash(vec2(band, uGSeed + 1.7)) - 0.5) * 2.0 * uVersatz * uGlitch;
q.x += (hash(vec2(floor(uv.y * uRes.y / 3.0), uGSeed)) - 0.5) * 0.004 * uGlitch;
}
// chromatische Aberration: radial, am Bildrand (|c| = .5) genau uAberr
vec2 off = c * 2.0 * uAberr + vec2(uGlitch * 0.004, 0.0);
vec3 col = vec3(hdr(q + off).r, hdr(q).g, hdr(q - off).b);
col = srgb(aces(col * uBelicht));
// Grading: Lift (Schatten kühl), Gain (Lichter warm), Sättigung
col = col * uGain + uLift * (1.0 - col);
float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = mix(vec3(l), col, uSatt);
// Vignette
float d = length(c * vec2(uRes.x / uRes.y, 1.0) / vec2(uRes.x / uRes.y * 0.5 + 0.5, 1.0));
col *= 1.0 - uVignDunkel * smoothstep(uVignOff, 1.05, d);
col *= 1.0 - uSchwarz; // Abblende zwischen Einstellungen
// Filmkorn, in Schatten und Mitten stärker
float k = hash(floor(uv * uRes) + uSeed) - 0.5;
col += k * uKorn * (1.0 - l * 0.6) * 1.6;
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}`;
if (renderer) {
renderer.outputColorSpace = THREE.LinearSRGBColorSpace; // Umrechnung macht der Post-Shader
const gl = renderer.getContext();
const kannHalb = renderer.capabilities.isWebGL2 ? renderer.extensions.has('EXT_color_buffer_float') || renderer.extensions.has('EXT_color_buffer_half_float') : renderer.extensions.has('OES_texture_half_float') && renderer.extensions.has('EXT_color_buffer_half_float');
rtTyp = kannHalb ? THREE.HalfFloatType : THREE.UnsignedByteType;
// Software-Rendering (SwiftShader, llvmpipe): ohne MSAA und mit Pixeldichte 1
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
software = /swiftshader|llvmpipe|software/i.test(dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : '');
rtSzene = new THREE.WebGLRenderTarget(4, 4, { type: rtTyp, depthBuffer: true, samples: renderer.capabilities.isWebGL2 && !software ? 4 : 0 });
const mat = (fs, uniforms) => new THREE.ShaderMaterial({ vertexShader: VS, fragmentShader: fs, uniforms, depthTest: false, depthWrite: false });
hell = mat(FS_HELL, { tQ: { value: null }, uTexel: { value: new THREE.Vector2() }, uSchwelle: { value: W.bloom.schwelle }, uWeiche: { value: W.bloom.weiche } });
abw = mat(FS_AB, { tQ: { value: null }, uTexel: { value: new THREE.Vector2() } });
aufw = mat(FS_AUF, { tQ: { value: null }, tStufe: { value: null }, uTexel: { value: new THREE.Vector2() }, uGewicht: { value: 1 } });
post = mat(FS_POST, {
tSzene: { value: rtSzene.texture }, tBloom: { value: null }, uRes: { value: new THREE.Vector2(1, 1) },
uAberr: { value: W.aberr.kino }, uVerz: { value: W.verzerrung }, uVignOff: { value: W.vignette.offset }, uVignDunkel: { value: W.vignette.dunkel },
uKorn: { value: W.korn.staerke }, uSeed: { value: 0 }, uBloom: { value: W.bloom.staerke }, uGlitch: { value: 0 }, uGSeed: { value: 0 },
uSplit: { value: 0 }, uBelicht: { value: W.grading.belichtung }, uSatt: { value: W.grading.saettigung },
uBaender: { value: W.glitch.baender }, uVersatz: { value: W.glitch.versatz }, uSchwarz: { value: 0 },
uLift: { value: new THREE.Vector3(...W.grading.lift) }, uGain: { value: new THREE.Vector3(...W.grading.gain) },
});
}
function bloomStufen() { return mobil() ? W.bloom.stufenMobil : W.bloom.stufen; }
function bloomBauen(w, h) {
[...runter, ...hoch].forEach(r => r.dispose()); runter = []; hoch = [];
const n = bloomStufen();
let bw = Math.max(1, Math.round(w / (mobil() ? 4 : 2))), bh = Math.max(1, Math.round(h / (mobil() ? 4 : 2))); // mobil: halbe Bloom-Auflösung
for (let i = 0; i < n; i++) {
const o = { type: rtTyp, depthBuffer: false, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter };
runter.push(new THREE.WebGLRenderTarget(bw, bh, o)); hoch.push(new THREE.WebGLRenderTarget(bw, bh, o));
bw = Math.max(1, bw >> 1); bh = Math.max(1, bh >> 1);
}
}
// Gewichte je Stufe wie UnrealBloom: mix(f, 1.2 − f, radius), f von 1 (fein) bis .2 (weit)
function gewicht(i, n) { const f = 1 - 0.8 * i / Math.max(1, n - 1); return f + (1.2 - 2 * f) * W.bloom.radius; }
function pass(m, ziel) { quad.material = m; renderer.setRenderTarget(ziel); renderer.render(quadScene, quadCam); }
function bloomRechnen() {
const n = runter.length;
hell.uniforms.tQ.value = rtSzene.texture; hell.uniforms.uTexel.value.set(0.5 / rtSzene.width, 0.5 / rtSzene.height);
pass(hell, runter[0]);
for (let i = 1; i < n; i++) {
abw.uniforms.tQ.value = runter[i - 1].texture; abw.uniforms.uTexel.value.set(0.5 / runter[i - 1].width, 0.5 / runter[i - 1].height);
pass(abw, runter[i]);
}
// weiteste Stufe: nur gewichtet kopieren (Aufwärts-Pass mit sich selbst als Quelle und Gewicht)
let quelle = runter[n - 1];
for (let i = n - 2; i >= 0; i--) {
aufw.uniforms.tQ.value = quelle.texture; aufw.uniforms.tStufe.value = runter[i].texture;
aufw.uniforms.uTexel.value.set(0.5 / quelle.width, 0.5 / quelle.height);
// Kette summiert mit Faktor 1, am Ende mal Gewicht der weitesten Stufe: also hier w_i / w_(n−1)
aufw.uniforms.uGewicht.value = gewicht(i, n) / gewicht(n - 1, n);
pass(aufw, hoch[i]); quelle = hoch[i];
}
post.uniforms.tBloom.value = quelle.texture;
post.uniforms.uBloom.value = W.bloom.staerke * gewicht(n - 1, n);
}
/* ---------- Größe und Bildausschnitt ---------- */
function groesse() {
const w = innerWidth, h = held.clientHeight, dpr = software ? 1 : Math.min(devicePixelRatio || 1, 1.5);
zeigeWerte();
if (!renderer) return;
renderer.setPixelRatio(dpr); renderer.setSize(w, h, false);
const pw = Math.round(w * dpr), ph = Math.round(h * dpr);
rtSzene.setSize(pw, ph);
bloomBauen(pw, ph);
post.uniforms.uRes.value.set(pw, ph);
post.uniforms.uVerz.value = mobil() ? 0 : W.verzerrung;
punkte.material.uniforms.uSkala.value = ph * 0.5;
camera.aspect = w / h;
camera.updateProjectionMatrix();
zeichnen(performance.now());
}
/* ---------- Zeiger, Tempo, Parallaxe ---------- */
const maus = { nx: 0, ny: 0, drin: false, lx: 0, ly: 0, lt: 0 };
let tempo = 0; // px/ms, geglättet
function bewegung(x, y, t) {
if (maus.lt) {
const dt = Math.max(1, t - maus.lt), v = Math.hypot(x - maus.lx, y - maus.ly) / dt;
tempo = Math.max(tempo, Math.min(4, v));
}
maus.lx = x; maus.ly = y; maus.lt = t;
maus.nx = x / innerWidth * 2 - 1; maus.ny = y / held.clientHeight * 2 - 1; maus.drin = true;
starten();
}
held.addEventListener('pointermove', e => { if (vorf || e.pointerType !== 'mouse') return; bewegung(e.clientX, e.clientY, performance.now()); });
held.addEventListener('pointerleave', () => { if (!vorf) { maus.drin = false; maus.lt = 0; } });
held.addEventListener('touchmove', e => { if (vorf) return; const t = e.touches[0]; bewegung(t.clientX, t.clientY, performance.now()); }, { passive: true });
held.addEventListener('touchend', () => { maus.drin = false; maus.lt = 0; }, { passive: true });
let sy = scrollY, st = performance.now();
addEventListener('scroll', () => { const t = performance.now(); tempo = Math.max(tempo, Math.min(4, Math.abs(scrollY - sy) / Math.max(1, t - st))); sy = scrollY; st = t; starten(); }, { passive: true });
/* ---------- Varianten ---------- */
function setzeVariante(v) {
variante = v;
$$('[data-variante]').forEach(b => b.setAttribute('aria-pressed', b.dataset.variante === v));
autoSchnitt = v === 'glitch' && !reduziert() ? performance.now() + 4200 : 0; // in „Schnitt“ alle 4,2 s ein neuer Schnitt
zeigeWerte(); starten(); zeichnen(performance.now());
}
$$('[data-variante]').forEach(b => b.addEventListener('click', () => { stoppeVorfuehrung(); setzeVariante(b.dataset.variante); if (b.dataset.variante === 'glitch') schnitt((einstellung + 1) % EINSTELLUNGEN.length); }));
// Einstellungswechsel: in „Kino“ und „Tempo“ als Abblende (600 ms), in „Schnitt“ mit Glitch-Burst
let glitchStart = -1, letzterBurst = -1e9, schnittFaellig = -1, schnittZiel = 0, autoSchnitt = 0, blendeStart = -1;
function schnitt(i, hart) {
const t = performance.now();
if (hart || reduziert() || !renderer) { einstellung = i; beschriften(); if (renderer) { kameraSetzen(t); zeichnen(t); } return; }
if (variante !== 'glitch') {
if (blendeStart >= 0) return;
blendeStart = t; schnittZiel = i; schnittFaellig = t + W.blende.ms / 2; starten(); return;
}
if (t - letzterBurst < W.glitch.abstandMin) return; // Photosensitivität: höchstens ein Burst je 3 s
letzterBurst = t; glitchStart = t;
schnittZiel = i; schnittFaellig = t + W.glitch.ms * W.glitch.spitze; // der Schnitt fällt auf die Spitze des Bursts
starten();
}
const blaettern = d => { stoppeVorfuehrung(); schnitt((einstellung + d + EINSTELLUNGEN.length) % EINSTELLUNGEN.length); };
$('[data-vor]').addEventListener('click', () => blaettern(1));
$('[data-zurueck]').addEventListener('click', () => blaettern(-1));
addEventListener('keydown', e => { if (e.target.closest('[role=slider]')) return; if (e.key === 'ArrowRight') blaettern(1); if (e.key === 'ArrowLeft') blaettern(-1); });
/* ---------- Vergleich Roh / Filmlook ---------- */
let split = 0.5, vergleich = false;
function setzeSplit(s) {
split = Math.min(0.98, Math.max(0.02, s));
teiler.style.left = `${split * 100}%`;
griff.setAttribute('aria-valuenow', Math.round(split * 100));
zeichnen(performance.now());
}
function setzeVergleich(an) {
vergleich = an;
$('[data-vergleich]').setAttribute('aria-pressed', an);
teiler.classList.toggle('an', an);
teiler.inert = !an; // unsichtbarer Griff bekommt keinen Fokus
setzeSplit(split);
}
$('[data-vergleich]').addEventListener('click', () => { stoppeVorfuehrung(); split = 0.5; setzeVergleich(!vergleich); });
let zieht = false;
griff.addEventListener('pointerdown', e => { zieht = true; griff.setPointerCapture(e.pointerId); stoppeVorfuehrung(); e.preventDefault(); });
griff.addEventListener('pointermove', e => { if (zieht) setzeSplit(e.clientX / innerWidth); });
griff.addEventListener('pointerup', () => { zieht = false; });
griff.addEventListener('keydown', e => {
const s = { ArrowLeft: -0.05, ArrowRight: 0.05, Home: -1, End: 1 }[e.key];
if (s !== undefined) { e.preventDefault(); stoppeVorfuehrung(); setzeSplit(split + s); }
});
/* ---------- Bildschleife ---------- */
let liveUhr = 0, laeuft = false, angehalten = false, letzte = performance.now(), zeit = 0, kornUhr = 0, mittel = 16;
const kam = { x: 0, y: 0 };
const tmp = new THREE.Vector3();
function kameraSetzen() {
const e = EINSTELLUNGEN[einstellung];
const still = reduziert();
// Hochformat: Kamera zurückziehen, damit Steg und Laterne im Bild bleiben
const f = Math.max(1, 0.6 / camera.aspect);
const amp = variante === 'tempo' ? W.parallax.tempo : W.parallax.kino;
const drift = still ? 0 : 1;
camera.position.set(
e.ziel[0] + (e.pos[0] - e.ziel[0]) * f + (Math.sin(zeit * 0.11) * 0.08 + kam.x * amp) * drift,
e.ziel[1] + (e.pos[1] - e.ziel[1]) * Math.min(f, 1.15) + (Math.sin(zeit * 0.09 + 1) * 0.03 - kam.y * amp * 0.4) * drift,
e.ziel[2] + (e.pos[2] - e.ziel[2]) * f + Math.sin(zeit * 0.07) * 0.06 * drift);
camera.fov = e.fov * (camera.aspect < 0.9 ? 1.3 : 1);
camera.updateProjectionMatrix();
camera.lookAt(tmp.set(...e.ziel));
}
function zeichnen(t) {
if (!renderer) return;
kameraSetzen();
wasser.material.uniforms.uKamera.value.copy(camera.position);
const u = post.uniforms, m = mobil();
if (variante === 'tempo' && !reduziert()) u.uAberr.value = W.aberr.min + (W.aberr.max - W.aberr.min) * Math.min(1, tempo / W.aberr.tempoVoll);
else u.uAberr.value = m ? 0 : W.aberr.kino;
anzeige.aberr = u.uAberr.value;
// Glitch-Hüllkurve: steigt bis 35 %, fällt bis 100 %
if (glitchStart >= 0) {
const p = (t - glitchStart) / W.glitch.ms;
if (p >= 1) { glitchStart = -1; u.uGlitch.value = 0; }
else {
u.uGlitch.value = p < W.glitch.spitze ? p / W.glitch.spitze : Math.pow(1 - (p - W.glitch.spitze) / (1 - W.glitch.spitze), 2);
u.uGSeed.value = Math.floor((t - glitchStart) / 60) * 7.31 + glitchStart % 97; // Bänder wechseln alle 60 ms, Helligkeit bleibt
}
} else u.uGlitch.value = 0;
// Abblende: 300 ms auf Schwarz, Schnitt, 300 ms auf (easeInOutQuad)
if (blendeStart >= 0) {
const p = (t - blendeStart) / W.blende.ms;
if (p >= 1) { blendeStart = -1; u.uSchwarz.value = 0; }
else u.uSchwarz.value = p < 0.5 ? kurve(p * 2) : 1 - kurve((p - 0.5) * 2);
} else u.uSchwarz.value = 0;
if (schnittFaellig >= 0 && t >= schnittFaellig) { schnittFaellig = -1; einstellung = schnittZiel; beschriften(); kameraSetzen(); wasser.material.uniforms.uKamera.value.copy(camera.position); }
u.uSplit.value = vergleich ? split : 0;
renderer.setRenderTarget(rtSzene); renderer.clear(); renderer.render(scene, camera);
bloomRechnen();
pass(post, null);
}
function bild(t) {
if (angehalten) { laeuft = false; return; }
const dt = Math.min(80, t - letzte); letzte = t;
if (!reduziert()) {
zeit += dt / 1000; zeitU.value = zeit;
// Laterne: ruhiges Atmen der Glühbirne, keine Blitze
lampenLicht.intensity = 6 * (0.96 + Math.sin(zeit * 2.1) * 0.025 + Math.sin(zeit * 5.3) * 0.015);
boot.rotation.z = Math.sin(zeit * 0.8) * 0.025; boot.position.y = 0.18 + Math.sin(zeit * 0.9 + 1) * 0.015;
const k = 1 - Math.pow(1 - W.parallax.lerp, dt / 16.667);
kam.x += ((maus.drin ? maus.nx : 0) - kam.x) * k; kam.y += ((maus.drin ? maus.ny : 0) - kam.y) * k;
tempo *= Math.pow(1 - W.aberr.abklingen, dt / 16.667);
kornUhr += dt; if (kornUhr > 1000 / W.korn.fps) { kornUhr = 0; post && (post.uniforms.uSeed.value = Math.random() * 100); }
if (vorf === null && autoSchnitt && t > autoSchnitt && variante === 'glitch') { autoSchnitt = t + 4200; schnitt((einstellung + 1) % EINSTELLUNGEN.length); }
}
zeichnen(t);
if (variante === 'tempo' && t - liveUhr > 120) { liveUhr = t; const l = document.querySelector('[data-live]'); if (l) l.textContent = anzeige.aberr.toFixed(4); }
if (reduziert()) { laeuft = false; return; }
mittel += (dt - mittel) * 0.1;
if (mittel > 45) setTimeout(() => requestAnimationFrame(bild), 60); else requestAnimationFrame(bild);
}
function starten() { if (renderer && !laeuft && !angehalten) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
/* ---------- Vorführung: Vergleich wischt, Zeiger erzeugt Tempo, Schnitte mit Burst ---------- */
let vorf = null, vorfStart = 0, phase = -1, schnitte = [];
const RUNDE = 17000;
function vorfuehren() {
stoppeVorfuehrung(true);
angehalten = false;
html.classList.add('vorfuehrung');
vorfStart = performance.now(); phase = -1;
if (reduziert() || !renderer) { // keine Bewegung: stehender Vergleich, Einstellungen wechseln hart
if (renderer) { split = 0.5; setzeVergleich(true); }
let i = 0;
vorf = { intervall: setInterval(() => { i = (i + 1) % 3; schnitt(i, true); }, 4000) };
return;
}
const schritt = t => {
const e = (t - vorfStart) % RUNDE;
const p = e < 5000 ? 0 : e < 10000 ? 1 : 2;
if (p !== phase) {
phase = p;
setzeVariante(['kino', 'tempo', 'glitch'][p]);
zeiger.classList.toggle('an', p === 1);
if (p === 0) { einstellung = 0; beschriften(); maus.drin = false; }
else if (vergleich) setzeVergleich(false);
if (p === 2) { maus.drin = false; schnitte = [10500, 13500, 16500]; }
}
// Phase 1: Vergleich wischt von 92 % auf 40 % (2400 ms easeInOutQuad), hält, dann weg
if (p === 0) {
if (e < 600) { if (!vergleich) setzeVergleich(true); setzeSplit(0.92); }
else if (e < 600 + W.wischer.ms) setzeSplit(0.92 - 0.52 * kurve((e - 600) / W.wischer.ms));
else if (e < 4600) setzeSplit(0.4);
else if (vergleich) setzeVergleich(false);
}
// Phase 2: gedachter Zeiger schwingt schnell über das Bild
if (p === 1) {
const s = (e - 5000) / 1000, r = held.getBoundingClientRect();
const x = r.width * (0.55 + 0.3 * Math.sin(s * 2.4) * Math.min(1, s)), y = r.height * (0.45 + 0.16 * Math.sin(s * 3.1 + 0.8));
bewegung(x, y, t);
zeiger.style.transform = `translate(${x + r.left - 2}px, ${y + r.top - 1}px)`;
}
// Phase 3: drei Schnitte im Abstand von 3 s, jeder mit Burst
if (p === 2 && schnitte.length && e >= schnitte[0]) { schnitte.shift(); letzterBurst = -1e9; schnitt((einstellung + 1) % EINSTELLUNGEN.length); }
vorf = requestAnimationFrame(schritt);
};
vorf = requestAnimationFrame(schritt);
starten();
}
function stoppeVorfuehrung(still) {
if (vorf && vorf.intervall) clearInterval(vorf.intervall); else if (vorf) cancelAnimationFrame(vorf);
vorf = null; zeiger.classList.remove('an');
if (!still) { html.classList.remove('vorfuehrung'); maus.drin = false; maus.lt = 0; }
}
function pause() { if (vorf && vorf.intervall) clearInterval(vorf.intervall); else if (vorf) cancelAnimationFrame(vorf); vorf = null; angehalten = true; }
function weiter() { angehalten = false; if (html.classList.contains('vorfuehrung')) vorfuehren(); else starten(); }
// Knopf „Anhalten/Abspielen“ (WCAG 2.2.2): dieselbe Pause wie FUNDUS_DEMO.pause(), das Bild bleibt stehen
const haltKnopf = $('[data-halt]');
const haltZeigen = an => { haltKnopf.setAttribute('aria-pressed', String(an)); haltKnopf.textContent = an ? 'Abspielen' : 'Anhalten'; };
haltKnopf.addEventListener('click', () => { if (angehalten) { haltZeigen(false); weiter(); } else { haltZeigen(true); pause(); } });
function neustart() {
stoppeVorfuehrung(); angehalten = false;
zeit = 0; kam.x = kam.y = 0; tempo = 0; glitchStart = -1; schnittFaellig = -1; blendeStart = -1;
if (renderer) setzeVergleich(false);
einstellung = 0; beschriften();
setzeVariante('kino');
}
/* ---------- Start ---------- */
addEventListener('resize', groesse);
beschriften();
if (renderer) { groesse(); starten(); } else zeigeWerte();
</script>
</body>
</html>