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.
This commit is contained in:
nvrl committed 2026-11-02 00:29:08 +01:00
1 parent 3de51b2517
commit b29062db3e
6 files changed
+161 -17

No files matched your search

+3 -2
View File
@@ -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;
}
+4
View File
@@ -261,6 +261,9 @@ 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.
// 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;
@@ -285,6 +288,7 @@ void main() {
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);
+19 -2
View File
@@ -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 $ = <T extends HTMLElement>(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<HTMLElement>(".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;
+51
View File
@@ -48,6 +48,57 @@ async function compute(src: string): Promise<string | null> {
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<string | null> {
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;
+17 -4
View File
@@ -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;
+67 -9
View File
@@ -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<HTMLElement>(".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<HTMLElement>(".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?.([
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");