/* * Glas-Logo: das WEBklar-Logo erscheint groß hinter einer klaren Glaskachel, deren Rand es an den Ecken * farbig bricht; dann lässt das Glas los und das blanke Logo bleibt stehen. Ein WebGL2-Durchgang, keine * Abhängigkeiten. Genutzt von der Anmeldung (login-logo.js) und vom Startbild der App (startbild.js). * * Übertragen aus dem React-Bauteil „liquid-glass-logo-reveal“ (21st.dev, Glass Reveal) in reines JavaScript; * Shader und Feder-Zeitleiste stammen aus zanwei/glass-icon-reveal: https://github.com/zanwei/glass-icon-reveal * Gegenüber dem Original: nur der helle Stil, keine Schleife, Standbild statt Endlosschleife, das Logo kommt * als Zeichenfläche, der Hintergrund (Schatten, Welle) läuft zum Rand der Zeichenfläche aus und das Rauschen gegen * Farbstufen lässt reines Weiß unberührt: so sitzt die Fläche nahtlos auf weißen Seiten. * * MIT License * * Copyright (c) 2026 Zanwei Guo * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ /** Heller Stil des Originals (LIGHT); Hintergrund und Logofarbe setzt glasLogo() für WEBklar. */ const HELL = { tileSize: 256, logoScale: 0.7, finalScale: 0.69, logoOffset: [0, 0], iconPlate: false, keepPlate: false, originalColors: false, opticalDepth: 1.1, dispersion: 1.7, colorBleed: 1.25, doubleBevel: 1, bevelWidth: 0.14, corner: 4.8, cornerPull: 2.1, cornerWidth: 2.2, cornerFocus: 0.58, edgeSmear: 2.85, stretch: 0, tilt: 0.35, bulge: 1, specular: 1, halo: 0, shadow: 1, ripple: 1, bounce: 0, response: 0.42, startSize: 0.2, startZoom: 2.6, glide: 2.3, present: 2.4, drift: 0.12, swell: 0.5, focus: 1, sheen: 1, lightTurn: 0.9, background: '#e9e9eb', plateColor: '#ffffff', logoColor: '#0e1428', } /** Vorgaben für das WEBklar-Logo: weiß wie die Seiten, Oval in #111111, Größen wie beim 21st.dev-Zeichen. */ export const WEBKLAR = { background: '#ffffff', logoColor: '#111111', logoScale: 0.6, finalScale: 0.8, startZoom: 2.3 } /* ------------------------------------------------------------------ * * Logo → Textur + vorzeichenbehaftetes Distanzfeld * ------------------------------------------------------------------ */ const TEX = 512 // Kantenlänge der Logotextur (Original 1024; 512 reicht für das Logo und rechnet auf dem Handy ~4× schneller) const BOX = 0.75 // Anteil der Textur, den das Logo füllt (der Rest ist Platz für das Distanzfeld) /** * Das WEBklar-Zeichen (Oval mit Stern und Punkt, wie favicon.svg) als Zeichenfläche: Stern und Punkt sind * ausgestanzt, damit der Shader die Form samt Löchern bricht. Gezeichnet statt als SVG-Bild geladen, so gibt es * keinen Ladeweg und keine „verschmutzte“ Zeichenfläche in älteren Safari-Versionen. */ export function webklarZeichen(doc = document) { const c = doc.createElement('canvas') c.width = c.height = TEX const ctx = c.getContext('2d') ctx.scale(TEX / 512, TEX / 512) ctx.fillStyle = '#ffffff' ctx.save() ctx.translate(256, 256) ctx.rotate((-28 * Math.PI) / 180) ctx.beginPath() ctx.ellipse(0, 0, 210, 150, 0, 0, Math.PI * 2) ctx.fill() ctx.globalCompositeOperation = 'destination-out' ctx.fill(new Path2D('M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z')) ctx.restore() ctx.globalCompositeOperation = 'destination-out' ctx.beginPath() ctx.arc(356, 172, 18, 0, Math.PI * 2) ctx.fill() return c } // Zeichnet die Quelle in eine TEX×TEX-Fläche, beschnitten auf ihre sichtbaren Pixel und in die Logobox eingepasst. // Undurchsichtige Bilder (JPG, flaches PNG) verlieren ihre Eckfarbe, damit das Zeichen die Form wird. function fitToTexture(source, sw, sh) { const tmp = document.createElement('canvas') tmp.width = tmp.height = TEX const tc = tmp.getContext('2d', { willReadFrequently: true }) const s0 = Math.min(TEX / sw, TEX / sh) const dx0 = (TEX - sw * s0) / 2 const dy0 = (TEX - sh * s0) / 2 tc.drawImage(source, dx0, dy0, sw * s0, sh * s0) const d = tc.getImageData(0, 0, TEX, TEX).data let opaque = true for (let i = 3; i < d.length; i += 4 * 97) if (d[i] < 250) { opaque = false; break } const key = [d[0], d[1], d[2]] const keyAlpha = (r, g, b) => Math.min(1, Math.max(0, (Math.hypot(r - key[0], g - key[1], b - key[2]) / 441 - 0.08) / 0.2)) let x0 = TEX, y0 = TEX, x1 = -1, y1 = -1 for (let y = 0; y < TEX; y++) { for (let x = 0; x < TEX; x++) { const i = (y * TEX + x) * 4 const a = opaque ? keyAlpha(d[i], d[i + 1], d[i + 2]) * 255 : d[i + 3] if (a > 8) { if (x < x0) x0 = x if (x > x1) x1 = x if (y < y0) y0 = y if (y > y1) y1 = y } } } if (x1 < 0) throw new Error('Glas-Logo: das Logo ist leer') const bw = x1 - x0 + 1 const bh = y1 - y0 + 1 const s = (TEX * BOX) / Math.max(bw, bh) const out = document.createElement('canvas') out.width = out.height = TEX const oc = out.getContext('2d', { willReadFrequently: true }) oc.imageSmoothingQuality = 'high' oc.drawImage(source, TEX / 2 - (x0 + bw / 2 - dx0) * s, TEX / 2 - (y0 + bh / 2 - dy0) * s, sw * s0 * s, sh * s0 * s) if (opaque) { const im = oc.getImageData(0, 0, TEX, TEX) const e = im.data for (let i = 0; i < e.length; i += 4) e[i + 3] = Math.round(e[i + 3] * keyAlpha(e[i], e[i + 1], e[i + 2])) oc.putImageData(im, 0, 0) } return out } // Distanzfeld aus dem Alphakanal (TinySDF-Verfahren): + außen, − innen, in Textureinheiten. function buildSdf(canvas) { const n = TEX * TEX const alpha = canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, TEX, TEX).data const INF = 1e20 const outer = new Float64Array(n) const inner = new Float64Array(n) for (let i = 0; i < n; i++) { const a = alpha[i * 4 + 3] / 255 if (a >= 1) { outer[i] = 0; inner[i] = INF } else if (a <= 0) { outer[i] = INF; inner[i] = 0 } else { const dd = 0.5 - a outer[i] = dd > 0 ? dd * dd : 0 inner[i] = dd < 0 ? dd * dd : 0 } } const f = new Float64Array(TEX) const z = new Float64Array(TEX + 1) const v = new Uint16Array(TEX) edt(outer, f, v, z, INF) edt(inner, f, v, z, INF) const sdf = new Float32Array(n) for (let i = 0; i < n; i++) sdf[i] = (Math.sqrt(outer[i]) - Math.sqrt(inner[i])) / TEX return sdf } function edt(grid, f, v, z, INF) { for (let x = 0; x < TEX; x++) edt1d(grid, x, TEX, f, v, z, INF) for (let y = 0; y < TEX; y++) edt1d(grid, y * TEX, 1, f, v, z, INF) } function edt1d(grid, offset, stride, f, v, z, INF) { v[0] = 0 z[0] = -INF z[1] = INF f[0] = grid[offset] for (let q = 1, k = 0, s = 0; q < TEX; q++) { f[q] = grid[offset + q * stride] const q2 = q * q do { const r = v[k] s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2 } while (s <= z[k] && --k > -1) k++ v[k] = q z[k] = s z[k + 1] = INF } for (let q = 0, k = 0; q < TEX; q++) { while (z[k + 1] < q) k++ const r = v[k] const qr = q - r grid[offset + q * stride] = f[r] + qr * qr } } // Ein Distanzfeld je Logo für die ganze Seite: der Aufbau dauert ~100 ms, Startbild und Anmeldung teilen es. const logoCache = new Map() function ladeLogo(schluessel, quelle) { let job = logoCache.get(schluessel) if (!job) { job = new Promise((resolve, reject) => { // Erst nach dem nächsten Bild rechnen, damit Weiß und Seite vorher stehen requestAnimationFrame(() => setTimeout(() => { try { const c = quelle() const canvas = fitToTexture(c, c.width, c.height) resolve({ canvas, sdf: buildSdf(canvas) }) } catch (err) { reject(err) } }, 0)) }) logoCache.set(schluessel, job) job.catch(() => logoCache.delete(schluessel)) } return job } /* ------------------------------------------------------------------ * * Shader (unverändert bis auf den auslaufenden Rand und das Rauschen in der letzten Zeile) * ------------------------------------------------------------------ */ const VERT = `#version 300 es in vec2 aPos; void main() { gl_Position = vec4(aPos, 0.0, 1.0); }` const FRAG = `#version 300 es precision highp float; out vec4 outColor; uniform vec2 uCenter; // device px, GL coords uniform float uTile; // tile size, device px uniform sampler2D uSdf; // signed distance, texture units, + outside uniform sampler2D uCol; // premultiplied colour, mipmapped uniform float uTexSize; uniform float uBoxFrac; uniform float uPlateScale; // plate size relative to the tile uniform float uShapeN; // superellipse exponent: 2 = circle uniform float uCornerN; // final exponent, used by the ghost uniform float uOptics; // 1 = full refraction, 0 = flat uniform float uPlateAlpha; // 1 = glass shown, 0 = bare logo uniform float uLogoHalf; // logo half size in plate units (zoom included) uniform vec2 uLogoOff; // logo centre, plate units uniform float uBold; // stroke swell, in logo half-size units uniform float uBlur; // edge softness, plate units uniform float uFrost; // milky lift inside a young bubble uniform float uDome; // lens bulge height uniform float uDrop; // 1 while the glass is a round droplet, 0 once it has spread into the tile uniform float uPinch; // the face's stretch toward its corners, at the rim uniform float uTilt; // extra stretch of the rising diagonal's corners (0–1) uniform float uRim; uniform float uHalo; uniform float uShadow; uniform float uGhost; uniform float uRipR; // ripple radius, plate sizes uniform float uRipA; uniform float uBare; // 1 once the logo may show without glass (the ending) uniform float uReach; // the rim band's full width, plate units uniform float uCornerPull; // how much harder the corners pull than the sides uniform float uStreak; // how far a corner smears content along the rim, in band widths uniform float uWiden; // how much wider the folding band grows toward the corners uniform float uSlab; // how far the slab stands above the logo, plate units uniform float uEtaSpread; // index spread across the spectrum (red highest) uniform float uFocus; // how far in along the diagonal a corner focuses, plate units uniform float uFocusK; // how quickly the view slides back from the focus across the band uniform float uFocusSplit; // how much further in red meets than violet, plate units uniform float uRimLine; // how far across the band the sight line takes to turn in to the focus uniform float uCornerOn; // 0 while the glass is round, 1 once it has spread into the tile uniform float uDissolve; // 0 → 1 as the glass lets go of the logo uniform float uGlow; // colour bleed in the rim, plate units on screen uniform float uFaceDisp; // lateral colour across the face uniform float uVivid; // chroma lift of the dispersed light uniform float uDisp; uniform float uDouble; uniform float uBevelW; uniform float uSpec; uniform float uIconPlate; uniform float uUseColor; uniform float uLightScene; // 1 = light backdrop, 0 = dark uniform float uLightGlass; // the same for what the glass sits on (the plate, when there is one) uniform float uKeep; // 1 = the glass settles into a flat plate instead of letting go of it uniform float uSheen; // strength of the light band crossing the face uniform float uSweep; // where that band is, plate sizes along its diagonal uniform float uLightAng; // how far the rim light has turned, radians uniform vec3 uBg; uniform vec3 uPlateCol; uniform vec3 uLogoCol; float px; // one device pixel in plate units vec2 rot(vec2 v, float a) { float c = cos(a), s = sin(a); return vec2(c * v.x - s * v.y, s * v.x + c * v.y); } // aa: footprint of one screen pixel at q, plate units. vec4 logoAt(vec2 q, float blur, float aa) { float L = uLogoHalf; float k = 0.5 * uBoxFrac / L; vec2 uv = vec2(0.5 + (q.x - uLogoOff.x) * k, 0.5 - (q.y - uLogoOff.y) * k); float d = texture(uSdf, uv).r / k; float w = 0.75 * aa + blur; float a = 1.0 - smoothstep(-w, w, d - uBold * L); vec3 c = uLogoCol; if (uUseColor > 0.5) { float lod = log2(max(1.0, w * k * uTexSize)); vec4 c0 = textureLod(uCol, uv, lod); vec4 c1 = textureLod(uCol, uv, lod + 3.0); vec4 c2 = textureLod(uCol, uv, 7.0); c = c2.rgb / max(c2.a, 1e-4); c = mix(c, c1.rgb / max(c1.a, 1e-4), smoothstep(0.0, 0.06, c1.a)); c = mix(c, c0.rgb / max(c0.a, 1e-4), smoothstep(0.02, 0.3, c0.a)); } return vec4(c, a); } // Superellipse field. Returns signed distance (plate units, − inside); g = 1 on the rim. float shape(vec2 p, float scale, float n, out vec2 nOut, out float g, out vec2 dg) { vec2 pp = p / scale; vec2 a = max(abs(pp), vec2(1e-6)); g = pow(pow(a.x, n) + pow(a.y, n), 1.0 / n); dg = sign(pp) * pow(a, vec2(n - 1.0)) * pow(max(g, 1e-6), 1.0 - n); float dgl = max(length(dg), 1e-6); nOut = dg / dgl; return (g - 1.0) / dgl * scale; } // Curvature radius (plate units) of the shape's contour through p. float contourRadius(vec2 p, float scale, float n) { vec2 a = max(abs(p / scale), vec2(1e-4)); vec2 a1 = pow(a, vec2(n - 1.0)); vec2 a2 = pow(a, vec2(n - 2.0)); float k = (n - 1.0) * (a2.x * a1.y * a1.y + a2.y * a1.x * a1.x) / pow(max(dot(a1, a1), 1e-30), 1.5); return scale / max(k, 1e-3); } // Where the glass at x = s / bwF across its rounded edge looks: a slab uSlab above the logo with a // quarter-round edge of radius bwF. A straight-down sight line bends inward by Snell's law, so the // outer rim looks deepest and the view eases back to the face across the edge. vec2 refractSample(float x, float bwF, float eta, float R, vec2 dirOut, vec2 p, float dome, float offMul) { float u = clamp(1.0 - x, 0.0, 0.995); float th = asin(u), thr = asin(u / eta); float z = uSlab + bwF * sqrt(1.0 - u * u); float off = offMul * z * tan(th - thr); off = off / (1.0 + off / (6.0 * R)); return p - (dirOut * off + p * dome) * uOptics; } float gauss(float x, float c, float w) { float d = (x - c) / w; return exp(-0.5 * d * d); } float band(float x, float a, float b, float w) { return smoothstep(a - w, a, x) * (1.0 - smoothstep(b, b + w, x)); } // Visible spectrum, red (0) to violet (1). float bump(float x, float c, float w) { float d = (x - c) / w; return exp(-0.5 * d * d); } vec3 spectrum(float x) { return vec3(bump(x, 0.34, 0.2) + 0.08 * bump(x, 0.93, 0.05), bump(x, 0.58, 0.1), bump(x, 0.8, 0.11)); } float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } void main() { px = 2.0 / uTile; vec2 p = (gl_FragCoord.xy - uCenter) * px; vec4 lg = logoAt(p, uBlur, px); vec3 plain = mix(uBg, lg.rgb, lg.a * uBare); vec2 nOut, dg; float g; float sd = shape(p, uPlateScale, uShapeN, nOut, g, dg); float s = -sd; float bw = max(uBevelW, 0.004) * uPlateScale; float x = clamp(s / bw, 0.0, 1.0); float R = contourRadius(p, uPlateScale, uShapeN); vec2 pq = p * p; float diag = 4.0 * pq.x * pq.y / max((pq.x + pq.y) * (pq.x + pq.y), 1e-6); float tilt = mix(1.0 - uTilt, 1.0, smoothstep(-0.02, 0.02, p.x * p.y)); float pinchPart = uPinch * min(g * g, 1.0) * diag * diag * tilt; float dome = uDome * (1.0 - min(g * g, 1.0)) + pinchPart; vec2 dirOut = normalize(mix(nOut, p / max(length(p), 1e-4), 0.2)); // Rounded corners curve both ways, so they draw content in from deeper than the sides. float cornerness = max(smoothstep(0.1, 0.9, 4.0 * nOut.x * nOut.x * nOut.y * nOut.y), smoothstep(0.15, 0.75, diag)); float offMul = 1.0 + 0.4 * max(uCornerPull - 1.0, 0.0) * tilt * cornerness * uCornerOn; // Around a corner every sight line meets at one focus a little way in along the diagonal. vec2 focus = sign(p) * (pow(0.5, 1.0 / uShapeN) - uFocus * 0.70710678) * uPlateScale; float focusW = tilt * cornerness * uOptics * uCornerOn; float bwF = max(uReach, 0.004) * (1.0 + uWiden * tilt * cornerness * uCornerOn); float xf = clamp(s / bwF, 0.0, 1.0); // Footprint of one pixel after refraction, measured in uniform control flow. vec2 qc = refractSample(xf, bwF, 1.5, R, dirOut, p, dome, offMul); float foot = max(max(length(dFdx(qc)), length(dFdy(qc))), 0.25 * px); // Backdrop: anticipation ghost, ripple, soft lift shadow and glow. vec3 bg = uBg; vec3 lift = mix(uBg + vec3(0.06), mix(uBg, vec3(1.0), 0.55), uLightScene); if (uGhost > 0.001) { vec2 gn, gdg; float gg; float gsd = shape(p, 1.0, uCornerN, gn, gg, gdg); bg = mix(bg, lift, uGhost * 0.12 * (1.0 - smoothstep(-0.16, 0.14, gsd))); bg *= 1.0 - uGhost * 0.03 * uLightScene * exp(-max(gsd, 0.0) / 0.12) * step(0.0, gsd); } float outside = max(sd, 0.0); if (uRipA > 0.001) { float d0 = (uRipR - 1.0) * uPlateScale; float rx = (sd - d0) / 0.022; float ring = exp(-rx * rx); bg += uRipA * ring * (0.04 * (0.55 + 0.45 * dot(nOut, normalize(vec2(-0.5, 0.8))))) * mix(0.5, 1.0, uLightScene); bg -= uRipA * 0.016 * uLightScene * exp(-(rx - 1.6) * (rx - 1.6)); } float shadowFall = max(sd, 0.0) / (0.9 * uPlateScale); bg *= 1.0 - uShadow * 0.03 * exp(-shadowFall * shadowFall) * step(0.0, sd); vec3 haloCol = mix(vec3(0.62, 0.69, 0.80), vec3(1.0), uLightScene); bg = mix(bg, haloCol, clamp(uHalo * (0.30 * exp(-outside / (0.025 * uPlateScale)) + 0.07 * exp(-outside / (0.12 * uPlateScale))), 0.0, 1.0) * step(0.0, sd)); // Schatten, Welle und Schein laufen zum Rand der Zeichenfläche hin aus: keine Kante, wo die Fläche auf die Seite trifft vec2 border = min(gl_FragCoord.xy, 2.0 * uCenter - gl_FragCoord.xy); bg = mix(uBg, bg, smoothstep(0.0, 0.2 * uTile, min(border.x, border.y))); vec3 col = bg; float soft = px + uBlur * 0.8 + 0.06 * uDissolve * (1.0 - uKeep) * uPlateScale; if (sd < soft) { // Lighting surface: a superellipse quarter, flat toward the face and steep at the rim. float y1 = 1.0 - x; const float K = 2.6; float base1 = max(1.0 - pow(y1, K), 1e-4); float db = min(pow(y1, K - 1.0) * pow(base1, 1.0 / K - 1.0), 25.0); // A second, lower step just inside the first draws the faint inner line. float s1 = bw * 0.62; float w2 = bw * 0.22; float t2 = clamp((s - s1) / w2, -1.0, 1.0); float dh2 = uDouble * bw * 0.16; float dd = dh2 * 0.5 * cos(t2 * 1.5707963) * 1.5707963 / w2 * step(abs(t2), 0.999); vec2 gDome = -uDome * 2.0 * g * dg * step(g, 1.0); vec2 grad = -(db + dd) * nOut + gDome; vec3 back = mix(uBg, uPlateCol, uIconPlate); vec3 liftG = mix(back + vec3(0.06), mix(back, vec3(1.0), 0.55), uLightGlass); float rblur = uBlur + 0.05 * length(qc - p) + min(uGlow * uOptics * (1.0 - xf) * foot / px, 0.04); // Each wavelength refracts by its own index, so everything the edge draws in fans into colour // while the face stays crisp. Around a corner the samples also smear along the rim. float cornerW = smoothstep(0.1, 0.9, 4.0 * nOut.x * nOut.x * nOut.y * nOut.y); float streak = 0.35 * uStreak * bw * mix(0.15, 1.0, cornerW) * pow(1.0 - xf, 1.5) * uOptics; vec2 tangent = vec2(-nOut.y, nOut.x); vec3 acc = vec3(0.0), wsum = vec3(0.0); for (int i = 0; i < 20; i++) { float lam = (float(i) + 0.5) / 20.0; float c = uDisp * (1.0 - 2.0 * lam); vec2 along = tangent * (streak * 2.0 * (fract(float(i) * 0.618034 + 0.31) - 0.5)); float cf = uDisp * (1.0 - 2.0 * lam) * (lam > 0.5 ? 1.0 : 0.5); vec2 sp = refractSample(xf, bwF, 1.5 + uEtaSpread * c, R, dirOut, p + along, dome + pinchPart * 0.3 * c, offMul); vec2 focusL = focus - sign(p) * (0.70710678 * uFocusSplit * c * uPlateScale); float wFocus = pow(1.0 - xf, uFocusK) * smoothstep(0.0, uRimLine * (1.0 + 0.6 * c), xf); sp = mix(sp, mix(p + along, focusL, wFocus), focusW); float faceOnly = smoothstep(0.7, 1.0, xf) * uOptics; sp *= 1.0 + uFaceDisp * cf * faceOnly; vec4 l = logoAt(sp, rblur + 0.12 * streak + uFaceDisp * 0.5 * max(c, 0.0) * faceOnly, foot); vec3 w = spectrum(lam); acc += mix(back, l.rgb, l.a) * w; wsum += w; } vec3 trans = acc / wsum; float tl = dot(trans, vec3(0.2126, 0.7152, 0.0722)); trans = clamp(tl + (trans - tl) * (1.0 + uVivid * uOptics), 0.0, 1.0); // A young droplet is milky; the haze only ever lightens. float hazeLum = dot(trans, vec3(0.2126, 0.7152, 0.0722)); float hazeGate = mix(1.0 - smoothstep(0.25, 0.75, hazeLum), smoothstep(0.25, 0.75, hazeLum), uLightGlass); trans = mix(trans, liftG, uFrost * 0.45 * hazeGate); float glassAmt = pow(1.0 - uDissolve, 1.5); float lit = uSpec * glassAmt; vec3 rimCol = mix(vec3(0.80, 0.87, 0.97), vec3(1.0), uLightGlass); // Light backdrop: the thick edge glows softly white over a clear face, with a hairline that is // bright along the top and right and a thin dark silhouette down the left and bottom. float sc = uPlateScale; float tLum = dot(trans, vec3(0.2126, 0.7152, 0.0722)); float lightOnly = smoothstep(0.35, 0.85, tLum); float hw = smoothstep(-0.85, 0.5, dot(nOut, rot(vec2(0.5, 0.866), uLightAng))); float tileBand = band(s, 0.016 * sc, 0.075 * sc, 0.022 * sc); float dropBand = (1.0 - smoothstep(0.06 * sc, 0.13 * sc, s)) * smoothstep(0.0, 0.004 * sc, s); float glow = mix(0.32 * tileBand, 0.75 * dropBand, uDrop) * lightOnly; float edgeLine = gauss(s, 0.005 * sc, 0.0045 * sc); float lip = gauss(s, 0.15 * sc, 0.007 * sc) * uDouble * (1.0 - uDrop); vec3 lightGlass = mix(trans, vec3(1.0), glow * glassAmt); lightGlass += (0.075 * hw * edgeLine + 0.06 * (0.4 + 0.6 * hw) * lip) * lit; lightGlass *= 1.0 - 0.05 * (1.0 - hw) * edgeLine * glassAmt * (1.0 - uDrop); lightGlass = mix(lightGlass, vec3(1.0), 0.55 * uDrop * glassAmt * edgeLine); // Dark backdrop: a cool rim light that grows toward the steep edge, and a small key highlight. vec3 Nl = normalize(vec3(-grad, 1.0)); float edge = 1.0 - Nl.z; vec2 ldir = rot(normalize(vec2(-0.62, 0.78)), uLightAng); float side = dot(nOut, ldir); float fres = pow(edge, 1.5); vec3 rim = rimCol * fres * (0.35 + 0.65 * max(side, -side * 0.8)) * uRim * 0.65; vec3 Hv = normalize(normalize(vec3(-0.45, 0.6, 0.66)) + vec3(0.0, 0.0, 1.0)); float spc = pow(max(dot(Nl, Hv), 0.0), 140.0) * 0.8 * smoothstep(0.0, 0.25, y1); vec3 darkGlass = trans * (1.0 - 0.25 * fres) + (rim + vec3(spc)) * lit; darkGlass = mix(darkGlass, rimCol, uDrop * 0.4 * uSpec * (1.0 - smoothstep(0.0, 0.1 * uPlateScale, s))); vec3 glass = mix(darkGlass, lightGlass, uLightGlass); // While the glass holds the logo a soft band of light crosses the face, brightest near the rim. float sweepD = dot(p / uPlateScale, normalize(vec2(0.8, 0.6))) - uSweep; float sheen = uSheen * glassAmt * exp(-sweepD * sweepD / 0.03) * (0.55 + 0.45 * (1.0 - x)); glass = mix(glass, vec3(1.0), sheen * mix(0.22, 0.42, uLightGlass)); float cov = clamp(0.5 - sd / soft, 0.0, 1.0); col = mix(bg, glass, cov); } vec3 outc = mix(plain, col, uPlateAlpha); // Rauschen gegen Farbstufen, aber nicht auf reinem Weiß: dort bleibt die Fläche exakt so weiß wie die Seite float weiss = step(0.9995, min(min(outc.r, outc.g), outc.b)); outc += (hash(gl_FragCoord.xy) - 0.5) / 255.0 * (1.0 - weiss); outColor = vec4(outc, 1.0); }` /* ------------------------------------------------------------------ * * Zeichner * ------------------------------------------------------------------ */ function createRenderer(canvas) { const gl = canvas.getContext('webgl2', { antialias: false, premultipliedAlpha: false }) if (!gl) return null const compile = (type, src) => { const sh = gl.createShader(type) gl.shaderSource(sh, src) gl.compileShader(sh) if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh) || 'shader') return sh } const prog = gl.createProgram() gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT)) gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG)) gl.linkProgram(prog) if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'program') gl.useProgram(prog) gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()) gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW) const loc = gl.getAttribLocation(prog, 'aPos') gl.enableVertexAttribArray(loc) gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) const U = {} const count = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS) for (let i = 0; i < count; i++) { const info = gl.getActiveUniform(prog, i) U[info.name] = gl.getUniformLocation(prog, info.name) } const sdfTex = gl.createTexture() const colTex = gl.createTexture() gl.uniform1i(U.uSdf, 0) gl.uniform1i(U.uCol, 1) let hasLogo = false const params = (filter) => { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) } function setLogo(logo) { gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, sdfTex) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false) gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, TEX, TEX, 0, gl.RED, gl.FLOAT, logo.sdf) params(gl.LINEAR) gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, colTex) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, logo.canvas) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false) gl.generateMipmap(gl.TEXTURE_2D) params(gl.LINEAR_MIPMAP_LINEAR) hasLogo = true } function render(fr, w, h, tilePx) { if (!hasLogo) return gl.viewport(0, 0, w, h) gl.uniform2f(U.uCenter, w / 2, h / 2) gl.uniform1f(U.uTile, tilePx) gl.uniform1f(U.uTexSize, TEX) gl.uniform1f(U.uBoxFrac, BOX) for (const k in fr) { const v = fr[k] if (typeof v === 'number' && U[k]) gl.uniform1f(U[k], v) } gl.uniform3fv(U.uBg, fr.uBg) gl.uniform3fv(U.uPlateCol, fr.uPlateCol) gl.uniform3fv(U.uLogoCol, fr.uLogoCol) gl.uniform2fv(U.uLogoOff, fr.uLogoOff) gl.drawArrays(gl.TRIANGLES, 0, 3) } function dispose() { gl.getExtension('WEBGL_lose_context')?.loseContext() } return { setLogo, render, dispose } } /* ------------------------------------------------------------------ * * Feder-Zeitleiste * ------------------------------------------------------------------ */ const TAIL = 1.1 // vom Loslassen des Glases bis alles ruht, Sekunden /** Sekunden vom ersten Bild bis zum ruhenden Logo (bei Tempo 1). */ export const dauer = (P = HELL) => P.present + TAIL const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x) const mix = (a, b, t) => a + (b - a) * t const smooth = (a, b, x) => { const t = clamp01((x - a) / (b - a)) return t * t * (3 - 2 * t) } const smoother = (a, b, x) => { const t = clamp01((x - a) / (b - a)) return t * t * t * (t * (t * 6 - 15) + 10) } function hexToRgb(hex) { let h = hex.replace('#', '') if (h.length === 3) h = h.replace(/./g, '$&$&') const v = parseInt(h.slice(0, 6), 16) || 0 return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255] } const luminance = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b // Sprungantwort einer Feder, losgelassen bei t = 0, von 0 nach 1 (Dämpfung 1 = kein Überschwingen, // response = Periode der ungedämpften Feder in Sekunden). function spring(t, response, damping) { if (t <= 0) return 0 const w = (2 * Math.PI) / response if (damping >= 1) return 1 - Math.exp(-w * t) * (1 + w * t) const wd = w * Math.sqrt(1 - damping * damping) return 1 - Math.exp(-damping * w * t) * (Math.cos(wd * t) + ((damping * w) / wd) * Math.sin(wd * t)) } const reachCache = new Map() function timeToReach(level, response, damping) { const key = `${level}/${response}/${damping}` let t = reachCache.get(key) if (t === undefined) { let lo = 0 let hi = 3 for (let i = 0; i < 40; i++) { const mid = (lo + hi) / 2 if (spring(mid, response, damping) < level) lo = mid else hi = mid } reachCache.set(key, (t = hi)) } return t } /** * Alle Bewegung steckt hier, der Shader sieht nur die Zahlen. Der Tropfen wächst auf einer Feder, wird beim * Landen vom Kreis zur Kachel, das Logo gleitet aus der Vergrößerung, das Glas hält es, während ein Lichtband * über die Fläche wandert; dann lässt das Glas los und das blanke Logo bleibt. */ export function frameAt(T, P = HELL, outro = 0) { const tx = P.present const keep = P.keepPlate ? 1 : 0 const damping = 1 - P.bounce const grow = spring(T + 0.01, P.response, damping) const tl = timeToReach(0.85, P.response, damping) - 0.01 // der Tropfen landet: die Welle läuft los const tm = timeToReach(0.92, P.response, damping) - 0.01 // und breitet sich zur Kachel aus const morph = spring(T - tm, 0.4, 1) const drop = 1 - smooth(0.05, 1, morph) const appear = smooth(0, 0.08, T) const drift = P.drift * spring(T - 0.3, 2.2, 1) // Ein Viertel des Zooms geht mit dem landenden Tropfen, der Rest gleitet langsam, damit das Logo groß am // Rand verweilt, wo das Glas es am stärksten bricht. const zoomIn = 0.25 * spring(T, 0.7, 1) + 0.75 * smoother(0, P.glide, T) const settle = spring(T - 0.5, 2.4, 1) const exit = spring(T - tx, 0.95, 1) const release = spring(T - tx - 0.05, 0.65, 1) const dome = 0.15 * P.bulge * (1 - smooth(0.4, 0.95, grow)) const view = (1 + (P.startZoom - 1) * (1 - zoomIn)) * (1 - 0.045 * settle) * mix(1, P.finalScale, exit) * (1 - 0.06 * outro) const bandW = mix(P.bevelWidth, Math.max(P.bevelWidth, 0.4), drop) const plateScale = mix(P.startSize, 1, grow) * (1 + drift) * (1 + 0.14 * release * (1 - keep)) * (1 - keep * (0.03 * release + 0.06 * outro)) const bg = hexToRgb(P.background) return { uPlateScale: plateScale, uShapeN: mix(2, P.corner, morph), uCornerN: P.corner, uOptics: 1 - smooth(0.55, 1, release), uPlateAlpha: keep ? appear * (1 - outro) : appear * (1 - smooth(0.45, 1, release)), uLogoHalf: P.logoScale * view, uLogoOff: [P.logoOffset[0] * view, P.logoOffset[1] * view], uKeep: keep, uSheen: P.sheen, uSweep: mix(-1.6, 1.6, smoother(0.9, tx - 0.1, T)), uLightAng: P.lightTurn * smoother(0.4, tx + 0.4, T), uBold: P.swell * 0.03 * (1 - zoomIn), uBlur: P.focus * 0.05 * (1 - smooth(0.3, 0.95, grow)) + 0.03 * outro, uFrost: P.focus * (1 - smooth(0.5, 0.95, grow)), uPinch: 0.38 * P.stretch * smooth(0.1, 0.9, morph) * smooth(0.75, 0.97, zoomIn), uTilt: P.tilt, uDome: dome, uDrop: drop, uRim: 1 + 0.8 * drop, uHalo: P.halo * (0.5 + 0.5 * drop) * (1 - keep * release), uShadow: Math.max(P.shadow * smooth(0.45, 0.8, grow) * (1 - smooth(0, 0.6, morph)), 0.8 * P.shadow * keep * release), uGhost: smooth(0, 0.03, T) * (1 - smooth(0.04, 0.08, T)), uRipR: 1 + 0.9 * Math.max(0, T - tl), uRipA: P.ripple * smooth(tl, tl + 0.06, T) * Math.exp(-Math.max(0, T - tl) / 0.6), uBare: (1 - keep) * smooth(tx - 0.25, tx, T) * (1 - outro), uReach: bandW * plateScale, uCornerPull: P.cornerPull, uStreak: P.edgeSmear, uWiden: P.cornerWidth, uSlab: 0.3 * P.opticalDepth, uEtaSpread: 0.06, uFocus: P.cornerFocus, uFocusK: 1.0, uFocusSplit: 0.056 * P.colorBleed, uRimLine: 0.09, uCornerOn: smooth(0.35, 1, morph), uGlow: 0.012 * P.colorBleed, uFaceDisp: 0.012 * P.dispersion, uVivid: 0.85, uDissolve: release, uDisp: P.dispersion, uDouble: P.doubleBevel, uBevelW: bandW * (1 - release), uSpec: P.specular, uIconPlate: P.iconPlate ? 1 : 0, uUseColor: P.originalColors ? 1 : 0, uLightScene: luminance(bg) > 0.5 ? 1 : 0, uLightGlass: luminance(P.iconPlate ? hexToRgb(P.plateColor) : bg) > 0.5 ? 1 : 0, uBg: bg, uPlateCol: hexToRgb(P.plateColor), uLogoCol: hexToRgb(P.logoColor), } } /** Einstellungen aus Vorgaben, WEBklar-Werten und eigenen Werten (wie die Props des Originals). */ export function einstellungen(eigene = {}) { return { ...HELL, ...WEBKLAR, ...eigene } } /* ------------------------------------------------------------------ * * Baustein * ------------------------------------------------------------------ */ /** * Spielt das Glas-Logo in `wrap` (Größe gibt das Element vor; die Kachel passt sich an, höchstens tileSize). * optionen: { einstellungen, tempo, standbild (gleich das ruhende Logo zeigen), rand, label } * rand: so oft passt die Kachel in die kürzere Seite von `wrap` (Original 1,75; auf Weiß reichen ~1,4, weil Schatten * und Welle dort kaum sichtbar sind und das Glas höchstens 1,28 Kacheln breit wird). * Gibt { fertig, abspielen (true, wenn es neu startet), zeigeEnde, entfernen } zurück; `fertig` erfüllt sich, sobald das Logo ruht * (auch ohne WebGL2, bei reduzierter Bewegung oder wenn etwas schiefgeht, damit niemand darauf hängen bleibt). */ export function glasLogo(wrap, { einstellungen: eigene, tempo = 1, standbild = false, rand = 1.75, label = 'WEBklar' } = {}) { const P = einstellungen(eigene) let melde const fertig = new Promise((resolve) => { melde = resolve }) wrap.setAttribute('role', 'img') wrap.setAttribute('aria-label', label) wrap.style.backgroundColor = P.background if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative' wrap.style.overflow = 'hidden' const motor = starteMotor(wrap, { P, tempo, rand, amEnde: () => melde() }) if (!motor) { zeigeErsatz(wrap, P) melde() return { fertig, abspielen: () => false, zeigeEnde() {}, entfernen() { wrap.replaceChildren() } } } ladeLogo('webklar', () => webklarZeichen()).then( (data) => motor.setLogo(data, { standbild }), (err) => { console.error(err); melde() }, ) return { fertig, abspielen: () => motor.abspielen(), zeigeEnde: () => motor.zeigeEnde(), entfernen: () => motor.dispose(), } } /** Ohne WebGL2: das ruhende Logo als CSS-Maske in der Logofarbe. */ function zeigeErsatz(wrap, P) { const span = document.createElement('span') span.setAttribute('aria-hidden', 'true') const groesse = `${Math.round(P.tileSize * P.logoScale * P.finalScale * 0.955)}px` const url = `url("${webklarZeichen().toDataURL('image/png')}")` Object.assign(span.style, { position: 'absolute', inset: '0', margin: 'auto', display: 'block', width: groesse, height: groesse, maxWidth: '40%', maxHeight: '40%', backgroundColor: P.logoColor, maskImage: url, webkitMaskImage: url, maskSize: 'contain', webkitMaskSize: 'contain', maskRepeat: 'no-repeat', webkitMaskRepeat: 'no-repeat', maskPosition: 'center', webkitMaskPosition: 'center', }) wrap.append(span) } // Besitzt die Zeichenfläche: eine frische je Aufruf (ein verlorener Kontext lässt sich auf demselben Element nie // wiederbeleben), gezeichnet nur, solange sichtbar und nur, wenn sich etwas geändert hat. function starteMotor(wrap, live) { const canvas = document.createElement('canvas') canvas.setAttribute('aria-hidden', 'true') canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block' wrap.appendChild(canvas) let renderer = null try { renderer = createRenderer(canvas) } catch (err) { console.error(err) } if (!renderer) { canvas.remove() return null } const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const end = dauer(live.P) let logo = null let time = 0 let playing = false let dirty = true let visible = false let lost = false let raf = 0 let last = 0 function tick(now) { raf = 0 const dt = Math.min(0.1, (now - last) / 1000) last = now if (playing && logo) { time += dt * live.tempo if (time >= end) { time = end playing = false live.amEnde() } dirty = true } if (dirty && logo && renderer && !lost) { const dpr = Math.min(window.devicePixelRatio || 1, 2) const cw = canvas.clientWidth const ch = canvas.clientHeight const w = Math.max(1, Math.round(cw * dpr)) const h = Math.max(1, Math.round(ch * dpr)) if (canvas.width !== w || canvas.height !== h) { canvas.width = w canvas.height = h } const tile = Math.min(live.P.tileSize, Math.min(cw, ch) / live.rand) renderer.render(frameAt(Math.min(time, end), live.P), w, h, tile * dpr) dirty = false } if (visible && playing && logo && !lost) raf = requestAnimationFrame(tick) } function kick() { if (raf || !visible || lost) return last = performance.now() raf = requestAnimationFrame(tick) } function zeigeEnde() { time = end playing = false dirty = true live.amEnde() kick() } const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting if (visible) kick() else if (raf) { cancelAnimationFrame(raf) raf = 0 } }) io.observe(wrap) const ro = new ResizeObserver(() => { dirty = true kick() }) ro.observe(wrap) const onLost = (e) => { e.preventDefault() lost = true } const onRestored = () => { lost = false try { renderer = createRenderer(canvas) if (renderer && logo) renderer.setLogo(logo) } catch (err) { console.error(err) } dirty = true kick() } canvas.addEventListener('webglcontextlost', onLost) canvas.addEventListener('webglcontextrestored', onRestored) return { setLogo(data, { standbild }) { logo = data renderer?.setLogo(data) // Reduzierte Bewegung: gleich das ruhende Logo (das Original zeigte ein Standbild mit Glas) if (standbild || reduceMotion) return zeigeEnde() time = 0 playing = true dirty = true kick() }, abspielen() { if (!logo || reduceMotion) return false time = 0 playing = true dirty = true kick() return true }, zeigeEnde, dispose() { if (raf) cancelAnimationFrame(raf) io.disconnect() ro.disconnect() canvas.removeEventListener('webglcontextlost', onLost) canvas.removeEventListener('webglcontextrestored', onRestored) renderer?.dispose() canvas.remove() }, } }