From 4285f573700a7b8306fde9d85b6fa6fc13c389e8 Mon Sep 17 00:00:00 2001 From: Aman Sharma Date: Fri, 10 Jul 2026 12:53:53 +0530 Subject: [PATCH] perf: demand-driven rendering + mobile-aware auto (1.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit svg-clone and webgl no longer run an unconditional rAF loop. The loop spins down after ~1s with no observed motion and wakes on scroll / resize / pointer / CSS transitions & animations / style mutations / ResizeObserver / update()/refresh(). Idle cost per instance drops from 60 rAF ticks + 360 SVG attr writes + 120 forced rect reads per second (svg-clone) and 60 GL draws/s (webgl) to zero. - syncClone: relative-geometry no-op guard — scrolling glass and background together no longer touches the filter (attr writes forced a full re-filter every frame, the main mobile jank source) - renderGL: uniform-input key early-out; static image backgrounds skip the whole GL pass when nothing moved - new `live` option: null (auto — video/canvas backgrounds stay per-frame), false forces on-demand for static canvases, true forces a permanent loop. Wired through web component, React and types. - auto mode resolves to css on phones (userAgentData.mobile, falling back to any-pointer:fine) — the svg filter chain re-runs on every scrolled frame and janks mobile GPUs - ResizeObserver now also watches the background element - bench/verify-perf.mjs: 14 checks — idle counters, wake-on-scroll, scrolled-vs-fresh alignment, live/canvas semantics, mobile auto Co-Authored-By: Claude Fable 5 --- LiquidGlass.mjs | 2 +- README.md | 16 ++++- bench/verify-perf.mjs | 132 ++++++++++++++++++++++++++++++++++++++++++ liquid-glass.d.ts | 11 +++- liquid-glass.js | 98 +++++++++++++++++++++++++------ package.json | 2 +- site/liquid-glass.js | 98 +++++++++++++++++++++++++------ 7 files changed, 319 insertions(+), 40 deletions(-) create mode 100644 bench/verify-perf.mjs diff --git a/LiquidGlass.mjs b/LiquidGlass.mjs index fb75c85..3daf693 100644 --- a/LiquidGlass.mjs +++ b/LiquidGlass.mjs @@ -19,7 +19,7 @@ import LG from "./liquid-glass.js"; const GLASS_KEYS = [ "mode", "frost", "refraction", "depth", "dispersion", "splay", "lightAngle", "lightIntensity", "curvature", "convexity", "zoom", "bevel", - "tint", "tintOpacity", "sheen", "sheenColor", "sheenAngle", "saturate", "brightness", "shadow", "radius", "background", + "tint", "tintOpacity", "sheen", "sheenColor", "sheenAngle", "saturate", "brightness", "shadow", "radius", "background", "live", ]; const LiquidGlass = React.forwardRef(function LiquidGlass({ as: Tag = "div", children, ...props }, forwardedRef) { diff --git a/README.md b/README.md index 21d21f7..1abbde0 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ import Glass from 'glasskit-js/react'; | `svg` | ✓ | **Chromium** | live backdrop | the "wow" surface on Chrome/Edge | | `svg-clone` | ✓ | **all** | a cloned DOM element | cross-browser refraction over DOM | | `webgl` | ✓ | **all** | an img/canvas/video | hero over a fixed background/video | -| `auto` | — | — | — | Chromium→`svg`, else `svg-clone` if `background` set, else `css` | +| `auto` | — | — | — | phones→`css`, Chromium→`svg`, else `svg-clone` if `background` set, else `css` | `svg-clone` is the cross-browser trick: Safari/Firefox don't allow an SVG filter in `backdrop-filter`, so it **clones the background element and filters the clone** instead. @@ -82,6 +82,20 @@ It refracts DOM, not `` pixels — use `webgl` for canvas/video backgrou - **`radius`** sets `border-radius` on the element *and* the refraction map — `radius={999}` alone gives you a pill; no need to also set it in `style`. - **`shadow`** is any CSS `box-shadow` (`"none"` removes it); the inner light border/bezel follow `lightIntensity`. +## Performance + +Rendering is **demand-driven**: `svg-clone` and `webgl` idle completely when nothing moves +(zero layouts, zero filter re-runs, zero GL passes) and wake on scroll / resize / pointer / +CSS motion / `update()`. Video and canvas backgrounds are the exception — their pixels can +change without any layout signal, so they re-render per frame; pass **`live: false`** for a +canvas you only draw once (call `refresh()` after redrawing it), or `live: true` to force a +permanent per-frame loop. + +On phones, `auto` resolves to **`css`** — Chromium re-runs the whole SVG filter chain on +every scrolled frame, which janks mobile GPUs. `css` (blur + tint + sheen + rim, no +refraction) is the mode to reach for on mobile; the refraction modes remain available by +requesting them explicitly. + ## Shapes Any **rounded rectangle (incl. pills & circles)** works with zero extra code — a diff --git a/bench/verify-perf.mjs b/bench/verify-perf.mjs new file mode 100644 index 0000000..2a99e61 --- /dev/null +++ b/bench/verify-perf.mjs @@ -0,0 +1,132 @@ +/* Verifies the demand-driven rendering work: + * - svg-clone / webgl loops spin down when nothing moves (0 rAF, 0 SVG attr writes, 0 GL draws) + * - they wake on scroll and on update(), then spin down again + * - a scrolled svg-clone stays byte-identical to a freshly-applied instance (no stale sync) + * - video/canvas backgrounds keep rendering per frame by default; `live: false` opts out + * - `auto` resolves to css on mobile, still svg on desktop Chromium + * Runs self-contained (no dev server needed). */ +import { chromium, devices } from "playwright-core"; + +const b = await chromium.launch({ channel: "chrome", headless: true }); +const p = await b.newPage({ viewport: { width: 800, height: 600 } }); +const errs = []; +p.on("pageerror", (e) => errs.push(e.message)); +let fail = false; +const check = (name, ok, extra = "") => { + console.log(`${name}: ${ok ? "✓" : "✗ FAIL"}${extra ? " (" + extra + ")" : ""}`); + if (!ok) fail = true; +}; + +await p.setContent(` +
+
+
+`); +await p.addScriptTag({ path: "site/liquid-glass.js" }); +// instrument BEFORE any apply: count rAF callbacks, SVG attribute writes and GL draws +await p.evaluate(() => { + window.__raf = 0; window.__attrs = 0; window.__draws = 0; + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = (cb) => raf((t) => { window.__raf++; cb(t); }); + const sa = SVGElement.prototype.setAttribute; + SVGElement.prototype.setAttribute = function (...a) { window.__attrs++; return sa.apply(this, a); }; + const da = WebGLRenderingContext.prototype.drawArrays; + WebGLRenderingContext.prototype.drawArrays = function (...a) { window.__draws++; return da.apply(this, a); }; +}); +const delta = async (key, ms) => { + const a = await p.evaluate((k) => window[k], key); + await p.waitForTimeout(ms); + return (await p.evaluate((k) => window[k], key)) - a; +}; + +/* ---------------- svg-clone: idle → wake on scroll → realign → idle ---------------- */ +await p.evaluate(() => { window.__i = Glasskit.apply(document.querySelector("#g"), { mode: "svg-clone", background: "#bg" }); }); +await p.waitForTimeout(2200); // > IDLE_LIMIT (60 frames) — loop must have spun down +check("svg-clone: rAF idle after settle", (await delta("__raf", 1000)) <= 5); +check("svg-clone: no SVG attr writes while idle", (await delta("__attrs", 1000)) === 0); + +const topBefore = await p.evaluate(() => document.querySelector("#g").firstElementChild.firstElementChild.style.top); +await p.mouse.move(400, 300); +await p.mouse.wheel(0, 100); // fixed bg + in-flow glass: relative offset changes by 100px +await p.waitForTimeout(500); +const topAfter = await p.evaluate(() => document.querySelector("#g").firstElementChild.firstElementChild.style.top); +check("svg-clone: scroll wakes the loop and re-syncs the clone", topBefore !== topAfter, `${topBefore} → ${topAfter}`); + +// alignment truth: the scrolled instance must render like a fresh one applied here. +// (Chromium's SVG filter output isn't bit-stable across filter instances — ±3/255 noise — +// so compare with a tight tolerance; a genuinely stale clone would be ~100px off and huge.) +const CLIP = { x: 80, y: 100, width: 300, height: 180 }; // glass viewport rect after the 100px scroll +await p.waitForTimeout(600); +const scrolled = await p.screenshot({ clip: CLIP }); +await p.evaluate(() => { window.__i.destroy(); window.__i = Glasskit.apply(document.querySelector("#g"), { mode: "svg-clone", background: "#bg" }); }); +await p.waitForTimeout(600); +const fresh = await p.screenshot({ clip: CLIP }); +const maxDelta = await p.evaluate(async ({ a, c }) => { + const load = (b64) => new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.src = "data:image/png;base64," + b64; }); + const [ia, ic] = await Promise.all([load(a), load(c)]); + const cv = document.createElement("canvas"); cv.width = ia.width; cv.height = ia.height; + const cx = cv.getContext("2d", { willReadFrequently: true }); + cx.drawImage(ia, 0, 0); const da = cx.getImageData(0, 0, cv.width, cv.height).data; + cx.clearRect(0, 0, cv.width, cv.height); + cx.drawImage(ic, 0, 0); const dc = cx.getImageData(0, 0, cv.width, cv.height).data; + let max = 0; + for (let i = 0; i < da.length; i++) if (i % 4 !== 3) max = Math.max(max, Math.abs(da[i] - dc[i])); + return max; +}, { a: scrolled.toString("base64"), c: fresh.toString("base64") }); +check("svg-clone: scrolled render matches fresh-apply render", maxDelta <= 4, `max channel delta ${maxDelta}`); +await p.waitForTimeout(1800); +check("svg-clone: idles again after motion stops", (await delta("__raf", 1000)) <= 5); +await p.evaluate(() => window.__i.destroy()); + +/* ---------------- webgl: static image idles; update() wakes; canvas is live ---------------- */ +await p.evaluate(async () => { + const cv = document.createElement("canvas"); cv.width = 512; cv.height = 512; + const cx = cv.getContext("2d"); + const gr = cx.createLinearGradient(0, 0, 512, 512); gr.addColorStop(0, "#16a"); gr.addColorStop(1, "#fa0"); + cx.fillStyle = gr; cx.fillRect(0, 0, 512, 512); + window.__cv = cv; + const img = new Image(); img.src = cv.toDataURL(); + await new Promise((r) => { img.onload = r; }); + img.style.cssText = "position:fixed;inset:0;width:100%;height:100%"; + document.body.appendChild(img); + const g2 = document.createElement("div"); + g2.style.cssText = "position:fixed;left:80px;top:100px;width:300px;height:180px;border-radius:24px"; + document.body.appendChild(g2); + window.__g2 = g2; + window.__iw = Glasskit.apply(g2, { mode: "webgl", background: img }); +}); +await p.waitForTimeout(2200); +check("webgl img: 0 GL draws/s while idle", (await delta("__draws", 1000)) === 0); +check("webgl img: rAF idle", (await delta("__raf", 1000)) <= 5); + +const d0 = await p.evaluate(() => window.__draws); +await p.evaluate(() => window.__iw.update({ refraction: 130 })); +await p.waitForTimeout(400); +check("webgl img: update() triggers a re-render", (await p.evaluate(() => window.__draws)) > d0); +await p.waitForTimeout(1800); +check("webgl img: idles again after update()", (await delta("__draws", 1000)) === 0); +await p.evaluate(() => window.__iw.destroy()); + +await p.evaluate(() => { window.__ic = Glasskit.apply(window.__g2, { mode: "webgl", background: window.__cv }); }); +await p.waitForTimeout(1200); +check("webgl canvas (live by default): keeps rendering", (await delta("__draws", 1000)) >= 30); +await p.evaluate(() => window.__ic.update({ live: false })); +await p.waitForTimeout(2200); +check("webgl canvas live:false: idles", (await delta("__draws", 1000)) === 0); +await p.evaluate(() => window.__ic.destroy()); + +/* ---------------- auto mode: css on phones, svg on desktop Chromium ---------------- */ +const mob = await b.newContext({ ...devices["Pixel 7"] }); +const mp = await mob.newPage(); +await mp.setContent("
"); +await mp.addScriptTag({ path: "site/liquid-glass.js" }); +check("auto on mobile (Pixel 7) → css", (await mp.evaluate(() => Glasskit.apply(document.querySelector("#g"), { mode: "auto" }).mode)) === "css"); +await mob.close(); +check("auto on desktop Chromium → svg", (await p.evaluate(() => { + const d = document.createElement("div"); document.body.appendChild(d); + return Glasskit.apply(d, { mode: "auto" }).mode; +})) === "svg"); + +check("no page errors", errs.length === 0, errs.join("; ")); +await b.close(); +process.exit(fail ? 1 : 0); diff --git a/liquid-glass.d.ts b/liquid-glass.d.ts index aca7bf9..f2d689b 100644 --- a/liquid-glass.d.ts +++ b/liquid-glass.d.ts @@ -9,7 +9,8 @@ export interface LiquidGlassOptions { * - `svg` real refraction on the live backdrop. Chromium only. * - `svg-clone` real refraction in Chrome/Safari/Firefox (clones `background`). DOM only. * - `webgl` real refraction of a supplied `background` (img/canvas/video). - * - `auto` Chromium→svg; else `background` set→svg-clone; else css. + * - `auto` phones→css (mobile GPUs jank on filter chains); Chromium→svg; + * else `background` set→svg-clone; else css. * @default "auto" */ mode?: LiquidGlassMode; @@ -60,6 +61,14 @@ export interface LiquidGlassOptions { radius?: number | null; /** Element or selector to refract. Required for `svg-clone` and `webgl`. */ background?: Element | string | null; + /** + * Per-frame re-rendering (`svg-clone`/`webgl`). By default the engine renders on demand — + * it idles when nothing moves and wakes on scroll/resize/pointer/CSS motion — except for + * video/canvas backgrounds, whose pixels can change without any layout signal, so they + * stay per-frame. `false` forces on-demand even for a (static) canvas; `true` forces a + * permanent per-frame loop. @default null (auto) + */ + live?: boolean | null; } export interface LiquidGlassInstance { diff --git a/liquid-glass.js b/liquid-glass.js index 49d5608..5605dcb 100644 --- a/liquid-glass.js +++ b/liquid-glass.js @@ -11,7 +11,7 @@ * 'svg-clone' real refraction in Chrome / Safari / Firefox by cloning a `background` * element and filtering the clone. Cross-browser. Refracts DOM (not ). * 'webgl' real refraction in a shader of a supplied `background` (img/canvas/video). - * 'auto' Chromium -> 'svg'; else `background` set -> 'svg-clone'; else 'css'. + * 'auto' phones -> 'css'; Chromium -> 'svg'; else `background` set -> 'svg-clone'; else 'css'. * * Params map onto Figma's Glass panel: frost, refraction, depth, dispersion, splay, * lightAngle, lightIntensity (+ optical extras: curvature, convexity, tint). @@ -48,7 +48,9 @@ brightness: 1.04, shadow: '0 8px 30px rgba(0,0,0,0.18)', // outer drop shadow; 'none'/'' removes it, or pass any CSS box-shadow radius: null, // null = read element border-radius - background: null // Element|selector — required for 'svg-clone' & 'webgl' + background: null, // Element|selector — required for 'svg-clone' & 'webgl' + live: null // per-frame re-render: true = always, false = never, + // null = auto (only video/canvas backgrounds, whose pixels can change) }; /* ---------------------------- helpers ---------------------------- */ @@ -111,8 +113,20 @@ } function ns(tag) { return document.createElementNS('http://www.w3.org/2000/svg', tag); } + function isMobile() { + if (typeof navigator === 'undefined') return false; + var uad = navigator.userAgentData; + if (uad && typeof uad.mobile === 'boolean') return uad.mobile; + // no UA-CH (Safari/Firefox): a device with no fine pointer at all is a phone/tablet + try { return typeof matchMedia === 'function' && !matchMedia('(any-pointer:fine)').matches; } + catch (e) { return false; } + } + function pickMode(requested, hasBackground) { if (requested && requested !== 'auto') return requested; + // phones: backdrop-filter SVG chains re-run on every scrolled frame and jank mobile + // GPUs — auto serves the css tier there; refraction stays available explicitly + if (isMobile()) return 'css'; if (isChromium()) return 'svg'; return hasBackground ? 'svg-clone' : 'css'; } @@ -340,9 +354,16 @@ el.style.backgroundColor = 'transparent'; syncClone(); } + var lastSync = ''; function syncClone() { - if (!clone) return; + if (!clone) return false; var er = el.getBoundingClientRect(), br = bg.getBoundingClientRect(); + // geometry is all relative — when glass and background scroll together nothing here + // changes, and skipping the writes below keeps the SVG filter from re-running + var key = [br.left - er.left, br.top - er.top, br.width, br.height, + er.width, er.height, o.frost].join(','); + if (key === lastSync) return false; + lastSync = key; clone.style.left = (br.left - er.left) + 'px'; clone.style.top = (br.top - er.top) + 'px'; clone.style.width = br.width + 'px'; @@ -353,9 +374,11 @@ feImage.setAttribute('x', ox); feImage.setAttribute('y', oy); filter.setAttribute('x', ox - pad); filter.setAttribute('y', oy - pad); filter.setAttribute('width', er.width + pad * 2); filter.setAttribute('height', er.height + pad * 2); + return true; } function reclone() { if (!lensWrap) return; + lastSync = ''; // the styled node is being replaced — force the next sync through var nc = bg.cloneNode(true); nc.removeAttribute('id'); nc.style.position = 'absolute'; nc.style.margin = '0'; nc.style.filter = 'url(#' + id + ')'; nc.style.webkitFilter = 'url(#' + id + ')'; @@ -476,7 +499,8 @@ } function loadGLBg(src) { if (!gl || !src) return; - if (typeof src === 'string') { var im = new Image(); im.crossOrigin = 'anonymous'; im.onload = function () { glState.src = im; uploadGL(im); }; im.src = src; } + glGen++; // new backdrop content — the next renderGL must not early-out + if (typeof src === 'string') { var im = new Image(); im.crossOrigin = 'anonymous'; im.onload = function () { glState.src = im; uploadGL(im); glGen++; wake(); }; im.src = src; } else { glState.src = src; uploadGL(src); } } function uploadGL(src) { @@ -484,25 +508,32 @@ gl.bindTexture(gl.TEXTURE_2D, glState.tex); try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src); } catch (e) {} } + var lastGL = '', glGen = 0; function renderGL() { - if (!gl || !glState || !glState.src) return; + if (!gl || !glState || !glState.src) return false; // render the drawing buffer at device-pixel resolution so high-frequency // detail (text edges) doesn't alias when the browser upscales on HiDPI screens. var dpr = window.devicePixelRatio || 1; var r = el.getBoundingClientRect(); var w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr)); + var rad = o.radius == null ? readRadius(el) : o.radius, a = o.lightAngle * Math.PI / 180; + // sample UVs are relative to the BACKGROUND's on-screen rect, not the window + var br = bg && bg.getBoundingClientRect ? bg.getBoundingClientRect() : null; + var bw = br && br.width ? br.width : innerWidth, bh = br && br.height ? br.height : innerHeight; + var ox = (br && br.width ? r.left - br.left : r.left), oy = (br && br.height ? r.top - br.top : r.top); + // everything a uniform depends on is relative — a static-image glass scrolling with + // its background produces the same frame, so skip the whole GL pass + var key = [glGen, dpr, w, h, rad, bw, bh, ox, oy].join(','); + if (!isLive() && key === lastGL) return false; + lastGL = key; if (glcanvas.width !== w || glcanvas.height !== h) { glcanvas.width = w; glcanvas.height = h; } gl.viewport(0, 0, w, h); var tag = glState.src.tagName; if (tag === 'CANVAS' || tag === 'VIDEO') uploadGL(glState.src); var p = glState.prog, U = function (n) { return gl.getUniformLocation(p, n); }; - var rad = o.radius == null ? readRadius(el) : o.radius, a = o.lightAngle * Math.PI / 180; // every pixel-space uniform scales by dpr together, keeping the shader internally consistent - // sample UVs are relative to the BACKGROUND's on-screen rect, not the window - var br = bg && bg.getBoundingClientRect ? bg.getBoundingClientRect() : null; - var bw = br && br.width ? br.width : innerWidth, bh = br && br.height ? br.height : innerHeight; gl.uniform2f(U('u_win'), bw * dpr, bh * dpr); - gl.uniform2f(U('u_origin'), (br && br.width ? r.left - br.left : r.left) * dpr, (br && br.height ? r.top - br.top : r.top) * dpr); + gl.uniform2f(U('u_origin'), ox * dpr, oy * dpr); gl.uniform2f(U('u_size'), w, h); gl.uniform1f(U('u_radius'), rad * dpr); gl.uniform1f(U('u_bezel'), o.depth * dpr); @@ -519,12 +550,38 @@ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } - /* ---- per-frame loop only for modes that need positional sync ---- */ - function tick() { if (mode === 'svg-clone') syncClone(); else if (mode === 'webgl') renderGL(); rafId = requestAnimationFrame(tick); } - if (mode === 'svg-clone' || mode === 'webgl') rafId = requestAnimationFrame(tick); + /* ---- demand-driven frame loop (svg-clone / webgl positional sync) ---- + * The loop spins down after ~1s with no observed motion — an idle glass card costs + * zero layouts, zero filter re-runs and zero GL passes (the old loop burned all three + * at 60fps, which is what janked phones). It wakes on anything that can move things: + * scroll / resize / pointer / CSS motion (window-level, passive), style mutations on + * the element, ResizeObserver, update()/refresh(). Video and canvas backgrounds keep + * it alive for per-frame texture upload — pass `live: false` for a static canvas. */ + var needsLoop = mode === 'svg-clone' || mode === 'webgl'; + var idleFrames = 0, IDLE_LIMIT = 60; + var WAKE_EVENTS = ['scroll', 'resize', 'wheel', 'pointermove', 'touchmove', 'transitionstart', 'animationstart']; + function isLive() { + if (o.live != null) return !!o.live; + var t = glState && glState.src && glState.src.tagName; + return t === 'VIDEO' || t === 'CANVAS'; + } + function tick() { + var moved = mode === 'svg-clone' ? syncClone() : renderGL(); + idleFrames = (moved || isLive()) ? 0 : idleFrames + 1; + rafId = idleFrames > IDLE_LIMIT ? 0 : requestAnimationFrame(tick); + } + function wake() { idleFrames = 0; if (needsLoop && !rafId) rafId = requestAnimationFrame(tick); } + var mo = null; + if (needsLoop) { + WAKE_EVENTS.forEach(function (t) { addEventListener(t, wake, { passive: true, capture: true }); }); + mo = new MutationObserver(wake); // catches JS-driven moves of the element itself + mo.observe(el, { attributes: true, attributeFilter: ['style', 'class'] }); + wake(); + } - var ro = new ResizeObserver(function () { refreshMap(); if (mode === 'svg-clone') syncClone(); }); + var ro = new ResizeObserver(function () { refreshMap(); if (mode === 'svg-clone') syncClone(); wake(); }); ro.observe(el); + if (needsLoop && bg instanceof Element) ro.observe(bg); // background resizes move the sample window too // when an explicit radius is given, round the element itself (not just the refraction map) function applyRadius() { if (o.radius != null) el.style.borderRadius = num(o.radius, 0) + 'px'; } @@ -537,11 +594,15 @@ Object.assign(o, patch || {}); if (patch && patch.background != null && mode === 'webgl') loadGLBg(resolveEl(o.background)); refreshMap(); applyFilterParams(); applyOverlay(); applyRadius(); + glGen++; lastSync = ''; wake(); // params feed the GL/clone passes — force a fresh frame return instance; }, - refresh: function () { if (mode === 'svg-clone') reclone(); else if (mode === 'webgl') loadGLBg(resolveEl(o.background)); return instance; }, + refresh: function () { if (mode === 'svg-clone') reclone(); else if (mode === 'webgl') loadGLBg(resolveEl(o.background)); wake(); return instance; }, destroy: function () { + needsLoop = false; if (rafId) cancelAnimationFrame(rafId); + WAKE_EVENTS.forEach(function (t) { removeEventListener(t, wake, true); }); + if (mo) mo.disconnect(); ro.disconnect(); // free the WebGL context explicitly — browsers cap ~16 per page if (gl) { var lc = gl.getExtension('WEBGL_lose_context'); if (lc) lc.loseContext(); gl = null; } @@ -555,7 +616,7 @@ /* ===================== web component ===================== */ var ATTRS = ['mode', 'frost', 'refraction', 'depth', 'dispersion', 'splay', 'light-angle', 'light-intensity', 'curvature', 'convexity', 'zoom', 'bevel', 'tint', 'tint-opacity', 'sheen', 'sheen-color', - 'sheen-angle', 'shadow', 'radius', 'background']; + 'sheen-angle', 'shadow', 'radius', 'background', 'live']; function camel(s) { return s.replace(/-([a-z])/g, function (_, c) { return c.toUpperCase(); }); } function defineElement() { if (typeof customElements === 'undefined' || customElements.get('glass-kit')) return; @@ -565,7 +626,8 @@ ATTRS.forEach(function (a) { if (!node.hasAttribute(a)) return; var v = node.getAttribute(a), key = camel(a); - opts[key] = (a === 'mode' || a === 'tint' || a === 'sheen-color' || a === 'shadow' || a === 'background') ? v : parseFloat(v); + if (a === 'live') opts.live = !(v === 'false' || v === '0'); + else opts[key] = (a === 'mode' || a === 'tint' || a === 'sheen-color' || a === 'shadow' || a === 'background') ? v : parseFloat(v); }); return opts; } @@ -582,5 +644,5 @@ } if (typeof window !== 'undefined') { if (document.readyState !== 'loading') defineElement(); else document.addEventListener('DOMContentLoaded', defineElement); } - return { apply: apply, defineElement: defineElement, isChromium: isChromium, pickMode: pickMode, DEFAULTS: DEFAULTS, version: '1.4.0' }; + return { apply: apply, defineElement: defineElement, isChromium: isChromium, pickMode: pickMode, DEFAULTS: DEFAULTS, version: '1.5.0' }; }); diff --git a/package.json b/package.json index e8df61b..ba6b9c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "glasskit-js", - "version": "1.4.0", + "version": "1.5.0", "description": "Drop-in Apple/Figma 'Liquid Glass' for any element — switch between pure CSS, SVG displacement (live backdrop), cross-browser clone-mode, or WebGL. Real refraction, chromatic aberration, specular highlight. Zero dependencies. Class + web component.", "keywords": [ "liquid-glass", diff --git a/site/liquid-glass.js b/site/liquid-glass.js index 49d5608..5605dcb 100644 --- a/site/liquid-glass.js +++ b/site/liquid-glass.js @@ -11,7 +11,7 @@ * 'svg-clone' real refraction in Chrome / Safari / Firefox by cloning a `background` * element and filtering the clone. Cross-browser. Refracts DOM (not ). * 'webgl' real refraction in a shader of a supplied `background` (img/canvas/video). - * 'auto' Chromium -> 'svg'; else `background` set -> 'svg-clone'; else 'css'. + * 'auto' phones -> 'css'; Chromium -> 'svg'; else `background` set -> 'svg-clone'; else 'css'. * * Params map onto Figma's Glass panel: frost, refraction, depth, dispersion, splay, * lightAngle, lightIntensity (+ optical extras: curvature, convexity, tint). @@ -48,7 +48,9 @@ brightness: 1.04, shadow: '0 8px 30px rgba(0,0,0,0.18)', // outer drop shadow; 'none'/'' removes it, or pass any CSS box-shadow radius: null, // null = read element border-radius - background: null // Element|selector — required for 'svg-clone' & 'webgl' + background: null, // Element|selector — required for 'svg-clone' & 'webgl' + live: null // per-frame re-render: true = always, false = never, + // null = auto (only video/canvas backgrounds, whose pixels can change) }; /* ---------------------------- helpers ---------------------------- */ @@ -111,8 +113,20 @@ } function ns(tag) { return document.createElementNS('http://www.w3.org/2000/svg', tag); } + function isMobile() { + if (typeof navigator === 'undefined') return false; + var uad = navigator.userAgentData; + if (uad && typeof uad.mobile === 'boolean') return uad.mobile; + // no UA-CH (Safari/Firefox): a device with no fine pointer at all is a phone/tablet + try { return typeof matchMedia === 'function' && !matchMedia('(any-pointer:fine)').matches; } + catch (e) { return false; } + } + function pickMode(requested, hasBackground) { if (requested && requested !== 'auto') return requested; + // phones: backdrop-filter SVG chains re-run on every scrolled frame and jank mobile + // GPUs — auto serves the css tier there; refraction stays available explicitly + if (isMobile()) return 'css'; if (isChromium()) return 'svg'; return hasBackground ? 'svg-clone' : 'css'; } @@ -340,9 +354,16 @@ el.style.backgroundColor = 'transparent'; syncClone(); } + var lastSync = ''; function syncClone() { - if (!clone) return; + if (!clone) return false; var er = el.getBoundingClientRect(), br = bg.getBoundingClientRect(); + // geometry is all relative — when glass and background scroll together nothing here + // changes, and skipping the writes below keeps the SVG filter from re-running + var key = [br.left - er.left, br.top - er.top, br.width, br.height, + er.width, er.height, o.frost].join(','); + if (key === lastSync) return false; + lastSync = key; clone.style.left = (br.left - er.left) + 'px'; clone.style.top = (br.top - er.top) + 'px'; clone.style.width = br.width + 'px'; @@ -353,9 +374,11 @@ feImage.setAttribute('x', ox); feImage.setAttribute('y', oy); filter.setAttribute('x', ox - pad); filter.setAttribute('y', oy - pad); filter.setAttribute('width', er.width + pad * 2); filter.setAttribute('height', er.height + pad * 2); + return true; } function reclone() { if (!lensWrap) return; + lastSync = ''; // the styled node is being replaced — force the next sync through var nc = bg.cloneNode(true); nc.removeAttribute('id'); nc.style.position = 'absolute'; nc.style.margin = '0'; nc.style.filter = 'url(#' + id + ')'; nc.style.webkitFilter = 'url(#' + id + ')'; @@ -476,7 +499,8 @@ } function loadGLBg(src) { if (!gl || !src) return; - if (typeof src === 'string') { var im = new Image(); im.crossOrigin = 'anonymous'; im.onload = function () { glState.src = im; uploadGL(im); }; im.src = src; } + glGen++; // new backdrop content — the next renderGL must not early-out + if (typeof src === 'string') { var im = new Image(); im.crossOrigin = 'anonymous'; im.onload = function () { glState.src = im; uploadGL(im); glGen++; wake(); }; im.src = src; } else { glState.src = src; uploadGL(src); } } function uploadGL(src) { @@ -484,25 +508,32 @@ gl.bindTexture(gl.TEXTURE_2D, glState.tex); try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src); } catch (e) {} } + var lastGL = '', glGen = 0; function renderGL() { - if (!gl || !glState || !glState.src) return; + if (!gl || !glState || !glState.src) return false; // render the drawing buffer at device-pixel resolution so high-frequency // detail (text edges) doesn't alias when the browser upscales on HiDPI screens. var dpr = window.devicePixelRatio || 1; var r = el.getBoundingClientRect(); var w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr)); + var rad = o.radius == null ? readRadius(el) : o.radius, a = o.lightAngle * Math.PI / 180; + // sample UVs are relative to the BACKGROUND's on-screen rect, not the window + var br = bg && bg.getBoundingClientRect ? bg.getBoundingClientRect() : null; + var bw = br && br.width ? br.width : innerWidth, bh = br && br.height ? br.height : innerHeight; + var ox = (br && br.width ? r.left - br.left : r.left), oy = (br && br.height ? r.top - br.top : r.top); + // everything a uniform depends on is relative — a static-image glass scrolling with + // its background produces the same frame, so skip the whole GL pass + var key = [glGen, dpr, w, h, rad, bw, bh, ox, oy].join(','); + if (!isLive() && key === lastGL) return false; + lastGL = key; if (glcanvas.width !== w || glcanvas.height !== h) { glcanvas.width = w; glcanvas.height = h; } gl.viewport(0, 0, w, h); var tag = glState.src.tagName; if (tag === 'CANVAS' || tag === 'VIDEO') uploadGL(glState.src); var p = glState.prog, U = function (n) { return gl.getUniformLocation(p, n); }; - var rad = o.radius == null ? readRadius(el) : o.radius, a = o.lightAngle * Math.PI / 180; // every pixel-space uniform scales by dpr together, keeping the shader internally consistent - // sample UVs are relative to the BACKGROUND's on-screen rect, not the window - var br = bg && bg.getBoundingClientRect ? bg.getBoundingClientRect() : null; - var bw = br && br.width ? br.width : innerWidth, bh = br && br.height ? br.height : innerHeight; gl.uniform2f(U('u_win'), bw * dpr, bh * dpr); - gl.uniform2f(U('u_origin'), (br && br.width ? r.left - br.left : r.left) * dpr, (br && br.height ? r.top - br.top : r.top) * dpr); + gl.uniform2f(U('u_origin'), ox * dpr, oy * dpr); gl.uniform2f(U('u_size'), w, h); gl.uniform1f(U('u_radius'), rad * dpr); gl.uniform1f(U('u_bezel'), o.depth * dpr); @@ -519,12 +550,38 @@ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } - /* ---- per-frame loop only for modes that need positional sync ---- */ - function tick() { if (mode === 'svg-clone') syncClone(); else if (mode === 'webgl') renderGL(); rafId = requestAnimationFrame(tick); } - if (mode === 'svg-clone' || mode === 'webgl') rafId = requestAnimationFrame(tick); + /* ---- demand-driven frame loop (svg-clone / webgl positional sync) ---- + * The loop spins down after ~1s with no observed motion — an idle glass card costs + * zero layouts, zero filter re-runs and zero GL passes (the old loop burned all three + * at 60fps, which is what janked phones). It wakes on anything that can move things: + * scroll / resize / pointer / CSS motion (window-level, passive), style mutations on + * the element, ResizeObserver, update()/refresh(). Video and canvas backgrounds keep + * it alive for per-frame texture upload — pass `live: false` for a static canvas. */ + var needsLoop = mode === 'svg-clone' || mode === 'webgl'; + var idleFrames = 0, IDLE_LIMIT = 60; + var WAKE_EVENTS = ['scroll', 'resize', 'wheel', 'pointermove', 'touchmove', 'transitionstart', 'animationstart']; + function isLive() { + if (o.live != null) return !!o.live; + var t = glState && glState.src && glState.src.tagName; + return t === 'VIDEO' || t === 'CANVAS'; + } + function tick() { + var moved = mode === 'svg-clone' ? syncClone() : renderGL(); + idleFrames = (moved || isLive()) ? 0 : idleFrames + 1; + rafId = idleFrames > IDLE_LIMIT ? 0 : requestAnimationFrame(tick); + } + function wake() { idleFrames = 0; if (needsLoop && !rafId) rafId = requestAnimationFrame(tick); } + var mo = null; + if (needsLoop) { + WAKE_EVENTS.forEach(function (t) { addEventListener(t, wake, { passive: true, capture: true }); }); + mo = new MutationObserver(wake); // catches JS-driven moves of the element itself + mo.observe(el, { attributes: true, attributeFilter: ['style', 'class'] }); + wake(); + } - var ro = new ResizeObserver(function () { refreshMap(); if (mode === 'svg-clone') syncClone(); }); + var ro = new ResizeObserver(function () { refreshMap(); if (mode === 'svg-clone') syncClone(); wake(); }); ro.observe(el); + if (needsLoop && bg instanceof Element) ro.observe(bg); // background resizes move the sample window too // when an explicit radius is given, round the element itself (not just the refraction map) function applyRadius() { if (o.radius != null) el.style.borderRadius = num(o.radius, 0) + 'px'; } @@ -537,11 +594,15 @@ Object.assign(o, patch || {}); if (patch && patch.background != null && mode === 'webgl') loadGLBg(resolveEl(o.background)); refreshMap(); applyFilterParams(); applyOverlay(); applyRadius(); + glGen++; lastSync = ''; wake(); // params feed the GL/clone passes — force a fresh frame return instance; }, - refresh: function () { if (mode === 'svg-clone') reclone(); else if (mode === 'webgl') loadGLBg(resolveEl(o.background)); return instance; }, + refresh: function () { if (mode === 'svg-clone') reclone(); else if (mode === 'webgl') loadGLBg(resolveEl(o.background)); wake(); return instance; }, destroy: function () { + needsLoop = false; if (rafId) cancelAnimationFrame(rafId); + WAKE_EVENTS.forEach(function (t) { removeEventListener(t, wake, true); }); + if (mo) mo.disconnect(); ro.disconnect(); // free the WebGL context explicitly — browsers cap ~16 per page if (gl) { var lc = gl.getExtension('WEBGL_lose_context'); if (lc) lc.loseContext(); gl = null; } @@ -555,7 +616,7 @@ /* ===================== web component ===================== */ var ATTRS = ['mode', 'frost', 'refraction', 'depth', 'dispersion', 'splay', 'light-angle', 'light-intensity', 'curvature', 'convexity', 'zoom', 'bevel', 'tint', 'tint-opacity', 'sheen', 'sheen-color', - 'sheen-angle', 'shadow', 'radius', 'background']; + 'sheen-angle', 'shadow', 'radius', 'background', 'live']; function camel(s) { return s.replace(/-([a-z])/g, function (_, c) { return c.toUpperCase(); }); } function defineElement() { if (typeof customElements === 'undefined' || customElements.get('glass-kit')) return; @@ -565,7 +626,8 @@ ATTRS.forEach(function (a) { if (!node.hasAttribute(a)) return; var v = node.getAttribute(a), key = camel(a); - opts[key] = (a === 'mode' || a === 'tint' || a === 'sheen-color' || a === 'shadow' || a === 'background') ? v : parseFloat(v); + if (a === 'live') opts.live = !(v === 'false' || v === '0'); + else opts[key] = (a === 'mode' || a === 'tint' || a === 'sheen-color' || a === 'shadow' || a === 'background') ? v : parseFloat(v); }); return opts; } @@ -582,5 +644,5 @@ } if (typeof window !== 'undefined') { if (document.readyState !== 'loading') defineElement(); else document.addEventListener('DOMContentLoaded', defineElement); } - return { apply: apply, defineElement: defineElement, isChromium: isChromium, pickMode: pickMode, DEFAULTS: DEFAULTS, version: '1.4.0' }; + return { apply: apply, defineElement: defineElement, isChromium: isChromium, pickMode: pickMode, DEFAULTS: DEFAULTS, version: '1.5.0' }; });