From b29062db3e93e828973a9b4a2d5f9ea92e8148b3 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 2 Nov 2026 00:29:08 +0100 Subject: [PATCH] Stop repainting the playing card every frame, sharpen the drop on phones --energy is now set only on the art and the glow, and only when it changes, so the card is no longer restyled every frame. The halo pulses through transform and opacity, without forcing layout or paint. The card background is blurred and darkened once on a small canvas, so the full-screen CSS blur is gone. The action rings no longer use backdrop-filter, and neither do the mode tabs and the up-next pill on phones. The chrome layer reads the layout only after a resize or a font load. The drop's fragment shader skips the icon, the ring and the frost while none of them shows. That saves enough to render the drop at up to 1.5 canvas px per CSS px on phones, up from 1. ?debug=drop shows fps, the worst frame and chrome draws per second, refreshed once a second. --- web/src/blob/blob.ts | 5 ++- web/src/blob/shaders.ts | 48 ++++++++++++----------- web/src/cards.ts | 21 ++++++++++- web/src/color.ts | 51 +++++++++++++++++++++++++ web/src/style.css | 21 +++++++++-- web/src/visual.ts | 84 ++++++++++++++++++++++++++++++++++------- 6 files changed, 187 insertions(+), 43 deletions(-) diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index bc7a8fc..87c44fb 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -307,12 +307,13 @@ export class LiquidBlob { return this.morph < 1 || moving || spinning || showing || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; } - /// Canvas pixels per CSS pixel at full quality. Phones stay at 1 for the battery. + /// Canvas pixels per CSS pixel at full quality. Phones stay at 1.5 at most, for + /// the battery; the quality steps take that down on phones that cannot keep up. /// Low-density desktop screens render at 2 and scale down, which smooths the /// edges and the icon that would otherwise fall on too few pixels. private density(): number { const dpr = devicePixelRatio || 1; - if (this.touch) return Math.min(dpr, 1); + if (this.touch) return Math.min(dpr, 1.5); return dpr < 1.5 ? 2 : 1.5; } diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts index 25ca9ea..065dc0a 100644 --- a/web/src/blob/shaders.ts +++ b/web/src/blob/shaders.ts @@ -261,30 +261,34 @@ void main() { // The icon sits on a disc of frosted glass, painted onto the surface so it turns // with the drop: the artwork behind it blurred and dimmed, lit along its rim. A // ring round it shows how much has played, or runs while the track loads. - vec2 ip = vObj.xy; - float front = smoothstep(0.35, 0.6, vObj.z) * uMorph; - float show = max(uIconA, uBusy) * front; - float disc = length(ip) - 0.36; - float da = fwidth(disc); - float inDisc = 1.0 - smoothstep(-da, da, disc); - vec3 frost = textureLod(uArt, 0.5 + vec2(ip.x, -ip.y) * 0.48, 5.0).rgb * 0.3 + uAccent * 0.03; - frost += 0.35 * (1.0 - smoothstep(0.0, 0.025, abs(disc + 0.01))) * smoothstep(-0.3, 0.35, ip.y); - col = mix(col, frost, show * inDisc); + // Skipped while nothing of it shows; the test is on uniforms only, so every + // pixel takes the same branch and fwidth() inside it stays sound. + if (max(uIconA, uBusy) > 0.002) { + vec2 ip = vObj.xy; + float front = smoothstep(0.35, 0.6, vObj.z) * uMorph; + float show = max(uIconA, uBusy) * front; + float disc = length(ip) - 0.36; + float da = fwidth(disc); + float inDisc = 1.0 - smoothstep(-da, da, disc); + vec3 frost = textureLod(uArt, 0.5 + vec2(ip.x, -ip.y) * 0.48, 5.0).rgb * 0.3 + uAccent * 0.03; + frost += 0.35 * (1.0 - smoothstep(0.0, 0.025, abs(disc + 0.01))) * smoothstep(-0.3, 0.35, ip.y); + col = mix(col, frost, show * inDisc); - float ringD = abs(length(ip) - 0.41) - 0.011; - float ra = fwidth(ringD); - float ring = (1.0 - smoothstep(-ra, ra, ringD)) * front; - float turn = fract(atan(ip.x, ip.y) / 6.2831853 + 1.0); // 0 at the top, clockwise - float played = 1.0 - smoothstep(uProgress - 0.003, uProgress + 0.003, turn); - float seg = fract(turn - uClock * 0.9); - float arc = smoothstep(0.0, 0.04, seg) * (1.0 - smoothstep(0.18, 0.28, seg)); - col = mix(col, mix(vec3(0.32), uAccent, played), ring * uIconA * (1.0 - uBusy)); - col = mix(col, mix(vec3(0.25), vec3(0.96), arc), ring * uBusy); + float ringD = abs(length(ip) - 0.41) - 0.011; + float ra = fwidth(ringD); + float ring = (1.0 - smoothstep(-ra, ra, ringD)) * front; + float turn = fract(atan(ip.x, ip.y) / 6.2831853 + 1.0); // 0 at the top, clockwise + float played = 1.0 - smoothstep(uProgress - 0.003, uProgress + 0.003, turn); + float seg = fract(turn - uClock * 0.9); + float arc = smoothstep(0.0, 0.04, seg) * (1.0 - smoothstep(0.18, 0.28, seg)); + col = mix(col, mix(vec3(0.32), uAccent, played), ring * uIconA * (1.0 - uBusy)); + col = mix(col, mix(vec3(0.25), vec3(0.96), arc), ring * uBusy); - float dIcon = sdPlayPause(ip, uIcon); - float ia = fwidth(dIcon); - float ink = 1.0 - smoothstep(-ia, ia, dIcon); - col = mix(col, vec3(0.96), front * uIconA * (1.0 - uBusy) * ink); + float dIcon = sdPlayPause(ip, uIcon); + float ia = fwidth(dIcon); + float ink = 1.0 - smoothstep(-ia, ia, dIcon); + col = mix(col, vec3(0.96), front * uIconA * (1.0 - uBusy) * ink); + } // The very edge fades out, so the drop sits in the page instead of on it. float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph; outColor = vec4(col * a, a); diff --git a/web/src/cards.ts b/web/src/cards.ts index 514496c..2bdf6ad 100644 --- a/web/src/cards.ts +++ b/web/src/cards.ts @@ -2,7 +2,7 @@ // and dropped again once it is far behind, so a long session stays light. import { type Track, artistImg, artistUrl, trackUrl } from "./api"; -import { accentFor, accentForName } from "./color"; +import { accentFor, accentForName, backdrop } from "./color"; export interface CardActions { like(card: Card): void; @@ -36,6 +36,8 @@ export class Card { private drawn = ""; private dragging = false; private accent: string | null = null; + /// The blurred background, once made. + private backdrop: string | null = null; /// `station`: the artist radio this card was picked for, if any. constructor( @@ -66,7 +68,17 @@ export class Card { const $ = (sel: string) => el.querySelector(sel) as T; const img = artistImg(t); if (img) { - $(".bg").style.backgroundImage = `url("${img}")`; + const bg = $(".bg"); + if (this.backdrop) this.showBackdrop(bg); + else { + // The plain image under a CSS blur until the blurred one is ready. + bg.style.backgroundImage = `url("${img}")`; + backdrop(img).then((b) => { + this.backdrop = b; + const bg = this.el?.querySelector(".bg"); + if (b && bg) this.showBackdrop(bg); + }, () => {}); + } $(".art").style.backgroundImage = `url("${img}")`; $(".artist-avatar").style.backgroundImage = `url("${img}")`; if (this.accent) el.style.setProperty("--card-accent", this.accent); @@ -129,6 +141,11 @@ export class Card { return el; } + private showBackdrop(bg: HTMLElement): void { + bg.style.backgroundImage = `url("${this.backdrop}")`; + bg.classList.add("baked"); + } + /// Where a radio track comes from, e.g. "Lower radio · via Motus". private whyText(): string { const { station } = this; diff --git a/web/src/color.ts b/web/src/color.ts index d00180b..0ee48a3 100644 --- a/web/src/color.ts +++ b/web/src/color.ts @@ -48,6 +48,57 @@ async function compute(src: string): Promise { return `hsl(${hue.toFixed(0)} ${s.toFixed(0)}% 62%)`; } +const BACKDROP = 64; // px square; it is shown blurred, so this is plenty +const BLUR = 2; // px each side, per box blur pass at that size +const DIM = 0.45; +const SATURATE = 1.3; + +/// The card's background from the artist image, as a data URL: cropped square, +/// blurred and darkened here, once, as `.bg`'s CSS filter would do on every frame. +export async function backdrop(src: string): Promise { + const img = new Image(); + img.src = src; + await img.decode(); + const c = document.createElement("canvas"); + c.width = c.height = BACKDROP; + const ctx = c.getContext("2d", { willReadFrequently: true }); + if (!ctx) return null; + const side = Math.min(img.naturalWidth, img.naturalHeight); + ctx.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, BACKDROP, BACKDROP); + const data = ctx.getImageData(0, 0, BACKDROP, BACKDROP); + const px = data.data; + // Three box passes each way come close to a gaussian. + for (let pass = 0; pass < 3; pass++) { + box(px, false); + box(px, true); + } + for (let i = 0; i < px.length; i += 4) { + const l = 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2]; + for (let ch = 0; ch < 3; ch++) px[i + ch] = (l + (px[i + ch] - l) * SATURATE) * DIM; + px[i + 3] = 255; + } + ctx.putImageData(data, 0, 0); + return c.toDataURL(); +} + +/// One box blur pass over the backdrop's pixels, along rows or columns; the +/// edges repeat outwards. +function box(px: Uint8ClampedArray, columns: boolean): void { + const src = px.slice(); + const n = BACKDROP; + const along = columns ? n * 4 : 4; + for (let line = 0; line < n; line++) { + const start = (columns ? line : line * n) * 4; + for (let i = 0; i < n; i++) { + for (let ch = 0; ch < 3; ch++) { + let sum = 0; + for (let k = -BLUR; k <= BLUR; k++) sum += src[start + Math.min(n - 1, Math.max(0, i + k)) * along + ch]; + px[start + i * along + ch] = sum / (2 * BLUR + 1); + } + } + } +} + function hsl(r: number, g: number, b: number): [number, number, number] { r /= 255; g /= 255; diff --git a/web/src/style.css b/web/src/style.css index 81af233..1f0bda0 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -397,6 +397,9 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; background: #111 center / cover no-repeat; filter: blur(40px) brightness(0.45) saturate(1.3); } +/* Already blurred and darkened (color.ts): a CSS blur over the whole screen would + be worked out again whenever anything on top of it moves. */ +.bg.baked { filter: none; } .glow { position: absolute; @@ -404,6 +407,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; pointer-events: none; background: radial-gradient(55% 40% at 50% 40%, color-mix(in oklab, var(--card-accent) 55%, transparent), transparent 70%); opacity: calc(0.18 + var(--energy) * 0.55); + will-change: opacity; } /* Grain over the card's background only: content and actions sit above it. */ .card::after { @@ -467,8 +471,12 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; .card.liquid .art::before { content: ""; position: absolute; - inset: calc(-50% - var(--energy) * 15%); - background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) calc(28% + var(--energy) * 55%), transparent), transparent); + inset: -65%; + background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) 83%, transparent), transparent); + /* Pulses through transform and opacity only, so it never has to be painted again. */ + transform: scale(calc(0.87 + var(--energy) * 0.13)); + opacity: calc(0.34 + var(--energy) * 0.66); + will-change: transform, opacity; pointer-events: none; } .card.liquid .content > :not(.art, .chrome-layer) { position: relative; z-index: 1; } @@ -666,8 +674,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; display: grid; place-items: center; border-radius: 50%; - background: rgba(255, 255, 255, 0.12); - backdrop-filter: blur(8px); + background: rgba(255, 255, 255, 0.16); transition: background var(--fast), transform var(--fast); } .act:hover .ring { background: rgba(255, 255, 255, 0.2); } @@ -766,6 +773,12 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; transition: opacity var(--fast); } #up-next:hover { opacity: 1; } +/* Phones redo a backdrop blur on every frame the card under it pulses: a denser + fill instead. */ +@media (pointer: coarse) { + #mode-tabs { backdrop-filter: none; background: rgba(28, 28, 32, 0.72); } + #up-next { backdrop-filter: none; background: #000b; } +} .up-next-avatar { flex: none; width: 2rem; diff --git a/web/src/visual.ts b/web/src/visual.ts index ce6c542..594a668 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -44,6 +44,9 @@ interface Dressed { waveCanvas: HTMLCanvasElement; title: Plate; wave: Plate; + /// Placed as the layout now is; cleared when something may have moved, as + /// reading the layout on every frame would force it to be worked out each time. + placed: boolean; layoutKey: string; titleKey: string; waveKey: string; @@ -80,13 +83,18 @@ export class Visualizer { /// Undefined until first needed; null where WebGL2 failed. private chrome: Chrome | null | undefined; private dressed: Dressed | null = null; - private logo: { el: HTMLElement; canvas: HTMLCanvasElement; plate: Plate; key: string } | null = null; + /// `measured` as for `Dressed.placed`. + private logo: { el: HTMLElement; canvas: HTMLCanvasElement; plate: Plate; key: string; measured: boolean; observer: ResizeObserver } | null = null; /// Where the light comes from, and where the pointer wants it. private tilt: [number, number] = [...REST_TILT]; private aim: [number, number] = [...REST_TILT]; private chromeAt = 0; private chromeDirty = true; private kickDrawn = 0; + /// The playing card's art and glow, and the `--energy` they were last given. + private energized: { el: HTMLElement; targets: HTMLElement[]; value: string } | null = null; + /// Frames and chrome draws counted for `?debug=drop`, shown once a second. + private stats = { from: 0, frames: 0, worst: 0, chrome: 0 }; constructor( private player: Player, @@ -183,6 +191,7 @@ export class Visualizer { waveCanvas, title: chrome.plate("text"), wave: chrome.plate("wave"), + placed: false, layoutKey: "", titleKey: "", waveKey: "", @@ -196,6 +205,7 @@ export class Visualizer { buffered: 0, // Text loading around the title moves it; the layer follows. observer: new ResizeObserver(() => { + d.placed = false; d.layoutKey = ""; this.chromeDirty = true; this.start(); @@ -248,6 +258,7 @@ export class Visualizer { for (const el of document.querySelectorAll(".chromed")) el.classList.remove("chromed"); if (this.logo) { this.logo.canvas.remove(); + this.logo.observer.disconnect(); this.chrome?.drop(this.logo.plate); } this.logo = null; @@ -255,8 +266,14 @@ export class Visualizer { /// Fonts arrived: shapes are drawn again on the next frame. private redress(): void { - if (this.dressed) this.dressed.layoutKey = this.dressed.titleKey = this.dressed.waveKey = ""; - if (this.logo) this.logo.key = ""; + if (this.dressed) { + this.dressed.placed = false; + this.dressed.layoutKey = this.dressed.titleKey = this.dressed.waveKey = ""; + } + if (this.logo) { + this.logo.key = ""; + this.logo.measured = false; + } this.chromeDirty = true; this.start(); } @@ -265,12 +282,25 @@ export class Visualizer { const el = document.querySelector(".logo b"); if (!el) return; el.style.setProperty("--pad", `${LOGO_PAD}px`); - this.logo = { el, canvas: plateCanvas(el), plate: chrome.plate("text"), key: "" }; + const logo = { + el, + canvas: plateCanvas(el), + plate: chrome.plate("text"), + key: "", + measured: false, + observer: new ResizeObserver(() => { + logo.measured = false; + this.redraw(); + }), + }; + logo.observer.observe(el); + this.logo = logo; } /// Size and place the layer over the title and the waveform, and draw their /// shapes again where their size or text changed. private reshape(chrome: Chrome, d: Dressed): boolean { + if (d.placed) return true; const el = d.titleEl, c = d.waveCanvas, wrap = c.parentElement!; if (!el.clientWidth || !c.clientWidth) return false; // Layout offsets, not client rects: the card scales as it leaves. @@ -319,6 +349,7 @@ export class Visualizer { // The title's plate starts PAD up and left of it, as the layer does. chrome.place(d.title, el.offsetLeft, 0); chrome.place(d.wave, wrap.offsetLeft + c.offsetLeft + PAD, waveTop - top); + d.placed = true; return true; } @@ -331,10 +362,12 @@ export class Visualizer { /// Light the logo once, whenever its size changed. private shineLogo(chrome: Chrome): void { const logo = this.logo; - if (!logo) return; + if (!logo || logo.measured) return; const el = logo.el; const key = `${el.clientWidth}x${el.clientHeight}`; - if (key === logo.key || !el.clientWidth) return; + if (!el.clientWidth) return; + logo.measured = true; + if (key === logo.key) return; logo.key = key; chrome.shape(logo.plate, el.clientWidth + 2 * LOGO_PAD, el.clientHeight + 2 * LOGO_PAD, 1, 0, 1.5, (ctx) => textMask(el, LOGO_PAD, ctx)).then((ok) => { if (!ok || this.logo !== logo) return; @@ -365,6 +398,7 @@ export class Visualizer { if (!want || now - this.chromeAt < 1000 / (this.touch ? TOUCH_CHROME_FPS : CHROME_FPS)) return want; this.chromeAt = now; this.chromeDirty = false; + this.stats.chrome++; this.kickDrawn = kick; this.shineLogo(chrome); if (!d || !this.reshape(chrome, d)) return want; @@ -382,7 +416,7 @@ export class Visualizer { attach(card: Card | null, again = false): void { if (this.card === card && !again) return; if (this.card && this.card !== card) { - this.card.el?.style.setProperty("--energy", "0"); + this.energize(null, ""); this.card.el?.classList.remove("liquid", "turning"); this.grab = null; } @@ -409,6 +443,21 @@ export class Visualizer { this.start(); } + /// Set `--energy` on the art and the glow only: set on the card, everything in + /// it would be styled again on every frame. + private energize(el: HTMLElement | null, value: string): void { + let e = this.energized; + if (e && e.el !== el) { + for (const t of e.targets) t.style.removeProperty("--energy"); + e = this.energized = null; + } + if (!el) return; + e ??= this.energized = { el, targets: [...el.querySelectorAll(".art, .glow")], value: "" }; + if (value === e.value) return; + e.value = value; + for (const t of e.targets) t.style.setProperty("--energy", value); + } + start(): void { clearTimeout(this.idleTimer); this.idleTimer = 0; @@ -533,17 +582,26 @@ export class Visualizer { ? this.features.fromAnalyser(an, now / 1000, dt) : this.features.fromLoudness(card.loudnessAt(progress), now / 1000, dt); this.debug?.(f); - this.dropDebug?.([ - `setting ${this.setting} · reduced motion ${this.reduced.matches} · touch ${this.touch} · mixing ${mixed}`, - `blob ${this.blob === undefined ? "not asked" : this.blob === null ? `failed: ${LiquidBlob.failure}` : "made"} · card liquid ${!!card.el?.classList.contains("liquid")}`, - this.blob?.describe() ?? "", - ].join("\n")); + const st = this.stats; + st.frames++; + st.worst = Math.max(st.worst, dt); + // Once a second: the readout itself asks the GPU things that stall it. + if (this.dropDebug && now - st.from >= 1000) { + const secs = (now - st.from) / 1000; + this.dropDebug([ + `fps ${(st.frames / secs).toFixed(0)} · worst ${(st.worst * 1000).toFixed(0)} ms · chrome ${(st.chrome / secs).toFixed(0)}/s`, + `setting ${this.setting} · reduced motion ${this.reduced.matches} · touch ${this.touch} · mixing ${mixed}`, + `blob ${this.blob === undefined ? "not asked" : this.blob === null ? `failed: ${LiquidBlob.failure}` : "made"} · card liquid ${!!card.el?.classList.contains("liquid")}`, + this.blob?.describe() ?? "", + ].join("\n")); + } + if (now - st.from >= 1000) this.stats = { from: now, frames: 0, worst: 0, chrome: 0 }; let busy = this.shine(now, dt, f.kick) || playing; const liquid = !!this.blob && !!card.el?.classList.contains("liquid"); if (!this.reduced.matches) { // The kick drives the pulse; without an analyser the kicks come from the waveform. const energy = Math.min(1, mixed ? 0.65 * f.kick + 0.35 * f.low : 0.5 * f.low + 0.4 * f.kick); - card.el?.style.setProperty("--energy", energy.toFixed(3)); + this.energize(card.el, energy.toFixed(2)); busy ||= energy > 0.01; if (liquid) { const buffering = !!card.el?.classList.contains("buffering");