Files
SoulStream/src/lib/heroGpu.js
T
KeelrLeek 547cee6649 dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands
on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a
translucent black card nets 0.81 - darker than its own pane and inside the
~0.85 band already rejected once on the artist page. Solid mode's ladder is
measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new
--glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass
(0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary
.btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are
NEVER redefined - they are consumed inside color-mix() and gradient stops where
an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible
chip; the album row's text-overflow fade works by BEING opaque), and they paint
modal chrome that has to stay solid. 129 additive :global(:root.glass)
declarations across 18 files, all 129 verified inside a glass block by walking
the diff - solid mode byte-identical, confirmed structurally, by computed style
with the class off, and visually.

Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING
the last ~19px of the listeners bar and nothing translucent can occlude, so
.tcount masks out just before the buttons instead; Playlist's banner scrim and
the discography's pinned ambience both landed on opaque --bg-base and now
dissolve like the album banner; and the 0.9 black hero tint on
Home/CatalogSearch/Discography paints straight onto the pane when there is no
artwork, so :first-child (the {#if} renders no element) flips it to white.
Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and
the Settings copy for the mode now says so instead of claiming cards do.
Measured with a known ground substituted behind the transparent window, since
CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a
white fill AND its backdrop-filter dropped, +30 for the bare pane beside it.

RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live
resize and the window stays transparent through it, so the desktop punches
through sharp - measured by toggling window_glass alone, desktop icon labels
readable through the titlebar. The blur cannot be kept (documented since Win10
2004, and the modern DWM backdrop was already rejected for the
overlay-promotion blink), so armGlassResizeGuard grounds the canvas to
--bg-sidebar for the length of the gesture.

STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not
frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin
bake moved to display resolution and off the ready task (was a same-task 4K
WebGL readback plus software blur, 100-170ms per rotation AND per artist
entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the
whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded;
one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so
all three periscopes stop cloning mid-gesture on ANY pane rather than only
their own; bounded reclone with the querySelectorAll census deleted, the gate
widened 250->1000ms, and the build/attach split across an idle callback and a
rAF behind a generation guard; the hero rotation tick gated on scroll recency
and its payload cache sized to the working set (2->4 entries, 24->32MiB,
32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED.
Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were
measured innocent - do not re-chase them.

98/98 tests throughout.
2026-08-27 17:24:20 -05:00

649 lines
27 KiB
JavaScript

export const HERO_MASTER_WIDTH = 3840;
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.));
void main(){ gl_Position = vec4(pos[gl_VertexID], 0., 1.); }`;
// FidelityFX FSR 1 EASU, transliterated from AMD's reference implementation.
// `flipInput` is used only for the first pass because Rust's RGBA rows arrive
// top-first while render-target textures use conventional GL bottom-first rows.
const FS_EASU = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 inSize;
uniform vec2 outSize;
uniform float flipInput;
out vec4 frag;
float lumaOf(vec3 c){ return c.b*0.5 + (c.r*0.5 + c.g); }
vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(inSize)-1), 0).rgb; }
void easuSet(inout vec2 dir, inout float len, vec2 pp, bool biS, bool biT, bool biU, bool biV,
float lA, float lB, float lC, float lD, float lE){
float w = 0.0;
if (biS) w = (1.0-pp.x)*(1.0-pp.y);
if (biT) w = pp.x*(1.0-pp.y);
if (biU) w = (1.0-pp.x)*pp.y;
if (biV) w = pp.x*pp.y;
float dc = lD-lC, cb = lC-lB;
float lenX = max(abs(dc), abs(cb));
lenX = 1.0/max(lenX, 1e-5);
float dirX = lD-lB;
dir.x += dirX*w;
lenX = clamp(abs(dirX)*lenX, 0.0, 1.0);
lenX *= lenX;
len += lenX*w;
float ec = lE-lC, ca = lC-lA;
float lenY = max(abs(ec), abs(ca));
lenY = 1.0/max(lenY, 1e-5);
float dirY = lE-lA;
dir.y += dirY*w;
lenY = clamp(abs(dirY)*lenY, 0.0, 1.0);
lenY *= lenY;
len += lenY*w;
}
void easuTap(inout vec3 aC, inout float aW, vec2 off, vec2 dir, vec2 len, float lob, float clp, vec3 c){
vec2 v = vec2(off.x*dir.x + off.y*dir.y, off.x*(-dir.y) + off.y*dir.x);
v *= len;
float d2 = min(v.x*v.x + v.y*v.y, clp);
float wB = 0.4*d2 - 1.0;
float wA = lob*d2 - 1.0;
wB *= wB; wA *= wA;
wB = (25.0/16.0)*wB - (25.0/16.0 - 1.0);
float w = wB*wA;
aC += c*w; aW += w;
}
void main(){
vec2 ip = floor(gl_FragCoord.xy);
if (flipInput > 0.5) ip.y = outSize.y - 1.0 - ip.y;
vec2 pp = (ip + 0.5)*(inSize/outSize) - 0.5;
vec2 fp = floor(pp);
pp -= fp;
ivec2 F = ivec2(fp);
vec3 bC = tex(F+ivec2(0,-1)), cC = tex(F+ivec2(1,-1));
vec3 eC = tex(F+ivec2(-1,0)), fC = tex(F), gC = tex(F+ivec2(1,0)), hC = tex(F+ivec2(2,0));
vec3 iC = tex(F+ivec2(-1,1)), jC = tex(F+ivec2(0,1)), kC = tex(F+ivec2(1,1)), lC2 = tex(F+ivec2(2,1));
vec3 nC = tex(F+ivec2(0,2)), oC = tex(F+ivec2(1,2));
float bL = lumaOf(bC), cL = lumaOf(cC), eL = lumaOf(eC), fL = lumaOf(fC);
float gL = lumaOf(gC), hL = lumaOf(hC), iL = lumaOf(iC), jL = lumaOf(jC);
float kL = lumaOf(kC), lL = lumaOf(lC2), nL = lumaOf(nC), oL = lumaOf(oC);
vec2 dir = vec2(0.0); float len = 0.0;
easuSet(dir, len, pp, true,false,false,false, bL, eL, fL, gL, jL);
easuSet(dir, len, pp, false,true,false,false, cL, fL, gL, hL, kL);
easuSet(dir, len, pp, false,false,true,false, fL, iL, jL, kL, nL);
easuSet(dir, len, pp, false,false,false,true, gL, jL, kL, lL, oL);
vec2 dir2 = dir*dir;
float dirR = dir2.x + dir2.y;
bool zro = dirR < (1.0/32768.0);
dirR = inversesqrt(max(dirR, 1e-12));
dirR = zro ? 1.0 : dirR;
dir.x = zro ? 1.0 : dir.x;
dir *= dirR;
len = len*0.5;
len *= len;
float stretch = (dir.x*dir.x + dir.y*dir.y)/max(max(abs(dir.x), abs(dir.y)), 1e-5);
vec2 len2 = vec2(1.0 + (stretch - 1.0)*len, 1.0 - 0.5*len);
float lob = 0.5 + ((1.0/4.0 - 0.04) - 0.5)*len;
float clp = 1.0/max(lob, 1e-5);
vec3 min4 = min(min(fC, gC), min(jC, kC));
vec3 max4 = max(max(fC, gC), max(jC, kC));
vec3 aC = vec3(0.0); float aW = 0.0;
easuTap(aC, aW, vec2( 0.,-1.)-pp, dir, len2, lob, clp, bC);
easuTap(aC, aW, vec2( 1.,-1.)-pp, dir, len2, lob, clp, cC);
easuTap(aC, aW, vec2(-1., 1.)-pp, dir, len2, lob, clp, iC);
easuTap(aC, aW, vec2( 0., 1.)-pp, dir, len2, lob, clp, jC);
easuTap(aC, aW, vec2( 0., 0.)-pp, dir, len2, lob, clp, fC);
easuTap(aC, aW, vec2(-1., 0.)-pp, dir, len2, lob, clp, eC);
easuTap(aC, aW, vec2( 1., 1.)-pp, dir, len2, lob, clp, kC);
easuTap(aC, aW, vec2( 1., 0.)-pp, dir, len2, lob, clp, gC);
easuTap(aC, aW, vec2( 0., 2.)-pp, dir, len2, lob, clp, nC);
easuTap(aC, aW, vec2( 1., 2.)-pp, dir, len2, lob, clp, oC);
easuTap(aC, aW, vec2( 2., 1.)-pp, dir, len2, lob, clp, lC2);
easuTap(aC, aW, vec2( 2., 0.)-pp, dir, len2, lob, clp, hC);
frag = vec4(min(max4, max(min4, aC/aW)), 1.0);
}`;
const FS_RCAS = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 size;
uniform float con;
out vec4 frag;
vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(size)-1), 0).rgb; }
void main(){
ivec2 sp = ivec2(gl_FragCoord.xy);
vec3 b = tex(sp+ivec2(0,-1)), d = tex(sp+ivec2(-1,0)), e = tex(sp), f = tex(sp+ivec2(1,0)), h = tex(sp+ivec2(0,1));
vec3 mn4 = min(min(b,d),min(f,h)), mx4 = max(max(b,d),max(f,h));
vec3 hitMin = min(mn4,e)/(4.0*mx4+1e-5);
vec3 hitMax = (1.0-max(mx4,e))/(4.0*mn4-4.0-1e-5);
vec3 lobeRGB = max(-hitMin,hitMax);
float lobe = max(-0.1875, min(max(lobeRGB.r,max(lobeRGB.g,lobeRGB.b)),0.0))*con;
frag = vec4(((b+d+f+h)*lobe+e)/(4.0*lobe+1.0), 1.0);
}`;
// Exact shipped Ruby Chrome 30% + vintage fade 100% + native-4K grain 70%.
// `trunc`, not `floor`, matches Rust's signed integer division toward zero.
const FS_GRADE = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform float grainAmp;
uniform float seed;
out vec4 frag;
float ss(float e0,float e1,float v){ float t=clamp((v-e0)/(e1-e0),0.,1.); return t*t*(3.-2.*t); }
uint grainHash(uint x){
x ^= x >> 16; x *= 0x7feb352du;
x ^= x >> 15; x *= 0x846ca68bu;
return x ^ (x >> 16);
}
float grainByte(ivec2 p,uint salt){
uint key = uint(p.x)*0x9e3779b1u ^ uint(p.y)*0x85ebca6bu ^ salt;
return float(grainHash(key) & 255u);
}
void main(){
ivec2 pixel = ivec2(gl_FragCoord.xy);
vec3 c = texelFetch(src, pixel, 0).rgb;
float luma = c.r*0.2126+c.g*0.7152+c.b*0.0722;
float sh = 1.0-ss(0.16,0.60,luma), hi = ss(0.38,0.90,luma);
vec3 g = mix(vec3(luma), c, 1.24);
g = (g-0.5)*1.12+0.5;
g = mix(g, g*g*(3.-2.*g), 0.18);
g.r += hi*0.070+sh*0.018; g.g -= hi*0.028; g.b += sh*0.045+hi*0.018;
g = clamp(g,0.,1.);
vec3 color = mix(c, g, 0.30);
float l2 = color.r*0.2126+color.g*0.7152+color.b*0.0722;
vec3 faded = clamp(mix(vec3(l2*0.78+0.078), color*0.78+vec3(0.085,0.078,0.068), 0.88),0.,1.);
uint grainSeed = uint(seed);
float r1 = grainByte(pixel,grainSeed)-128.;
float r2 = grainByte(pixel,grainSeed^0xa511e9b3u)-128.;
float delta = clamp(trunc((r1+r2)*grainAmp/256.), -grainAmp, grainAmp)/255.;
frag = vec4(clamp(faded+delta,0.,1.), 1.0);
}`;
const FS_BLIT = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 outSize;
uniform vec2 uvScale;
uniform vec2 uvOffset;
uniform float flipInput;
out vec4 frag;
void main(){
vec2 uv = gl_FragCoord.xy/outSize;
uv = uvOffset + uv*uvScale;
if (flipInput > 0.5) uv.y = 1.0-uv.y;
frag = vec4(texture(src, uv).rgb, 1.0);
}`;
// Preserve the 16:9 master like CSS object-fit cover, aligned center/top.
export function heroCoverUv(outputWidth, outputHeight) {
const sourceAspect = HERO_MASTER_WIDTH / HERO_MASTER_HEIGHT;
const outputAspect = outputWidth / outputHeight;
if (outputAspect > sourceAspect) {
const visibleHeight = sourceAspect / outputAspect;
return {
scale: [1, visibleHeight],
offset: [0, 1 - visibleHeight],
};
}
const visibleWidth = outputAspect / sourceAspect;
return {
scale: [visibleWidth, 1],
offset: [(1 - visibleWidth) / 2, 0],
};
}
function parsePayload(payload) {
const bytes = payload instanceof Uint8Array
? payload
: new Uint8Array(payload);
if (bytes.byteLength < HEADER_BYTES || !MAGIC.every((v, i) => bytes[i] === v)) {
throw new Error("invalid live hero payload");
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const width = view.getUint32(4, true);
const height = view.getUint32(8, true);
const seed = view.getUint32(12, true);
const expected = HEADER_BYTES + width * height * 4;
if (!width || !height || !Number.isSafeInteger(expected) || expected !== bytes.byteLength) {
throw new Error("invalid live hero dimensions");
}
return { width, height, seed, rgba: bytes.subarray(HEADER_BYTES) };
}
function compile(gl, source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const error = gl.getShaderInfoLog(shader) || "shader compilation failed";
gl.deleteShader(shader);
throw new Error(error);
}
return shader;
}
function program(gl, fragment) {
const result = gl.createProgram();
const vertex = compile(gl, VS, gl.VERTEX_SHADER);
const pixel = compile(gl, fragment, gl.FRAGMENT_SHADER);
gl.attachShader(result, vertex);
gl.attachShader(result, pixel);
gl.linkProgram(result);
gl.deleteShader(vertex);
gl.deleteShader(pixel);
if (!gl.getProgramParameter(result, gl.LINK_STATUS)) {
const error = gl.getProgramInfoLog(result) || "shader link failed";
gl.deleteProgram(result);
throw new Error(error);
}
return result;
}
function mipLevels(width, height) {
return 1 + Math.floor(Math.log2(Math.max(width, height)));
}
export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previousBloomCanvas, onLost) {
const gl = canvas.getContext("webgl2", {
alpha: false,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
if (!gl) throw new Error("WebGL2 is unavailable");
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
if ("drawingBufferColorSpace" in gl) gl.drawingBufferColorSpace = "srgb";
const maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE);
if (maxTexture < HERO_MASTER_WIDTH) {
throw new Error(`GPU texture limit ${maxTexture}px is below 4K`);
}
const programs = {
easu: program(gl, FS_EASU),
rcas: program(gl, FS_RCAS),
grade: program(gl, FS_GRADE),
blit: program(gl, FS_BLIT),
};
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();
onLost?.(new Error("WebGL context lost"));
};
canvas.addEventListener("webglcontextlost", contextLost);
function texture(width, height, levels = 1) {
const value = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, value);
gl.texStorage2D(gl.TEXTURE_2D, levels, gl.RGBA8, width, height);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
levels > 1 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR,
);
return value;
}
function target(width, height, levels = 1) {
const tx = texture(width, height, levels);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tx, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
gl.deleteFramebuffer(fb);
gl.deleteTexture(tx);
throw new Error("GPU hero framebuffer is incomplete");
}
return { tx, fb, width, height, levels };
}
function disposeTarget(value, keepTexture = false) {
if (!value) return;
if (value.fb) gl.deleteFramebuffer(value.fb);
if (!keepTexture && value.tx) gl.deleteTexture(value.tx);
}
// `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;
const height = output?.height ?? canvas.height;
gl.viewport(0, 0, width, height);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, src);
gl.uniform1i(gl.getUniformLocation(prog, "src"), 0);
for (const [name, value] of Object.entries(uniforms)) {
const location = gl.getUniformLocation(prog, name);
if (location === null) continue;
if (Array.isArray(value)) gl.uniform2f(location, value[0], value[1]);
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})`);
}
function copyCanvas(from, to) {
if (!from.width || !from.height) return;
// Guarded like copyBloom below: assigning width/height reallocates
// the backing store even when the value is unchanged — two ~10-20MB
// reallocs per rotation for nothing. The full-surface drawImage on
// an alpha:false context overwrites every pixel, so reuse is clean.
if (to.width !== from.width) to.width = from.width;
if (to.height !== from.height) to.height = from.height;
const context = to.getContext("2d", { alpha: false });
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(from, 0, 0, to.width, to.height);
}
function snapshot() {
if (!master || !canvas.width) return false;
copyCanvas(canvas, previousCanvas);
copyCanvas(bloomCanvas, previousBloomCanvas);
return true;
}
function buildMaster(payload) {
const { width, height, seed, rgba } = parsePayload(payload);
if (width > maxTexture || height > maxTexture) {
throw new Error(`hero source ${width}x${height} exceeds the GPU texture limit`);
}
const downsample = width > HERO_MASTER_WIDTH || height > HERO_MASTER_HEIGHT;
const sourceLevels = downsample ? mipLevels(width, height) : 1;
const source = texture(width, height, sourceLevels);
const temporaries = [];
let scaled = null;
let sharpened = null;
let finished = null;
try {
gl.bindTexture(gl.TEXTURE_2D, source);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
width,
height,
gl.RGBA,
gl.UNSIGNED_BYTE,
rgba,
);
if (downsample) gl.generateMipmap(gl.TEXTURE_2D);
if (downsample || (width === HERO_MASTER_WIDTH && height === HERO_MASTER_HEIGHT)) {
scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(scaled);
draw(programs.blit, source, scaled, {
outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
uvScale: [1, 1],
uvOffset: [0, 0],
flipInput: 1,
});
} else {
let currentTexture = source;
let currentWidth = width;
let currentHeight = height;
let firstPass = true;
const firstWidth = Math.min(width * 2, HERO_MASTER_WIDTH);
if (firstWidth < HERO_MASTER_WIDTH) {
const intermediate = target(
firstWidth,
Math.max(1, Math.round((height * firstWidth) / width)),
);
temporaries.push(intermediate);
draw(programs.easu, currentTexture, intermediate, {
inSize: [currentWidth, currentHeight],
outSize: [intermediate.width, intermediate.height],
flipInput: 1,
});
currentTexture = intermediate.tx;
currentWidth = intermediate.width;
currentHeight = intermediate.height;
firstPass = false;
}
scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(scaled);
draw(programs.easu, currentTexture, scaled, {
inSize: [currentWidth, currentHeight],
outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
flipInput: firstPass ? 1 : 0,
});
}
sharpened = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(sharpened);
draw(programs.rcas, scaled.tx, sharpened, {
size: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
con: Math.pow(2, -0.25),
});
finished = target(
HERO_MASTER_WIDTH,
HERO_MASTER_HEIGHT,
mipLevels(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT),
);
draw(programs.grade, sharpened.tx, finished, {
grainAmp: 20,
seed,
});
gl.bindTexture(gl.TEXTURE_2D, finished.tx);
gl.generateMipmap(gl.TEXTURE_2D);
// flush, NEVER finish — the same reasoning resize() records
// below: gl.finish() blocks the main thread until the GPU has
// drained, and it stalls hardest exactly when the GPU is busy.
// Here that meant the whole 4K EASU/RCAS/grade chain drained
// inside the rotation task — a main-thread block in the measured
// 100ms+ class on a 4-core iGPU, every ~20s hero rotation, worst
// in glass mode when the compositor shares the GPU. GL command
// ordering makes the temporaries' deletion in `finally` safe
// without draining (deletion of referenced resources is
// deferred), and nothing reads pixels back before the later
// drawImage copies impose their own sync. renderMs now reports
// enqueue time, not drain time — fine for the debug attribute.
gl.flush();
disposeTarget(finished, true);
return { tx: finished.tx, width: HERO_MASTER_WIDTH, height: HERO_MASTER_HEIGHT };
} catch (error) {
disposeTarget(finished);
throw error;
} finally {
for (const value of temporaries) disposeTarget(value);
gl.deleteTexture(source);
}
}
function copyBloom(rect = canvas.getBoundingClientRect()) {
if (!canvas.width || !canvas.height) return;
const cssHeight = 106;
const scale = rect.height > 0 ? canvas.height / rect.height : 1;
const sourceHeight = Math.max(1, Math.min(canvas.height, Math.round(cssHeight * scale)));
// 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";
context.drawImage(
canvas,
0,
0,
canvas.width,
sourceHeight,
0,
0,
bloomCanvas.width,
bloomCanvas.height,
);
}
/** 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);
const scale = Math.min(
dpr,
HERO_MASTER_WIDTH / rect.width,
HERO_MASTER_HEIGHT / rect.height,
);
const width = Math.max(1, Math.round(rect.width * scale));
const height = Math.max(1, Math.round(rect.height * scale));
// A store within a couple of percent of the box is not visibly softer —
// that is the same argument reflow() already makes for every
// intermediate frame of a drag, and it holds just as well for the final
// one. Without the tolerance, ANY change reallocates: measured two
// ~52ms hitches landing at 1827ms and 1909ms against a drag that ended
// at 1823ms, i.e. the whole cost of a resize arriving after the user
// had stopped. paint() below is geometrically exact at any store size,
// so skipping the realloc costs nothing but sharpness we cannot see.
const REALLOC_TOLERANCE = 0.02;
const off = (a, b) => Math.abs(a - b) / Math.max(1, b) > REALLOC_TOLERANCE;
if (off(canvas.width, width) || off(canvas.height, height)) {
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
}
paint(rect);
// gl.finish() blocks the main thread until the GPU has drained, and it
// stalls hardest exactly when the GPU is busy — which, 140ms after a
// drag, it still is. Nothing here reads back, so there is nothing to
// synchronise with; the compositor will show the frame when it is done.
gl.flush();
}
/** 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) {
if (destroyed) throw new Error("GPU hero renderer is closed");
const hadPrevious = snapshot();
const started = performance.now();
const next = buildMaster(payload);
const previous = master;
master = next;
resize();
if (previous?.tx) gl.deleteTexture(previous.tx);
return {
hadPrevious,
renderMs: performance.now() - started,
renderer: gl.getParameter(gl.RENDERER),
};
}
// Keep the compiled programs/context warm between artist navigations, but
// release the 4K master texture and canvas backing stores while there is no
// selected GPU hero. A stale master is both visually wrong and roughly
// 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]) {
target.width = 1;
target.height = 1;
}
}
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()) {
for (const value of Object.values(programs)) gl.deleteProgram(value);
}
master = null;
}
// `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 };
}