diff --git a/packages/core/src/Engine.ts b/packages/core/src/Engine.ts index c909e49093..e5d44f0a07 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -139,6 +139,11 @@ export class Engine extends EventDispatcher { private _postProcessPasses = new Array(); private _activePostProcessPasses = new Array(); + /** Flush the render target pool's free list when the canvas resizes; canvas-sized entries are now stale. */ + private _onCanvasResize = (): void => { + this._renderTargetPool.gc(); + }; + private _animate = () => { if (this._vSyncCount) { const raf = this.xrManager?._getRequestAnimationFrame() || requestAnimationFrame; @@ -256,6 +261,7 @@ export class Engine extends EventDispatcher { this._batcherManager = new BatcherManager(this); this._renderTargetPool = new RenderTargetPool(this); + canvas._sizeUpdateFlagManager.addListener(this._onCanvasResize); this.inputManager = new InputManager(this, configuration.input); const { xrDevice } = configuration; @@ -324,6 +330,8 @@ export class Engine extends EventDispatcher { const time = this._time; time._update(); + this._renderTargetPool.tick(time.frameCount); + const deltaTime = time.deltaTime; this._frameInProcess = true; @@ -502,6 +510,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..5e45aa5a4a 100644 --- a/packages/core/src/RenderPipeline/BasicRenderPipeline.ts +++ b/packages/core/src/RenderPipeline/BasicRenderPipeline.ts @@ -166,9 +166,8 @@ export class BasicRenderPipeline { depthFormat = null; } const viewport = camera.pixelViewport; - const internalColorTarget = PipelineUtils.recreateRenderTargetIfNeeded( - engine, - this._internalColorTarget, + const pool = engine._renderTargetPool; + this._internalColorTarget = pool.allocateRenderTarget( viewport.width, viewport.height, camera._getInternalColorTextureFormat(), @@ -183,9 +182,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,35 +191,34 @@ export class BasicRenderPipeline { TextureWrapMode.Clamp, TextureFilterMode.Bilinear ); - this._copyBackgroundTexture = copyBackgroundTexture; } + } - this._internalColorTarget = internalColorTarget; - } else { - const internalColorTarget = this._internalColorTarget; - const copyBackgroundTexture = this._copyBackgroundTexture; + // Scalable ambient obscurance pass + // Before opaque pass so materials can sample ambient occlusion in BRDF + const saoPass = this._saoPass; + 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); + } 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 (internalColorTarget) { - pool.freeRenderTarget(internalColorTarget); + if (this._internalColorTarget) { + pool.freeRenderTarget(this._internalColorTarget); this._internalColorTarget = null; } - if (copyBackgroundTexture) { - pool.freeTexture(copyBackgroundTexture); + if (this._copyBackgroundTexture) { + pool.freeTexture(this._copyBackgroundTexture); this._copyBackgroundTexture = null; } } - - // 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(); - } - - this._drawRenderPass(context, camera, finalClearFlags, cubeFace, mipLevel); } private _drawRenderPass( diff --git a/packages/core/src/RenderPipeline/RenderTargetPool.ts b/packages/core/src/RenderPipeline/RenderTargetPool.ts index 3eae130d62..9456eb4db7 100644 --- a/packages/core/src/RenderPipeline/RenderTargetPool.ts +++ b/packages/core/src/RenderPipeline/RenderTargetPool.ts @@ -5,9 +5,73 @@ import { RenderTarget, Texture2D, TextureFilterMode, TextureFormat, TextureWrapM * @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; + } + + /** An entry idle for at least this many frames is destroyed by `tick()`. */ + maxFreeAgeFrames: number = 60; + + private _engine: Engine; + private _freeRenderTargets: RenderTarget[] = []; + private _freeRenderTargetFrames: number[] = []; + private _freeTextures: Texture2D[] = []; - private _engine: Engine; + private _freeTextureFrames: number[] = []; constructor(engine: Engine) { this._engine = engine; @@ -41,8 +105,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 +166,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; @@ -122,78 +184,72 @@ export class RenderTargetPool { freeRenderTarget(renderTarget: RenderTarget): void { if (!renderTarget || renderTarget.destroyed) return; this._freeRenderTargets.push(renderTarget); + this._freeRenderTargetFrames.push(this._engine.time.frameCount); } freeTexture(texture: Texture2D): void { if (!texture || texture.destroyed) return; this._freeTextures.push(texture); + this._freeTextureFrames.push(this._engine.time.frameCount); + } + + 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); + } + } } 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; 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; } - 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; - } + private _removeFreeRenderTargetAt(index: number): void { + 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; + } - 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; - } + private _removeFreeTextureAt(index: number): void { + 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; + } - 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; - } - } + private _destroyFreeRenderTargetAt(index: number): void { + const rt = this._freeRenderTargets[index]; + this._removeFreeRenderTargetAt(index); + RenderTargetPool._destroyRenderTargetResource(rt); + } - return true; + private _destroyFreeTextureAt(index: number): void { + const tex = this._freeTextures[index]; + this._removeFreeTextureAt(index); + tex.destroy(true); } } diff --git a/tests/src/core/RenderPipeline/RenderTargetPool.test.ts b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts new file mode 100644 index 0000000000..7d82dfb103 --- /dev/null +++ b/tests/src/core/RenderPipeline/RenderTargetPool.test.ts @@ -0,0 +1,172 @@ +import { TextureFilterMode, TextureFormat, TextureWrapMode } from "@galacean/engine-core"; +// 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. + */ +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 + ); +} + +/** + * 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; + let pool: 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(() => { + engine.destroy(); + }); + + beforeEach(() => { + // Each test gets a fresh pool so leaked entries from earlier tests don't bleed across. + pool = new RenderTargetPoolClass(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); + }); + + 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("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); + }); + }); + + describe("gc()", () => { + 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); + }); + }); +});