Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1f3f2e1
perf(pipeline): share internal RT across cameras via per-frame pool l…
May 26, 2026
a8c141d
perf(pipeline): address CR — unify memory cap, document byte contract
May 26, 2026
60e4b03
perf(pipeline): tick() re-evaluates memory cap; test cleans up engine
May 26, 2026
3a67d1a
perf(pipeline): drop maxFreeBytes cap; frame-age + resize-evict are e…
May 26, 2026
0cdd970
style(pipeline): trim verbose RT pool comments
May 26, 2026
bad635b
style(pipeline): hoist static helpers, drop @internal-class member docs
May 26, 2026
6b9c1ea
test(pipeline): cover texture free-list paths; make age cap inclusive
May 29, 2026
97f822d
test(pipeline): fix RenderTargetPool test engine import to unblock co…
May 29, 2026
a40750b
refactor(pipeline): allocate internal RT directly from pool
May 29, 2026
af1b711
style(pipeline): drop redundant internal-RT comments
May 29, 2026
00149a0
style(pipeline): simplify free-list swap-pop helpers
May 29, 2026
59d45b5
refactor(pipeline): flush pool free list on canvas resize instead of …
May 29, 2026
1dac986
fix(pipeline): release internal RT lease in finally so a render-pass …
Jun 18, 2026
07b7e5d
refactor(pipeline): drop frame-age tick eviction from RenderTargetPool
Jun 26, 2026
3e6a08d
style(pipeline): inline _onCanvasResize as expression body
GuoLei1990 Jun 27, 2026
8bcdee6
refactor(pipeline): drop try/finally RT-lease guard, return leases on…
GuoLei1990 Jun 27, 2026
1be6eed
refactor(pipeline): hoist pool local to dedupe lookup in render()
GuoLei1990 Jun 27, 2026
007c3a2
test(e2e): relax CharacterSpacing diff threshold for font raster drift
GuoLei1990 Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/core/src/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ export class Engine extends EventDispatcher {
private _waitingGC: boolean = false;
private _postProcessPasses = new Array<PostProcessPass>();
private _activePostProcessPasses = new Array<PostProcessPass>();
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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();

Expand Down
26 changes: 14 additions & 12 deletions packages/core/src/RenderPipeline/BasicRenderPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}

/**
Expand Down
252 changes: 238 additions & 14 deletions packages/core/src/RenderPipeline/RenderTargetPool.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -121,33 +151,227 @@ 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`.
* 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 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;
rt.destroy(true);
colorTexture?.destroy(true);
if (depthTexture && depthTexture !== colorTexture) {
depthTexture.destroy(true);
}
}

private static _matchRenderTarget(
Expand Down
Loading
Loading