Files
SoulStream/src/components/HeroGpuCanvas.svelte
T
KeelrLeek ae99757ddd 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.
2026-07-29 08:11:20 -05:00

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>