From f3f2b031d02e9b7e817f73cd7d9231377696f50d Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 5 Sep 2026 15:50:53 +0300 Subject: [PATCH] perf: release GPU resources correctly and reuse render targets Dispose scene resources before renderer allocation records are cleared and release the bloom high-pass material. Avoid unused bloom depth buffers and the final screen pass swap, keeping the same visual output and quality. Batch sign-stage layout reads before applying existing masks. Validated approved assets, types, client and SSR builds, prerendered content, artifacts and transfer budgets. Checked 32 matching transition renders, 66 browser interactions and repeated quality-change resource cleanup. --- src/scene/post-processing.ts | 27 ++++++++++++++++++++++++--- src/scene/resources.ts | 10 +++++++++- src/scene/world/sign-stage.ts | 24 +++++++++++++++++------- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/scene/post-processing.ts b/src/scene/post-processing.ts index 403a836..9f5d740 100644 --- a/src/scene/post-processing.ts +++ b/src/scene/post-processing.ts @@ -43,6 +43,7 @@ interface BloomPassLike { strength: number; radius: number; threshold: number; + materialHighPassFilter?: { dispose(): void }; renderTargetBright?: RenderTargetLike; renderTargetsHorizontal?: readonly RenderTargetLike[]; renderTargetsVertical?: readonly RenderTargetLike[]; @@ -63,9 +64,27 @@ export interface PostProcessingFactories { const productionFactories: PostProcessingFactories = { createComposer: (renderer) => new EffectComposer(renderer), createRenderPass: (scene, camera) => new RenderPass(scene, camera), - createBloomPass: () => - new UnrealBloomPass(new THREE.Vector2(1, 1), 0.3, 0.38, 1.05), - createOutputPass: () => new OutputPass(), + createBloomPass: () => { + const bloom = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.3, 0.38, 1.05); + // These fullscreen passes overwrite every color pixel. No pass samples + // their depth, and scene depth lives in the composer's separate targets. + for (const target of [ + bloom.renderTargetBright, + ...bloom.renderTargetsHorizontal, + ...bloom.renderTargetsVertical, + ]) { + target.depthBuffer = false; + } + return bloom; + }, + createOutputPass: () => { + const output = new OutputPass(); + // Output is always the last pass and renders directly to the canvas. + // Neither RenderPass nor bloom swaps buffers, so swapping here would only + // alternate the scene between two identical full-resolution GPU targets. + output.needsSwap = false; + return output; + }, }; export function createPostProcessor( @@ -114,6 +133,8 @@ export function createPostProcessor( if (disposed) return; disposed = true; renderPass.dispose(); + // Three r185 omits this material from UnrealBloomPass.dispose(). + bloomPass.materialHighPassFilter?.dispose(); bloomPass.dispose(); outputPass.dispose(); composer.dispose(); diff --git a/src/scene/resources.ts b/src/scene/resources.ts index 1f1ff5c..f00321e 100644 --- a/src/scene/resources.ts +++ b/src/scene/resources.ts @@ -146,7 +146,15 @@ export class SceneResourceBag { this.listeners.clear(); for (const observer of this.observers) observer.disconnect(); this.observers.clear(); - for (const resource of this.resources.keys()) resource.dispose(); + // Texture, target and material disposal needs the renderer's allocation + // records. Disposing the renderer first clears those records before the + // resources can release their WebGL handles during a quality change. + for (const [resource, kind] of this.resources) { + if (kind !== "renderer") resource.dispose(); + } + for (const [resource, kind] of this.resources) { + if (kind === "renderer") resource.dispose(); + } this.resources.clear(); } diff --git a/src/scene/world/sign-stage.ts b/src/scene/world/sign-stage.ts index 684ca28..061862d 100644 --- a/src/scene/world/sign-stage.ts +++ b/src/scene/world/sign-stage.ts @@ -14,6 +14,14 @@ export function createSignStage(resources: SceneResourceBag) { (element): element is HTMLElement => element instanceof HTMLElement, ) : []; + // Read the complete stage before writing masks. Interleaving each panel's + // reads and writes forces style recalculation again for the next panel. + const measurements = panels.map((panel) => ({ + panel, + heading: panel.querySelector(':scope > h2'), + bounds: null as DOMRect | null, + headingOffset: '', + })); let dirty = true; let cutoff = 0; let stageBottom = 176; @@ -91,14 +99,16 @@ export function createSignStage(resources: SceneResourceBag) { readingTop = topEdge + feather; // Only the panel crossing the edge needs a composited mask. Masking // the complete document would allocate a very tall intermediate layer. - for (const panel of panels) { - const bounds = panel.getBoundingClientRect(); - const heading = panel.querySelector(':scope > h2'); + for (const measurement of measurements) { + const bounds = measurement.panel.getBoundingClientRect(); + measurement.bounds = bounds; + if (measurement.heading) + measurement.headingOffset = `${(measurement.heading.getBoundingClientRect().top - bounds.top).toFixed(2)}px`; + } + for (const { panel, bounds, heading, headingOffset } of measurements) { + if (!bounds) continue; if (heading) - panel.style.setProperty( - '--chapter-heading-offset', - `${(heading.getBoundingClientRect().top - bounds.top).toFixed(2)}px`, - ); + panel.style.setProperty('--chapter-heading-offset', headingOffset); const top = topEdge * reveal; const bottom = short && width < 768 ? height - 164 : Infinity; if (