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 }; }