hero: repaint on a resize instead of rebuilding the canvas for it
Collapsing the sidebar dropped frames only on the artist page. The cause was one observer: the app grid animates its columns for 280ms, a ResizeObserver fires on every frame of that, and this canvas answered each one with a full resize() -- 171-224ms of main thread per toggle, 10-12 frames over 16ms, worst frame 54ms. Muting that single observer took the worst frame to 4.3ms; muting every other observer changed nothing. Two independent per-frame stalls inside it. Reallocating the ~3200x1630 backing store was 115-205ms of the total, and the box moved about a pixel per frame, so every one of those reallocations bought an invisible sharpness change. gl.getError() after every draw is a synchronous round trip to the GPU process, and it stalls hardest exactly when the GPU is busy compositing the animation that provoked it. reflow() is the observer's path now: it repaints into the CURRENT store and matches the store to the box once, 140ms after the box settles. What makes that correct is taking the cover crop from the DISPLAYED box while outSize normalizes by the store -- the two may then disagree, because a crop chosen for a 1.80 box drawn into a 1.96 store is stretched by exactly the ratio the compositor undoes painting store into box. Geometrically exact at every intermediate width, no reallocation. The error check is skipped on that path only; the settle pass that follows is checked, so a real failure still surfaces. After: 1.5-2.4ms per toggle across 46 calls, max 0.2ms each. No frame over 33ms, 0-2 over 16ms. The Now Playing collapse rides the same transition and improved with it. resize is no longer exposed: every caller outside this file is reacting to a box that may still be moving, and that is reflow's job.
This commit is contained in:
1 parent
e334cd51c2
commit
ae99757ddd
2 files changed
+91
-15
No files matched your search
@@ -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) {
|
||||
|
||||
+87
-14
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user