Glas- und Kristallobjekte in 3D
Ein Brillant, ein Eisblock und eine Linse aus echtem Transmissionsglas brechen die große Serif-Typo und die Glutsonne dahinter.
Ein 3D-Objekt aus physikalischem Glas (Logo, Kristall, Eisblock, Linse) liegt vor Inhalt und bricht ihn. In three.js reicht MeshPhysicalMaterial mit transmission 1, ior 1.3 bis 1.6, thickness und niedriger roughness; dispersion ergibt Farbsäume. Drei (React Three Fiber) bietet MeshTransmissionMaterial mit chromaticAberration, anisotropicBlur und distortion. Hinter dem Objekt muss etwas liegen: Text, Bilder oder Farbflächen. Eine Environment-Map liefert die Reflexe. Optional kommen Kaustiken dazu, als vorgebackene Textur oder Echtzeit-Pass. Das Objekt folgt Cursor oder Scroll mit Trägheit und dreht sich langsam.
Glas zeigt Rendering-Können auf den ersten Blick. Die Typografie dahinter wird verzerrt, vergrößert und farbig gesäumt, das wirkt wertvoll und präzise. Eis und Kristall stehen als Material für Klarheit und Wert.
Wann einsetzen
Hero eines Studios, Fintech und Krypto, Parfum und Schmuck, Logo-Reveal, Manufakturen mit Glas oder Optik.
Wann nicht
Wenn hinter dem Glas nichts liegt, das es brechen kann. Auf schwachen Geräten ohne Ersatzbild. Als reines Deko-Objekt ohne Bezug zur Marke.
Varianten
- Glaslogo oder Glasobjekt über großer Typo
- Prozedurale Eisblöcke mit Frost (Igloo Inc.)
- Diamant- und Schmuck-Konfigurator mit hohem ior
- Glas mit Kaustiken und Sound (Codrops: Volatile Nexus)
- Glaslinse, die dem Cursor folgt und vergrößert
Umsetzung
Transmission rendert die undurchsichtigen Objekte der Szene ein zweites Mal in ein Render-Target. Das kostet. HTML hinter dem Canvas bricht das Glas nicht: Text und Bilder müssen als Textur in der Szene liegen. Bewährt: HTML legt das Layout fest, die Texte bleiben im DOM (transparent, lesbar, markierbar), und ein 2D-Canvas malt sie an derselben Stelle auf eine Rückwand-Plane. Bei einem einzelnen Objekt reicht MeshPhysicalMaterial. Den Transmission-Puffer verkleinern (three: renderer.transmissionResolutionScale, Drei: resolution und samples), backside nur wenn nötig. Ersatz ohne WebGL: vorgerendertes Video mit Alpha, Standbild oder CSS-Glas mit backdrop-filter.
Robust umsetzen
Telefon. Transmission-Puffer halbe Auflösung, pixelRatio höchstens 1,5. Hover durch Tippen und Ziehen ersetzen. Auf schwachen Geräten Standbild oder Video.
Ohne Bewegung. Keine Eigenrotation, kein Cursor-Folgen. Das Objekt steht still, Wechsel ohne Animation. Nur bei Änderungen rendern.
Barrierefrei. Canvas mit aria-hidden. Text hinter dem Glas bleibt echtes HTML im DOM, auch wenn WebGL ihn nachmalt. Bedienelemente bleiben HTML-Buttons mit aria-pressed.
Code
<!DOCTYPE html>
<!--
Glas- und Kristallobjekte in 3D
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/glas-transmission-3d/
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>Glas- und Kristallobjekte in 3D · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
:root {
--grund: #0c0d10; --text: #ecebe6; --leise: rgba(236, 235, 230, .56); --linie: rgba(236, 235, 230, .16);
--glut: #e8743b; --rose: #d9466f;
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, Objektwechsel 1200 ms */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* Bühne: HTML legt das Layout fest, WebGL malt Typo und Sonne nach und bricht sie im Glas */
.buehne { position: relative; height: 100svh; min-height: 560px; overflow: hidden; isolation: isolate; }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; display: block; }
.kopf { position: absolute; z-index: 2; inset: 0 0 auto; display: flex; justify-content: space-between; align-items: center; padding: clamp(18px, 3vw, 36px) clamp(20px, 4vw, 56px); }
.marke { font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
.marke span { font: italic 400 19px/1 'Instrument Serif', serif; color: var(--leise); margin-left: 4px; letter-spacing: 0; }
.kopf nav { display: flex; gap: 28px; }
.kopf nav a { color: var(--leise); text-decoration: none; transition: color .15s linear; }
.kopf nav a:hover, .kopf nav a:focus-visible { color: var(--text); }
.satz { position: absolute; z-index: 0; left: clamp(20px, 4vw, 56px); top: 50%; translate: 0 -54%; font-family: 'Instrument Serif', serif; font-weight: 400; line-height: .86; letter-spacing: -.035em; }
.satz .z1 { display: block; font-style: italic; font-size: clamp(88px, 17.5vw, 300px); }
.satz .z2 { display: block; font-size: clamp(64px, 11.2vw, 196px); padding-left: .9em; color: #c9c7c0; }
.ueber { position: absolute; z-index: 0; left: clamp(20px, 4vw, 56px); top: calc(50% - clamp(88px, 17.5vw, 300px) * .86 - 6px); }
.sonne { position: absolute; z-index: 0; width: clamp(240px, 33vw, 520px); aspect-ratio: 1; border-radius: 50%; right: 9vw; top: 50%; translate: 0 -56%; background: radial-gradient(circle at 35% 30%, #f7b267 0%, var(--glut) 42%, var(--rose) 100%); }
.fuss { position: absolute; z-index: 2; inset: auto 0 0; display: flex; justify-content: space-between; align-items: end; gap: 24px; padding: clamp(18px, 3vw, 36px) clamp(20px, 4vw, 56px); }
.text p { max-width: 34ch; font-size: 15px; line-height: 1.5; color: var(--leise); }
.text a { display: inline-flex; gap: 10px; align-items: center; margin-top: 14px; color: var(--text); text-decoration: none; font-size: 15px; border-bottom: 1px solid var(--linie); padding-bottom: 4px; transition: border-color .15s linear; }
.text a:hover, .text a:focus-visible { border-color: var(--text); }
.steuer { display: grid; justify-items: end; gap: 12px; }
.modi { display: flex; gap: 6px; }
/* Bewegung anhalten (WCAG 2.2.2): das Glasobjekt dreht sich ohne Zutun weiter */
.steuer-zeile { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px 14px; }
.halt { color: var(--leise); background: rgba(12, 13, 16, .4); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s linear, border-color .15s linear; }
.halt:hover, .halt[aria-pressed='true'] { color: var(--text); border-color: var(--text); }
.halt:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.vorschau .halt { display: none; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: rgba(12, 13, 16, .4); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s linear; }
.modi button[aria-pressed='true'] { transition: none; }
.modi button:hover { color: var(--text); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; max-width: 470px; }
.werte b { color: var(--text); font-weight: 500; }
/* Gedachter Zeiger in der Vorführung */
.zeiger { position: absolute; z-index: 3; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border: 1px solid rgba(236, 235, 230, .8); border-radius: 50%; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
/* WebGL aktiv: die nachgemalten Elemente bleiben als HTML im Dokument (lesbar, markierbar), aber unsichtbar */
.gl [data-gl] { color: transparent; }
.gl .sonne { opacity: 0; }
/* Ohne WebGL: CSS-Glas als Ersatz */
.glas-ersatz { display: none; }
.ohne-webgl .glas-ersatz { display: block; position: absolute; z-index: 1; width: clamp(180px, 22vw, 340px); aspect-ratio: 1; right: 22vw; top: 50%; translate: 0 -50%; rotate: 45deg; border-radius: 18%; border: 1px solid rgba(255, 255, 255, .35); background: linear-gradient(135deg, rgba(255, 255, 255, .22), rgba(131, 161, 197, .08) 50%, rgba(255, 255, 255, .14)); backdrop-filter: blur(6px) saturate(1.4); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .5), 0 30px 80px rgba(0, 0, 0, .35); }
/* Unter der Bühne: kurze Materialkunde */
.mehr { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(20px, 4vw, 56px); padding: clamp(56px, 8vw, 112px) clamp(20px, 4vw, 56px); border-top: 1px solid var(--linie); }
.mehr h2 { font: italic 400 34px/1 'Instrument Serif', serif; margin: 10px 0 12px; letter-spacing: -.01em; }
.mehr p:not(.mono) { color: var(--leise); font-size: 15px; line-height: 1.55; max-width: 36ch; }
.vorschau body { overflow: hidden; }
.vorschau .mehr, .vorschau .kopf nav, .vorschau .text a { display: none; }
.vorschau .buehne { height: 100vh; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (max-width: 760px) {
.kopf nav { display: none; }
.satz { top: 40%; }
.satz .z1 { font-size: 27vw; }
.satz .z2 { font-size: 17vw; padding-left: .5em; }
.ueber { top: calc(40% - 27vw * .86 - 34px); }
.sonne { width: 62vw; right: -10vw; top: 44%; }
.fuss { flex-direction: column; align-items: stretch; gap: 18px; }
.steuer { justify-items: start; }
.steuer-zeile { justify-content: flex-start; }
.werte { justify-content: flex-start; }
.mehr { grid-template-columns: 1fr; }
.ohne-webgl .glas-ersatz { right: 18vw; width: 42vw; top: 42%; }
}
@media (max-height: 640px) and (min-width: 761px) { .text p { display: none; } }
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
.gl [data-gl] { color: CanvasText; } /* nachgemalter Text als echter Text sichtbar */
}
</style>
</head>
<body>
<section class="buehne" data-buehne aria-labelledby="titel">
<header class="kopf">
<p class="marke">Nordlicht<span>Glashütte</span></p>
<nav class="mono" aria-label="Hauptmenü"><a href="#kollektion">Kollektion</a><a href="#manufaktur">Manufaktur</a><a href="#besuch">Atelierbesuch</a></nav>
</header>
<div class="sonne" data-sonne aria-hidden="true"></div>
<p class="ueber mono" data-gl>Winterkollektion 26 · Lauscha</p>
<h1 class="satz" id="titel"><span class="z1" data-gl>Klarheit,</span><span class="z2" data-gl>mundgeblasen.</span></h1>
<div class="glas-ersatz" aria-hidden="true"></div>
<canvas data-canvas aria-hidden="true"></canvas>
<div class="fuss">
<div class="text">
<p>Seit 1921 formen wir in Lauscha Glas vor der Flamme. Jedes Stück trägt die Spur des Atems, der es geblasen hat.</p>
<a href="#kollektion">Kollektion ansehen <span aria-hidden="true">→</span></a>
</div>
<div class="steuer">
<div class="steuer-zeile">
<div class="modi mono" role="group" aria-label="Glasobjekt wählen" data-modi>
<button type="button" data-modus="kristall" aria-pressed="true">Kristall</button>
<button type="button" data-modus="eis" aria-pressed="false">Eisblock</button>
<button type="button" data-modus="linse" aria-pressed="false">Linse</button>
</div>
<button type="button" class="halt mono" data-halt aria-pressed="false" aria-label="Bewegung anhalten">Anhalten</button>
</div>
<p class="werte mono" data-werte aria-live="polite"></p>
</div>
</div>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
</section>
<section class="mehr" id="kollektion">
<div><p class="mono">01 · Bleikristall</p><h2>Schliff</h2><p>Hoher Brechungsindex, harte Kanten. Das Licht wird im Stein gefangen und farbig wieder freigegeben.</p></div>
<div><p class="mono">02 · Kaltes Glas</p><h2>Eis</h2><p>Mattierte Kanten, ein Hauch Blau im Kern. Ruhig, kühl und schwer wie ein Block aus dem Bergsee.</p></div>
<div><p class="mono">03 · Optik</p><h2>Linse</h2><p>Geschliffen nach alter Fernrohrtradition. Sie vergrößert, was dahinter liegt, und dreht es sanft um.</p></div>
</section>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const buehne = document.querySelector('[data-buehne]');
const canvas = document.querySelector('[data-canvas]');
const zeiger = document.querySelector('[data-zeiger]');
const werteEl = document.querySelector('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];
/* Gemessene bzw. beobachtete Werte (meta.json → messwerte) */
const LERP = 0.08; // Cursor-Folgen, 0.05–0.1 laut Messung; bei 60 Bildern/s, auf echte Bildrate umgerechnet
const WECHSEL_MS = 1200; // Objektwechsel: neues Objekt wächst, expo.out
const AUS_MS = 600; // altes Objekt schrumpft, expo.out
const expoOut = t => t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
const MODI = {
kristall: { name: 'Kristall', ior: 2.4, roughness: 0.08, thickness: 0.9, dispersion: 3, attenuationColor: '#ffffff', attenuationDistance: Infinity, drehung: 0.25, groesse: 0.21, quelle: 'Trionn: ior 2.4, roughness .08' },
eis: { name: 'Eisblock', ior: 1.18, roughness: 0.14, thickness: 2, dispersion: 1, attenuationColor: '#83a1c5', attenuationDistance: 2.4, frost: 0.12, drehung: 0.16, groesse: 0.26, quelle: 'Igloo: ior 1.18, thickness 2, Frost #83a1c5' },
linse: { name: 'Linse', ior: 1.5, roughness: 0, thickness: 1.1, dispersion: 3, attenuationColor: '#ffffff', attenuationDistance: Infinity, drehung: 0, groesse: 0.19, quelle: 'Kronglas ior 1.5, folgt dem Zeiger' },
};
let modus = 'kristall';
function zeigeWerte() {
const m = MODI[modus];
werteEl.innerHTML = `<span>transmission <b>1</b></span><span>ior <b>${m.ior}</b></span><span>roughness <b>${m.roughness}</b></span><span>thickness <b>${m.thickness}</b></span><span>dispersion <b>${m.dispersion}</b></span>`
+ (m.attenuationDistance !== Infinity ? `<span>attenuation <b>${m.attenuationColor}</b></span><span>frost <b>${m.frost}</b></span>` : '')
+ (reduziert() ? '<span>Bewegung <b>aus</b></span>' : `<span>lerp <b>${LERP}</b></span>`);
}
/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
let neustart = () => {}, vorfuehren = () => {}, pause = () => {}, weiter = () => {};
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); if (haltKnopf.getAttribute('aria-pressed') === 'true') window.FUNDUS_DEMO.weiter(); setzeModus(k.dataset.modus); }));
function knopfZustand() { knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === modus))); }
let setzeModus = m => { modus = m; knopfZustand(); zeigeWerte(); };
let stoppeVorfuehrung = () => {};
if (!renderer) {
html.classList.add('ohne-webgl');
zeigeWerte();
} else {
html.classList.add('gl');
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
if ('transmissionResolutionScale' in renderer) renderer.transmissionResolutionScale = beruehrung ? 0.5 : 1;
const szene = new THREE.Scene();
const kamera = new THREE.PerspectiveCamera(30, 1, 0.1, 50);
kamera.position.set(0, 0, 10);
const OBJ_Z = 1.4;
/* Eigene Studio-Umgebung: dunkler Raum mit drei Softboxen, über PMREM vorgefiltert */
const pmrem = new THREE.PMREMGenerator(renderer);
const studio = new THREE.Scene();
studio.add(new THREE.Mesh(new THREE.SphereGeometry(20, 24, 12), new THREE.MeshBasicMaterial({ color: 0x07080a, side: THREE.BackSide })));
const box = (w, h, farbe, staerke, pos, blick) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(farbe).multiplyScalar(staerke), side: THREE.DoubleSide }));
m.position.set(...pos); m.lookAt(...blick); studio.add(m);
};
box(14, 4, '#ffffff', 5, [0, 9, 2], [0, 0, 0]); // Deckenlicht
box(2, 12, '#cfe0ff', 4, [-9, 1, 4], [0, 0, 0]); // kühle Kante links
box(2, 10, '#ffd2b0', 3, [9, -1, 3], [0, 0, 0]); // warme Kante rechts
box(8, 1.2, '#ffffff', 2.5, [0, -3, 9], [0, 0, 0]); // Spitzlicht von vorn
szene.environment = pmrem.fromScene(studio, 0.02).texture;
/* Eigene Schliff-Umgebung nur für den Kristall: heller Verlaufsraum mit einem Kranz aus Lichtstreifen.
Ein Brillant lebt von Licht, das im Stein totalreflektiert wird. Single-Pass-Transmission sieht nur die
dunkle Rückwand, deshalb holt der Shader unten diese innere Reflexion aus dieser Umgebung. */
const schliff = new THREE.Scene();
{
const lw = document.createElement('canvas'); lw.width = 8; lw.height = 256;
const c = lw.getContext('2d'), g = c.createLinearGradient(0, 0, 0, 256);
g.addColorStop(0, '#5a5f6b'); g.addColorStop(0.45, '#23262d'); g.addColorStop(0.55, '#2a2420'); g.addColorStop(1, '#14151a');
c.fillStyle = g; c.fillRect(0, 0, 8, 256);
const t = new THREE.CanvasTexture(lw); t.colorSpace = THREE.SRGBColorSpace;
schliff.add(new THREE.Mesh(new THREE.SphereGeometry(20, 32, 16), new THREE.MeshBasicMaterial({ map: t, side: THREE.BackSide })));
}
const streifen = (w, h, farbe, staerke, pos) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(farbe).multiplyScalar(staerke), side: THREE.DoubleSide }));
m.position.set(...pos); m.lookAt(0, 0, 0); schliff.add(m);
};
const KRANZ = ['#ffffff', '#cfe0ff', '#ffd2b0', '#ffffff', '#f7b267', '#ffffff', '#d9466f', '#cfe0ff', '#ffffff', '#e8743b', '#ffffff', '#b9c8ff'];
for (let r = 0; r < 2; r++) KRANZ.forEach((f, i) => { // 2 × 12 Streifen rundum, versetzt in der Höhe: jede Facette trifft irgendwo Licht
const a = (i + r * 0.5) / KRANZ.length * Math.PI * 2, y = (r ? -1 : 1) * (1.5 + (i % 3) * 2.6);
streifen(0.9, 5, f, f === '#ffffff' ? 8 : 5, [Math.cos(a) * 11, y, Math.sin(a) * 11]);
});
streifen(12, 3, '#ffffff', 6, [0, 11, 0]); // Deckenlicht
streifen(10, 2, '#ffe6d2', 3, [0, -11, 0]); // Bodenreflex
const schliffUmgebung = pmrem.fromScene(schliff, 0.01).texture;
/* Rückwand: Typo und Sonne aus dem HTML-Layout, in eine Textur gemalt */
const wandLeinwand = document.createElement('canvas');
const wandTextur = new THREE.CanvasTexture(wandLeinwand);
wandTextur.colorSpace = THREE.SRGBColorSpace;
wandTextur.minFilter = THREE.LinearFilter; wandTextur.generateMipmaps = false;
const wand = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: wandTextur, toneMapped: false }));
szene.add(wand);
// Wörter in Spans legen, damit jedes an seiner echten Stelle gemalt werden kann
document.querySelectorAll('[data-gl]').forEach(el => {
el.innerHTML = el.textContent.split(/(\s+)/).map(w => /^\s+$/.test(w) || !w ? w : `<span class="wort">${w}</span>`).join('');
});
function maleWand() {
const r = buehne.getBoundingClientRect();
const dpr = Math.min(devicePixelRatio || 1, 2);
wandLeinwand.width = Math.round(r.width * dpr); wandLeinwand.height = Math.round(r.height * dpr);
const c = wandLeinwand.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
c.fillStyle = '#0c0d10'; c.fillRect(0, 0, r.width, r.height);
// Sonne
const s = document.querySelector('[data-sonne]').getBoundingClientRect();
const sx = s.left - r.left, sy = s.top - r.top, sr = s.width / 2;
const g = c.createRadialGradient(sx + sr * 0.7, sy + sr * 0.6, 0, sx + sr, sy + sr, sr);
g.addColorStop(0, '#f7b267'); g.addColorStop(0.42, '#e8743b'); g.addColorStop(1, '#d9466f');
c.fillStyle = g; c.beginPath(); c.arc(sx + sr, sy + sr, sr, 0, Math.PI * 2); c.fill();
// feine Linien wie ein Messraster, damit auch Leerflächen brechen
c.strokeStyle = 'rgba(236,235,230,.07)'; c.lineWidth = 1;
for (let x = 0.5; x < r.width; x += 96) { c.beginPath(); c.moveTo(x, 0); c.lineTo(x, r.height); c.stroke(); }
// Wörter
document.querySelectorAll('[data-gl]').forEach(el => {
const cs = getComputedStyle(el);
c.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
c.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
c.fillStyle = el.classList.contains('z2') ? '#c9c7c0' : el.classList.contains('mono') ? 'rgba(236,235,230,.56)' : '#ecebe6';
c.textBaseline = 'alphabetic';
const gross = cs.textTransform === 'uppercase';
el.querySelectorAll('.wort').forEach(w => {
const t = gross ? w.textContent.toUpperCase() : w.textContent;
const wr = w.getBoundingClientRect();
const m = c.measureText(t);
c.fillText(t, wr.left - r.left, wr.top - r.top + m.fontBoundingBoxAscent);
});
});
wandTextur.needsUpdate = true;
}
/* Lichtfleck unter dem Objekt: angedeutete Kaustik, additiv */
const fleckLeinwand = document.createElement('canvas'); fleckLeinwand.width = fleckLeinwand.height = 256;
{
const c = fleckLeinwand.getContext('2d');
const g = c.createRadialGradient(128, 128, 0, 128, 128, 128);
g.addColorStop(0, 'rgba(255,255,255,.55)'); g.addColorStop(0.5, 'rgba(255,255,255,.12)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(0, 0, 256, 256);
c.globalCompositeOperation = 'source-atop'; c.strokeStyle = 'rgba(255,255,255,.5)'; c.lineWidth = 1.4;
for (let i = 0; i < 14; i++) { c.beginPath(); const a = i / 14 * Math.PI * 2; c.ellipse(128 + Math.cos(a) * 26, 128 + Math.sin(a) * 26, 52, 22, a, 0, Math.PI * 2); c.stroke(); }
}
const fleck = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(fleckLeinwand), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.35, toneMapped: false }));
fleck.position.z = 0.02; szene.add(fleck);
/* ---------- Die drei Glasobjekte ---------- */
function kristallForm() {
// Brillant als Drehkörper: Tafel, zwei Kronen- und zwei Pavillonringe, Rundiste; 16 Facetten je Ring, flach schattiert
const p = [[0, -1.05], [0.6, -0.42], [0.98, 0.02], [1, 0.1], [0.86, 0.3], [0.6, 0.5], [0, 0.5]].map(([x, y]) => new THREE.Vector2(x, y));
const g = new THREE.LatheGeometry(p, 16).toNonIndexed(); g.computeVertexNormals(); return g;
}
function eisForm() {
// abgerundeter Würfel, Radius 0.14
const g = new THREE.BoxGeometry(1.5, 1.5, 1.5, 6, 6, 6), pos = g.attributes.position, nor = g.attributes.normal;
const v = new THREE.Vector3(), innen = new THREE.Vector3(), n = new THREE.Vector3(), h = 0.75, r = 0.14;
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i);
innen.set(THREE.MathUtils.clamp(v.x, -h + r, h - r), THREE.MathUtils.clamp(v.y, -h + r, h - r), THREE.MathUtils.clamp(v.z, -h + r, h - r));
n.subVectors(v, innen).normalize(); v.copy(innen).addScaledVector(n, r);
pos.setXYZ(i, v.x, v.y, v.z); nor.setXYZ(i, n.x, n.y, n.z);
}
g.scale(0.82, 0.82, 0.82); return g;
}
function linsenForm() { const g = new THREE.SphereGeometry(1, 48, 24); g.scale(1, 1, 0.36); return g; }
const objekte = {};
for (const [k, m] of Object.entries(MODI)) {
const mat = new THREE.MeshPhysicalMaterial({
color: 0xffffff, metalness: 0, transmission: 1, ior: m.ior, roughness: m.roughness, thickness: m.thickness,
dispersion: m.dispersion, attenuationColor: new THREE.Color(m.attenuationColor), attenuationDistance: m.attenuationDistance,
envMapIntensity: k === 'linse' ? 1.2 : 1.8,
emissive: new THREE.Color(m.attenuationColor), emissiveIntensity: m.frost || 0, // Frost-Schimmer (Igloo uColorFrost)
specularIntensity: 1, clearcoat: k === 'kristall' ? 1 : 0, flatShading: k === 'kristall',
});
if (k === 'kristall') {
mat.envMap = schliffUmgebung;
/* Feuer: innere Totalreflexion als Umgebungsblick. Der Strahl wird an der Facette gebrochen,
an der gegenüberliegenden Pavillonfacette gespiegelt und tastet dann die Schliff-Umgebung ab,
je Farbkanal mit leicht anderem ior (Dispersion). So bleibt keine Facette schwarz. */
mat.onBeforeCompile = sh => {
sh.uniforms.uFeuer = { value: 1.3 };
sh.fragmentShader = 'uniform float uFeuer;\n' + sh.fragmentShader.replace('#include <transmission_fragment>', `#include <transmission_fragment>
#if defined( USE_TRANSMISSION ) && defined( ENVMAP_TYPE_CUBE_UV )
{
vec3 nO = normalize( inverse( mat3( modelMatrix ) ) * n );
vec3 nQ = floor( nO * 24.0 + 0.5 ); // je Facette konstant (flache Normale, gerundet gegen Rechenrauschen)
vec3 zufall = fract( sin( vec3( dot( nQ, vec3( 12.9, 78.2, 37.7 ) ), dot( nQ, vec3( 39.3, 11.1, 83.4 ) ), dot( nQ, vec3( 73.1, 52.9, 9.7 ) ) ) ) * 437.5 ) - 0.5;
vec3 nP = normalize( mat3( modelMatrix ) * normalize( vec3( -nO.x, -abs( nO.y ) - 0.35, -nO.z ) + 0.9 * zufall ) );
float f0 = pow2( ( material.ior - 1.0 ) / ( material.ior + 1.0 ) );
float fr = f0 + ( 1.0 - f0 ) * pow( 1.0 - saturate( dot( n, v ) ), 5.0 );
vec3 feuer;
for ( int c = 0; c < 3; c ++ ) {
float i = material.ior * ( 1.0 + ( float( c ) - 1.0 ) * 0.035 );
vec3 d = reflect( refract( - v, n, 1.0 / i ), nP );
d = refract( d, - n, i ); if ( dot( d, d ) < 0.01 ) d = reflect( - v, n );
feuer[ c ] = textureCubeUV( envMap, envMapRotation * d, 0.04 )[ c ];
}
totalDiffuse += uFeuer * material.transmission * ( 1.0 - fr ) * feuer * envMapIntensity;
}
#endif`);
};
mat.customProgramCacheKey = () => 'kristall-feuer';
}
const geo = k === 'kristall' ? kristallForm() : k === 'eis' ? eisForm() : linsenForm();
const mesh = new THREE.Mesh(geo, mat);
mesh.visible = false; szene.add(mesh);
objekte[k] = { mesh, wachs: 0, von: 0, bis: 0, start: 0, dauer: WECHSEL_MS, rot: new THREE.Euler(), pos: new THREE.Vector3(), basis: 1 };
}
/* ---------- Maße und Zeiger ---------- */
let B = 1, H = 1, sichtH = 1, sichtB = 1;
const heim = new THREE.Vector2(0.6, 0.5); // Ruheposition (Anteil der Bühne)
const zeigerZiel = new THREE.Vector2(0, 0), zeigerIst = new THREE.Vector2(0, 0); // -1..1
const ortZiel = new THREE.Vector2(), ortIst = new THREE.Vector2(); // Bühnenanteil für die Linse
let zeigerDa = false;
function inWelt(ax, ay, z) {
const h = 2 * (kamera.position.z - z) * Math.tan(THREE.MathUtils.degToRad(kamera.fov / 2));
return new THREE.Vector3((ax - 0.5) * h * (B / H), -(ay - 0.5) * h, z);
}
function groesse() {
const r = buehne.getBoundingClientRect(); B = r.width; H = r.height;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, beruehrung ? 1.5 : 2));
renderer.setSize(B, H, false);
kamera.aspect = B / H; kamera.updateProjectionMatrix();
sichtH = 2 * kamera.position.z * Math.tan(THREE.MathUtils.degToRad(kamera.fov / 2)); sichtB = sichtH * kamera.aspect;
wand.scale.set(sichtB, sichtH, 1);
if (B < 761) heim.set(0.6, 0.5); else heim.set(0.58, 0.5);
const proPx = 2 * (kamera.position.z - OBJ_Z) * Math.tan(THREE.MathUtils.degToRad(kamera.fov / 2)) / H;
for (const [k, o] of Object.entries(objekte)) o.basis = Math.min(B, H) * MODI[k].groesse * proPx;
if (!zeigerDa) { ortZiel.copy(heim); ortIst.copy(heim); }
maleWand(); malen();
}
buehne.addEventListener('pointermove', e => {
if (html.classList.contains('vorfuehrung')) return;
if (beruehrung && e.pointerType !== 'mouse' && e.buttons === 0 && e.pointerType !== 'touch') return;
const r = buehne.getBoundingClientRect();
setzeZeiger((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height);
});
buehne.addEventListener('pointerdown', e => {
if (e.target.closest('button, a')) return;
stoppeVorfuehrung();
const r = buehne.getBoundingClientRect();
setzeZeiger((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height);
});
buehne.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) { zeigerDa = false; zeigerZiel.set(0, 0); ortZiel.copy(heim); anstossen(); } });
function setzeZeiger(ax, ay) {
if (reduziert()) return;
zeigerDa = true;
zeigerZiel.set(ax * 2 - 1, ay * 2 - 1);
ortZiel.set(THREE.MathUtils.clamp(ax, 0.12, 0.88), THREE.MathUtils.clamp(ay, 0.18, 0.78));
anstossen();
}
/* ---------- Wechsel ---------- */
setzeModus = (m, sofort) => {
const alt = objekte[modus], neu = objekte[m], jetzt = performance.now();
const ohne = sofort || reduziert();
if (m !== modus) Object.assign(alt, { von: alt.wachs, bis: 0, start: jetzt, dauer: ohne ? 0 : AUS_MS });
Object.assign(neu, { von: m === modus ? neu.wachs : 0, bis: 1, start: jetzt + (ohne || m === modus ? 0 : 150), dauer: ohne ? 0 : WECHSEL_MS });
if (m !== modus && !ohne) neu.rot.y -= 1.6; // kommt mit einer Drehung herein
modus = m;
if (m === 'linse') { ortIst.copy(zeigerDa ? ortZiel : heim); }
knopfZustand(); zeigeWerte(); anstossen();
};
/* ---------- Bildschleife ---------- */
let laeuft = false, letzte = performance.now(), sichtbar = true, angehalten = false;
function anstossen() { if (!laeuft && sichtbar && !angehalten) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
function bild(t) {
if (!sichtbar || angehalten) { laeuft = false; return; }
const dt = Math.min(64, t - letzte) / 1000; letzte = t;
const k = 1 - Math.pow(1 - LERP, dt * 60);
let ruhig = true;
if (!reduziert()) {
zeigerIst.lerp(zeigerZiel, k); ortIst.lerp(ortZiel, k);
if (zeigerIst.distanceTo(zeigerZiel) > 1e-4 || ortIst.distanceTo(ortZiel) > 1e-4) ruhig = false;
} else { zeigerIst.set(0, 0); ortIst.copy(heim); }
for (const [name, o] of Object.entries(objekte)) {
const p = o.dauer ? THREE.MathUtils.clamp((t - o.start) / o.dauer, 0, 1) : (t >= o.start ? 1 : 0);
o.wachs = o.von + (o.bis - o.von) * expoOut(p);
if (p < 1) ruhig = false;
if (!reduziert()) {
o.rot.y += dt * MODI[name].drehung;
if (MODI[name].drehung) ruhig = false;
}
}
malen(t);
if (ruhig && !html.classList.contains('vorfuehrung')) { laeuft = false; return; }
requestAnimationFrame(bild);
}
function malen(t = performance.now()) {
let fleckZiel = null;
for (const [name, o] of Object.entries(objekte)) {
const m = o.mesh;
m.visible = o.wachs > 0.001;
if (!m.visible) continue;
const ort = name === 'linse' ? ortIst : heim;
const w = inWelt(ort.x, ort.y, OBJ_Z);
// Ruheneigung je Objekt, dazu Kippen zum Zeiger
const neigX = name === 'kristall' ? 0.42 : name === 'eis' ? 0.5 : 0;
const neigZ = name === 'eis' ? 0.6 : 0;
m.position.copy(w);
if (name !== 'linse') { m.position.x += zeigerIst.x * 0.18; m.position.y -= zeigerIst.y * 0.12; }
m.rotation.set(neigX + zeigerIst.y * 0.45, o.rot.y + zeigerIst.x * 0.6, neigZ);
if (name === 'linse') m.rotation.set(zeigerIst.y * 0.35, zeigerIst.x * 0.45, 0);
m.scale.setScalar(o.basis * (0.55 + 0.45 * o.wachs) * Math.min(1, o.wachs * 1.6));
if (!fleckZiel || o.wachs > fleckZiel.w) fleckZiel = { p: m.position, w: o.wachs, s: o.basis };
}
if (fleckZiel) {
fleck.position.set(fleckZiel.p.x * (10 - 0.02) / (10 - OBJ_Z) + 0.35 * fleckZiel.s, fleckZiel.p.y * (10 - 0.02) / (10 - OBJ_Z) - 0.9 * fleckZiel.s, 0.02);
fleck.scale.setScalar(fleckZiel.s * 3.2);
fleck.rotation.z = reduziert() ? 0 : t * 0.00008;
fleck.material.opacity = 0.32 * fleckZiel.w;
}
renderer.render(szene, kamera);
}
new IntersectionObserver(([e]) => { sichtbar = e.isIntersecting; if (sichtbar) anstossen(); }).observe(buehne);
let rz; addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(groesse, 120); });
/* ---------- Vorführung: gedachter Zeiger, Objekte im Wechsel ---------- */
let uhr = null, vStart = 0, vZeit = 0, vPausiert = false;
const REIHE = ['kristall', 'eis', 'linse'], TAKT = 4200;
function vSchritt(t) {
const s = (t - vStart) / 1000;
const ax = 0.56 + 0.3 * Math.sin(s * 0.9), ay = 0.48 + 0.2 * Math.sin(s * 1.4 + 0.6);
zeiger.style.transform = `translate(${ax * B}px, ${ay * H}px)`;
zeigerDa = true; zeigerZiel.set(ax * 2 - 1, ay * 2 - 1);
ortZiel.set(THREE.MathUtils.clamp(ax, 0.12, 0.88), THREE.MathUtils.clamp(ay, 0.18, 0.78));
const soll = REIHE[Math.floor(Math.max(0, t - vStart) / TAKT) % REIHE.length];
if (soll !== modus) setzeModus(soll);
anstossen();
uhr = requestAnimationFrame(vSchritt);
}
vorfuehren = () => {
cancelAnimationFrame(uhr);
html.classList.add('vorfuehrung');
if (reduziert()) {
// ohne Bewegung: die drei Objekte im Takt still nacheinander zeigen
vStart = performance.now();
const takt = () => { setzeModus(REIHE[Math.floor((performance.now() - vStart) / TAKT) % 3]); uhr = setTimeout(takt, 300); };
setzeModus('kristall', true); takt(); return;
}
vStart = performance.now(); setzeModus('kristall');
uhr = requestAnimationFrame(vSchritt);
};
pause = () => { angehalten = true; cancelAnimationFrame(uhr); clearTimeout(uhr); uhr = null; if (html.classList.contains('vorfuehrung')) { vPausiert = true; vZeit = performance.now() - vStart; } };
weiter = () => {
angehalten = false; anstossen();
if (!vPausiert || !html.classList.contains('vorfuehrung')) return;
vPausiert = false; vStart = performance.now() - vZeit;
if (reduziert()) { vorfuehren(); return; }
uhr = requestAnimationFrame(vSchritt);
};
stoppeVorfuehrung = () => {
if (!html.classList.contains('vorfuehrung')) return;
cancelAnimationFrame(uhr); clearTimeout(uhr); uhr = null; vPausiert = false;
html.classList.remove('vorfuehrung');
};
neustart = () => {
stoppeVorfuehrung();
zeigerDa = false; zeigerZiel.set(0, 0); ortZiel.copy(heim);
for (const o of Object.values(objekte)) { o.wachs = 0; o.von = 0; o.bis = 0; o.rot.set(0, 0.3, 0); }
modus = 'kristall'; setzeModus('kristall');
};
groesse();
document.fonts.ready.then(() => Promise.all(['italic 400 100px "Instrument Serif"', '400 100px "Instrument Serif"', '500 11px "Geist Mono"'].map(f => document.fonts.load(f)))).then(() => { maleWand(); malen(); });
neustart();
}
zeigeWerte();
// Knopf „Anhalten/Abspielen“: dieselbe pause()/weiter() wie die API
const haltKnopf = document.querySelector('[data-halt]');
function haltZeigen(an) { haltKnopf.setAttribute('aria-pressed', String(an)); haltKnopf.textContent = an ? 'Abspielen' : 'Anhalten'; haltKnopf.setAttribute('aria-label', an ? 'Bewegung abspielen' : 'Bewegung anhalten'); }
haltKnopf.addEventListener('click', () => (haltKnopf.getAttribute('aria-pressed') === 'true' ? window.FUNDUS_DEMO.weiter : window.FUNDUS_DEMO.pause)());
</script>
</body>
</html>