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.
259 lines
8.6 KiB
Svelte
259 lines
8.6 KiB
Svelte
<script>
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { getCatalogHeroGpuSource } from "../lib/state.svelte.js";
|
|
import { createHeroGpuRenderer } from "../lib/heroGpu.js";
|
|
|
|
let {
|
|
source = "",
|
|
scope = "",
|
|
plan = null,
|
|
active = true,
|
|
onloading = () => {},
|
|
onready = () => {},
|
|
onerror = () => {},
|
|
} = $props();
|
|
|
|
let canvas;
|
|
let bloomCanvas;
|
|
let previousCanvas;
|
|
let previousBloomCanvas;
|
|
let renderer = null;
|
|
let rendererError = null;
|
|
let rendererReady = $state(false);
|
|
let painted = $state(false);
|
|
let previousVisible = $state(false);
|
|
let previousFading = $state(false);
|
|
let generation = 0;
|
|
let transitionTimer;
|
|
let resizeObserver;
|
|
let renderedScope = null;
|
|
|
|
function reportFailure(error, failedSource = source) {
|
|
if (!failedSource) return;
|
|
onerror(failedSource, error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
|
|
onMount(() => {
|
|
try {
|
|
renderer = createHeroGpuRenderer(
|
|
canvas,
|
|
bloomCanvas,
|
|
previousCanvas,
|
|
previousBloomCanvas,
|
|
(error) => reportFailure(error),
|
|
);
|
|
// 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) {
|
|
// Initialization often overlaps the art search, before `source`
|
|
// exists. Mark it settled so the first real source can trigger the
|
|
// normal fallback path instead of remaining on an empty canvas.
|
|
rendererError = error instanceof Error ? error : new Error(String(error));
|
|
rendererReady = true;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const selected = source;
|
|
const selectedScope = scope;
|
|
const cropPlan = plan;
|
|
const visible = active;
|
|
const ready = rendererReady;
|
|
// Start payload preparation even while WebGL programs are compiling.
|
|
// The keyed state cache makes the renderer-ready rerun share this work.
|
|
const payloadReady = selected && visible && (!ready || renderer)
|
|
? getCatalogHeroGpuSource(selected, cropPlan)
|
|
: null;
|
|
if (!selected || !visible) {
|
|
generation += 1;
|
|
clearTimeout(transitionTimer);
|
|
painted = false;
|
|
previousVisible = false;
|
|
previousFading = false;
|
|
if (ready && renderer) renderer.clear();
|
|
return;
|
|
}
|
|
if (!ready) return;
|
|
if (!renderer) {
|
|
reportFailure(rendererError ?? new Error("live GPU renderer unavailable"), selected);
|
|
return;
|
|
}
|
|
|
|
// Preserve carousel crossfades within one artist, but never hold the
|
|
// previous artist's finished frame while the next artist is processing.
|
|
if (renderedScope !== selectedScope) {
|
|
renderedScope = selectedScope;
|
|
generation += 1;
|
|
clearTimeout(transitionTimer);
|
|
painted = false;
|
|
previousVisible = false;
|
|
previousFading = false;
|
|
renderer.clear();
|
|
}
|
|
|
|
const request = ++generation;
|
|
let cancelled = false;
|
|
onloading(selected);
|
|
(async () => {
|
|
try {
|
|
const payload = await payloadReady;
|
|
if (!payload) throw new Error("live hero payload unavailable");
|
|
if (cancelled || request !== generation || source !== selected) return;
|
|
const result = renderer.render(payload);
|
|
if (cancelled || request !== generation || source !== selected) return;
|
|
|
|
canvas.dataset.heroGpu = "ready";
|
|
canvas.dataset.heroSource = selected;
|
|
canvas.dataset.heroRenderMs = result.renderMs.toFixed(2);
|
|
canvas.dataset.heroRenderer = result.renderer;
|
|
clearTimeout(transitionTimer);
|
|
if (result.hadPrevious) {
|
|
previousVisible = true;
|
|
previousFading = false;
|
|
requestAnimationFrame(() => {
|
|
if (request !== generation) return;
|
|
// Commit a style pass at opacity 1 before starting the
|
|
// fade: rAF callbacks run before style recalc, so
|
|
// without this forced reflow the visible+fading class
|
|
// changes collapse into one recalc (opacity 0 -> 0)
|
|
// and the 1.4s transition never fires.
|
|
void previousCanvas.offsetWidth;
|
|
previousFading = true;
|
|
});
|
|
transitionTimer = setTimeout(() => {
|
|
previousVisible = false;
|
|
previousFading = false;
|
|
}, 1_400);
|
|
}
|
|
painted = true;
|
|
onready(selected, result);
|
|
} catch (error) {
|
|
if (!cancelled && request === generation && source === selected) {
|
|
canvas.dataset.heroGpu = "failed";
|
|
reportFailure(error, selected);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
});
|
|
|
|
onDestroy(() => {
|
|
generation += 1;
|
|
clearTimeout(transitionTimer);
|
|
resizeObserver?.disconnect();
|
|
renderer?.destroy();
|
|
});
|
|
</script>
|
|
|
|
<!-- Each `previous` canvas must come AFTER its `current` sibling: they share
|
|
a z-index, so DOM order decides stacking, and the outgoing frame has to
|
|
overlay the already-updated current canvas while it fades out. -->
|
|
<div class="gpu-hero" class:active={Boolean(source)} class:painted aria-hidden="true">
|
|
<canvas bind:this={bloomCanvas} class="bloom current"></canvas>
|
|
<canvas bind:this={previousBloomCanvas} class="bloom previous" class:fading={previousFading} class:visible={previousVisible}></canvas>
|
|
<canvas bind:this={canvas} class="banner current"></canvas>
|
|
<canvas bind:this={previousCanvas} class="banner previous" class:fading={previousFading} class:visible={previousVisible}></canvas>
|
|
</div>
|
|
|
|
<style>
|
|
.gpu-hero {
|
|
position: relative;
|
|
display: block;
|
|
z-index: 1;
|
|
/* The 70px header strip must be PADDING, not a margin on the canvas:
|
|
this box has no border, so a child margin-top would collapse through
|
|
it and drag the whole wrapper — including the top-0 bloom strip —
|
|
70px down. That left only the static seed gradient behind the
|
|
frosted results-head, and offset the previous canvas 70px below the
|
|
current one during crossfades. */
|
|
padding-top: 70px;
|
|
opacity: 0;
|
|
transition: opacity 180ms ease;
|
|
}
|
|
|
|
/* Keep the WebGL context/shaders warm while metadata is resolving without
|
|
contributing a second 16:9 box beside a provisional <img>. */
|
|
.gpu-hero:not(.active) {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
}
|
|
|
|
.gpu-hero.painted {
|
|
opacity: 1;
|
|
}
|
|
|
|
.gpu-hero.painted:not(.active) {
|
|
opacity: 0;
|
|
}
|
|
|
|
.banner {
|
|
position: relative;
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
aspect-ratio: 16 / 9;
|
|
max-height: 78vh;
|
|
z-index: 1;
|
|
}
|
|
|
|
.banner.previous {
|
|
position: absolute;
|
|
/* Matches the wrapper's 70px padding-top, so the outgoing frame sits
|
|
exactly over the in-flow current canvas. */
|
|
top: 70px;
|
|
left: 0;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.banner.previous.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.banner.previous.fading {
|
|
opacity: 0;
|
|
transition: opacity 1.4s ease;
|
|
}
|
|
|
|
.bloom {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 106px;
|
|
filter: blur(34px) saturate(1.6);
|
|
transform: scale(1.15);
|
|
opacity: 0.95;
|
|
z-index: 0;
|
|
}
|
|
|
|
.bloom.previous {
|
|
opacity: 0;
|
|
}
|
|
|
|
.bloom.previous.visible {
|
|
opacity: 0.95;
|
|
}
|
|
|
|
.bloom.previous.fading {
|
|
opacity: 0;
|
|
transition: opacity 1.4s ease;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.gpu-hero,
|
|
.banner.previous.fading,
|
|
.bloom.previous.fading {
|
|
transition-duration: 1ms;
|
|
}
|
|
}
|
|
</style>
|