WebGL-Ladeszene mit echtem Fortschritt
Ein Konfigurator, der ehrlich lädt: Prozent nach Bytes, Dateiliste mit Häkchen, dann taucht die Leuchte aus dem Dunkel auf.
- Ein Vollbild-Quad mit Shader (oder ein DOM-Overlay) verdeckt die 3D-Szene.
- THREE.LoadingManager meldet, welche Dateien fertig sind (Modelle, Texturen, Umgebungsbild). Ein Balken (scaleX) und ein Zähler zeigen den Fortschritt.
- Ist alles geladen, wird die Szene kompiliert und enthüllt: Alpha-Uniform von 1 auf 0, Noise-Dissolve oder Pixelraster.
- Gleichzeitig fliegt die Kamera aus der Dunkelheit auf ihre Startposition.
Die Wartezeit ist ehrlich: Man sieht, dass etwas passiert und wie lange es noch dauert. Die Enthüllung macht aus dem Warten einen Auftakt. Die Szene erscheint fertig und ruckelfrei, statt Stück für Stück aufzubauen.
Wann einsetzen
Bei 3D-lastigen Seiten, deren Szene vor der ersten Interaktion vollständig geladen sein muss: Produktkonfiguratoren, immersive Portfolios, Kampagnen. Im Shop nur für die 3D-Bühne, nie für die ganze Seite.
Wann nicht
Wenn die Szene progressiv laden kann (erst Platzhalter oder Low-Poly, dann Details). Das ist dem Ladebildschirm vorzuziehen. Nicht vor normalen Inhalten wie Text, Produktbildern oder Kaufbox.
Varianten
- Shader-Overlay mit uAlpha-Fade, 3000 ms (Three.js Journey)
- Noise-Dissolve des Overlays mit glühender Kante
- Pixelraster: Zellen bis 42 px verschwinden diagonal, expoInOut (Lusion)
- Progressives Laden ohne Ladebildschirm (Platzhaltergeometrie)
- Kleinere Szene statt Ladebildschirm: ZERO hat laut Codrops-Artikel über 1 GB 3D-Assets auf unter 10 MB komprimiert
Umsetzung
const manager = new THREE.LoadingManager(onLoad, (url, geladen, gesamt) => { … }); new GLTFLoader(manager), new TextureLoader(manager) usw. In onLoad: renderer.compile(scene, camera), dann gsap.to(overlay.uniforms.uAlpha, { value: 0, duration: 3 }) wie bei Three.js Journey. Ohne GSAP genügt ein eigener Tween in der Bildschleife (die Demo nutzt cubic-bezier(0.25, 0.1, 0.25, 1)).
Fallstrick: onProgress zählt Dateien, nicht Bytes. Bei einer großen GLB steht der Balken lange still und springt dann. Besser: Dateigrößen aus einem Build-Manifest nehmen und den Byte-Fortschritt pro Datei (XHR-progress) addieren. Die Anzeige per Lerp glätten und 100 % kurz stehen lassen, bevor enthüllt wird. Draco oder Meshopt (Geometrie) und KTX2 (Texturen) verkürzen die Ladezeit. Shader-Kompilierung nach dem Laden kann ruckeln, deshalb renderer.compile() vor der Enthüllung. Die Overlay-Farbe im Shader mit #include <colorspace_fragment> ausgeben, sonst wirkt sie zu dunkel.
Robust umsetzen
Telefon. Leichtere Assets ausliefern (Geräteklasse erkennen, kleinere Texturen). Auf sehr schwachen Geräten oder ohne WebGL ein statisches Bild oder Video zeigen. Pixelratio begrenzen (höchstens 2).
Ohne Bewegung. Kein Kameraflug. Die Szene per kurzem Fade (bis 300 ms) oder sofort zeigen.
Barrierefrei. Balken mit role=progressbar und aria-valuenow. Textliche Beschreibung der Szeneninhalte. Ein Knopf „Ohne 3D ansehen“ bricht das Laden ab und zeigt ein Bild. Die übrige Seite (Kaufbox, Text) ist während des Ladens voll bedienbar.
Code
<!DOCTYPE html>
<!--
WebGL-Ladeszene mit echtem Fortschritt
Fundus · Preloader & Intro · https://cecodesign.de/lab/fundus/preloader-intro/webgl-ladeszene-fortschritt/
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>WebGL-Ladeszene mit echtem Fortschritt · 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: 'Young Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/young-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept d2c-produkt-zuerst: warme Neutraltöne, fast schwarze Tinte, Grün für „verfügbar“ */
--grund: #f5f4f1; --kachel: #ece9e2; --tinte: #212121; --leise: #6b6b6b; --linie: rgba(33, 33, 33, .12);
--gruen: #10714e; --ton: #a2503a;
--nacht: #1f1c1a; --nacht-text: #f1ece4; --nacht-leise: rgba(241, 236, 228, .56);
--glut: #e8824a;
--auf: cubic-bezier(0, 0, 0.2, 1); /* Öffnen (Rezept) */
--zu: cubic-bezier(0.4, 0, 1, 1); /* Schließen (Rezept) */
--rand: clamp(16px, 3.3vw, 48px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
button { font: inherit; color: inherit; }
a { color: inherit; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.ziffern { font-variant-numeric: tabular-nums; }
/* ---------- Kopf ---------- */
.leiste { background: var(--tinte); color: #f5f4f1; text-align: center; font-size: 12.5px; padding: 8px var(--rand); letter-spacing: .01em; }
.leiste b { font-weight: 600; }
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; padding: 16px var(--rand); border-bottom: 1px solid var(--linie); background: var(--grund); }
.kopf nav { display: flex; gap: 26px; font-size: 14.5px; font-weight: 500; }
.kopf nav a { text-decoration: none; color: var(--leise); transition: color .15s var(--auf); }
.kopf nav a:hover, .kopf nav a:focus-visible, .kopf nav a[aria-current] { color: var(--tinte); }
.marke { display: flex; align-items: center; gap: 10px; text-decoration: none; justify-self: center; }
.marke svg { width: 26px; height: 26px; }
.marke span { font: 400 21px/1 'Young Serif', Georgia, serif; letter-spacing: -.01em; }
.werkzeug { justify-self: end; display: flex; align-items: center; gap: 6px; }
.werkzeug button { height: 38px; min-width: 38px; border: 0; background: none; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 10px; cursor: pointer; font-size: 14.5px; font-weight: 500; transition: background-color .15s var(--auf); }
.werkzeug button:hover, .werkzeug button:focus-visible { background: var(--kachel); }
.werkzeug button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.werkzeug svg { width: 19px; height: 19px; }
.korb-zahl { min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--tinte); color: #fff; font-size: 11.5px; font-weight: 600; display: inline-grid; place-items: center; transition: transform .2s var(--auf); }
.korb-zahl.hupf { transform: scale(1.25); }
.pfad { padding: 14px var(--rand) 0; font-size: 13px; color: var(--leise); display: flex; gap: 8px; }
.pfad a { text-decoration: none; }
.pfad a:hover { color: var(--tinte); }
/* ---------- Produkt: Bühne + Kaufbox ---------- */
.produkt { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(340px, 1fr); gap: clamp(24px, 3.4vw, 56px); padding: 14px var(--rand) clamp(40px, 6vw, 80px); align-items: start; }
.spalte { display: grid; gap: 12px; min-width: 0; }
.buehne { position: relative; height: clamp(440px, calc(100svh - 280px), 760px); border-radius: 6px; overflow: hidden; isolation: isolate;
background: radial-gradient(60% 55% at 50% 62%, #f6efe2 0%, rgba(246, 239, 226, 0) 70%), var(--kachel); touch-action: pan-y; cursor: grab; outline: none; }
.buehne:active { cursor: grabbing; }
.buehne:focus-visible { box-shadow: 0 0 0 2px var(--tinte); }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* Ersatzbild (ohne WebGL oder „Ohne 3D ansehen“) */
.ersatz { position: absolute; inset: 0; display: grid; place-items: center; opacity: 0; pointer-events: none; transition: opacity .4s var(--auf); }
.ersatz svg { height: 78%; width: auto; max-width: 86%; }
.ersatz-an .ersatz { opacity: 1; }
.ersatz-an canvas { visibility: hidden; }
/* DOM-Decke nur ohne WebGL (sonst übernimmt das Shader-Quad) */
.decke { position: absolute; inset: 0; background: var(--nacht); opacity: 0; pointer-events: none; }
.ohne-gl .decke { opacity: 1; }
.ohne-gl .decke.weg { opacity: 0; transition: opacity .6s var(--auf); }
/* Ladeanzeige über der Szene */
.lader { position: absolute; inset: 0; z-index: 2; color: var(--nacht-text); display: grid; grid-template-rows: auto 1fr auto; padding: clamp(18px, 2.6vw, 30px); transition: opacity .3s var(--auf); }
.lader.weg { opacity: 0; pointer-events: none; visibility: hidden; transition: opacity .3s var(--zu), visibility 0s .3s; }
.lader-kopf { display: flex; justify-content: space-between; gap: 16px; color: var(--nacht-leise); }
.lader-mitte { align-self: center; display: grid; justify-items: center; gap: 14px; text-align: center; }
.lader-claim { font: 400 clamp(22px, 2.3vw, 32px)/1.1 'Young Serif', Georgia, serif; letter-spacing: -.01em; }
.lader-zahl { font-size: clamp(64px, 8vw, 120px); font-weight: 500; letter-spacing: -.045em; line-height: .9; font-variant-numeric: tabular-nums; }
.lader-zahl small { font-size: .32em; letter-spacing: 0; margin-left: .08em; vertical-align: .95em; color: var(--nacht-leise); font-weight: 500; }
.balken { position: relative; width: min(320px, 70%); height: 1px; margin-top: 10px; }
.balken::before { content: ''; position: absolute; inset: 0; background: currentColor; opacity: .2; }
.balken i { position: absolute; inset: 0; background: currentColor; transform-origin: 0 0; transform: scaleX(0); }
.lader-fuss { display: flex; justify-content: space-between; align-items: end; gap: 20px; }
.dateien { list-style: none; padding: 0; display: grid; gap: 3px; font: 400 10.5px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .04em; color: var(--nacht-leise); }
.dateien li { display: grid; grid-template-columns: 12px 1fr auto; gap: 10px; min-width: 250px; }
.dateien li span:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.dateien li::before { content: '·'; }
.dateien li.fertig { color: var(--nacht-text); }
.dateien li.fertig::before { content: '✓'; color: var(--glut); }
.lader-rechts { display: grid; justify-items: end; gap: 10px; text-align: right; color: var(--nacht-leise); }
.lader-rechts b { color: var(--nacht-text); font-weight: 500; }
.ohne3d { color: var(--nacht-text); background: none; border: 1px solid rgba(241, 236, 228, .3); border-radius: 999px; height: 34px; padding: 0 14px; cursor: pointer; letter-spacing: inherit; text-transform: inherit; font: inherit; transition: border-color .15s var(--auf), background-color .15s var(--auf); }
.ohne3d:hover, .ohne3d:focus-visible { border-color: var(--nacht-text); background: rgba(241, 236, 228, .08); }
.ohne3d:focus-visible { outline: 2px solid var(--nacht-text); outline-offset: 2px; }
/* Nach der Enthüllung */
.etikett { position: absolute; left: clamp(16px, 2.2vw, 26px); top: clamp(16px, 2.2vw, 24px); z-index: 1; display: grid; gap: 4px; opacity: 0; transition: opacity .4s var(--auf); }
.etikett b { font-size: 15px; font-weight: 600; letter-spacing: -.005em; }
.etikett span { color: var(--leise); }
.hinweis { position: absolute; left: 50%; bottom: 18px; translate: -50% 0; z-index: 1; display: flex; align-items: center; gap: 10px; color: var(--leise); background: rgba(245, 244, 241, .72); backdrop-filter: blur(8px); border-radius: 999px; padding: 8px 14px; opacity: 0; transition: opacity .4s var(--auf); white-space: nowrap; }
.hinweis svg { width: 18px; height: 12px; }
.bereit .etikett, .bereit .hinweis { opacity: 1; }
.ersatz-an .hinweis { display: none; }
.buehne-knoepfe { position: absolute; right: clamp(12px, 2vw, 20px); top: clamp(12px, 2vw, 20px); z-index: 1; display: flex; gap: 8px; }
.neu-laden, .ruhe { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--linie); background: rgba(245, 244, 241, .7); backdrop-filter: blur(8px); cursor: pointer; color: var(--tinte); letter-spacing: inherit; text-transform: inherit; font: inherit; opacity: 0; visibility: hidden; transition: opacity .4s var(--auf), visibility 0s .4s, border-color .15s var(--auf), background-color .15s var(--auf), color .15s var(--auf); }
.neu-laden svg, .ruhe svg { width: 13px; height: 13px; }
.neu-laden:hover, .neu-laden:focus-visible, .ruhe:hover, .ruhe:focus-visible { border-color: var(--tinte); }
.neu-laden:focus-visible, .ruhe:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.bereit .neu-laden, .ersatz-an .neu-laden, .bereit .ruhe { opacity: 1; visibility: visible; transition-delay: 0s; }
/* Pause (WCAG 2.2.2): Leuchte dreht und pendelt sonst endlos */
.ruhe .zeichen-spiel, .ruhe[aria-pressed='true'] .zeichen-pause { display: none; }
.ruhe[aria-pressed='true'] .zeichen-spiel { display: block; }
.ruhe[aria-pressed='true'] { background: var(--tinte); color: var(--grund); border-color: var(--tinte); }
.vorschau .ruhe { display: none; }
/* Demo-Bedienung unter der Bühne */
.steuer { display: grid; gap: 10px; color: var(--leise); }
.modi { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.modi > span { margin-right: 6px; }
.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 var(--auf), color .15s var(--auf); }
.modi button:hover, .modi button:focus-visible { color: var(--tinte); border-color: var(--leise); }
.modi button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--grund); border-color: var(--tinte); }
.werte { display: flex; flex-wrap: wrap; gap: 3px 16px; font-size: 10.5px; }
.werte b { color: var(--tinte); font-weight: 500; }
/* Kaufbox */
.kaufbox { position: sticky; top: 20px; display: grid; gap: 18px; padding-top: 6px; }
.kaufbox .art { color: var(--leise); font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
.kaufbox h1 { font-size: clamp(26px, 1.4rem + 1vw, 36px); font-weight: 600; letter-spacing: -.02em; line-height: 1.05; margin-top: -8px; }
.sterne { display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--leise); }
.sterne svg { width: 82px; height: 14px; color: var(--tinte); }
.sterne a { text-underline-offset: 3px; }
.preis { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.preis b { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
.preis span { font-size: 13px; color: var(--leise); }
.text { font-size: 15.5px; line-height: 1.55; color: #3d3b38; max-width: 46ch; }
.wahl { display: grid; gap: 10px; border-top: 1px solid var(--linie); padding-top: 16px; }
.wahl-kopf { display: flex; justify-content: space-between; font-size: 14px; }
.wahl-kopf span { color: var(--leise); }
.wahl-kopf b { font-weight: 600; }
.glasuren { display: flex; gap: 10px; }
.glasuren button { width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--linie); background: var(--c); cursor: pointer; position: relative; padding: 0; transition: transform .15s var(--auf); }
.glasuren button::after { content: ''; position: absolute; inset: -5px; border-radius: 50%; border: 1.5px solid var(--tinte); opacity: 0; transform: scale(.9); transition: opacity .15s var(--auf), transform .2s var(--auf); }
.glasuren button[aria-pressed='true']::after { opacity: 1; transform: none; }
.glasuren button:hover { transform: scale(1.06); }
.glasuren button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 8px; }
.groessen { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.groessen button { height: 48px; border-radius: 8px; border: 1px solid var(--linie); background: #fff; cursor: pointer; font-size: 14.5px; font-weight: 500; display: flex; justify-content: center; align-items: center; gap: 8px; transition: border-color .15s var(--auf), box-shadow .15s var(--auf); }
.groessen button span { color: var(--leise); font-weight: 400; font-variant-numeric: tabular-nums; }
.groessen button[aria-pressed='true'] { border-color: var(--tinte); box-shadow: inset 0 0 0 1px var(--tinte); }
.groessen button:hover, .groessen button:focus-visible { border-color: var(--tinte); }
.groessen button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.kaufen { height: 56px; border-radius: 999px; border: 0; background: var(--tinte); color: #fff; font-size: 16px; font-weight: 600; cursor: pointer; display: flex; justify-content: center; align-items: center; gap: 10px; transition: background-color .15s var(--auf), transform .15s var(--auf); }
.kaufen:hover, .kaufen:focus-visible { background: #000; }
.kaufen:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.kaufen:active { transform: scale(.985); }
.lager { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.lager i { width: 8px; height: 8px; border-radius: 50%; background: var(--gruen); box-shadow: 0 0 0 4px rgba(16, 113, 78, .14); }
.lager span { color: var(--leise); }
.vertrauen { list-style: none; padding: 16px 0 0; border-top: 1px solid var(--linie); display: grid; gap: 10px; font-size: 14px; }
.vertrauen li { display: flex; align-items: center; gap: 12px; }
.vertrauen svg { width: 20px; height: 20px; flex: none; color: var(--leise); }
/* Toast „In den Warenkorb“ */
.toast { position: fixed; right: var(--rand); bottom: 24px; z-index: 9; display: flex; gap: 14px; align-items: center; background: var(--tinte); color: #f5f4f1; border-radius: 12px; padding: 14px 18px; font-size: 14px; box-shadow: 0 18px 40px -18px rgba(0, 0, 0, .45);
opacity: 0; transform: translateY(12px); pointer-events: none; transition: opacity .2s var(--zu), transform .2s var(--zu); }
.toast.an { opacity: 1; transform: none; transition: opacity .25s var(--auf), transform .25s var(--auf); }
.toast span { color: rgba(245, 244, 241, .6); }
/* ---------- Unter dem Produkt ---------- */
.werkstatt { display: grid; gap: clamp(36px, 5vw, 72px); padding: clamp(56px, 8vw, 110px) var(--rand); border-top: 1px solid var(--linie); }
.werkstatt h2 { font: 400 clamp(2.25rem, 1.4rem + 3.6vw, 4.5rem)/1.05 'Young Serif', Georgia, serif; letter-spacing: -.01em; text-wrap: balance; max-width: 15ch; }
.werkstatt h2 em { font-style: normal; color: var(--ton); }
.fakten { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(20px, 3vw, 48px); max-width: 1100px; }
.fakten div { border-top: 1px solid var(--tinte); padding-top: 14px; display: grid; gap: 8px; }
.fakten b { font-size: clamp(26px, 2.4vw, 36px); font-weight: 600; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.fakten p { font-size: 14.5px; line-height: 1.5; color: var(--leise); }
/* Gedachter Zeiger in der Vorführung */
.zeiger { position: fixed; z-index: 8; left: 0; top: 0; width: 28px; height: 28px; margin: -14px 0 0 -14px; border: 1.5px solid var(--tinte); border-radius: 50%; pointer-events: none; display: none; transition: transform .9s cubic-bezier(0.16, 1, 0.3, 1), scale .15s var(--auf), background-color .15s var(--auf); }
.zeiger.drueckt { scale: .72; background: rgba(33, 33, 33, .18); }
.vorfuehrung .zeiger { display: block; }
/* ---------- Vorschau 960 × 720 ---------- */
.vorschau body { overflow: hidden; height: 100vh; }
.vorschau .leiste, .vorschau .pfad, .vorschau .werkstatt, .vorschau .kopf nav, .vorschau .vertrauen, .vorschau .text, .vorschau .werte, .vorschau .sterne, .vorschau .lager, .vorschau .werkzeug button:not(.korb) { display: none; }
.vorschau .kopf { grid-template-columns: 1fr auto; padding: 14px 28px; }
.vorschau .marke { justify-self: start; }
.vorschau .produkt { grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); padding: 18px 28px; gap: 28px; }
.vorschau .buehne { height: calc(100vh - 150px); }
.vorschau .kaufbox { gap: 16px; padding-top: 10px; }
.vorschau .steuer { justify-content: flex-start; }
.vorschau .lader-claim { font-size: 24px; }
.vorschau .lader-zahl { font-size: 96px; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
@media (max-width: 1100px) {
.produkt { grid-template-columns: minmax(0, 1.3fr) minmax(300px, 1fr); }
.kopf nav { gap: 18px; }
}
@media (max-width: 860px) {
.kopf { grid-template-columns: auto 1fr; }
.kopf nav { display: none; }
.marke { justify-self: start; }
.produkt { grid-template-columns: 1fr; }
.buehne { height: auto; aspect-ratio: 4 / 5; max-height: 640px; }
.kaufbox { position: static; }
.werkstatt { grid-template-columns: 1fr; }
.steuer { flex-direction: column; align-items: stretch; }
.werte { justify-content: flex-start; text-align: left; }
}
@media (max-width: 560px) {
.leiste-mehr { display: none; }
.werkzeug .such, .werkzeug .merk { display: none; }
.marke span { font-size: 19px; }
.pfad { font-size: 12px; }
.lader-kopf span:last-child { display: none; }
.lader-fuss { flex-direction: column; align-items: stretch; gap: 14px; }
.dateien { display: none; }
.lader-rechts { justify-items: start; text-align: left; grid-template-columns: 1fr auto; align-items: center; }
.lader-rechts p:first-child { grid-column: 1 / -1; }
.fakten { grid-template-columns: 1fr; gap: 18px; }
.hinweis { bottom: 12px; }
.etikett b { font-size: 14px; }
.etikett span { display: none; }
.ruhe { width: 34px; padding: 0; justify-content: center; }
.ruhe span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } /* schmal: nur Zeichen, Name bleibt für Screenreader */
.werte { font-size: 10px; }
}
@media (max-width: 360px) { .buehne-knoepfe { flex-direction: column-reverse; align-items: flex-end; } }
@media (hover: none) {
.buehne { cursor: default; }
.glasuren button { width: 44px; height: 44px; }
.glasuren button:hover { transform: none; }
}
</style>
</head>
<body>
<p class="leiste">Versandkostenfrei ab <b>80 €</b><span class="leiste-mehr"> · Jede Leuchte handgedreht in Trier-Olewig</span></p>
<header class="kopf">
<nav aria-label="Hauptmenü"><a href="#leuchten" aria-current="page">Leuchten</a><a href="#geschirr">Geschirr</a><a href="#vasen">Vasen</a><a href="#werkstatt">Werkstatt</a></nav>
<a class="marke" href="#start" aria-label="Tonwerk Olewig, Startseite">
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true"><circle cx="13" cy="13" r="12" stroke="currentColor" stroke-width="1.4"/><path d="M8 19c0-6 2-10 5-12 3 2 5 6 5 12" stroke="currentColor" stroke-width="1.4"/><path d="M6.5 19h13" stroke="currentColor" stroke-width="1.4"/></svg>
<span>Tonwerk Olewig</span>
</a>
<div class="werkzeug">
<button type="button" class="such" aria-label="Suche"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="9" cy="9" r="6"/><path d="m13.5 13.5 4 4"/></svg></button>
<button type="button" class="merk" aria-label="Merkliste"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M10 16.5s-6.5-3.8-6.5-8.3A3.5 3.5 0 0 1 10 6.3a3.5 3.5 0 0 1 6.5 1.9c0 4.5-6.5 8.3-6.5 8.3Z"/></svg></button>
<button type="button" class="korb" aria-label="Warenkorb"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M4 6.5h12l-1 10H5l-1-10Z"/><path d="M7.5 6.5V5a2.5 2.5 0 0 1 5 0v1.5"/></svg><span class="korb-zahl" data-korb>0</span></button>
</div>
</header>
<p class="pfad"><a href="#leuchten">Leuchten</a> / <a href="#pendel">Pendelleuchten</a> / <span>Olewig No. 3</span></p>
<main class="produkt" id="start">
<div class="spalte">
<div class="buehne" data-buehne tabindex="0" aria-label="3D-Ansicht der Pendelleuchte. Ziehen oder Pfeiltasten drehen die Ansicht.">
<canvas data-canvas aria-hidden="true"></canvas>
<div class="ersatz" aria-hidden="true">
<svg viewBox="0 0 400 520" data-ersatz>
<defs>
<linearGradient id="e-glasur" x1="0" x2="1"><stop offset="0" stop-color="#000" stop-opacity=".28"/><stop offset=".35" stop-color="#fff" stop-opacity=".18"/><stop offset=".55" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".34"/></linearGradient>
<radialGradient id="e-licht" cx=".5" cy="0" r="1"><stop offset="0" stop-color="#ffe2b0" stop-opacity=".8"/><stop offset="1" stop-color="#ffd59a" stop-opacity="0"/></radialGradient>
</defs>
<path d="M197 0h6v212h-6z" fill="#2a2522"/>
<path d="M183 206h34l3 24h-40Z" fill="#b08a4e"/>
<path d="M64 416h272l64 104H0Z" fill="url(#e-licht)"/>
<path data-ersatz-glasur d="M180 231A146 141 0 0 0 54 372v30h292v-30A146 141 0 0 0 220 231Z" fill="#3e4652"/>
<path d="M180 231A146 141 0 0 0 54 372v30h292v-30A146 141 0 0 0 220 231Z" fill="url(#e-glasur)"/>
<path d="M54 402h292l3 14H51Z" fill="#c9a98a"/>
<ellipse cx="200" cy="416" rx="146" ry="9" fill="#fff4dc"/>
</svg>
</div>
<div class="decke" data-decke></div>
<div class="lader" data-lader>
<div class="lader-kopf mono"><span>3D-Ansicht · Olewig No. 3</span><span data-phase>Lädt</span></div>
<div class="lader-mitte">
<p class="lader-claim">Licht aus Moselton.</p>
<p class="lader-zahl" aria-hidden="true"><span data-zahl>0</span><small>%</small></p>
<div class="balken" role="progressbar" aria-label="3D-Ansicht lädt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-balken><i data-fuellung></i></div>
</div>
<div class="lader-fuss">
<ul class="dateien" data-dateien aria-hidden="true"></ul>
<div class="lader-rechts mono">
<p>Dateien <b class="ziffern" data-dateizahl>0 / 8</b> · <b class="ziffern" data-bytes>0,0 / 3,9 MB</b></p>
<button type="button" class="ohne3d" data-ohne3d>Ohne 3D ansehen</button>
</div>
</div>
</div>
<div class="etikett"><b data-etikett-name>Glasur Moselschiefer</b><span class="mono" data-etikett-mass>Ø 32 cm · Höhe 28 cm</span></div>
<div class="buehne-knoepfe">
<button type="button" class="ruhe mono" aria-pressed="false" data-ruhe><svg class="zeichen-pause" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1h3v10H2zM7 1h3v10H7z" fill="currentColor"/></svg><svg class="zeichen-spiel" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 1l9 5-9 5z" fill="currentColor"/></svg><span>Bewegung anhalten</span></button>
<button type="button" class="neu-laden mono" data-neu><svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M12 7a5 5 0 1 1-1.5-3.6M12 1.8v2.6H9.4"/></svg><span data-neu-text>Neu laden</span></button>
</div>
<p class="hinweis mono" aria-hidden="true"><svg viewBox="0 0 18 12" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1 6h16M4 3 1 6l3 3M14 3l3 3-3 3"/></svg>Ziehen zum Drehen</p>
</div>
<div class="steuer mono">
<div class="modi" role="group" aria-label="Enthüllung wählen" data-modi>
<span>Enthüllung</span>
<button type="button" data-modus="fade" aria-pressed="true">Fade · uAlpha</button>
<button type="button" data-modus="noise" aria-pressed="false">Noise-Dissolve</button>
<button type="button" data-modus="raster" aria-pressed="false">Pixelraster</button>
</div>
<p class="werte" data-werte></p>
</div>
</div>
<aside class="kaufbox" aria-labelledby="titel">
<p class="art">Pendelleuchte · Steinzeug</p>
<h1 id="titel">Olewig No. 3</h1>
<p class="sterne"><svg viewBox="0 0 82 14" aria-hidden="true"><g fill="currentColor"><path id="st" d="m7 0 1.7 4.6 4.9.2-3.8 3 1.3 4.7L7 9.8l-4.1 2.7 1.3-4.7-3.8-3 4.9-.2Z"/><use href="#st" x="17"/><use href="#st" x="34"/><use href="#st" x="51"/><use href="#st" x="68"/></g></svg><span>4,8 · <a href="#bewertungen">212 Bewertungen</a></span></p>
<p class="preis"><b data-preis>189,00 €</b><span>inkl. MwSt., zzgl. Versand</span></p>
<p class="text">Auf der Scheibe gedreht aus Westerwälder Steinzeugton und zweimal gebrannt. Der Rand bleibt unglasiert, damit man sieht, woraus die Leuchte gemacht ist.</p>
<div class="wahl">
<p class="wahl-kopf"><span>Glasur</span><b data-glasur-name>Moselschiefer</b></p>
<div class="glasuren" role="group" aria-label="Glasur wählen" data-glasuren></div>
</div>
<div class="wahl">
<p class="wahl-kopf"><span>Größe</span><b data-groesse-name>M · Ø 32 cm</b></p>
<div class="groessen" role="group" aria-label="Größe wählen">
<button type="button" data-groesse="s" aria-pressed="false">S <span>Ø 24 cm</span></button>
<button type="button" data-groesse="m" aria-pressed="true">M <span>Ø 32 cm</span></button>
</div>
</div>
<button type="button" class="kaufen" data-kaufen>In den Warenkorb</button>
<p class="lager"><i aria-hidden="true"></i>Auf Lager <span>· Versand in 3–5 Werktagen</span></p>
<ul class="vertrauen">
<li><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M3 10a7 7 0 1 0 2-4.9M3 3v3.5h3.5"/></svg>30 Tage Rückgabe, Abholung kostenlos</li>
<li><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M10 2v5M7 7h6l-1 4H8L7 7ZM8.5 11v3.5a1.5 1.5 0 0 0 3 0V11"/></svg>Fassung E27, Textilkabel 1,5 m, kürzbar</li>
<li><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M2 6h10v8H2zM12 9h3.5L18 11.5V14h-6"/><circle cx="5.5" cy="15" r="1.5"/><circle cx="14.5" cy="15" r="1.5"/></svg>Bruchsicher verpackt in Holzwolle</li>
</ul>
</aside>
</main>
<section class="werkstatt" id="werkstatt">
<h2>Jede Glasur läuft <em>ein bisschen</em> anders.</h2>
<div class="fakten">
<div><b class="ziffern">1.240 °C</b><p>Im zweiten Brand schmilzt die Glasur. Wo sie dicker steht, wird sie dunkler.</p></div>
<div><b class="ziffern">3 Wochen</b><p>Drehen, trocknen, schrühen, glasieren, brennen. Dann erst geht die Leuchte raus.</p></div>
<div><b class="ziffern">2 Hände</b><p>Laura und Matthis drehen jede Leuchte selbst, in der Werkstatt am Olewiger Bach.</p></div>
</div>
</section>
<div class="toast" role="status" aria-live="polite" data-toast></div>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
<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 $ = s => document.querySelector(s);
const buehne = $('[data-buehne]'), canvas = $('[data-canvas]'), lader = $('[data-lader]');
const zahlEl = $('[data-zahl]'), fuellung = $('[data-fuellung]'), balken = $('[data-balken]');
const dateizahlEl = $('[data-dateizahl]'), bytesEl = $('[data-bytes]'), dateienEl = $('[data-dateien]'), phaseEl = $('[data-phase]');
const werteEl = $('[data-werte]'), zeiger = $('[data-zeiger]'), toast = $('[data-toast]'), decke = $('[data-decke]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];
/* ---------- Produktdaten ---------- */
const GLASUREN = [
{ id: 'kalk', name: 'Kalkweiß', farbe: '#e6e0d4', hell: true },
{ id: 'schiefer', name: 'Moselschiefer', farbe: '#3e4652' },
{ id: 'rotliegend', name: 'Rotliegend', farbe: '#9e4c37' },
{ id: 'reb', name: 'Rebgrün', farbe: '#6c7650' },
];
const GROESSEN = { s: { name: 'S · Ø 24 cm', mass: 'Ø 24 cm · Höhe 21 cm', preis: '159,00 €', skala: 0.8 }, m: { name: 'M · Ø 32 cm', mass: 'Ø 32 cm · Höhe 28 cm', preis: '189,00 €', skala: 1 } };
let glasur = 1, groesse = 'm', korb = 0;
/* ---------- Werte (meta.json → messwerte/kurve) ----------
Three.js Journey: uAlpha 1 → 0, 3000 ms. Kurve laut meta.kurve: ease (0.25, 0.1, 0.25, 1).
Lusion: ease.expoInOut, Pixelraster min(42 px, Breite / 30). Immersive Garden: Fortschrittslinie 1 px, Spur opacity .2. */
function bezier(x1, y1, x2, y2) {
const b = (u, a, c) => 3 * (1 - u) * (1 - u) * u * a + 3 * (1 - u) * u * u * c + u * u * u;
return t => { if (t <= 0) return 0; if (t >= 1) return 1; let lo = 0, hi = 1, u = t; for (let i = 0; i < 22; i++) { u = (lo + hi) / 2; if (b(u, x1, x2) < t) lo = u; else hi = u; } return b(u, y1, y2); };
}
const ease = bezier(0.25, 0.1, 0.25, 1);
const expoInOut = t => t <= 0 ? 0 : t >= 1 ? 1 : t < .5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2;
const MODI = {
fade: { nr: 0, dauer: 3000, kamera: 3000, kurve: ease, werte: [['Overlay', 'uAlpha 1 → 0'], ['Dauer', '3000 ms'], ['Kurve', 'ease (0.25, 0.1, 0.25, 1)']] },
noise: { nr: 1, dauer: 3000, kamera: 3000, kurve: ease, werte: [['Overlay', 'uProgress 0 → 1'], ['Dauer', '3000 ms'], ['Kurve', 'ease (0.25, 0.1, 0.25, 1)'], ['Maske', 'fbm 4 Okt. × 2.6 + 30 % von unten'], ['Glutkante', '0.035 · #e8824a']] },
raster: { nr: 2, dauer: 3000, kamera: 3000, kurve: expoInOut, werte: [['Overlay', 'uProgress 0 → 1'], ['Dauer', '3000 ms'], ['Kurve', 'expoInOut'], ['Raster', 'min(42, Breite / 30) px'], ['Reihenfolge', '55 % Diagonale, 45 % Zufall']] },
};
const REDUZIERT = { nr: 0, dauer: 300, kamera: 0, kurve: t => t, werte: [['Reduziert', 'uAlpha 1 → 0 · 300 ms linear'], ['Kamera', 'steht auf Endposition']], ohneKamera: true };
const NETZ = { bandbreite: 1900, latenz: 90 }; // KB/s und ms, simuliert; alle Dateien starten parallel (HTTP/2)
const HALT = 350; // ms, die 100 % stehen bleiben
let haltStart = 0;
const GLAETTUNG = 8; // Anzeige folgt dem Byte-Fortschritt mit 1 − e^(−8·dt)
let modus = 'fade';
const aktuell = () => reduziert() ? REDUZIERT : MODI[modus];
/* ---------- Pausierbare Uhr: Download, Tweens und Vorführung laufen darauf ---------- */
let uhrZeit = 0, uhrLetzt = performance.now(), angehalten = false;
const jetzt = () => uhrZeit;
let rohDt = 0; // ungebremste Bildzeit: Ziehen bleibt auch angehalten möglich
function uhrTick(t) { const dt = Math.min(250, t - uhrLetzt); uhrLetzt = t; rohDt = dt; if (!angehalten) uhrZeit += dt; return angehalten ? 0 : dt; }
/* ---------- Werte-Anzeige ---------- */
function zeigeWerte() {
const m = aktuell();
const dazu = m.ohneKamera ? [] : [['Kamera', 'Abstand 11 → 6 · Belichtung 0.12 → 1 · 3000 ms']];
werteEl.innerHTML = [...m.werte, ...dazu, ['Balken', '1 px · Spur .2 · Bytes, geglättet 1 − e^(−8·dt)']].map(([k, v]) => `<span>${k} <b>${v}</b></span>`).join('') + `<span>Netz <b>simuliert ${(NETZ.bandbreite / 1000).toFixed(1).replace('.', ',')} MB/s</b></span>`;
knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === modus)));
}
/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null;
const probe = document.createElement('canvas');
const probeGl = probe.getContext('webgl2') || probe.getContext('webgl');
let langsam = false; // Software-Rendering (SwiftShader, llvmpipe): weniger Pixel, keine Kantenglättung
if (probeGl) {
const info = probeGl.getExtension('WEBGL_debug_renderer_info');
langsam = /swiftshader|llvmpipe|software/i.test(info ? probeGl.getParameter(info.UNMASKED_RENDERER_WEBGL) : '');
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: !langsam, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
if (!renderer) buehne.classList.add('ohne-gl');
const szene = new THREE.Scene();
const kamera = new THREE.PerspectiveCamera(30, 1, 0.1, 60);
const ziel = new THREE.Vector3(0, -0.34, 0);
const KAM_START = { abstand: 11, hoehe: -0.5, winkel: -0.9, belichtung: 0.12 };
const KAM_ENDE = { abstand: 6, hoehe: -0.12, winkel: 0, belichtung: 1 };
const orbit = { winkel: 0, hoehe: 0, zielWinkel: 0, zielHoehe: 0 };
let flug = 1; // 0 = Startposition, 1 = Endposition
/* Overlay: Vollbild-Quad mit ShaderMaterial, gl_Position ohne Matrizen (wie Three.js Journey) */
const overlay = new THREE.ShaderMaterial({
transparent: true, depthTest: false, depthWrite: false,
uniforms: {
uModus: { value: 0 }, uAlpha: { value: 1 }, uProgress: { value: 0 }, uPx: { value: 42 }, uRes: { value: new THREE.Vector2(1, 1) },
uSeed: { value: 0 }, uNacht: { value: new THREE.Color('#1f1c1a') }, uGlut: { value: new THREE.Color('#e8824a') }, uKante: { value: 0.035 },
},
vertexShader: /* glsl */`void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: /* glsl */`
precision highp float;
uniform float uModus, uAlpha, uProgress, uPx, uSeed, uKante;
uniform vec2 uRes; uniform vec3 uNacht, uGlut;
float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21) + uSeed); 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 < 4; i++) { v += a * noise(p); p = p * 2.03 + 17.1; a *= 0.5; } return v; }
void main() {
vec2 fc = gl_FragCoord.xy;
vec3 farbe = uNacht; float a = uAlpha;
if (uModus > 0.5 && uModus < 1.5) {
// Noise-Dissolve: fbm-Maske, 30 % Verlauf von unten, glühende Kante
float m = fbm(fc / uRes.y * 2.6) * 0.7 + (fc.y / uRes.y) * 0.3;
float p = mix(0.15 - uKante, 0.88 + uKante, uProgress); // Wertebereich der Maske ≈ 0.17 … 0.83
a = smoothstep(p - 0.004, p + 0.004, m);
float kante = (1.0 - smoothstep(0.0, uKante, abs(m - p))) * step(0.001, uProgress) * (1.0 - step(0.999, uProgress));
farbe = mix(uNacht, uGlut, kante);
a = max(a, kante * kante * 0.9);
} else if (uModus > 1.5) {
// Pixelraster: Zellen schrumpfen in diagonaler, leicht zufälliger Reihenfolge
vec2 zelle = floor(fc / uPx), f = fract(fc / uPx) - 0.5;
float diag = (zelle.x * uPx / uRes.x + (uRes.y - zelle.y * uPx) / uRes.y) * 0.5;
float ordnung = mix(diag, hash(zelle), 0.45);
float s = clamp((ordnung * 0.8 + 0.2 - uProgress) / 0.2, 0.0, 1.0);
a = step(max(abs(f.x), abs(f.y)), 0.5 * s);
}
gl_FragColor = vec4(farbe, a);
#include <colorspace_fragment>
}`,
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), overlay);
quad.frustumCulled = false; quad.renderOrder = 999;
if (renderer) {
renderer.setPixelRatio(langsam ? 0.75 : Math.min(devicePixelRatio || 1, 2));
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.toneMappingExposure = KAM_START.belichtung;
szene.add(quad);
}
/* ---------- „Dateien“: echte Texturen und Profile als Blob-URLs ---------- */
function leinwand(b, h, malen) { const c = document.createElement('canvas'); c.width = b; c.height = h; malen(c.getContext('2d', { willReadFrequently: true }), b, h); return c; }
function zufall(seed) { let s = seed; return () => (s = (s * 16807) % 2147483647) / 2147483647; }
function malGlasur(g, i) {
return leinwand(512, 256, (x, b, h) => {
const r = zufall(97 + i * 31);
x.fillStyle = g.farbe; x.fillRect(0, 0, b, h);
// Wolken: dicker und dünner aufgetragene Glasur
for (let k = 0; k < 70; k++) {
const cx = r() * b, cy = r() * h, rad = 20 + r() * 70, hell = r() > .5;
const grad = x.createRadialGradient(cx, cy, 0, cx, cy, rad);
grad.addColorStop(0, hell ? 'rgba(255,250,240,.10)' : 'rgba(10,8,6,.12)'); grad.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = grad; x.fillRect(cx - rad, cy - rad, rad * 2, rad * 2);
}
// Läufer: die Glasur läuft nach unten (v wächst zum Rand)
for (let k = 0; k < 120; k++) {
const lx = r() * b, la = h * (.35 + r() * .65), lb = 1 + r() * 3;
const grad = x.createLinearGradient(0, h - la, 0, h);
grad.addColorStop(0, 'rgba(0,0,0,0)'); grad.addColorStop(1, g.hell ? 'rgba(120,100,80,.07)' : 'rgba(0,0,0,.10)');
x.fillStyle = grad; x.fillRect(lx, h - la, lb, la);
}
// Eisensprenkel im Steinzeug
for (let k = 0; k < 700; k++) { x.fillStyle = `rgba(40,26,18,${.1 + r() * .35})`; const s = .6 + r() * 1.6; x.fillRect(r() * b, r() * h, s, s); }
});
}
function malUmgebung() {
return leinwand(512, 256, (x, b, h) => {
const g = x.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, '#fffaf0'); g.addColorStop(.48, '#efe8dc'); g.addColorStop(.52, '#d9cfc0'); g.addColorStop(1, '#8f8578');
x.fillStyle = g; x.fillRect(0, 0, b, h);
[[.28, .3, 90, 46], [.74, .26, 60, 70], [.5, .1, 160, 22]].forEach(([u, v, w, hh]) => {
x.filter = 'blur(10px)'; x.fillStyle = '#ffffff'; x.fillRect(u * b - w / 2, v * h - hh / 2, w, hh);
});
x.filter = 'none';
});
}
function malKabel() {
return leinwand(64, 256, (x, b, h) => {
x.fillStyle = '#2a2522'; x.fillRect(0, 0, b, h);
for (let y = 0; y < h; y += 4) { x.strokeStyle = y % 8 ? 'rgba(255,255,255,.07)' : 'rgba(0,0,0,.25)'; x.beginPath(); x.moveTo(0, y); x.lineTo(b, y + 6); x.stroke(); }
});
}
function profil() {
// Kuppel mit leicht ausgestelltem Kragen; Einheiten ≈ 0,44 m
const aussen = [], innen = [], rand = [];
const N = 30, a0 = 0.2;
for (let i = 0; i <= N; i++) { const a = a0 + (Math.PI / 2 - a0) * (i / N); aussen.push([0.72 * Math.sin(a), 0.62 * Math.cos(a) - 0.28]); }
aussen.push([0.728, -0.36], [0.738, -0.41]);
rand.push([0.738, -0.41], [0.752, -0.46], [0.75, -0.474], [0.738, -0.48], [0.724, -0.47], [0.716, -0.41]);
innen.push([0.716, -0.41], [0.71, -0.33]);
for (let i = N; i >= 0; i--) { const a = a0 + (Math.PI / 2 - a0) * (i / N); innen.push([0.69 * Math.sin(a), 0.6 * Math.cos(a) - 0.29]); }
return { aussen, rand, innen, fassung: { radius: 0.15, hoehe: 0.12, y: 0.37 } };
}
const blobUrl = (inhalt, typ) => URL.createObjectURL(new Blob([inhalt], { type: typ }));
// toDataURL statt toBlob: toBlob kodiert in Leerlaufphasen und verhungert neben einer laufenden Bildschleife
const alsPng = async c => URL.createObjectURL(await (await fetch(c.toDataURL('image/png'))).blob());
/* Manifest wie aus dem Build: Größe in KB bestimmt den Byte-Fortschritt */
const MANIFEST = [
{ name: 'olewig-no3.glb', kb: 1840, art: 'modell' },
{ name: 'fassung-e27.glb', kb: 180, art: 'fassung' },
...GLASUREN.map((g, i) => ({ name: `glasur-${g.id}.ktx2`, kb: [312, 296, 304, 288][i], art: 'glasur', i })),
{ name: 'umgebung-werkstatt.hdr', kb: 640, art: 'umgebung' },
{ name: 'kabel-textil.ktx2', kb: 92, art: 'kabel' },
];
const GESAMT_KB = MANIFEST.reduce((s, d) => s + d.kb, 0);
dateienEl.innerHTML = MANIFEST.map(d => `<li><span>${d.name}</span><span data-kb>${(d.kb / 1000).toFixed(2).replace('.', ',')} MB</span></li>`).join('');
const zeilen = [...dateienEl.children];
let quellenBereit = null;
function quellen() {
if (!quellenBereit) quellenBereit = (async () => {
const p = profil();
const q = {
modell: blobUrl(JSON.stringify({ aussen: p.aussen, rand: p.rand, innen: p.innen }), 'application/json'),
fassung: blobUrl(JSON.stringify(p.fassung), 'application/json'),
umgebung: await alsPng(malUmgebung()),
kabel: await alsPng(malKabel()),
glasur: await Promise.all(GLASUREN.map((g, i) => alsPng(malGlasur(g, i)))),
};
return q;
})();
return quellenBereit;
}
/* ---------- Szene ---------- */
const pendel = new THREE.Group(); pendel.position.y = 3.2; szene.add(pendel);
const leuchte = new THREE.Group(); leuchte.position.y = -3.2 + 0.12; pendel.add(leuchte);
const glasurMat = new THREE.MeshPhysicalMaterial({ color: '#ffffff', roughness: 0.32, clearcoat: 0.6, clearcoatRoughness: 0.25, side: THREE.DoubleSide });
const innenMat = new THREE.MeshStandardMaterial({ color: '#f4ede0', roughness: 0.5, emissive: '#ffcf8f', emissiveIntensity: 0.22, side: THREE.DoubleSide });
const tonMat = new THREE.MeshStandardMaterial({ color: '#c6a383', roughness: 0.92, side: THREE.DoubleSide });
const messingMat = new THREE.MeshStandardMaterial({ color: '#b48a4a', metalness: 1, roughness: 0.32 });
const kabelMat = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.85 });
const birneMat = new THREE.MeshBasicMaterial({ color: '#fff1d6' });
const texturen = { glasur: [], umgebung: null, kabel: null };
let modellGebaut = false, umgebungRT = null;
const licht = new THREE.PointLight('#ffc884', 2.2, 2.2, 1.6); licht.position.set(0, 0.02, 0); leuchte.add(licht);
const sonne = new THREE.DirectionalLight('#fff4e6', 1.1); sonne.position.set(2.5, 3, 4); szene.add(sonne);
szene.add(new THREE.HemisphereLight('#fffaf0', '#b9ae9f', 0.55));
const kegelMat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false, side: THREE.DoubleSide,
uniforms: { uFarbe: { value: new THREE.Color('#ffe2b0') }, uStaerke: { value: 0.32 } },
vertexShader: `varying float vY; varying vec3 vN; varying vec3 vV;
void main() { vY = uv.y; vec4 mv = modelViewMatrix * vec4(position, 1.0); vN = normalize(normalMatrix * normal); vV = normalize(-mv.xyz); gl_Position = projectionMatrix * mv; }`,
fragmentShader: `uniform vec3 uFarbe; uniform float uStaerke; varying float vY; varying vec3 vN; varying vec3 vV;
void main() { float rand = pow(abs(dot(normalize(vN), normalize(vV))), 2.0); gl_FragColor = vec4(uFarbe, pow(vY, 2.4) * rand * uStaerke);
#include <colorspace_fragment>
}`,
});
const kegel = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 2.6, 3.4, 48, 1, true), kegelMat);
kegel.position.y = -0.48 - 1.7; leuchte.add(kegel);
function baueModell(daten) {
if (modellGebaut) return;
const vec = arr => arr.map(([x, y]) => new THREE.Vector2(x, y));
const S = 72;
const aussen = new THREE.Mesh(new THREE.LatheGeometry(vec(daten.aussen).reverse(), S), glasurMat);
const rand = new THREE.Mesh(new THREE.LatheGeometry(vec(daten.rand).reverse(), S), tonMat);
const innen = new THREE.Mesh(new THREE.LatheGeometry(vec(daten.innen).reverse(), S), innenMat);
const birne = new THREE.Mesh(new THREE.SphereGeometry(0.11, 24, 16), birneMat); birne.position.y = 0.08;
leuchte.add(aussen, rand, innen, birne);
modellGebaut = true;
}
function baueFassung(f) {
if (leuchte.getObjectByName('fassung')) return;
const fassung = new THREE.Mesh(new THREE.CylinderGeometry(f.radius * 0.86, f.radius, f.hoehe, 40), messingMat);
fassung.name = 'fassung'; fassung.position.y = f.y; leuchte.add(fassung);
const kabel = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.014, 3.2, 10), kabelMat);
kabel.position.y = f.y + 1.6; leuchte.add(kabel);
}
function setzeGlasur(i) {
glasur = i;
const t = texturen.glasur[i];
if (t) { glasurMat.map = t; glasurMat.color.set('#ffffff'); } else glasurMat.color.set(GLASUREN[i].farbe);
glasurMat.roughness = GLASUREN[i].hell ? 0.4 : 0.28;
glasurMat.needsUpdate = true;
$('[data-glasur-name]').textContent = GLASUREN[i].name;
$('[data-etikett-name]').textContent = 'Glasur ' + GLASUREN[i].name;
$('[data-ersatz-glasur]').setAttribute('fill', GLASUREN[i].farbe);
document.querySelectorAll('[data-glasur]').forEach(b => b.setAttribute('aria-pressed', String(+b.dataset.glasur === i)));
}
/* ---------- Laden mit LoadingManager: echter Fortschritt ---------- */
let lauf = 0, phase = 'aus', dateien = [], anzeige = 0, letzteZahl = -1, enthuellStart = 0, ohne3d = false;
const ladeTex = new THREE.TextureLoader();
function aufTextur(t, wiederholen) { t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4; if (wiederholen) { t.wrapS = THREE.RepeatWrapping; } return t; }
async function verarbeite(d, q) {
if (d.art === 'modell') { const daten = await (await fetch(q.modell)).json(); baueModell(daten); }
else if (d.art === 'fassung') { const daten = await (await fetch(q.fassung)).json(); baueFassung(daten); }
else if (d.art === 'glasur') { const t = aufTextur(await ladeTex.loadAsync(q.glasur[d.i]), true); texturen.glasur[d.i]?.dispose(); texturen.glasur[d.i] = t; if (d.i === glasur) setzeGlasur(glasur); }
else if (d.art === 'kabel') { const t = aufTextur(await ladeTex.loadAsync(q.kabel), true); t.repeat.set(1, 18); texturen.kabel?.dispose(); texturen.kabel = t; kabelMat.map = t; kabelMat.needsUpdate = true; }
else if (d.art === 'umgebung') {
const t = await ladeTex.loadAsync(q.umgebung); t.mapping = THREE.EquirectangularReflectionMapping; t.colorSpace = THREE.SRGBColorSpace;
if (renderer) { const pm = new THREE.PMREMGenerator(renderer); umgebungRT?.dispose(); umgebungRT = pm.fromEquirectangular(t); szene.environment = umgebungRT.texture; szene.environmentIntensity = 0.85; pm.dispose(); }
t.dispose();
}
}
async function laden() {
const meinLauf = ++lauf;
dateien = []; phase = 'laedt'; anzeige = 0; letzteZahl = -1; ohne3d = false;
$('[data-neu-text]').textContent = 'Neu laden';
buehne.classList.remove('bereit', 'ersatz-an');
lader.classList.remove('weg'); decke.classList.remove('weg');
phaseEl.textContent = 'Lädt';
overlay.uniforms.uAlpha.value = 1; overlay.uniforms.uProgress.value = 0; overlay.uniforms.uSeed.value = Math.random() * 10;
overlay.uniforms.uModus.value = aktuell().nr;
flug = reduziert() ? 1 : 0;
zeilen.forEach(z => z.classList.remove('fertig'));
const q = await quellen();
if (meinLauf !== lauf) return;
// Ein LoadingManager zählt alle Dateien; onLoad startet die Enthüllung
const manager = new THREE.LoadingManager(
() => { if (meinLauf === lauf) bereitMachen(); },
(url, geladen, gesamt) => { if (meinLauf === lauf) dateizahlEl.textContent = `${geladen} / ${gesamt}`; },
);
dateien = MANIFEST.map((d, k) => ({ ...d, k, url: d.name, geladen: 0, start: jetzt() + NETZ.latenz * (1 + k * 0.15), fertig: false }));
dateien.forEach(d => manager.itemStart(d.url)); // alle Requests gehen gleichzeitig raus
dateizahlEl.textContent = `0 / ${dateien.length}`;
// Fortschritt pro Datei, wie XHR-progress: die Bandbreite teilt sich auf alle laufenden Downloads
dateien.ankommen = async d => {
try { await verarbeite(d, q); } catch (e) { manager.itemError(d.url); }
if (meinLauf !== lauf) return;
zeilen[d.k].classList.add('fertig');
manager.itemEnd(d.url);
};
}
function netzTick(dt) {
if (phase !== 'laedt' || !dateien.length) return;
const t = jetzt();
const aktiv = dateien.filter(d => !d.fertig && t >= d.start);
if (aktiv.length) {
const anteil = NETZ.bandbreite * (dt / 1000) / aktiv.length;
aktiv.forEach(d => {
d.geladen = Math.min(d.kb, d.geladen + anteil);
if (d.geladen >= d.kb) { d.fertig = true; dateien.ankommen(d); }
});
}
}
function bereitMachen() {
// Shader vor der Enthüllung kompilieren, sonst ruckelt der erste Frame
if (renderer) renderer.compile(szene, kamera);
phase = 'warte';
}
function enthuellen() {
phase = 'enthuellt'; enthuellStart = jetzt();
phaseEl.textContent = 'Bereit';
lader.classList.add('weg');
if (!renderer) { decke.classList.add('weg'); buehne.classList.add('ersatz-an'); }
balken.setAttribute('aria-valuenow', '100');
}
function zeigeOhne3d() {
lauf++; ohne3d = true; phase = 'aus';
lader.classList.add('weg'); decke.classList.add('weg');
overlay.uniforms.uAlpha.value = 0; overlay.uniforms.uModus.value = 0;
buehne.classList.add('ersatz-an'); buehne.classList.remove('bereit');
$('[data-neu-text]').textContent = '3D laden';
}
/* ---------- Kamera ---------- */
function setzeKamera() {
const f = flug;
const abstand = THREE.MathUtils.lerp(KAM_START.abstand, KAM_ENDE.abstand, f) * (kamera.aspect < 0.95 ? 1.04 : 1);
const hoehe = THREE.MathUtils.lerp(KAM_START.hoehe, KAM_ENDE.hoehe, f) + orbit.hoehe;
const winkel = THREE.MathUtils.lerp(KAM_START.winkel, KAM_ENDE.winkel, f) + orbit.winkel;
kamera.position.set(Math.sin(winkel) * Math.cos(hoehe) * abstand, Math.sin(hoehe) * abstand + ziel.y, Math.cos(winkel) * Math.cos(hoehe) * abstand);
kamera.lookAt(ziel);
if (renderer) renderer.toneMappingExposure = THREE.MathUtils.lerp(KAM_START.belichtung, KAM_ENDE.belichtung, f);
}
function groesseAnpassen() {
const b = buehne.clientWidth, h = buehne.clientHeight;
if (!b || !h) return;
kamera.aspect = b / h; kamera.updateProjectionMatrix();
if (renderer) {
renderer.setSize(b, h, false);
const pr = renderer.getPixelRatio();
overlay.uniforms.uRes.value.set(b * pr, h * pr);
overlay.uniforms.uPx.value = Math.round(Math.min(42, b / 30) * pr);
}
}
new ResizeObserver(groesseAnpassen).observe(buehne);
/* ---------- Bildschleife ---------- */
let skala = 1, zielSkala = 1, skalaStart = 0, skalaVon = 1;
function bild(t) {
const dt = uhrTick(t);
netzTick(dt);
// Anzeige: Byte-Fortschritt, geglättet
if (phase === 'laedt' || phase === 'warte') {
const geladen = dateien.reduce((s, d) => s + d.geladen, 0);
const soll = phase === 'warte' ? 1 : geladen / GESAMT_KB;
anzeige += (soll - anzeige) * (reduziert() ? 1 : 1 - Math.exp(-GLAETTUNG * dt / 1000));
if (phase === 'warte' && anzeige > 0.995) { anzeige = 1; phase = 'halt'; haltStart = jetzt(); phaseEl.textContent = 'Bereit'; }
fuellung.style.transform = `scaleX(${anzeige.toFixed(4)})`;
const zahl = Math.floor(anzeige * 100 + 0.0001);
if (zahl !== letzteZahl) {
letzteZahl = zahl; zahlEl.textContent = zahl; balken.setAttribute('aria-valuenow', String(zahl));
bytesEl.textContent = `${(geladen / 1000).toFixed(1).replace('.', ',')} / ${(GESAMT_KB / 1000).toFixed(1).replace('.', ',')} MB`;
}
}
// 100 % kurz stehen lassen, dann enthüllen
if (phase === 'halt' && jetzt() - haltStart > HALT) enthuellen();
// Enthüllung: Overlay und Kameraflug
if (phase === 'enthuellt') {
const m = aktuell();
const p = Math.min(1, (jetzt() - enthuellStart) / m.dauer);
const e = m.kurve(p);
overlay.uniforms.uModus.value = m.nr;
if (m.nr === 0) overlay.uniforms.uAlpha.value = 1 - e; else overlay.uniforms.uProgress.value = e;
const pk = m.kamera ? Math.min(1, (jetzt() - enthuellStart) / m.kamera) : 1;
flug = m === REDUZIERT ? 1 : m.kurve(pk);
if (p >= 1 && pk >= 1) { phase = 'fertig'; buehne.classList.add('bereit'); }
}
// Größenwechsel S/M: 250 ms Ausklang (Rezept)
const ps = Math.min(1, (jetzt() - skalaStart) / 250);
skala = reduziert() ? zielSkala : THREE.MathUtils.lerp(skalaVon, zielSkala, bezier(0, 0, 0.2, 1)(ps));
leuchte.scale.setScalar(skala);
// Orbit mit Trägheit, leichtes Pendeln
const k = 1 - Math.exp(-6 * rohDt / 1000);
orbit.winkel += (orbit.zielWinkel - orbit.winkel) * k;
orbit.hoehe += (orbit.zielHoehe - orbit.hoehe) * k;
pendel.rotation.z = reduziert() ? 0 : Math.sin(jetzt() / 1000 * 0.9) * 0.012;
pendel.rotation.x = reduziert() ? 0 : Math.cos(jetzt() / 1000 * 0.7) * 0.008;
leuchte.rotation.y += reduziert() ? 0 : dt / 1000 * 0.06;
setzeKamera();
if (renderer && !ohne3d) renderer.render(szene, kamera);
requestAnimationFrame(bild);
}
/* ---------- Bedienung: Ziehen, Tasten, Konfigurator ---------- */
let ziehen = null;
buehne.addEventListener('pointerdown', e => {
if (e.target.closest('button') || phase !== 'fertig') return;
ziehen = { x: e.clientX, y: e.clientY, w: orbit.zielWinkel, h: orbit.zielHoehe };
if (e.pointerType === 'mouse') buehne.setPointerCapture(e.pointerId);
});
buehne.addEventListener('pointermove', e => {
if (!ziehen) return;
orbit.zielWinkel = ziehen.w + (e.clientX - ziehen.x) / buehne.clientWidth * 2.4;
if (e.pointerType === 'mouse') orbit.zielHoehe = THREE.MathUtils.clamp(ziehen.h - (e.clientY - ziehen.y) / buehne.clientHeight * 0.9, -0.3, 0.32);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(n => buehne.addEventListener(n, () => { ziehen = null; }));
buehne.addEventListener('keydown', e => {
const s = { ArrowLeft: [-0.25, 0], ArrowRight: [0.25, 0], ArrowUp: [0, 0.08], ArrowDown: [0, -0.08] }[e.key];
if (!s) return; e.preventDefault();
orbit.zielWinkel += s[0]; orbit.zielHoehe = THREE.MathUtils.clamp(orbit.zielHoehe + s[1], -0.3, 0.32);
});
const glasurenEl = $('[data-glasuren]');
glasurenEl.innerHTML = GLASUREN.map((g, i) => `<button type="button" data-glasur="${i}" style="--c:${g.farbe}" aria-label="${g.name}" aria-pressed="${i === glasur}"></button>`).join('');
glasurenEl.addEventListener('click', e => { const b = e.target.closest('[data-glasur]'); if (b) setzeGlasur(+b.dataset.glasur); });
document.querySelectorAll('[data-groesse]').forEach(b => b.addEventListener('click', () => setzeGroesse(b.dataset.groesse)));
function setzeGroesse(g) {
groesse = g; skalaVon = skala; zielSkala = GROESSEN[g].skala; skalaStart = jetzt();
document.querySelectorAll('[data-groesse]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.groesse === g)));
$('[data-groesse-name]').textContent = GROESSEN[g].name;
$('[data-preis]').textContent = GROESSEN[g].preis;
$('[data-etikett-mass]').textContent = GROESSEN[g].mass;
}
let toastZeit = null;
$('[data-kaufen]').addEventListener('click', () => {
korb++; const z = $('[data-korb]'); z.textContent = korb; z.classList.add('hupf'); setTimeout(() => z.classList.remove('hupf'), 200);
toast.innerHTML = `Olewig No. 3 liegt im Warenkorb <span>${GLASUREN[glasur].name} · ${groesse.toUpperCase()}</span>`;
toast.classList.add('an'); clearTimeout(toastZeit); toastZeit = setTimeout(() => toast.classList.remove('an'), 2600);
});
$('[data-ohne3d]').addEventListener('click', zeigeOhne3d);
$('[data-neu]').addEventListener('click', () => { stoppeVorfuehrung(); weiter(); laden(); });
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); weiter(); modus = k.dataset.modus; zeigeWerte(); laden(); }));
/* ---------- Vorführung ---------- */
let schritte = [], vorfuehrLauf = 0;
const warte = ms => new Promise(r => { const ende = jetzt() + ms; const pruefe = () => jetzt() >= ende ? r() : schritte.push(setTimeout(pruefe, 30)); pruefe(); });
function zeigerAuf(el) { const r = el.getBoundingClientRect(); zeiger.style.transform = `translate(${r.left + r.width / 2}px, ${r.top + r.height / 2}px)`; }
async function klick(el) { zeigerAuf(el); await warte(700); zeiger.classList.add('drueckt'); await warte(150); el.click(); zeiger.classList.remove('drueckt'); }
function stoppeVorfuehrung() { vorfuehrLauf++; schritte.forEach(clearTimeout); schritte = []; html.classList.remove('vorfuehrung'); }
async function vorfuehren() {
stoppeVorfuehrung(); weiter();
const meiner = vorfuehrLauf; html.classList.add('vorfuehrung');
const reihe = ['noise', 'raster', 'fade'];
let n = 0;
const z0 = buehne.getBoundingClientRect(); zeiger.style.transform = `translate(${z0.right - 60}px, ${z0.bottom - 60}px)`;
while (meiner === vorfuehrLauf) {
modus = reihe[n % reihe.length]; zeigeWerte(); laden();
while (phase !== 'fertig' && meiner === vorfuehrLauf) await warte(60);
if (meiner !== vorfuehrLauf) return;
await warte(700);
const naechste = document.querySelector(`[data-glasur="${(glasur + 1) % GLASUREN.length}"]`);
if (naechste && naechste.offsetParent) { await klick(naechste); if (meiner !== vorfuehrLauf) return; }
await warte(1600);
n++;
}
}
function neustart() { stoppeVorfuehrung(); weiter(); orbit.zielWinkel = orbit.zielHoehe = 0; laden(); }
const ruheKnopf = $('[data-ruhe]');
function pause() { angehalten = true; ruheKnopf.setAttribute('aria-pressed', 'true'); }
function weiter() { angehalten = false; ruheKnopf.setAttribute('aria-pressed', 'false'); }
ruheKnopf.addEventListener('click', () => { stoppeVorfuehrung(); angehalten ? weiter() : pause(); });
zeigeWerte();
setzeGlasur(glasur);
setzeGroesse('m'); skala = 1; skalaVon = 1;
groesseAnpassen();
requestAnimationFrame(t => { uhrLetzt = t; requestAnimationFrame(bild); });
if (!new URLSearchParams(location.search).has('vorschau')) laden();
</script>
</body>
</html>