diff --git a/src/components/HeroGpuCanvas.svelte b/src/components/HeroGpuCanvas.svelte index bd0321a..2abd5e5 100644 --- a/src/components/HeroGpuCanvas.svelte +++ b/src/components/HeroGpuCanvas.svelte @@ -42,7 +42,10 @@ previousBloomCanvas, (error) => reportFailure(error), ); - resizeObserver = new ResizeObserver(() => renderer?.resize()); + // reflow(), not resize(): this fires every frame of the 280ms + // column animation the sidebar and Now Playing collapses run, and + // resize() reallocates the backing store. + resizeObserver = new ResizeObserver(() => renderer?.reflow()); resizeObserver.observe(canvas); rendererReady = true; } catch (error) { diff --git a/src/lib/heroGpu.js b/src/lib/heroGpu.js index 416f1b0..363d6bd 100644 --- a/src/lib/heroGpu.js +++ b/src/lib/heroGpu.js @@ -4,6 +4,12 @@ export const HERO_MASTER_HEIGHT = 2160; const HEADER_BYTES = 16; const MAGIC = [0x53, 0x48, 0x47, 0x31]; // SHG1 +// How long the canvas' box must hold still before its backing store is matched +// to it. Comfortably past the app grid's 280ms column animation, so a sidebar +// or Now Playing collapse pays for exactly one store reallocation at the end +// rather than one per frame. +const RESIZE_SETTLE_MS = 140; + const VS = `#version 300 es precision highp float; const vec2 pos[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.)); @@ -274,6 +280,8 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ }; let master = null; let destroyed = false; + // Set by reflow() while the canvas' box is still moving; see resize(). + let settleTimer = 0; const contextLost = (event) => { event.preventDefault(); @@ -315,7 +323,12 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ if (!keepTexture && value.tx) gl.deleteTexture(value.tx); } - function draw(prog, src, output, uniforms = {}) { + // `checkError` is opt-out for one caller only: gl.getError() is a + // synchronous round-trip to the GPU process, so asking after every frame of + // an animated resize makes the main thread wait on a GPU that is already + // busy compositing that animation — measured as isolated 14-22ms paints. + // The settle pass that follows is checked, so a real failure still surfaces. + function draw(prog, src, output, uniforms = {}, checkError = true) { gl.useProgram(prog); gl.bindFramebuffer(gl.FRAMEBUFFER, output?.fb ?? null); const width = output?.width ?? canvas.width; @@ -331,6 +344,7 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ else gl.uniform1f(location, value); } gl.drawArrays(gl.TRIANGLES, 0, 3); + if (!checkError) return; const error = gl.getError(); if (error !== gl.NO_ERROR) throw new Error(`WebGL hero draw failed (${error})`); } @@ -449,14 +463,16 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ } } - function copyBloom() { + function copyBloom(rect = canvas.getBoundingClientRect()) { if (!canvas.width || !canvas.height) return; const cssHeight = 106; - const rect = canvas.getBoundingClientRect(); const scale = rect.height > 0 ? canvas.height / rect.height : 1; const sourceHeight = Math.max(1, Math.min(canvas.height, Math.round(cssHeight * scale))); - bloomCanvas.width = canvas.width; - bloomCanvas.height = sourceHeight; + // Guarded: assigning width/height reallocates the bitmap even when the + // value is unchanged, and the strip is redrawn edge to edge below, so + // there is nothing to clear. + if (bloomCanvas.width !== canvas.width) bloomCanvas.width = canvas.width; + if (bloomCanvas.height !== sourceHeight) bloomCanvas.height = sourceHeight; const context = bloomCanvas.getContext("2d", { alpha: false }); context.imageSmoothingEnabled = true; context.imageSmoothingQuality = "high"; @@ -473,8 +489,42 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ ); } + /** Paint the master into whatever backing store the canvas currently has. + * + * The crop comes from the DISPLAYED box and the normalization from the + * store, which is what lets the two disagree: `outSize` only maps fragment + * coordinates onto 0..1, so a crop chosen for a 1.80 box drawn into a 1.96 + * store is stretched by exactly the ratio the compositor then undoes when + * it paints that store into the box. The picture is geometrically correct + * at every box width without the store having to follow it. */ + function paint(rect, settled = true) { + const cover = heroCoverUv(rect.width, rect.height); + draw( + programs.blit, + master.tx, + null, + { + outSize: [canvas.width, canvas.height], + uvScale: cover.scale, + uvOffset: cover.offset, + flipInput: 0, + }, + settled, + ); + // The bloom is the top strip blurred 34px and scaled 1.15 — a few per + // cent of horizontal stretch in it is not resolvable, so a moving box + // does not re-copy it. It is refreshed when the box settles. + if (settled) copyBloom(rect); + } + + /** Match the backing store to the box and repaint. Reallocating the store + * is the expensive half of this file — measured 115-205 ms of the 171-224 ms + * a single sidebar collapse used to cost — so this is only ever for a box + * that has stopped moving. */ function resize() { if (destroyed || !master) return; + clearTimeout(settleTimer); + settleTimer = 0; const rect = canvas.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; const dpr = Math.max(1, window.devicePixelRatio || 1); @@ -487,15 +537,33 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ const height = Math.max(1, Math.round(rect.height * scale)); if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; - const cover = heroCoverUv(width, height); - draw(programs.blit, master.tx, null, { - outSize: [width, height], - uvScale: cover.scale, - uvOffset: cover.offset, - flipInput: 0, - }); + paint(rect); gl.finish(); - copyBloom(); + } + + /** The ResizeObserver's path, and the reason it is not `resize`. + * + * A ResizeObserver fires on EVERY frame of an animated layout change, and + * the app grid animates its columns for 280 ms whenever the sidebar or the + * Now Playing panel collapses. Reallocating a ~3200x1630 store fifteen + * times across that transition cost 171-224 ms of main thread and dropped + * eleven frames per toggle; the box moved about one pixel per frame, so + * every one of those reallocations bought an invisible sharpness change. + * + * Repainting at the current store instead costs about a millisecond and is + * exactly as correct (see paint), and the store is matched to the box once, + * after it settles. */ + function reflow() { + if (destroyed || !master) return; + if (canvas.width <= 1 || canvas.height <= 1) { + resize(); + return; + } + const rect = canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + paint(rect, false); + clearTimeout(settleTimer); + settleTimer = setTimeout(resize, RESIZE_SETTLE_MS); } function render(payload) { @@ -520,6 +588,8 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ // 44 MiB once its mip chain is included. function clear() { if (destroyed) return; + clearTimeout(settleTimer); + settleTimer = 0; if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx); master = null; for (const target of [canvas, bloomCanvas, previousCanvas, previousBloomCanvas]) { @@ -531,6 +601,7 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ function destroy() { if (destroyed) return; destroyed = true; + clearTimeout(settleTimer); canvas.removeEventListener("webglcontextlost", contextLost); if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx); if (!gl.isContextLost()) { @@ -539,5 +610,7 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ master = null; } - return { render, resize, clear, destroy }; + // `resize` is deliberately not exposed: every caller outside this file is + // reacting to a box that may still be moving, and that is reflow's job. + return { render, reflow, clear, destroy }; }