Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
98 changes: 98 additions & 0 deletions src/core/TextureMemoryManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
TextureMemoryManager,
type TextureMemoryManagerSettings,
} from './TextureMemoryManager.js';
import type { Stage } from './Stage.js';
import type { Texture } from './textures/Texture.js';

function makeSettings(
overrides: Partial<TextureMemoryManagerSettings> = {},
): TextureMemoryManagerSettings {
return {
criticalThreshold: 200e6,
targetThresholdLevel: 0.5,
cleanupInterval: 5000,
debugLogging: false,
baselineMemoryAllocation: 26e6,
doNotExceedCriticalThreshold: false,
...overrides,
};
}

// The only Stage method the OOM path touches is queueFrameEvent.
function makeStage(): {
stage: Stage;
queueFrameEvent: ReturnType<typeof vi.fn>;
} {
const queueFrameEvent = vi.fn();
const stage = { queueFrameEvent } as unknown as Stage;
return { stage, queueFrameEvent };
}

function makeManager(overrides: Partial<TextureMemoryManagerSettings> = {}): {
mgr: TextureMemoryManager;
queueFrameEvent: ReturnType<typeof vi.fn>;
} {
const { stage, queueFrameEvent } = makeStage();
const mgr = new TextureMemoryManager(stage, makeSettings(overrides));
return { mgr, queueFrameEvent };
}

// setTextureMemUse expects a Texture with a mutable memUsed field; nothing else
// is read on the OOM path.
function fakeTexture(): Texture {
return { memUsed: 0 } as unknown as Texture;
}

describe('TextureMemoryManager — out-of-memory event', () => {
it('queues an outOfMemory frame event with the estimate and threshold', () => {
const { mgr, queueFrameEvent } = makeManager({ criticalThreshold: 200e6 });
// memUsed = baseline (26e6) + texture (100e6) = 126e6
mgr.setTextureMemUse(fakeTexture(), 100e6);

mgr.handleOutOfMemory();

expect(queueFrameEvent).toHaveBeenCalledTimes(1);
expect(queueFrameEvent).toHaveBeenCalledWith('outOfMemory', {
memUsed: 126e6,
criticalThreshold: 200e6,
});
});

it('requests an immediate cleanup as a best-effort mitigation', () => {
const { mgr } = makeManager();
expect(mgr.criticalCleanupRequested).toBe(false);

mgr.handleOutOfMemory();

expect(mgr.criticalCleanupRequested).toBe(true);
});

it('does not change the critical threshold itself', () => {
const { mgr } = makeManager({ criticalThreshold: 200e6 });
const before = mgr.getMemoryInfo().criticalThreshold;

mgr.handleOutOfMemory();

expect(mgr.getMemoryInfo().criticalThreshold).toBe(before);
});

it('reports the current estimate each time it fires', () => {
const { mgr, queueFrameEvent } = makeManager({ criticalThreshold: 200e6 });

mgr.setTextureMemUse(fakeTexture(), 50e6);
mgr.handleOutOfMemory();
mgr.setTextureMemUse(fakeTexture(), 80e6);
mgr.handleOutOfMemory();

expect(queueFrameEvent.mock.calls[0]![1]).toEqual({
memUsed: 76e6, // 26e6 baseline + 50e6
criticalThreshold: 200e6,
});
expect(queueFrameEvent.mock.calls[1]![1]).toEqual({
memUsed: 156e6, // 26e6 baseline + 50e6 + 80e6
criticalThreshold: 200e6,
});
});
});
30 changes: 28 additions & 2 deletions src/core/TextureMemoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,36 @@ export class TextureMemoryManager {
}, 1000);
}

// If the threshold is 0, we disable the memory manager by replacing the
// setTextureMemUse method with a no-op function.
// If the threshold is 0, we disable memory tracking/cleanup by replacing the
// setTextureMemUse method with a no-op function. Note this only disables LRU
// tracking — GPU out-of-memory detection still runs (see handleOutOfMemory).
if (criticalThreshold === 0) {
this.setTextureMemUse = () => {};
}
}

/**
* React to a real GPU out-of-memory reported by the renderer.
*
* @remarks
* WebGL never exposes the VRAM budget up front, so the only certain signal is
* a `GL_OUT_OF_MEMORY` after the fact. When it fires we queue an `outOfMemory`
* frame event carrying the estimated memory in use and the critical threshold
* in effect — the estimate is a *measured ceiling* (the real budget is at or
* below it). What to do about it (lower the threshold, persist, reload) is
* application policy, not the renderer's; see the `outOfMemory` event docs on
* the public Renderer for the recommended integration.
*
* The engine also requests an immediate cleanup as a best-effort mitigation
* to free non-renderable textures before the app reacts.
*/
handleOutOfMemory(): void {
this.stage.queueFrameEvent('outOfMemory', {
memUsed: this.memUsed,
criticalThreshold: this.criticalThreshold,
});

// Free whatever non-renderable textures we can right now.
this.criticalCleanupRequested = true;
}
}
78 changes: 78 additions & 0 deletions src/core/platforms/web/WebPlatform.outOfMemory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Tests that the GPU out-of-memory probe runs at the idle transition (end of a
* render burst), not on every active frame.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WebPlatform } from './WebPlatform.js';
import type { Stage } from '../../Stage.js';

function makeIdleStage(outOfMemory: boolean) {
const checkForOutOfMemory = vi.fn(() => outOfMemory);
const handleOutOfMemory = vi.fn();
const stage = {
isContextLost: false,
targetFrameTime: 0,
updateFrameTime: vi.fn(),
updateAnimations: vi.fn(() => false),
hasSceneUpdates: vi.fn(() => false), // idle
calculateFps: vi.fn(),
drawFrame: vi.fn(),
flushFrameEvents: vi.fn(),
shManager: { cleanup: vi.fn() },
eventBus: { emit: vi.fn() },
txMemManager: {
checkCleanup: vi.fn(() => false),
cleanup: vi.fn(),
handleOutOfMemory,
},
renderer: { checkForOutOfMemory },
} as unknown as Stage;
return { stage, checkForOutOfMemory, handleOutOfMemory };
}

describe('WebPlatform render loop — out-of-memory probe at idle', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

function runOneIdleFrame(stage: Stage) {
let capturedLoop: ((t?: number) => void) | null = null;
const raf = vi.fn((cb: (t?: number) => void) => {
capturedLoop = cb;
return 1;
});
vi.stubGlobal('requestAnimationFrame', raf);
vi.stubGlobal(
'setTimeout',
vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
);

new WebPlatform().startLoop(stage);
capturedLoop!(0);
}

it('probes the renderer once when the scene goes idle', () => {
const { stage, checkForOutOfMemory } = makeIdleStage(false);
runOneIdleFrame(stage);
expect(checkForOutOfMemory).toHaveBeenCalledTimes(1);
});

it('handles OOM when the probe reports it at idle', () => {
const { stage, handleOutOfMemory } = makeIdleStage(true);
runOneIdleFrame(stage);
expect(handleOutOfMemory).toHaveBeenCalledTimes(1);
});

it('does not handle OOM when the probe reports none', () => {
const { stage, handleOutOfMemory } = makeIdleStage(false);
runOneIdleFrame(stage);
expect(handleOutOfMemory).not.toHaveBeenCalled();
});

it('does not probe on an active (non-idle) frame', () => {
const { stage, checkForOutOfMemory } = makeIdleStage(false);
(stage.hasSceneUpdates as ReturnType<typeof vi.fn>).mockReturnValue(true);
runOneIdleFrame(stage);
expect(checkForOutOfMemory).not.toHaveBeenCalled();
});
});
8 changes: 8 additions & 0 deletions src/core/platforms/web/WebPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ export class WebPlatform extends Platform {
setTimeout(requestLoop, Math.max(targetFrameTime, 15));

if (isIdle === false) {
// The render burst has settled. Probe for a GPU out-of-memory now
// rather than every frame: GL errors accumulate and persist until
// drained, so a single check here still catches any OOM raised during
// the active frames, without paying the getError() CPU/GPU sync on
// every frame. Queues the `outOfMemory` event, flushed below.
if (stage.renderer.checkForOutOfMemory() === true) {
stage.txMemManager.handleOutOfMemory();
}
stage.shManager.cleanup();
stage.eventBus.emit('idle');
isIdle = true;
Expand Down
12 changes: 12 additions & 0 deletions src/core/renderers/CoreRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,16 @@ export abstract class CoreRenderer {
* on the next render call.
*/
invalidateQuadBuffer?(): void;

/**
* Probe the backend for a GPU out-of-memory condition since the last call.
* Returns `true` when an out-of-memory was seen. Backends that cannot detect
* this (e.g. Canvas2D) return `false`.
*
* @remarks
* Called once per frame by the Stage. Backends where the probe is expensive
* (a CPU/GPU sync, e.g. WebGL `gl.getError()`) rely on this once-per-frame
* cadence rather than checking per draw/upload.
*/
abstract checkForOutOfMemory(): boolean;
}
5 changes: 5 additions & 0 deletions src/core/renderers/canvas/CanvasRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ export class CanvasRenderer extends CoreRenderer {
return null;
}

// Canvas2D has no GPU out-of-memory signal to probe.
checkForOutOfMemory(): boolean {
return false;
}

/**
* Updates the clear color of the canvas renderer.
*
Expand Down
36 changes: 36 additions & 0 deletions src/core/renderers/webgl/WebGlRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ import type { Dimensions } from '../../../common/CommonTypes.js';

export type WebGlRendererOptions = CoreRendererOptions;

const GL_OUT_OF_MEMORY = 0x0505;

/**
* Upper bound on how many queued GL errors we drain per frame in
* {@link WebGlRenderer.checkForOutOfMemory}. Keeps the per-frame `getError()`
* sync cost fixed even if the error queue is unexpectedly deep.
*/
const MAX_DRAINED_GL_ERRORS = 8;

interface CoreWebGlSystem {
parameters: CoreWebGlParameters;
extensions: CoreWebGlExtensions;
Expand Down Expand Up @@ -1283,6 +1292,33 @@ export class WebGlRenderer extends CoreRenderer {
return bufferInfo;
}

/**
* Drain the GL error queue once and report whether a GL_OUT_OF_MEMORY was
* seen since the last call.
*
* @remarks
* `gl.getError()` forces a CPU↔GPU sync, so this is deliberately invoked at
* most once per frame by the Stage rather than after each texture upload.
* `getError()` returns one error at a time, so we drain a bounded number of
* queued errors to ensure a non-OOM error ahead of the OOM doesn't mask it
* for this frame. Non-OOM errors are ignored here (the renderer otherwise
* only inspects them in development builds).
*/
override checkForOutOfMemory(): boolean {
const glw = this.glw;
let outOfMemory = false;
for (let i = 0; i < MAX_DRAINED_GL_ERRORS; i++) {
const error = glw.getError();
if (error === 0) {
break;
}
if (error === GL_OUT_OF_MEMORY) {
outOfMemory = true;
}
}
return outOfMemory;
}

getDefaultShaderNode(): WebGlShaderNode {
if (this.defaultShaderNode !== null) {
return this.defaultShaderNode as WebGlShaderNode;
Expand Down
Loading
Loading