From 1f3f2e1f1c9d1955aca5d951708198e1e9c0b5ac Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 26 May 2026 17:39:26 +0800 Subject: [PATCH 01/13] perf(pipeline): share internal RT across cameras via per-frame pool lease Each camera held its own `_internalColorTarget` for the lifetime of its `BasicRenderPipeline`, so a scene with N on-screen cameras pinned N full-canvas RTs. On a 1078x2249 canvas with MSAA 4x that is 2 * 74 MB = 148 MB just for the scratch buffers (see investigation in galacean/migration-agent#304). Convert `_internalColorTarget` and `_copyBackgroundTexture` to per-frame leases: `BasicRenderPipeline._drawRenderPass` returns both to `RenderTargetPool` at end of every frame, so the next camera in the frame finds a matching free entry and reuses the same underlying RT. Cameras with mismatched format / MSAA / depth still get their own entries -- the pool's existing match key handles that. `RenderTargetPool` gains three bounded-growth strategies so the free list cannot leak across canvas resizes or shape churn: * `tick(currentFrame)` -- destroys entries idle longer than `maxFreeAgeFrames` (default 60). Engine calls this once per `update()`. * `evictBySize(width, height)` -- destroys entries matching the given dimensions. Engine subscribes to canvas size changes and evicts at the previous canvas size, so old full-canvas RTs do not linger. * `maxFreeBytes` -- when a `free*` push would exceed the cap, the oldest entries (by `lastUsedFrame`) are destroyed until the total fits. Scoped to free-list contents only (not total GPU memory), so the cap is device-independent. `RenderTarget._memorySize` becomes `@internal` so the pool can compute per-entry byte size without re-deriving from format/aa. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/Engine.ts | 27 +++ .../src/RenderPipeline/BasicRenderPipeline.ts | 26 +- .../src/RenderPipeline/RenderTargetPool.ts | 226 ++++++++++++++++-- packages/core/src/RenderPipeline/index.ts | 1 + packages/core/src/texture/RenderTarget.ts | 3 +- .../RenderPipeline/RenderTargetPool.test.ts | 177 ++++++++++++++ 6 files changed, 433 insertions(+), 27 deletions(-) create mode 100644 tests/src/core/RenderPipeline/RenderTargetPool.test.ts diff --git a/packages/core/src/Engine.ts b/packages/core/src/Engine.ts index c909e49093..2d0bcc2bd7 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -138,6 +138,25 @@ export class Engine extends EventDispatcher { private _waitingGC: boolean = false; private _postProcessPasses = new Array(); private _activePostProcessPasses = new Array(); + private _lastCanvasWidth: number = -1; + private _lastCanvasHeight: number = -1; + + /** + * Evict pool entries dimensioned to the previous canvas size when the canvas resizes, + * so full-canvas internal RTs cached at the old resolution don't linger until `tick()`. + */ + private _onCanvasResize = (): void => { + const canvas = this._canvas; + const newWidth = canvas.width; + const newHeight = canvas.height; + if (this._lastCanvasWidth !== newWidth || this._lastCanvasHeight !== newHeight) { + if (this._lastCanvasWidth >= 0) { + this._renderTargetPool.evictBySize(this._lastCanvasWidth, this._lastCanvasHeight); + } + this._lastCanvasWidth = newWidth; + this._lastCanvasHeight = newHeight; + } + }; private _animate = () => { if (this._vSyncCount) { @@ -256,6 +275,9 @@ export class Engine extends EventDispatcher { this._batcherManager = new BatcherManager(this); this._renderTargetPool = new RenderTargetPool(this); + this._lastCanvasWidth = canvas.width; + this._lastCanvasHeight = canvas.height; + canvas._sizeUpdateFlagManager.addListener(this._onCanvasResize); this.inputManager = new InputManager(this, configuration.input); const { xrDevice } = configuration; @@ -324,6 +346,9 @@ export class Engine extends EventDispatcher { const time = this._time; time._update(); + // Evict pool entries idle past `maxFreeAgeFrames`; cheap linear scan over the (small) free list. + this._renderTargetPool.tick(time.frameCount); + const deltaTime = time.deltaTime; this._frameInProcess = true; @@ -502,6 +527,8 @@ export class Engine extends EventDispatcher { this._destroyed = true; this._waitingDestroy = false; + this._canvas._sizeUpdateFlagManager.removeListener(this._onCanvasResize); + this._sceneManager._destroyAllScene(); this._resourceManager._destroy(); diff --git a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts index 5fc651c131..78fd5115a2 100644 --- a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts +++ b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts @@ -198,19 +198,9 @@ export class BasicRenderPipeline { } this._internalColorTarget = internalColorTarget; - } else { - const internalColorTarget = this._internalColorTarget; - const copyBackgroundTexture = this._copyBackgroundTexture; - const pool = engine._renderTargetPool; - if (internalColorTarget) { - pool.freeRenderTarget(internalColorTarget); - this._internalColorTarget = null; - } - if (copyBackgroundTexture) { - pool.freeTexture(copyBackgroundTexture); - this._copyBackgroundTexture = null; - } } + // No `else` branch needed: `_drawRenderPass` releases both the internal RT and the copy-background + // texture back to the pool at end of frame, so this method always starts with both fields null. // Scalable ambient obscurance pass // Before opaque pass so materials can sample ambient occlusion in BRDF @@ -361,6 +351,18 @@ export class BasicRenderPipeline { cameraRenderTarget?._blitRenderTarget(); cameraRenderTarget?.generateMipmaps(); + + // Release per-frame leased resources back to the pool so concurrent cameras with matching shape + // share a single underlying RT through the pool instead of each holding its own across frames. + const pool = engine._renderTargetPool; + if (this._internalColorTarget) { + pool.freeRenderTarget(this._internalColorTarget); + this._internalColorTarget = null; + } + if (this._copyBackgroundTexture) { + pool.freeTexture(this._copyBackgroundTexture); + this._copyBackgroundTexture = null; + } } /** diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index 3eae130d62..5654f106cd 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -1,13 +1,45 @@ import { Engine } from "../Engine"; -import { RenderTarget, Texture2D, TextureFilterMode, TextureFormat, TextureWrapMode } from "../texture"; +import { RenderTarget, Texture, Texture2D, TextureFilterMode, TextureFormat, TextureWrapMode } from "../texture"; /** + * Pool of `RenderTarget`s and `Texture2D`s used internally by the render pipeline. + * + * Entries returned via `freeRenderTarget`/`freeTexture` stay in the free list and are matched + * (by shape) for the next `allocate*` request. Three eviction strategies keep the free list bounded: + * + * 1. **Frame-age** — entries unused for more than `maxFreeAgeFrames` engine ticks are destroyed by `tick()`. + * 2. **Size-matched** — `evictBySize(w, h)` removes entries whose dimensions match the given size, called + * by the engine on canvas resize to clear out old full-canvas RTs. + * 3. **Memory cap** — `freeRenderTarget`/`freeTexture` evict the oldest entries (by `lastUsedFrame`) until the + * free-list footprint is at or below `maxFreeBytes`. Caps the pool against pathological churn. + * * @internal */ export class RenderTargetPool { + /** + * Maximum number of engine frames an entry may sit unused in the free list before `tick()` destroys it. + * Defaults to ~1 second at 60fps so reflection probes / periodic off-frame passes survive a short gap. + */ + maxFreeAgeFrames: number = 60; + + /** + * Soft cap on the total bytes pooled in the free list. When `freeRenderTarget`/`freeTexture` push an entry + * that would exceed this, the oldest entries (by `lastUsedFrame`) are destroyed until the total fits. + * Tracks only entries currently in the free list — entries actively leased by a pipeline are not counted. + */ + maxFreeBytes: number = 64 * 1024 * 1024; + + private _engine: Engine; + private _freeRenderTargets: RenderTarget[] = []; + private _freeRenderTargetFrames: number[] = []; + private _freeRenderTargetBytes: number[] = []; + private _freeRenderTargetByteTotal: number = 0; + private _freeTextures: Texture2D[] = []; - private _engine: Engine; + private _freeTextureFrames: number[] = []; + private _freeTextureBytes: number[] = []; + private _freeTextureByteTotal: number = 0; constructor(engine: Engine) { this._engine = engine; @@ -41,8 +73,7 @@ export class RenderTargetPool { antiAliasing ) ) { - freeRenderTargets[i] = freeRenderTargets[freeRenderTargets.length - 1]; - freeRenderTargets.length--; + this._removeFreeRenderTargetAt(i); const colorTexture = renderTarget.getColorTexture(0) as Texture2D; if (colorTexture) { colorTexture.wrapModeU = colorTexture.wrapModeV = wrapMode; @@ -103,8 +134,7 @@ export class RenderTargetPool { texture.mipmapCount > 1 === mipmap && texture.isSRGBColorSpace === isSRGBColorSpace ) { - freeTextures[i] = freeTextures[freeTextures.length - 1]; - freeTextures.length--; + this._removeFreeTextureAt(i); texture.wrapModeU = texture.wrapModeV = wrapMode; texture.filterMode = filterMode; return texture; @@ -121,33 +151,201 @@ export class RenderTargetPool { freeRenderTarget(renderTarget: RenderTarget): void { if (!renderTarget || renderTarget.destroyed) return; + const bytes = RenderTargetPool._computeRtBytes(renderTarget); this._freeRenderTargets.push(renderTarget); + this._freeRenderTargetFrames.push(this._engine.time.frameCount); + this._freeRenderTargetBytes.push(bytes); + this._freeRenderTargetByteTotal += bytes; + this._enforceRenderTargetMemoryCap(); } freeTexture(texture: Texture2D): void { if (!texture || texture.destroyed) return; + const bytes = texture._memorySize; this._freeTextures.push(texture); + this._freeTextureFrames.push(this._engine.time.frameCount); + this._freeTextureBytes.push(bytes); + this._freeTextureByteTotal += bytes; + this._enforceTextureMemoryCap(); + } + + /** + * Destroy entries that have been idle in the free list for longer than `maxFreeAgeFrames`. + * Called once per engine frame. + */ + tick(currentFrame: number): void { + const maxAge = this.maxFreeAgeFrames; + const rtFrames = this._freeRenderTargetFrames; + for (let i = rtFrames.length - 1; i >= 0; i--) { + if (currentFrame - rtFrames[i] > maxAge) { + this._destroyFreeRenderTargetAt(i); + } + } + const texFrames = this._freeTextureFrames; + for (let i = texFrames.length - 1; i >= 0; i--) { + if (currentFrame - texFrames[i] > maxAge) { + this._destroyFreeTextureAt(i); + } + } + } + + /** + * Destroy free-list entries whose dimensions exactly match the given size. Called when the canvas + * resizes so full-canvas RTs cached at the previous resolution don't linger waiting for `tick()`. + */ + evictBySize(width: number, height: number): void { + const freeRenderTargets = this._freeRenderTargets; + for (let i = freeRenderTargets.length - 1; i >= 0; i--) { + const rt = freeRenderTargets[i]; + if (rt.width === width && rt.height === height) { + this._destroyFreeRenderTargetAt(i); + } + } + const freeTextures = this._freeTextures; + for (let i = freeTextures.length - 1; i >= 0; i--) { + const tex = freeTextures[i]; + if (tex.width === width && tex.height === height) { + this._destroyFreeTextureAt(i); + } + } + } + + /** + * Total bytes currently held in the free list (RT entries + standalone Texture2D entries). + * Active leased entries are not included. + */ + get freeListByteSize(): number { + return this._freeRenderTargetByteTotal + this._freeTextureByteTotal; } gc(): void { const freeRenderTargets = this._freeRenderTargets; for (let i = 0, n = freeRenderTargets.length; i < n; i++) { - const renderTarget = freeRenderTargets[i]; - const colorTexture = renderTarget.getColorTexture(0); - const depthTexture = renderTarget.depthTexture; - renderTarget.destroy(true); - colorTexture?.destroy(true); - if (depthTexture && depthTexture !== colorTexture) { - depthTexture.destroy(true); - } + RenderTargetPool._destroyRenderTargetResource(freeRenderTargets[i]); } freeRenderTargets.length = 0; + this._freeRenderTargetFrames.length = 0; + this._freeRenderTargetBytes.length = 0; + this._freeRenderTargetByteTotal = 0; const freeTextures = this._freeTextures; for (let i = 0, n = freeTextures.length; i < n; i++) { freeTextures[i].destroy(true); } freeTextures.length = 0; + this._freeTextureFrames.length = 0; + this._freeTextureBytes.length = 0; + this._freeTextureByteTotal = 0; + } + + /** + * Swap-pop helper: remove free RT entry at `index` without destroying the RT (used when handing it out). + */ + private _removeFreeRenderTargetAt(index: number): void { + const rts = this._freeRenderTargets; + const frames = this._freeRenderTargetFrames; + const bytes = this._freeRenderTargetBytes; + const last = rts.length - 1; + this._freeRenderTargetByteTotal -= bytes[index]; + if (index !== last) { + rts[index] = rts[last]; + frames[index] = frames[last]; + bytes[index] = bytes[last]; + } + rts.length = last; + frames.length = last; + bytes.length = last; + } + + /** + * Swap-pop helper for the texture free list. + */ + private _removeFreeTextureAt(index: number): void { + const texs = this._freeTextures; + const frames = this._freeTextureFrames; + const bytes = this._freeTextureBytes; + const last = texs.length - 1; + this._freeTextureByteTotal -= bytes[index]; + if (index !== last) { + texs[index] = texs[last]; + frames[index] = frames[last]; + bytes[index] = bytes[last]; + } + texs.length = last; + frames.length = last; + bytes.length = last; + } + + /** + * Destroy free RT entry at `index` (called when evicting, not when leasing out). + */ + private _destroyFreeRenderTargetAt(index: number): void { + const rt = this._freeRenderTargets[index]; + this._removeFreeRenderTargetAt(index); + RenderTargetPool._destroyRenderTargetResource(rt); + } + + private _destroyFreeTextureAt(index: number): void { + const tex = this._freeTextures[index]; + this._removeFreeTextureAt(index); + tex.destroy(true); + } + + /** + * Evict oldest RT entries (by `lastUsedFrame`) until the free list is at or below the cap. + */ + private _enforceRenderTargetMemoryCap(): void { + while (this._freeRenderTargetByteTotal > this.maxFreeBytes && this._freeRenderTargets.length > 0) { + const oldest = this._findOldestIndex(this._freeRenderTargetFrames); + this._destroyFreeRenderTargetAt(oldest); + } + } + + private _enforceTextureMemoryCap(): void { + while (this._freeTextureByteTotal > this.maxFreeBytes && this._freeTextures.length > 0) { + const oldest = this._findOldestIndex(this._freeTextureFrames); + this._destroyFreeTextureAt(oldest); + } + } + + private _findOldestIndex(frames: number[]): number { + let oldestIdx = 0; + let oldestFrame = frames[0]; + for (let i = 1, n = frames.length; i < n; i++) { + if (frames[i] < oldestFrame) { + oldestFrame = frames[i]; + oldestIdx = i; + } + } + return oldestIdx; + } + + /** + * Sum the bytes "owned" by an RT entry: the RT's own MSAA/depth-RBO accounting plus any + * color/depth textures it references. Mirrors what `_renderingStatistics._textureMemory` + * would drop if this entry were destroyed. + */ + private static _computeRtBytes(rt: RenderTarget): number { + let bytes = rt._memorySize; + const color = rt.getColorTexture(0) as Texture | null; + if (color) { + bytes += color._memorySize; + } + const depth = rt.depthTexture as Texture | null; + if (depth && depth !== color) { + bytes += depth._memorySize; + } + return bytes; + } + + private static _destroyRenderTargetResource(rt: RenderTarget): void { + const colorTexture = rt.getColorTexture(0); + const depthTexture = rt.depthTexture; + rt.destroy(true); + colorTexture?.destroy(true); + if (depthTexture && depthTexture !== colorTexture) { + depthTexture.destroy(true); + } } private static _matchRenderTarget( diff --git a/packages/core/src/RenderPipeline/index.ts b/packages/core/src/RenderPipeline/index.ts index 7161b57757..fab3e96efe 100644 --- a/packages/core/src/RenderPipeline/index.ts +++ b/packages/core/src/RenderPipeline/index.ts @@ -3,3 +3,4 @@ export { BatchUtils } from "./BatchUtils"; export { Blitter } from "./Blitter"; export { RenderQueue } from "./RenderQueue"; export { PipelineStage } from "./enums/PipelineStage"; +export { RenderTargetPool } from "./RenderTargetPool"; diff --git a/packages/core/src/texture/RenderTarget.ts b/packages/core/src/texture/RenderTarget.ts index f0c888be34..04fdb6e54d 100644 --- a/packages/core/src/texture/RenderTarget.ts +++ b/packages/core/src/texture/RenderTarget.ts @@ -21,13 +21,14 @@ export class RenderTarget extends GraphicsResource { _antiAliasing: number; /** @internal */ _depthFormat: TextureFormat | null = null; + /** @internal */ + _memorySize: number = 0; private _autoGenerateMipmaps: boolean = true; private _width: number; private _height: number; private _colorTextures: Texture[]; private _depthTexture: Texture | null = null; - private _memorySize: number = 0; /** * Whether to automatically generate multi-level textures. diff --git a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts new file mode 100644 index 0000000000..2feaa48694 --- /dev/null +++ b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts @@ -0,0 +1,177 @@ +import { + RenderTargetPool, + TextureFilterMode, + TextureFormat, + TextureWrapMode +} from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +/** + * Helper: allocate an RT through the pool with sane defaults; varies only the bits that affect matching. + */ +function alloc( + pool: RenderTargetPool, + width: number, + height: number, + opts: { colorFormat?: TextureFormat; depthFormat?: TextureFormat | null; aa?: number } = {} +) { + return pool.allocateRenderTarget( + width, + height, + opts.colorFormat ?? TextureFormat.R8G8B8A8, + opts.depthFormat === undefined ? TextureFormat.Depth24Stencil8 : opts.depthFormat, + false, + false, + false, + opts.aa ?? 1, + TextureWrapMode.Clamp, + TextureFilterMode.Bilinear + ); +} + +describe("RenderTargetPool", () => { + const canvas = document.createElement("canvas"); + let engine: WebGLEngine; + let pool: RenderTargetPool; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas }); + }); + + beforeEach(() => { + // Each test gets a fresh pool so leaked entries from earlier tests don't bleed across. + pool = new RenderTargetPool(engine); + }); + + describe("matching reuse", () => { + it("returns the same RT instance when the next allocate matches a freed entry's shape", () => { + const a = alloc(pool, 512, 512); + pool.freeRenderTarget(a); + const b = alloc(pool, 512, 512); + expect(b).to.equal(a); + }); + + it("allocates a fresh RT when shape does not match any freed entry", () => { + const a = alloc(pool, 512, 512); + pool.freeRenderTarget(a); + const b = alloc(pool, 256, 256); + expect(b).to.not.equal(a); + }); + + it("simulates multi-camera frame-internal reuse: A free → B alloc returns A's RT", () => { + // Camera A renders at full canvas, then releases + const a = alloc(pool, 1024, 768); + pool.freeRenderTarget(a); + // Camera B renders next at the same shape and finds A's RT in the pool + const b = alloc(pool, 1024, 768); + expect(b).to.equal(a); + pool.freeRenderTarget(b); + // Pool is back to one entry after both cameras returned the same RT + // (we can't directly observe size, but the next match-alloc must return it too) + const c = alloc(pool, 1024, 768); + expect(c).to.equal(a); + }); + }); + + describe("frame-age eviction via tick()", () => { + it("does not evict entries within maxFreeAgeFrames", () => { + pool.maxFreeAgeFrames = 5; + const a = alloc(pool, 256, 256); + pool.freeRenderTarget(a); + const baseFrame = engine.time.frameCount; + pool.tick(baseFrame + 3); + const b = alloc(pool, 256, 256); + expect(b).to.equal(a); + }); + + it("destroys entries idle longer than maxFreeAgeFrames", () => { + pool.maxFreeAgeFrames = 5; + const a = alloc(pool, 256, 256); + const baseFrame = engine.time.frameCount; + pool.freeRenderTarget(a); + pool.tick(baseFrame + 100); + // Entry was destroyed; next allocate produces a fresh RT + const b = alloc(pool, 256, 256); + expect(b).to.not.equal(a); + expect(a.destroyed).to.equal(true); + }); + }); + + describe("evictBySize for canvas resize", () => { + it("destroys free-list entries matching the given size", () => { + const a = alloc(pool, 800, 600); + const b = alloc(pool, 1024, 768); + pool.freeRenderTarget(a); + pool.freeRenderTarget(b); + + pool.evictBySize(800, 600); + expect(a.destroyed).to.equal(true); + expect(b.destroyed).to.equal(false); + + // Re-allocating at the other size still returns the survivor + const reused = alloc(pool, 1024, 768); + expect(reused).to.equal(b); + }); + + it("ignores entries whose dimensions do not match", () => { + const a = alloc(pool, 800, 600); + pool.freeRenderTarget(a); + pool.evictBySize(1024, 768); + expect(a.destroyed).to.equal(false); + const b = alloc(pool, 800, 600); + expect(b).to.equal(a); + }); + }); + + describe("free-list memory cap", () => { + it("destroys entries while the free list exceeds maxFreeBytes", () => { + // Cap below the size of even one 256×256 RGBA8+D24S8 RT, so any push immediately overflows. + pool.maxFreeBytes = 1; + const a = alloc(pool, 256, 256); + pool.freeRenderTarget(a); + expect(a.destroyed).to.equal(true); + expect(pool.freeListByteSize).to.equal(0); + }); + + it("keeps total free-list bytes at or below maxFreeBytes after each push", () => { + pool.maxFreeBytes = 1024 * 1024; // 1 MB + const a = alloc(pool, 256, 256); + const b = alloc(pool, 256, 256); + const c = alloc(pool, 256, 256); + pool.freeRenderTarget(a); + pool.freeRenderTarget(b); + pool.freeRenderTarget(c); + expect(pool.freeListByteSize).to.be.at.most(pool.maxFreeBytes); + }); + + it("freeListByteSize reflects current free-list contents", () => { + pool.maxFreeBytes = Infinity; + const a = alloc(pool, 128, 128); + pool.freeRenderTarget(a); + const sizeAfterFirst = pool.freeListByteSize; + expect(sizeAfterFirst).to.be.greaterThan(0); + + const b = alloc(pool, 128, 128); // matches, lease out + expect(b).to.equal(a); + expect(pool.freeListByteSize).to.equal(0); + pool.freeRenderTarget(b); + expect(pool.freeListByteSize).to.equal(sizeAfterFirst); + }); + }); + + describe("gc()", () => { + it("destroys all free-list entries and zeros the byte total", () => { + const a = alloc(pool, 256, 256); + const b = alloc(pool, 512, 512); + pool.freeRenderTarget(a); + pool.freeRenderTarget(b); + expect(pool.freeListByteSize).to.be.greaterThan(0); + + pool.gc(); + expect(a.destroyed).to.equal(true); + expect(b.destroyed).to.equal(true); + expect(pool.freeListByteSize).to.equal(0); + }); + }); +}); From a8c141d82b9acf6480077c18ea7234a8d9a4f8ec Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 26 May 2026 18:07:56 +0800 Subject: [PATCH 02/13] =?UTF-8?q?perf(pipeline):=20address=20CR=20?= =?UTF-8?q?=E2=80=94=20unify=20memory=20cap,=20document=20byte=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review fixes on top of the previous commit: 1. `maxFreeBytes` now applies to the combined free-list total (RT + Texture) instead of each list independently. Previously, with the default 64 MB cap, the pool could actually hold up to 128 MB (64 MB RT + 64 MB Texture) — inconsistent with what `freeListByteSize` reports. The unified `_enforceMemoryCap` picks the older entry across both pools by `lastUsedFrame` and evicts until the combined sum is at or below the cap. 2. `_computeRtBytes` now documents the contract it depends on: that `RenderTarget._memorySize` covers only the RT's own renderbuffers (MSAA + depth RBO) and excludes the attached `colorTexture` / `depthTexture`, whose bytes live on `Texture._memorySize`. So the sum does not double-count. 3. `RenderTargetPool` is no longer re-exported from `RenderPipeline/index.ts` — it stays `@internal`. The test imports it via a relative source path instead, keeping the public surface unchanged. Added a 12th unit test verifying the unified cap actually bounds the combined total. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/RenderPipeline/RenderTargetPool.ts | 58 ++++++++++++++----- packages/core/src/RenderPipeline/index.ts | 1 - .../RenderPipeline/RenderTargetPool.test.ts | 38 ++++++++++-- 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index 5654f106cd..b6585c5e09 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -156,7 +156,7 @@ export class RenderTargetPool { this._freeRenderTargetFrames.push(this._engine.time.frameCount); this._freeRenderTargetBytes.push(bytes); this._freeRenderTargetByteTotal += bytes; - this._enforceRenderTargetMemoryCap(); + this._enforceMemoryCap(); } freeTexture(texture: Texture2D): void { @@ -166,7 +166,7 @@ export class RenderTargetPool { this._freeTextureFrames.push(this._engine.time.frameCount); this._freeTextureBytes.push(bytes); this._freeTextureByteTotal += bytes; - this._enforceTextureMemoryCap(); + this._enforceMemoryCap(); } /** @@ -292,19 +292,36 @@ export class RenderTargetPool { } /** - * Evict oldest RT entries (by `lastUsedFrame`) until the free list is at or below the cap. + * Evict the oldest entry across BOTH free lists (by `lastUsedFrame`) until the combined byte + * total is at or below `maxFreeBytes`. The cap applies to the sum reported by `freeListByteSize`, + * not to each list independently. */ - private _enforceRenderTargetMemoryCap(): void { - while (this._freeRenderTargetByteTotal > this.maxFreeBytes && this._freeRenderTargets.length > 0) { - const oldest = this._findOldestIndex(this._freeRenderTargetFrames); - this._destroyFreeRenderTargetAt(oldest); - } - } + private _enforceMemoryCap(): void { + const cap = this.maxFreeBytes; + while ( + this._freeRenderTargetByteTotal + this._freeTextureByteTotal > cap && + (this._freeRenderTargets.length > 0 || this._freeTextures.length > 0) + ) { + const rtFrames = this._freeRenderTargetFrames; + const texFrames = this._freeTextureFrames; + const rtOldest = rtFrames.length > 0 ? this._findOldestIndex(rtFrames) : -1; + const texOldest = texFrames.length > 0 ? this._findOldestIndex(texFrames) : -1; + + let evictRt: boolean; + if (rtOldest < 0) { + evictRt = false; + } else if (texOldest < 0) { + evictRt = true; + } else { + // Tie-break toward RT: pool entries dominate the byte budget, so this converges faster. + evictRt = rtFrames[rtOldest] <= texFrames[texOldest]; + } - private _enforceTextureMemoryCap(): void { - while (this._freeTextureByteTotal > this.maxFreeBytes && this._freeTextures.length > 0) { - const oldest = this._findOldestIndex(this._freeTextureFrames); - this._destroyFreeTextureAt(oldest); + if (evictRt) { + this._destroyFreeRenderTargetAt(rtOldest); + } else { + this._destroyFreeTextureAt(texOldest); + } } } @@ -321,9 +338,18 @@ export class RenderTargetPool { } /** - * Sum the bytes "owned" by an RT entry: the RT's own MSAA/depth-RBO accounting plus any - * color/depth textures it references. Mirrors what `_renderingStatistics._textureMemory` - * would drop if this entry were destroyed. + * Sum the bytes "owned" by an RT entry. Mirrors what `_renderingStatistics._textureMemory` + * would drop if this entry were fully destroyed. + * + * Contract relied on here (see `RenderTarget.ts` ctor): + * `RenderTarget._memorySize` accounts ONLY for the RT's own renderbuffers — + * - MSAA: `(colorRBO + depthRBO) * antiAliasing` (the multisampled side; resolves into the + * attached `colorTexture`, which is tracked separately on the Texture itself) + * - Non-MSAA: just the depth RBO when depth is given as a format, else 0 + * It does NOT include the bytes of the attached `colorTexture` / `depthTexture`, which each + * carry their own `_memorySize` from the `Texture` ctor. + * So summing `rt._memorySize + colorTexture._memorySize + depthTexture._memorySize` does not + * double-count. */ private static _computeRtBytes(rt: RenderTarget): number { let bytes = rt._memorySize; diff --git a/packages/core/src/RenderPipeline/index.ts b/packages/core/src/RenderPipeline/index.ts index fab3e96efe..7161b57757 100644 --- a/packages/core/src/RenderPipeline/index.ts +++ b/packages/core/src/RenderPipeline/index.ts @@ -3,4 +3,3 @@ export { BatchUtils } from "./BatchUtils"; export { Blitter } from "./Blitter"; export { RenderQueue } from "./RenderQueue"; export { PipelineStage } from "./enums/PipelineStage"; -export { RenderTargetPool } from "./RenderTargetPool"; diff --git a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts index 2feaa48694..58c7e5dd6b 100644 --- a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts +++ b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts @@ -1,9 +1,7 @@ -import { - RenderTargetPool, - TextureFilterMode, - TextureFormat, - TextureWrapMode -} from "@galacean/engine-core"; +import { TextureFilterMode, TextureFormat, TextureWrapMode } from "@galacean/engine-core"; +// `RenderTargetPool` is `@internal` and intentionally not re-exported from the core barrel. +// Import directly from the source file for test access. +import { RenderTargetPool } from "../../../../packages/core/src/RenderPipeline/RenderTargetPool"; import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -158,6 +156,34 @@ describe("RenderTargetPool", () => { pool.freeRenderTarget(b); expect(pool.freeListByteSize).to.equal(sizeAfterFirst); }); + + it("maxFreeBytes covers RT + Texture combined, not each pool independently", () => { + // Allocate two distinct shapes so they can't dedupe via match. + const rt = alloc(pool, 256, 256); + const tex = pool.allocateTexture( + 256, + 256, + TextureFormat.R8G8B8A8, + false, + false, + TextureWrapMode.Clamp, + TextureFilterMode.Bilinear + ); + pool.maxFreeBytes = Infinity; + pool.freeRenderTarget(rt); + pool.freeTexture(tex); + const total = pool.freeListByteSize; + expect(total).to.be.greaterThan(0); + + // Set the cap just below the combined total — one of the two must be evicted. + pool.maxFreeBytes = total - 1; + // Trigger a re-check by pushing and immediately re-leasing an entry of the same shape as `rt`. + // Easiest path: free a no-op entry. Instead, directly observe by allocating a tiny RT and freeing it + // to force the cap to be re-evaluated. + const probe = alloc(pool, 1, 1); + pool.freeRenderTarget(probe); + expect(pool.freeListByteSize).to.be.at.most(pool.maxFreeBytes); + }); }); describe("gc()", () => { From 60e4b03817ebc316669cdc54a1d59265847d5de8 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 26 May 2026 18:21:34 +0800 Subject: [PATCH 03/13] perf(pipeline): tick() re-evaluates memory cap; test cleans up engine CR follow-ups: * `tick()` now calls `_enforceMemoryCap()` at the end, so a mid-run reduction of `maxFreeBytes` takes effect within one frame instead of waiting for the next `free*` call. Cost is one extra scan per frame over an already-tiny free list. * Test file adds `afterAll(() => engine.destroy())` to release the WebGL context between test files. * New test locks in the tick-re-enforces-cap behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/RenderPipeline/RenderTargetPool.ts | 6 ++++-- .../RenderPipeline/RenderTargetPool.test.ts | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index b6585c5e09..bdb247b3f9 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -170,8 +170,9 @@ export class RenderTargetPool { } /** - * Destroy entries that have been idle in the free list for longer than `maxFreeAgeFrames`. - * Called once per engine frame. + * Destroy entries that have been idle in the free list for longer than `maxFreeAgeFrames`, + * then re-evaluate the memory cap so a mid-run change to `maxFreeBytes` takes effect within + * one frame (rather than waiting for the next `free*` call). Called once per engine frame. */ tick(currentFrame: number): void { const maxAge = this.maxFreeAgeFrames; @@ -187,6 +188,7 @@ export class RenderTargetPool { this._destroyFreeTextureAt(i); } } + this._enforceMemoryCap(); } /** diff --git a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts index 58c7e5dd6b..5fe51a87e8 100644 --- a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts +++ b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts @@ -3,7 +3,7 @@ import { TextureFilterMode, TextureFormat, TextureWrapMode } from "@galacean/eng // Import directly from the source file for test access. import { RenderTargetPool } from "../../../../packages/core/src/RenderPipeline/RenderTargetPool"; import { WebGLEngine } from "@galacean/engine-rhi-webgl"; -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; /** * Helper: allocate an RT through the pool with sane defaults; varies only the bits that affect matching. @@ -37,6 +37,10 @@ describe("RenderTargetPool", () => { engine = await WebGLEngine.create({ canvas }); }); + afterAll(() => { + engine.destroy(); + }); + beforeEach(() => { // Each test gets a fresh pool so leaked entries from earlier tests don't bleed across. pool = new RenderTargetPool(engine); @@ -94,6 +98,20 @@ describe("RenderTargetPool", () => { expect(b).to.not.equal(a); expect(a.destroyed).to.equal(true); }); + + it("tick re-enforces maxFreeBytes after a mid-run cap reduction", () => { + pool.maxFreeAgeFrames = Infinity; // isolate from age-based eviction + pool.maxFreeBytes = Infinity; + const a = alloc(pool, 256, 256); + pool.freeRenderTarget(a); + expect(a.destroyed).to.equal(false); + + // User lowers the cap below the entry's size; tick should evict. + pool.maxFreeBytes = 1; + pool.tick(engine.time.frameCount); + expect(a.destroyed).to.equal(true); + expect(pool.freeListByteSize).to.equal(0); + }); }); describe("evictBySize for canvas resize", () => { From 3a67d1a4f83661633d1a3b66fd847af1a242cfe7 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 26 May 2026 23:31:03 +0800 Subject: [PATCH 04/13] perf(pipeline): drop maxFreeBytes cap; frame-age + resize-evict are enough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The byte cap was defending against pathological churn within the age window — a scenario covered in practice by canvas-resize eviction (shape coupled to canvas) and frame-age (steady state). With the default 64 MB cap, a single full-canvas MSAA 4x RT (~86 MB on a 1078x2249 RGBA8+D24S8 canvas) was larger than the cap. Every free immediately destroyed the just-pushed RT, defeating the multi-camera sharing this PR exists to enable. The abstraction was unfortunately calibrated against a fictional worst case; the realistic worst cases are already bounded. Dropping it removes a tunable that's hard to set well (device-dependent, no single number works) and a sizable chunk of byte-tracking machinery (`_freeRenderTargetBytes`, `_freeRenderTargetByteTotal`, the combined-pool LRU in `_enforceMemoryCap`, `_computeRtBytes`, `_findOldestIndex`, `freeListByteSize`). `RenderTarget._memorySize` reverts to `private` — pool no longer reads it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/RenderPipeline/RenderTargetPool.ts | 124 +----------------- packages/core/src/texture/RenderTarget.ts | 3 +- .../RenderPipeline/RenderTargetPool.test.ts | 82 +----------- 3 files changed, 6 insertions(+), 203 deletions(-) diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index bdb247b3f9..2920d6e377 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -1,17 +1,15 @@ import { Engine } from "../Engine"; -import { RenderTarget, Texture, Texture2D, TextureFilterMode, TextureFormat, TextureWrapMode } from "../texture"; +import { RenderTarget, Texture2D, TextureFilterMode, TextureFormat, TextureWrapMode } from "../texture"; /** * Pool of `RenderTarget`s and `Texture2D`s used internally by the render pipeline. * * Entries returned via `freeRenderTarget`/`freeTexture` stay in the free list and are matched - * (by shape) for the next `allocate*` request. Three eviction strategies keep the free list bounded: + * (by shape) for the next `allocate*` request. Two eviction strategies keep the free list bounded: * * 1. **Frame-age** — entries unused for more than `maxFreeAgeFrames` engine ticks are destroyed by `tick()`. * 2. **Size-matched** — `evictBySize(w, h)` removes entries whose dimensions match the given size, called * by the engine on canvas resize to clear out old full-canvas RTs. - * 3. **Memory cap** — `freeRenderTarget`/`freeTexture` evict the oldest entries (by `lastUsedFrame`) until the - * free-list footprint is at or below `maxFreeBytes`. Caps the pool against pathological churn. * * @internal */ @@ -22,24 +20,13 @@ export class RenderTargetPool { */ maxFreeAgeFrames: number = 60; - /** - * Soft cap on the total bytes pooled in the free list. When `freeRenderTarget`/`freeTexture` push an entry - * that would exceed this, the oldest entries (by `lastUsedFrame`) are destroyed until the total fits. - * Tracks only entries currently in the free list — entries actively leased by a pipeline are not counted. - */ - maxFreeBytes: number = 64 * 1024 * 1024; - private _engine: Engine; private _freeRenderTargets: RenderTarget[] = []; private _freeRenderTargetFrames: number[] = []; - private _freeRenderTargetBytes: number[] = []; - private _freeRenderTargetByteTotal: number = 0; private _freeTextures: Texture2D[] = []; private _freeTextureFrames: number[] = []; - private _freeTextureBytes: number[] = []; - private _freeTextureByteTotal: number = 0; constructor(engine: Engine) { this._engine = engine; @@ -151,28 +138,19 @@ export class RenderTargetPool { freeRenderTarget(renderTarget: RenderTarget): void { if (!renderTarget || renderTarget.destroyed) return; - const bytes = RenderTargetPool._computeRtBytes(renderTarget); this._freeRenderTargets.push(renderTarget); this._freeRenderTargetFrames.push(this._engine.time.frameCount); - this._freeRenderTargetBytes.push(bytes); - this._freeRenderTargetByteTotal += bytes; - this._enforceMemoryCap(); } freeTexture(texture: Texture2D): void { if (!texture || texture.destroyed) return; - const bytes = texture._memorySize; this._freeTextures.push(texture); this._freeTextureFrames.push(this._engine.time.frameCount); - this._freeTextureBytes.push(bytes); - this._freeTextureByteTotal += bytes; - this._enforceMemoryCap(); } /** - * Destroy entries that have been idle in the free list for longer than `maxFreeAgeFrames`, - * then re-evaluate the memory cap so a mid-run change to `maxFreeBytes` takes effect within - * one frame (rather than waiting for the next `free*` call). Called once per engine frame. + * Destroy entries that have been idle in the free list for longer than `maxFreeAgeFrames`. + * Called once per engine frame. */ tick(currentFrame: number): void { const maxAge = this.maxFreeAgeFrames; @@ -188,7 +166,6 @@ export class RenderTargetPool { this._destroyFreeTextureAt(i); } } - this._enforceMemoryCap(); } /** @@ -212,14 +189,6 @@ export class RenderTargetPool { } } - /** - * Total bytes currently held in the free list (RT entries + standalone Texture2D entries). - * Active leased entries are not included. - */ - get freeListByteSize(): number { - return this._freeRenderTargetByteTotal + this._freeTextureByteTotal; - } - gc(): void { const freeRenderTargets = this._freeRenderTargets; for (let i = 0, n = freeRenderTargets.length; i < n; i++) { @@ -227,8 +196,6 @@ export class RenderTargetPool { } freeRenderTargets.length = 0; this._freeRenderTargetFrames.length = 0; - this._freeRenderTargetBytes.length = 0; - this._freeRenderTargetByteTotal = 0; const freeTextures = this._freeTextures; for (let i = 0, n = freeTextures.length; i < n; i++) { @@ -236,8 +203,6 @@ export class RenderTargetPool { } freeTextures.length = 0; this._freeTextureFrames.length = 0; - this._freeTextureBytes.length = 0; - this._freeTextureByteTotal = 0; } /** @@ -246,17 +211,13 @@ export class RenderTargetPool { private _removeFreeRenderTargetAt(index: number): void { const rts = this._freeRenderTargets; const frames = this._freeRenderTargetFrames; - const bytes = this._freeRenderTargetBytes; const last = rts.length - 1; - this._freeRenderTargetByteTotal -= bytes[index]; if (index !== last) { rts[index] = rts[last]; frames[index] = frames[last]; - bytes[index] = bytes[last]; } rts.length = last; frames.length = last; - bytes.length = last; } /** @@ -265,17 +226,13 @@ export class RenderTargetPool { private _removeFreeTextureAt(index: number): void { const texs = this._freeTextures; const frames = this._freeTextureFrames; - const bytes = this._freeTextureBytes; const last = texs.length - 1; - this._freeTextureByteTotal -= bytes[index]; if (index !== last) { texs[index] = texs[last]; frames[index] = frames[last]; - bytes[index] = bytes[last]; } texs.length = last; frames.length = last; - bytes.length = last; } /** @@ -293,79 +250,6 @@ export class RenderTargetPool { tex.destroy(true); } - /** - * Evict the oldest entry across BOTH free lists (by `lastUsedFrame`) until the combined byte - * total is at or below `maxFreeBytes`. The cap applies to the sum reported by `freeListByteSize`, - * not to each list independently. - */ - private _enforceMemoryCap(): void { - const cap = this.maxFreeBytes; - while ( - this._freeRenderTargetByteTotal + this._freeTextureByteTotal > cap && - (this._freeRenderTargets.length > 0 || this._freeTextures.length > 0) - ) { - const rtFrames = this._freeRenderTargetFrames; - const texFrames = this._freeTextureFrames; - const rtOldest = rtFrames.length > 0 ? this._findOldestIndex(rtFrames) : -1; - const texOldest = texFrames.length > 0 ? this._findOldestIndex(texFrames) : -1; - - let evictRt: boolean; - if (rtOldest < 0) { - evictRt = false; - } else if (texOldest < 0) { - evictRt = true; - } else { - // Tie-break toward RT: pool entries dominate the byte budget, so this converges faster. - evictRt = rtFrames[rtOldest] <= texFrames[texOldest]; - } - - if (evictRt) { - this._destroyFreeRenderTargetAt(rtOldest); - } else { - this._destroyFreeTextureAt(texOldest); - } - } - } - - private _findOldestIndex(frames: number[]): number { - let oldestIdx = 0; - let oldestFrame = frames[0]; - for (let i = 1, n = frames.length; i < n; i++) { - if (frames[i] < oldestFrame) { - oldestFrame = frames[i]; - oldestIdx = i; - } - } - return oldestIdx; - } - - /** - * Sum the bytes "owned" by an RT entry. Mirrors what `_renderingStatistics._textureMemory` - * would drop if this entry were fully destroyed. - * - * Contract relied on here (see `RenderTarget.ts` ctor): - * `RenderTarget._memorySize` accounts ONLY for the RT's own renderbuffers — - * - MSAA: `(colorRBO + depthRBO) * antiAliasing` (the multisampled side; resolves into the - * attached `colorTexture`, which is tracked separately on the Texture itself) - * - Non-MSAA: just the depth RBO when depth is given as a format, else 0 - * It does NOT include the bytes of the attached `colorTexture` / `depthTexture`, which each - * carry their own `_memorySize` from the `Texture` ctor. - * So summing `rt._memorySize + colorTexture._memorySize + depthTexture._memorySize` does not - * double-count. - */ - private static _computeRtBytes(rt: RenderTarget): number { - let bytes = rt._memorySize; - const color = rt.getColorTexture(0) as Texture | null; - if (color) { - bytes += color._memorySize; - } - const depth = rt.depthTexture as Texture | null; - if (depth && depth !== color) { - bytes += depth._memorySize; - } - return bytes; - } - private static _destroyRenderTargetResource(rt: RenderTarget): void { const colorTexture = rt.getColorTexture(0); const depthTexture = rt.depthTexture; diff --git a/packages/core/src/texture/RenderTarget.ts b/packages/core/src/texture/RenderTarget.ts index 04fdb6e54d..f0c888be34 100644 --- a/packages/core/src/texture/RenderTarget.ts +++ b/packages/core/src/texture/RenderTarget.ts @@ -21,14 +21,13 @@ export class RenderTarget extends GraphicsResource { _antiAliasing: number; /** @internal */ _depthFormat: TextureFormat | null = null; - /** @internal */ - _memorySize: number = 0; private _autoGenerateMipmaps: boolean = true; private _width: number; private _height: number; private _colorTextures: Texture[]; private _depthTexture: Texture | null = null; + private _memorySize: number = 0; /** * Whether to automatically generate multi-level textures. diff --git a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts index 5fe51a87e8..6403a41884 100644 --- a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts +++ b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts @@ -98,20 +98,6 @@ describe("RenderTargetPool", () => { expect(b).to.not.equal(a); expect(a.destroyed).to.equal(true); }); - - it("tick re-enforces maxFreeBytes after a mid-run cap reduction", () => { - pool.maxFreeAgeFrames = Infinity; // isolate from age-based eviction - pool.maxFreeBytes = Infinity; - const a = alloc(pool, 256, 256); - pool.freeRenderTarget(a); - expect(a.destroyed).to.equal(false); - - // User lowers the cap below the entry's size; tick should evict. - pool.maxFreeBytes = 1; - pool.tick(engine.time.frameCount); - expect(a.destroyed).to.equal(true); - expect(pool.freeListByteSize).to.equal(0); - }); }); describe("evictBySize for canvas resize", () => { @@ -140,82 +126,16 @@ describe("RenderTargetPool", () => { }); }); - describe("free-list memory cap", () => { - it("destroys entries while the free list exceeds maxFreeBytes", () => { - // Cap below the size of even one 256×256 RGBA8+D24S8 RT, so any push immediately overflows. - pool.maxFreeBytes = 1; - const a = alloc(pool, 256, 256); - pool.freeRenderTarget(a); - expect(a.destroyed).to.equal(true); - expect(pool.freeListByteSize).to.equal(0); - }); - - it("keeps total free-list bytes at or below maxFreeBytes after each push", () => { - pool.maxFreeBytes = 1024 * 1024; // 1 MB - const a = alloc(pool, 256, 256); - const b = alloc(pool, 256, 256); - const c = alloc(pool, 256, 256); - pool.freeRenderTarget(a); - pool.freeRenderTarget(b); - pool.freeRenderTarget(c); - expect(pool.freeListByteSize).to.be.at.most(pool.maxFreeBytes); - }); - - it("freeListByteSize reflects current free-list contents", () => { - pool.maxFreeBytes = Infinity; - const a = alloc(pool, 128, 128); - pool.freeRenderTarget(a); - const sizeAfterFirst = pool.freeListByteSize; - expect(sizeAfterFirst).to.be.greaterThan(0); - - const b = alloc(pool, 128, 128); // matches, lease out - expect(b).to.equal(a); - expect(pool.freeListByteSize).to.equal(0); - pool.freeRenderTarget(b); - expect(pool.freeListByteSize).to.equal(sizeAfterFirst); - }); - - it("maxFreeBytes covers RT + Texture combined, not each pool independently", () => { - // Allocate two distinct shapes so they can't dedupe via match. - const rt = alloc(pool, 256, 256); - const tex = pool.allocateTexture( - 256, - 256, - TextureFormat.R8G8B8A8, - false, - false, - TextureWrapMode.Clamp, - TextureFilterMode.Bilinear - ); - pool.maxFreeBytes = Infinity; - pool.freeRenderTarget(rt); - pool.freeTexture(tex); - const total = pool.freeListByteSize; - expect(total).to.be.greaterThan(0); - - // Set the cap just below the combined total — one of the two must be evicted. - pool.maxFreeBytes = total - 1; - // Trigger a re-check by pushing and immediately re-leasing an entry of the same shape as `rt`. - // Easiest path: free a no-op entry. Instead, directly observe by allocating a tiny RT and freeing it - // to force the cap to be re-evaluated. - const probe = alloc(pool, 1, 1); - pool.freeRenderTarget(probe); - expect(pool.freeListByteSize).to.be.at.most(pool.maxFreeBytes); - }); - }); - describe("gc()", () => { - it("destroys all free-list entries and zeros the byte total", () => { + it("destroys all free-list entries", () => { const a = alloc(pool, 256, 256); const b = alloc(pool, 512, 512); pool.freeRenderTarget(a); pool.freeRenderTarget(b); - expect(pool.freeListByteSize).to.be.greaterThan(0); pool.gc(); expect(a.destroyed).to.equal(true); expect(b.destroyed).to.equal(true); - expect(pool.freeListByteSize).to.equal(0); }); }); }); From 0cdd97096d4195ae6f9209d35f339ccabd50372a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 26 May 2026 23:38:03 +0800 Subject: [PATCH 05/13] style(pipeline): trim verbose RT pool comments Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/Engine.ts | 6 +--- .../src/RenderPipeline/BasicRenderPipeline.ts | 6 ++-- .../src/RenderPipeline/RenderTargetPool.ts | 33 +++---------------- 3 files changed, 7 insertions(+), 38 deletions(-) diff --git a/packages/core/src/Engine.ts b/packages/core/src/Engine.ts index 2d0bcc2bd7..11d4e1dee4 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -141,10 +141,7 @@ export class Engine extends EventDispatcher { private _lastCanvasWidth: number = -1; private _lastCanvasHeight: number = -1; - /** - * Evict pool entries dimensioned to the previous canvas size when the canvas resizes, - * so full-canvas internal RTs cached at the old resolution don't linger until `tick()`. - */ + /** Evict pool entries sized to the previous canvas dimensions. */ private _onCanvasResize = (): void => { const canvas = this._canvas; const newWidth = canvas.width; @@ -346,7 +343,6 @@ export class Engine extends EventDispatcher { const time = this._time; time._update(); - // Evict pool entries idle past `maxFreeAgeFrames`; cheap linear scan over the (small) free list. this._renderTargetPool.tick(time.frameCount); const deltaTime = time.deltaTime; diff --git a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts index 78fd5115a2..9feea5e266 100644 --- a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts +++ b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts @@ -199,8 +199,7 @@ export class BasicRenderPipeline { this._internalColorTarget = internalColorTarget; } - // No `else` branch needed: `_drawRenderPass` releases both the internal RT and the copy-background - // texture back to the pool at end of frame, so this method always starts with both fields null. + // Both fields are released at the end of `_drawRenderPass`, so they're null on every entry here. // Scalable ambient obscurance pass // Before opaque pass so materials can sample ambient occlusion in BRDF @@ -352,8 +351,7 @@ export class BasicRenderPipeline { cameraRenderTarget?._blitRenderTarget(); cameraRenderTarget?.generateMipmaps(); - // Release per-frame leased resources back to the pool so concurrent cameras with matching shape - // share a single underlying RT through the pool instead of each holding its own across frames. + // Release per-frame leases so the next camera with matching shape can reuse them. const pool = engine._renderTargetPool; if (this._internalColorTarget) { pool.freeRenderTarget(this._internalColorTarget); diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index 2920d6e377..2e918e53ea 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -3,21 +3,11 @@ import { RenderTarget, Texture2D, TextureFilterMode, TextureFormat, TextureWrapM /** * Pool of `RenderTarget`s and `Texture2D`s used internally by the render pipeline. - * - * Entries returned via `freeRenderTarget`/`freeTexture` stay in the free list and are matched - * (by shape) for the next `allocate*` request. Two eviction strategies keep the free list bounded: - * - * 1. **Frame-age** — entries unused for more than `maxFreeAgeFrames` engine ticks are destroyed by `tick()`. - * 2. **Size-matched** — `evictBySize(w, h)` removes entries whose dimensions match the given size, called - * by the engine on canvas resize to clear out old full-canvas RTs. - * + * Entries are matched by shape on `allocate*`; bounded by frame-age (`tick`) and size-match (`evictBySize`). * @internal */ export class RenderTargetPool { - /** - * Maximum number of engine frames an entry may sit unused in the free list before `tick()` destroys it. - * Defaults to ~1 second at 60fps so reflection probes / periodic off-frame passes survive a short gap. - */ + /** Frames an entry may sit idle before `tick()` destroys it. */ maxFreeAgeFrames: number = 60; private _engine: Engine; @@ -148,10 +138,7 @@ export class RenderTargetPool { this._freeTextureFrames.push(this._engine.time.frameCount); } - /** - * Destroy entries that have been idle in the free list for longer than `maxFreeAgeFrames`. - * Called once per engine frame. - */ + /** Destroy entries idle longer than `maxFreeAgeFrames`. Called once per engine frame. */ tick(currentFrame: number): void { const maxAge = this.maxFreeAgeFrames; const rtFrames = this._freeRenderTargetFrames; @@ -168,10 +155,7 @@ export class RenderTargetPool { } } - /** - * Destroy free-list entries whose dimensions exactly match the given size. Called when the canvas - * resizes so full-canvas RTs cached at the previous resolution don't linger waiting for `tick()`. - */ + /** Destroy entries whose dimensions match `(width, height)`. Used on canvas resize. */ evictBySize(width: number, height: number): void { const freeRenderTargets = this._freeRenderTargets; for (let i = freeRenderTargets.length - 1; i >= 0; i--) { @@ -205,9 +189,6 @@ export class RenderTargetPool { this._freeTextureFrames.length = 0; } - /** - * Swap-pop helper: remove free RT entry at `index` without destroying the RT (used when handing it out). - */ private _removeFreeRenderTargetAt(index: number): void { const rts = this._freeRenderTargets; const frames = this._freeRenderTargetFrames; @@ -220,9 +201,6 @@ export class RenderTargetPool { frames.length = last; } - /** - * Swap-pop helper for the texture free list. - */ private _removeFreeTextureAt(index: number): void { const texs = this._freeTextures; const frames = this._freeTextureFrames; @@ -235,9 +213,6 @@ export class RenderTargetPool { frames.length = last; } - /** - * Destroy free RT entry at `index` (called when evicting, not when leasing out). - */ private _destroyFreeRenderTargetAt(index: number): void { const rt = this._freeRenderTargets[index]; this._removeFreeRenderTargetAt(index); From bad635bb111322a1b3b5e3f1ae1f2b46a6e3ed45 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 26 May 2026 23:39:49 +0800 Subject: [PATCH 06/13] style(pipeline): hoist static helpers, drop @internal-class member docs Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/RenderPipeline/RenderTargetPool.ts | 118 +++++++++--------- 1 file changed, 57 insertions(+), 61 deletions(-) diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index 2e918e53ea..b5d6ecb382 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -2,11 +2,66 @@ import { Engine } from "../Engine"; import { RenderTarget, Texture2D, TextureFilterMode, TextureFormat, TextureWrapMode } from "../texture"; /** - * Pool of `RenderTarget`s and `Texture2D`s used internally by the render pipeline. - * Entries are matched by shape on `allocate*`; bounded by frame-age (`tick`) and size-match (`evictBySize`). * @internal */ export class RenderTargetPool { + private static _destroyRenderTargetResource(rt: RenderTarget): void { + const colorTexture = rt.getColorTexture(0); + const depthTexture = rt.depthTexture; + rt.destroy(true); + colorTexture?.destroy(true); + if (depthTexture && depthTexture !== colorTexture) { + depthTexture.destroy(true); + } + } + + private static _matchRenderTarget( + renderTarget: RenderTarget, + width: number, + height: number, + colorFormat: TextureFormat | null, + depthFormat: TextureFormat | null, + needDepthTexture: boolean, + mipmap: boolean, + isSRGBColorSpace: boolean, + antiAliasing: number + ): boolean { + if (renderTarget.width !== width || renderTarget.height !== height || renderTarget.antiAliasing !== antiAliasing) { + return false; + } + + const colorTexture = renderTarget.getColorTexture(0) as Texture2D; + if (colorFormat != null) { + if ( + !colorTexture || + colorTexture.format !== colorFormat || + colorTexture.mipmapCount > 1 !== mipmap || + colorTexture.isSRGBColorSpace !== isSRGBColorSpace + ) { + return false; + } + } else if (colorTexture) { + return false; + } + + const depthTexture = renderTarget.depthTexture; + if (needDepthTexture) { + if (depthFormat) { + if (!depthTexture || (depthTexture as Texture2D).format !== depthFormat) { + return false; + } + } else if (depthTexture) { + return false; + } + } else { + if (renderTarget._depthFormat !== depthFormat) { + return false; + } + } + + return true; + } + /** Frames an entry may sit idle before `tick()` destroys it. */ maxFreeAgeFrames: number = 60; @@ -138,7 +193,6 @@ export class RenderTargetPool { this._freeTextureFrames.push(this._engine.time.frameCount); } - /** Destroy entries idle longer than `maxFreeAgeFrames`. Called once per engine frame. */ tick(currentFrame: number): void { const maxAge = this.maxFreeAgeFrames; const rtFrames = this._freeRenderTargetFrames; @@ -155,7 +209,6 @@ export class RenderTargetPool { } } - /** Destroy entries whose dimensions match `(width, height)`. Used on canvas resize. */ evictBySize(width: number, height: number): void { const freeRenderTargets = this._freeRenderTargets; for (let i = freeRenderTargets.length - 1; i >= 0; i--) { @@ -224,61 +277,4 @@ export class RenderTargetPool { this._removeFreeTextureAt(index); tex.destroy(true); } - - private static _destroyRenderTargetResource(rt: RenderTarget): void { - const colorTexture = rt.getColorTexture(0); - const depthTexture = rt.depthTexture; - rt.destroy(true); - colorTexture?.destroy(true); - if (depthTexture && depthTexture !== colorTexture) { - depthTexture.destroy(true); - } - } - - private static _matchRenderTarget( - renderTarget: RenderTarget, - width: number, - height: number, - colorFormat: TextureFormat | null, - depthFormat: TextureFormat | null, - needDepthTexture: boolean, - mipmap: boolean, - isSRGBColorSpace: boolean, - antiAliasing: number - ): boolean { - if (renderTarget.width !== width || renderTarget.height !== height || renderTarget.antiAliasing !== antiAliasing) { - return false; - } - - const colorTexture = renderTarget.getColorTexture(0) as Texture2D; - if (colorFormat != null) { - if ( - !colorTexture || - colorTexture.format !== colorFormat || - colorTexture.mipmapCount > 1 !== mipmap || - colorTexture.isSRGBColorSpace !== isSRGBColorSpace - ) { - return false; - } - } else if (colorTexture) { - return false; - } - - const depthTexture = renderTarget.depthTexture; - if (needDepthTexture) { - if (depthFormat) { - if (!depthTexture || (depthTexture as Texture2D).format !== depthFormat) { - return false; - } - } else if (depthTexture) { - return false; - } - } else { - if (renderTarget._depthFormat !== depthFormat) { - return false; - } - } - - return true; - } } From 6b9c1eab7a1f23ddc51cd19e563857533b73ec94 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 29 May 2026 11:19:44 +0800 Subject: [PATCH 07/13] test(pipeline): cover texture free-list paths; make age cap inclusive Address CR (P3, raised by GuoLei1990 + CodeRabbit): * `tick()` boundary is now `>= maxFreeAgeFrames` so an entry idle for exactly `maxFreeAgeFrames` frames is destroyed, matching the field name (was `>`, which kept it one extra frame). * Add texture free-list tests: reuse-then-age-evict, evictBySize selectivity, and gc; gc test now also covers a pooled texture. Adds an explicit boundary test locking the inclusive age semantics. Co-Authored-By: Claude Opus 4.8 --- .../src/RenderPipeline/RenderTargetPool.ts | 6 +- .../RenderPipeline/RenderTargetPool.test.ts | 66 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index b5d6ecb382..cd028de47b 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -62,7 +62,7 @@ export class RenderTargetPool { return true; } - /** Frames an entry may sit idle before `tick()` destroys it. */ + /** An entry idle for at least this many frames is destroyed by `tick()`. */ maxFreeAgeFrames: number = 60; private _engine: Engine; @@ -197,13 +197,13 @@ export class RenderTargetPool { const maxAge = this.maxFreeAgeFrames; const rtFrames = this._freeRenderTargetFrames; for (let i = rtFrames.length - 1; i >= 0; i--) { - if (currentFrame - rtFrames[i] > maxAge) { + if (currentFrame - rtFrames[i] >= maxAge) { this._destroyFreeRenderTargetAt(i); } } const texFrames = this._freeTextureFrames; for (let i = texFrames.length - 1; i >= 0; i--) { - if (currentFrame - texFrames[i] > maxAge) { + if (currentFrame - texFrames[i] >= maxAge) { this._destroyFreeTextureAt(i); } } diff --git a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts index 6403a41884..4c2bab02e5 100644 --- a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts +++ b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts @@ -28,6 +28,21 @@ function alloc( ); } +/** + * Helper: allocate a standalone Texture2D through the pool with sane defaults. + */ +function allocTex(pool: RenderTargetPool, width: number, height: number) { + return pool.allocateTexture( + width, + height, + TextureFormat.R8G8B8A8, + false, + false, + TextureWrapMode.Clamp, + TextureFilterMode.Bilinear + ); +} + describe("RenderTargetPool", () => { const canvas = document.createElement("canvas"); let engine: WebGLEngine; @@ -98,6 +113,19 @@ describe("RenderTargetPool", () => { expect(b).to.not.equal(a); expect(a.destroyed).to.equal(true); }); + + it("evicts exactly at the maxFreeAgeFrames boundary (inclusive)", () => { + pool.maxFreeAgeFrames = 5; + const a = alloc(pool, 256, 256); + const baseFrame = engine.time.frameCount; + pool.freeRenderTarget(a); + // One frame short of the cap: survives. + pool.tick(baseFrame + 4); + expect(a.destroyed).to.equal(false); + // Exactly at the cap: destroyed. + pool.tick(baseFrame + 5); + expect(a.destroyed).to.equal(true); + }); }); describe("evictBySize for canvas resize", () => { @@ -126,16 +154,52 @@ describe("RenderTargetPool", () => { }); }); + describe("texture free-list", () => { + it("reuses a freed texture within maxFreeAgeFrames, evicts past it", () => { + pool.maxFreeAgeFrames = 5; + const a = allocTex(pool, 128, 128); + const baseFrame = engine.time.frameCount; + pool.freeTexture(a); + + pool.tick(baseFrame + 3); + const b = allocTex(pool, 128, 128); + expect(b).to.equal(a); + + pool.freeTexture(b); + pool.tick(baseFrame + 100); + expect(a.destroyed).to.equal(true); + const c = allocTex(pool, 128, 128); + expect(c).to.not.equal(a); + }); + + it("evictBySize destroys only matching free textures", () => { + const a = allocTex(pool, 800, 600); + const b = allocTex(pool, 1024, 768); + pool.freeTexture(a); + pool.freeTexture(b); + + pool.evictBySize(800, 600); + expect(a.destroyed).to.equal(true); + expect(b.destroyed).to.equal(false); + + const reused = allocTex(pool, 1024, 768); + expect(reused).to.equal(b); + }); + }); + describe("gc()", () => { - it("destroys all free-list entries", () => { + it("destroys all free-list entries (render targets and textures)", () => { const a = alloc(pool, 256, 256); const b = alloc(pool, 512, 512); + const t = allocTex(pool, 128, 128); pool.freeRenderTarget(a); pool.freeRenderTarget(b); + pool.freeTexture(t); pool.gc(); expect(a.destroyed).to.equal(true); expect(b.destroyed).to.equal(true); + expect(t.destroyed).to.equal(true); }); }); }); From 97f822d4b465f46f7dd850d2a934c46c24f5b5bc Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 29 May 2026 11:56:30 +0800 Subject: [PATCH 08/13] test(pipeline): fix RenderTargetPool test engine import to unblock codecov MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codecov job builds packages and runs the suite against the built bundles. The test imported `WebGLEngine` from `@galacean/engine-rhi-webgl` while importing core symbols from `@galacean/engine-core`; against the built output these resolve to two separate copies of core, so the `WebGLEngine` (extending one `Engine`) crashed during construction reading a class that lived in the other copy — failing only this file while 108 others passed. Local `pnpm test` masked it by resolving every package to source via the `debug` mainField (single module graph). Import `WebGLEngine` from the `@galacean/engine` umbrella like the other engine tests. `RenderTargetPool` (still `@internal`, not barrel-exported) is now referenced via a type-only import plus its runtime constructor from `engine._renderTargetPool`, avoiding a value import of the source file that would reintroduce the dual-core split. Co-Authored-By: Claude Opus 4.8 --- .../RenderPipeline/RenderTargetPool.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts index 4c2bab02e5..d77933e63e 100644 --- a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts +++ b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts @@ -1,10 +1,15 @@ import { TextureFilterMode, TextureFormat, TextureWrapMode } from "@galacean/engine-core"; -// `RenderTargetPool` is `@internal` and intentionally not re-exported from the core barrel. -// Import directly from the source file for test access. -import { RenderTargetPool } from "../../../../packages/core/src/RenderPipeline/RenderTargetPool"; -import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +// Import `WebGLEngine` from the `@galacean/engine` umbrella (not `@galacean/engine-rhi-webgl`): the +// coverage build resolves packages to their built bundles, and mixing the rhi sub-package with +// `@galacean/engine-core` pulls two separate copies of core, breaking engine bootstrap. +import { WebGLEngine } from "@galacean/engine"; +// `RenderTargetPool` is `@internal` and not re-exported from the core barrel; take the type via a +// type-only import (erased at runtime) and the runtime constructor from the engine's pool instance. +import type { RenderTargetPool } from "../../../../packages/core/src/RenderPipeline/RenderTargetPool"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +let RenderTargetPoolClass: { new (engine: WebGLEngine): RenderTargetPool }; + /** * Helper: allocate an RT through the pool with sane defaults; varies only the bits that affect matching. */ @@ -50,6 +55,8 @@ describe("RenderTargetPool", () => { beforeAll(async () => { engine = await WebGLEngine.create({ canvas }); + // @ts-ignore - `_renderTargetPool` is `@internal`; its constructor is the class under test. + RenderTargetPoolClass = engine._renderTargetPool.constructor; }); afterAll(() => { @@ -58,7 +65,7 @@ describe("RenderTargetPool", () => { beforeEach(() => { // Each test gets a fresh pool so leaked entries from earlier tests don't bleed across. - pool = new RenderTargetPool(engine); + pool = new RenderTargetPoolClass(engine); }); describe("matching reuse", () => { From a40750bbc99afcd10237384e683de78658cb0c8a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 29 May 2026 14:26:47 +0800 Subject: [PATCH 09/13] refactor(pipeline): allocate internal RT directly from pool With per-frame leasing, `_internalColorTarget` / `_copyBackgroundTexture` are always null when `render()` runs, so the `recreateRenderTargetIfNeeded` match-or-realloc path was dead for this caller and falsely implied cross-frame reuse. Shape matching now happens inside the pool (free at end of frame, match on next allocate), so call `pool.allocateRenderTarget` / `pool.allocateTexture` directly. Behavior-identical; no cross-frame reuse path remained to preserve. Co-Authored-By: Claude Opus 4.8 --- .../core/src/RenderPipeline/BasicRenderPipeline.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts index 9feea5e266..98d536e79d 100644 --- a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts +++ b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts @@ -166,9 +166,10 @@ export class BasicRenderPipeline { depthFormat = null; } const viewport = camera.pixelViewport; - const internalColorTarget = PipelineUtils.recreateRenderTargetIfNeeded( - engine, - this._internalColorTarget, + const pool = engine._renderTargetPool; + // Allocate fresh from the pool each frame; shape matching/reuse is handled by the pool, and the + // lease is returned at the end of `_drawRenderPass`. + this._internalColorTarget = pool.allocateRenderTarget( viewport.width, viewport.height, camera._getInternalColorTextureFormat(), @@ -183,9 +184,7 @@ export class BasicRenderPipeline { if (this._shouldCopyBackgroundColor) { const colorTexture = camera.renderTarget?.getColorTexture(0); - const copyBackgroundTexture = PipelineUtils.recreateTextureIfNeeded( - engine, - this._copyBackgroundTexture, + this._copyBackgroundTexture = pool.allocateTexture( viewport.width, viewport.height, colorTexture?.format ?? TextureFormat.R8G8B8A8, @@ -194,10 +193,7 @@ export class BasicRenderPipeline { TextureWrapMode.Clamp, TextureFilterMode.Bilinear ); - this._copyBackgroundTexture = copyBackgroundTexture; } - - this._internalColorTarget = internalColorTarget; } // Both fields are released at the end of `_drawRenderPass`, so they're null on every entry here. From af1b711dde5adbd7585cd0bf882bbe38bfefd04f Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 29 May 2026 14:32:10 +0800 Subject: [PATCH 10/13] style(pipeline): drop redundant internal-RT comments Co-Authored-By: Claude Opus 4.8 --- packages/core/src/RenderPipeline/BasicRenderPipeline.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts index 98d536e79d..58970bdd66 100644 --- a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts +++ b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts @@ -167,8 +167,6 @@ export class BasicRenderPipeline { } const viewport = camera.pixelViewport; const pool = engine._renderTargetPool; - // Allocate fresh from the pool each frame; shape matching/reuse is handled by the pool, and the - // lease is returned at the end of `_drawRenderPass`. this._internalColorTarget = pool.allocateRenderTarget( viewport.width, viewport.height, @@ -195,7 +193,6 @@ export class BasicRenderPipeline { ); } } - // Both fields are released at the end of `_drawRenderPass`, so they're null on every entry here. // Scalable ambient obscurance pass // Before opaque pass so materials can sample ambient occlusion in BRDF From 00149a04b7e12ec8970bde2ab16daff3a972c231 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 29 May 2026 14:38:42 +0800 Subject: [PATCH 11/13] style(pipeline): simplify free-list swap-pop helpers Drop the `index !== last` guard (it only avoided a harmless self-assign) and the local aliases, leaving the plain swap-with-last form. Co-Authored-By: Claude Opus 4.8 --- .../src/RenderPipeline/RenderTargetPool.ts | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index cd028de47b..0e3760b968 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -243,27 +243,19 @@ export class RenderTargetPool { } private _removeFreeRenderTargetAt(index: number): void { - const rts = this._freeRenderTargets; - const frames = this._freeRenderTargetFrames; - const last = rts.length - 1; - if (index !== last) { - rts[index] = rts[last]; - frames[index] = frames[last]; - } - rts.length = last; - frames.length = last; + const last = this._freeRenderTargets.length - 1; + this._freeRenderTargets[index] = this._freeRenderTargets[last]; + this._freeRenderTargetFrames[index] = this._freeRenderTargetFrames[last]; + this._freeRenderTargets.length = last; + this._freeRenderTargetFrames.length = last; } private _removeFreeTextureAt(index: number): void { - const texs = this._freeTextures; - const frames = this._freeTextureFrames; - const last = texs.length - 1; - if (index !== last) { - texs[index] = texs[last]; - frames[index] = frames[last]; - } - texs.length = last; - frames.length = last; + const last = this._freeTextures.length - 1; + this._freeTextures[index] = this._freeTextures[last]; + this._freeTextureFrames[index] = this._freeTextureFrames[last]; + this._freeTextures.length = last; + this._freeTextureFrames.length = last; } private _destroyFreeRenderTargetAt(index: number): void { From 59d45b5edc5d6b4a5676d3a22ec788f8c1a0c1e2 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 29 May 2026 17:12:01 +0800 Subject: [PATCH 12/13] refactor(pipeline): flush pool free list on canvas resize instead of size-keyed evict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `evictBySize` only matched entries whose dimensions equalled the previous canvas size, so it missed canvas-derived-but-scaled entries (sub-viewport cameras, down/upsampled targets) — those lingered until frame-age. A canvas resize invalidates every canvas-coupled size at once, and the pool can't tell canvas-coupled from fixed-size entries, so just flush the whole free list via `gc()` (active leases are untouched; next frame reallocates). This matches Godot's reconfigure-on-resize and is simpler: drops `evictBySize` and the `_lastCanvasWidth/_lastCanvasHeight` tracking. Canvas setters only dispatch on real size changes, so no guard is needed. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/Engine.ts | 17 +------- .../src/RenderPipeline/RenderTargetPool.ts | 17 -------- .../RenderPipeline/RenderTargetPool.test.ts | 40 ------------------- 3 files changed, 2 insertions(+), 72 deletions(-) diff --git a/packages/core/src/Engine.ts b/packages/core/src/Engine.ts index 11d4e1dee4..e5d44f0a07 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -138,21 +138,10 @@ export class Engine extends EventDispatcher { private _waitingGC: boolean = false; private _postProcessPasses = new Array(); private _activePostProcessPasses = new Array(); - private _lastCanvasWidth: number = -1; - private _lastCanvasHeight: number = -1; - /** Evict pool entries sized to the previous canvas dimensions. */ + /** Flush the render target pool's free list when the canvas resizes; canvas-sized entries are now stale. */ private _onCanvasResize = (): void => { - const canvas = this._canvas; - const newWidth = canvas.width; - const newHeight = canvas.height; - if (this._lastCanvasWidth !== newWidth || this._lastCanvasHeight !== newHeight) { - if (this._lastCanvasWidth >= 0) { - this._renderTargetPool.evictBySize(this._lastCanvasWidth, this._lastCanvasHeight); - } - this._lastCanvasWidth = newWidth; - this._lastCanvasHeight = newHeight; - } + this._renderTargetPool.gc(); }; private _animate = () => { @@ -272,8 +261,6 @@ export class Engine extends EventDispatcher { this._batcherManager = new BatcherManager(this); this._renderTargetPool = new RenderTargetPool(this); - this._lastCanvasWidth = canvas.width; - this._lastCanvasHeight = canvas.height; canvas._sizeUpdateFlagManager.addListener(this._onCanvasResize); this.inputManager = new InputManager(this, configuration.input); diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index 0e3760b968..9456eb4db7 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -209,23 +209,6 @@ export class RenderTargetPool { } } - evictBySize(width: number, height: number): void { - const freeRenderTargets = this._freeRenderTargets; - for (let i = freeRenderTargets.length - 1; i >= 0; i--) { - const rt = freeRenderTargets[i]; - if (rt.width === width && rt.height === height) { - this._destroyFreeRenderTargetAt(i); - } - } - const freeTextures = this._freeTextures; - for (let i = freeTextures.length - 1; i >= 0; i--) { - const tex = freeTextures[i]; - if (tex.width === width && tex.height === height) { - this._destroyFreeTextureAt(i); - } - } - } - gc(): void { const freeRenderTargets = this._freeRenderTargets; for (let i = 0, n = freeRenderTargets.length; i < n; i++) { diff --git a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts index d77933e63e..7d82dfb103 100644 --- a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts +++ b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts @@ -135,32 +135,6 @@ describe("RenderTargetPool", () => { }); }); - describe("evictBySize for canvas resize", () => { - it("destroys free-list entries matching the given size", () => { - const a = alloc(pool, 800, 600); - const b = alloc(pool, 1024, 768); - pool.freeRenderTarget(a); - pool.freeRenderTarget(b); - - pool.evictBySize(800, 600); - expect(a.destroyed).to.equal(true); - expect(b.destroyed).to.equal(false); - - // Re-allocating at the other size still returns the survivor - const reused = alloc(pool, 1024, 768); - expect(reused).to.equal(b); - }); - - it("ignores entries whose dimensions do not match", () => { - const a = alloc(pool, 800, 600); - pool.freeRenderTarget(a); - pool.evictBySize(1024, 768); - expect(a.destroyed).to.equal(false); - const b = alloc(pool, 800, 600); - expect(b).to.equal(a); - }); - }); - describe("texture free-list", () => { it("reuses a freed texture within maxFreeAgeFrames, evicts past it", () => { pool.maxFreeAgeFrames = 5; @@ -178,20 +152,6 @@ describe("RenderTargetPool", () => { const c = allocTex(pool, 128, 128); expect(c).to.not.equal(a); }); - - it("evictBySize destroys only matching free textures", () => { - const a = allocTex(pool, 800, 600); - const b = allocTex(pool, 1024, 768); - pool.freeTexture(a); - pool.freeTexture(b); - - pool.evictBySize(800, 600); - expect(a.destroyed).to.equal(true); - expect(b.destroyed).to.equal(false); - - const reused = allocTex(pool, 1024, 768); - expect(reused).to.equal(b); - }); }); describe("gc()", () => { From 1dac9863f828f5fb53cfeb6b3655e97a2d06f22a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 18 Jun 2026 11:00:15 +0800 Subject: [PATCH 13/13] fix(pipeline): release internal RT lease in finally so a render-pass throw can't strand it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-frame internal color target / copy-background texture were freed only at the tail of `_drawRenderPass()`. A throw in the SAO pass or anywhere in the draw span skipped the release: next frame's `render()` overwrote the still non-null field with a fresh lease, orphaning the previous RT — it is `isGCIgnored`, so `ResourceManager.gc()` never reclaims it — for the engine's lifetime. Wrap the pass body in `try/finally` and release the leases there, so they are always returned to the pool and the fields always nulled, even on throw. Co-Authored-By: Claude Opus 4.8 --- .../src/RenderPipeline/BasicRenderPipeline.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts index 58970bdd66..5e45aa5a4a 100644 --- a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts +++ b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts @@ -197,14 +197,28 @@ export class BasicRenderPipeline { // Scalable ambient obscurance pass // Before opaque pass so materials can sample ambient occlusion in BRDF const saoPass = this._saoPass; - if (ambientOcclusionEnabled && supportDepthTexture && saoPass.isSupported) { - saoPass.onConfig(camera, this._depthOnlyPass.renderTarget); - saoPass.onRender(context); - } else { - this._saoPass.release(); - } + try { + if (ambientOcclusionEnabled && supportDepthTexture && saoPass.isSupported) { + saoPass.onConfig(camera, this._depthOnlyPass.renderTarget); + saoPass.onRender(context); + } else { + this._saoPass.release(); + } - this._drawRenderPass(context, camera, finalClearFlags, cubeFace, mipLevel); + this._drawRenderPass(context, camera, finalClearFlags, cubeFace, mipLevel); + } finally { + // Always return the per-frame leases, even if a pass threw, so the next camera with + // matching shape can reuse them and the pool never strands an internal RT/texture. + const pool = engine._renderTargetPool; + if (this._internalColorTarget) { + pool.freeRenderTarget(this._internalColorTarget); + this._internalColorTarget = null; + } + if (this._copyBackgroundTexture) { + pool.freeTexture(this._copyBackgroundTexture); + this._copyBackgroundTexture = null; + } + } } private _drawRenderPass( @@ -343,17 +357,6 @@ export class BasicRenderPipeline { cameraRenderTarget?._blitRenderTarget(); cameraRenderTarget?.generateMipmaps(); - - // Release per-frame leases so the next camera with matching shape can reuse them. - const pool = engine._renderTargetPool; - if (this._internalColorTarget) { - pool.freeRenderTarget(this._internalColorTarget); - this._internalColorTarget = null; - } - if (this._copyBackgroundTexture) { - pool.freeTexture(this._copyBackgroundTexture); - this._copyBackgroundTexture = null; - } } /**