@solidtv/renderer 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/core/TextureMemoryManager.d.ts +16 -0
- package/dist/src/core/TextureMemoryManager.js +26 -2
- package/dist/src/core/TextureMemoryManager.js.map +1 -1
- package/dist/src/core/platforms/web/WebPlatform.js +8 -0
- package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
- package/dist/src/core/renderers/CoreRenderer.d.ts +11 -0
- package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +1 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +4 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +13 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +33 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/text-rendering/SdfFontHandler.js +5 -1
- package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +92 -7
- package/dist/src/main-api/Renderer.js +8 -5
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/core/TextureMemoryManager.test.ts +98 -0
- package/src/core/TextureMemoryManager.ts +28 -2
- package/src/core/platforms/web/WebPlatform.outOfMemory.test.ts +78 -0
- package/src/core/platforms/web/WebPlatform.ts +8 -0
- package/src/core/renderers/CoreRenderer.ts +12 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +5 -0
- package/src/core/renderers/webgl/WebGlRenderer.ts +36 -0
- package/src/core/text-rendering/SdfFontHandler.ts +6 -1
- package/src/core/text-rendering/tests/SdfFontHandler.test.ts +112 -0
- package/src/main-api/Renderer.ts +99 -11
|
@@ -342,10 +342,36 @@ export class TextureMemoryManager {
|
|
|
342
342
|
}, 1000);
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
// If the threshold is 0, we disable
|
|
346
|
-
// setTextureMemUse method with a no-op function.
|
|
345
|
+
// If the threshold is 0, we disable memory tracking/cleanup by replacing the
|
|
346
|
+
// setTextureMemUse method with a no-op function. Note this only disables LRU
|
|
347
|
+
// tracking — GPU out-of-memory detection still runs (see handleOutOfMemory).
|
|
347
348
|
if (criticalThreshold === 0) {
|
|
348
349
|
this.setTextureMemUse = () => {};
|
|
349
350
|
}
|
|
350
351
|
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* React to a real GPU out-of-memory reported by the renderer.
|
|
355
|
+
*
|
|
356
|
+
* @remarks
|
|
357
|
+
* WebGL never exposes the VRAM budget up front, so the only certain signal is
|
|
358
|
+
* a `GL_OUT_OF_MEMORY` after the fact. When it fires we queue an `outOfMemory`
|
|
359
|
+
* frame event carrying the estimated memory in use and the critical threshold
|
|
360
|
+
* in effect — the estimate is a *measured ceiling* (the real budget is at or
|
|
361
|
+
* below it). What to do about it (lower the threshold, persist, reload) is
|
|
362
|
+
* application policy, not the renderer's; see the `outOfMemory` event docs on
|
|
363
|
+
* the public Renderer for the recommended integration.
|
|
364
|
+
*
|
|
365
|
+
* The engine also requests an immediate cleanup as a best-effort mitigation
|
|
366
|
+
* to free non-renderable textures before the app reacts.
|
|
367
|
+
*/
|
|
368
|
+
handleOutOfMemory(): void {
|
|
369
|
+
this.stage.queueFrameEvent('outOfMemory', {
|
|
370
|
+
memUsed: this.memUsed,
|
|
371
|
+
criticalThreshold: this.criticalThreshold,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Free whatever non-renderable textures we can right now.
|
|
375
|
+
this.criticalCleanupRequested = true;
|
|
376
|
+
}
|
|
351
377
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that the GPU out-of-memory probe runs at the idle transition (end of a
|
|
3
|
+
* render burst), not on every active frame.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { WebPlatform } from './WebPlatform.js';
|
|
7
|
+
import type { Stage } from '../../Stage.js';
|
|
8
|
+
|
|
9
|
+
function makeIdleStage(outOfMemory: boolean) {
|
|
10
|
+
const checkForOutOfMemory = vi.fn(() => outOfMemory);
|
|
11
|
+
const handleOutOfMemory = vi.fn();
|
|
12
|
+
const stage = {
|
|
13
|
+
isContextLost: false,
|
|
14
|
+
targetFrameTime: 0,
|
|
15
|
+
updateFrameTime: vi.fn(),
|
|
16
|
+
updateAnimations: vi.fn(() => false),
|
|
17
|
+
hasSceneUpdates: vi.fn(() => false), // idle
|
|
18
|
+
calculateFps: vi.fn(),
|
|
19
|
+
drawFrame: vi.fn(),
|
|
20
|
+
flushFrameEvents: vi.fn(),
|
|
21
|
+
shManager: { cleanup: vi.fn() },
|
|
22
|
+
eventBus: { emit: vi.fn() },
|
|
23
|
+
txMemManager: {
|
|
24
|
+
checkCleanup: vi.fn(() => false),
|
|
25
|
+
cleanup: vi.fn(),
|
|
26
|
+
handleOutOfMemory,
|
|
27
|
+
},
|
|
28
|
+
renderer: { checkForOutOfMemory },
|
|
29
|
+
} as unknown as Stage;
|
|
30
|
+
return { stage, checkForOutOfMemory, handleOutOfMemory };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('WebPlatform render loop — out-of-memory probe at idle', () => {
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function runOneIdleFrame(stage: Stage) {
|
|
39
|
+
let capturedLoop: ((t?: number) => void) | null = null;
|
|
40
|
+
const raf = vi.fn((cb: (t?: number) => void) => {
|
|
41
|
+
capturedLoop = cb;
|
|
42
|
+
return 1;
|
|
43
|
+
});
|
|
44
|
+
vi.stubGlobal('requestAnimationFrame', raf);
|
|
45
|
+
vi.stubGlobal(
|
|
46
|
+
'setTimeout',
|
|
47
|
+
vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
new WebPlatform().startLoop(stage);
|
|
51
|
+
capturedLoop!(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('probes the renderer once when the scene goes idle', () => {
|
|
55
|
+
const { stage, checkForOutOfMemory } = makeIdleStage(false);
|
|
56
|
+
runOneIdleFrame(stage);
|
|
57
|
+
expect(checkForOutOfMemory).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles OOM when the probe reports it at idle', () => {
|
|
61
|
+
const { stage, handleOutOfMemory } = makeIdleStage(true);
|
|
62
|
+
runOneIdleFrame(stage);
|
|
63
|
+
expect(handleOutOfMemory).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not handle OOM when the probe reports none', () => {
|
|
67
|
+
const { stage, handleOutOfMemory } = makeIdleStage(false);
|
|
68
|
+
runOneIdleFrame(stage);
|
|
69
|
+
expect(handleOutOfMemory).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not probe on an active (non-idle) frame', () => {
|
|
73
|
+
const { stage, checkForOutOfMemory } = makeIdleStage(false);
|
|
74
|
+
(stage.hasSceneUpdates as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
|
75
|
+
runOneIdleFrame(stage);
|
|
76
|
+
expect(checkForOutOfMemory).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -77,6 +77,14 @@ export class WebPlatform extends Platform {
|
|
|
77
77
|
setTimeout(requestLoop, Math.max(targetFrameTime, 15));
|
|
78
78
|
|
|
79
79
|
if (isIdle === false) {
|
|
80
|
+
// The render burst has settled. Probe for a GPU out-of-memory now
|
|
81
|
+
// rather than every frame: GL errors accumulate and persist until
|
|
82
|
+
// drained, so a single check here still catches any OOM raised during
|
|
83
|
+
// the active frames, without paying the getError() CPU/GPU sync on
|
|
84
|
+
// every frame. Queues the `outOfMemory` event, flushed below.
|
|
85
|
+
if (stage.renderer.checkForOutOfMemory() === true) {
|
|
86
|
+
stage.txMemManager.handleOutOfMemory();
|
|
87
|
+
}
|
|
80
88
|
stage.shManager.cleanup();
|
|
81
89
|
stage.eventBus.emit('idle');
|
|
82
90
|
isIdle = true;
|
|
@@ -67,4 +67,16 @@ export abstract class CoreRenderer {
|
|
|
67
67
|
* on the next render call.
|
|
68
68
|
*/
|
|
69
69
|
invalidateQuadBuffer?(): void;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Probe the backend for a GPU out-of-memory condition since the last call.
|
|
73
|
+
* Returns `true` when an out-of-memory was seen. Backends that cannot detect
|
|
74
|
+
* this (e.g. Canvas2D) return `false`.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* Called once per frame by the Stage. Backends where the probe is expensive
|
|
78
|
+
* (a CPU/GPU sync, e.g. WebGL `gl.getError()`) rely on this once-per-frame
|
|
79
|
+
* cadence rather than checking per draw/upload.
|
|
80
|
+
*/
|
|
81
|
+
abstract checkForOutOfMemory(): boolean;
|
|
70
82
|
}
|
|
@@ -264,6 +264,11 @@ export class CanvasRenderer extends CoreRenderer {
|
|
|
264
264
|
return null;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// Canvas2D has no GPU out-of-memory signal to probe.
|
|
268
|
+
checkForOutOfMemory(): boolean {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
267
272
|
/**
|
|
268
273
|
* Updates the clear color of the canvas renderer.
|
|
269
274
|
*
|
|
@@ -42,6 +42,15 @@ import type { Dimensions } from '../../../common/CommonTypes.js';
|
|
|
42
42
|
|
|
43
43
|
export type WebGlRendererOptions = CoreRendererOptions;
|
|
44
44
|
|
|
45
|
+
const GL_OUT_OF_MEMORY = 0x0505;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Upper bound on how many queued GL errors we drain per frame in
|
|
49
|
+
* {@link WebGlRenderer.checkForOutOfMemory}. Keeps the per-frame `getError()`
|
|
50
|
+
* sync cost fixed even if the error queue is unexpectedly deep.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_DRAINED_GL_ERRORS = 8;
|
|
53
|
+
|
|
45
54
|
interface CoreWebGlSystem {
|
|
46
55
|
parameters: CoreWebGlParameters;
|
|
47
56
|
extensions: CoreWebGlExtensions;
|
|
@@ -1283,6 +1292,33 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
1283
1292
|
return bufferInfo;
|
|
1284
1293
|
}
|
|
1285
1294
|
|
|
1295
|
+
/**
|
|
1296
|
+
* Drain the GL error queue once and report whether a GL_OUT_OF_MEMORY was
|
|
1297
|
+
* seen since the last call.
|
|
1298
|
+
*
|
|
1299
|
+
* @remarks
|
|
1300
|
+
* `gl.getError()` forces a CPU↔GPU sync, so this is deliberately invoked at
|
|
1301
|
+
* most once per frame by the Stage rather than after each texture upload.
|
|
1302
|
+
* `getError()` returns one error at a time, so we drain a bounded number of
|
|
1303
|
+
* queued errors to ensure a non-OOM error ahead of the OOM doesn't mask it
|
|
1304
|
+
* for this frame. Non-OOM errors are ignored here (the renderer otherwise
|
|
1305
|
+
* only inspects them in development builds).
|
|
1306
|
+
*/
|
|
1307
|
+
override checkForOutOfMemory(): boolean {
|
|
1308
|
+
const glw = this.glw;
|
|
1309
|
+
let outOfMemory = false;
|
|
1310
|
+
for (let i = 0; i < MAX_DRAINED_GL_ERRORS; i++) {
|
|
1311
|
+
const error = glw.getError();
|
|
1312
|
+
if (error === 0) {
|
|
1313
|
+
break;
|
|
1314
|
+
}
|
|
1315
|
+
if (error === GL_OUT_OF_MEMORY) {
|
|
1316
|
+
outOfMemory = true;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return outOfMemory;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1286
1322
|
getDefaultShaderNode(): WebGlShaderNode {
|
|
1287
1323
|
if (this.defaultShaderNode !== null) {
|
|
1288
1324
|
return this.defaultShaderNode as WebGlShaderNode;
|
|
@@ -12,6 +12,7 @@ import { UpdateType } from '../CoreNode.js';
|
|
|
12
12
|
import { hasZeroWidthSpace } from './Utils.js';
|
|
13
13
|
import { normalizeFontMetrics } from './TextLayoutEngine.js';
|
|
14
14
|
import { isProductionEnvironment } from '../../utils.js';
|
|
15
|
+
import type { TextureError } from '../TextureError.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* SDF Font Data structure matching msdf-bmfont-xml output
|
|
@@ -397,7 +398,11 @@ export const loadFont = (
|
|
|
397
398
|
resolve();
|
|
398
399
|
});
|
|
399
400
|
|
|
400
|
-
|
|
401
|
+
// EventEmitter invokes listeners as (target, data), so the error payload
|
|
402
|
+
// is the SECOND argument. The first arg is the Texture that emitted the
|
|
403
|
+
// event. Reading it as the only param (the previous behavior) rejected
|
|
404
|
+
// and logged the Texture instead of the actual TextureError.
|
|
405
|
+
atlasTexture.on('failed', (_target, error: TextureError) => {
|
|
401
406
|
// Cleanup on error
|
|
402
407
|
fontLoadPromises.delete(fontFamily);
|
|
403
408
|
if (fontCache[fontFamily]) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { loadFont } from '../SdfFontHandler.js';
|
|
3
|
+
import { EventEmitter } from '../../../common/EventEmitter.js';
|
|
4
|
+
import { TextureError, TextureErrorCode } from '../../TextureError.js';
|
|
5
|
+
import type { Stage } from '../../Stage.js';
|
|
6
|
+
|
|
7
|
+
// Minimal XHR stand-in: synchronously delivers valid SDF font JSON so loadFont
|
|
8
|
+
// proceeds to create the atlas texture and attach its event listeners.
|
|
9
|
+
class FakeXHR {
|
|
10
|
+
status = 200;
|
|
11
|
+
response: unknown = null;
|
|
12
|
+
responseType = '';
|
|
13
|
+
onload: (() => void) | null = null;
|
|
14
|
+
onerror: (() => void) | null = null;
|
|
15
|
+
open(): void {}
|
|
16
|
+
send(): void {
|
|
17
|
+
this.response = { chars: [{}] };
|
|
18
|
+
if (this.onload !== null) {
|
|
19
|
+
this.onload();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A texture is just an EventEmitter to loadFont; stub the few props it reads.
|
|
25
|
+
function makeFakeTexture() {
|
|
26
|
+
const tex = new EventEmitter() as unknown as EventEmitter & {
|
|
27
|
+
state: string;
|
|
28
|
+
preventCleanup: boolean;
|
|
29
|
+
setRenderableOwner: (owner: string, val: boolean) => void;
|
|
30
|
+
};
|
|
31
|
+
tex.state = 'loading'; // not 'loaded' -> goes through the listener path
|
|
32
|
+
tex.preventCleanup = false;
|
|
33
|
+
tex.setRenderableOwner = () => {};
|
|
34
|
+
return tex;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Drain microtasks + one macrotask so the async loader reaches listener setup.
|
|
38
|
+
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
39
|
+
|
|
40
|
+
describe('SdfFontHandler loadFont — failed event argument', () => {
|
|
41
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
42
|
+
let originalXHR: unknown;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
46
|
+
originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
|
|
47
|
+
.XMLHttpRequest;
|
|
48
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
49
|
+
FakeXHR;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
errSpy.mockRestore();
|
|
54
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
55
|
+
originalXHR;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects with the TextureError (second emit arg), not the emitting texture', async () => {
|
|
59
|
+
const tex = makeFakeTexture();
|
|
60
|
+
const stage = {
|
|
61
|
+
txManager: { createTexture: () => tex },
|
|
62
|
+
} as unknown as Stage;
|
|
63
|
+
|
|
64
|
+
const promise = loadFont(stage, {
|
|
65
|
+
fontFamily: 'TestSdfFailFont',
|
|
66
|
+
atlasUrl: 'atlas.png',
|
|
67
|
+
atlasDataUrl: 'atlas.json',
|
|
68
|
+
} as Parameters<typeof loadFont>[1]);
|
|
69
|
+
|
|
70
|
+
await flush();
|
|
71
|
+
|
|
72
|
+
const error = new TextureError(
|
|
73
|
+
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
|
|
74
|
+
'boom',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Attach the rejection assertion before emitting so the handler is ready.
|
|
78
|
+
const assertion = expect(promise).rejects.toBe(error);
|
|
79
|
+
|
|
80
|
+
// EventEmitter calls listeners as (target, data) -> (tex, error).
|
|
81
|
+
tex.emit('failed', error);
|
|
82
|
+
|
|
83
|
+
await assertion;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('logs the error, not the texture, on failure', async () => {
|
|
87
|
+
const tex = makeFakeTexture();
|
|
88
|
+
const stage = {
|
|
89
|
+
txManager: { createTexture: () => tex },
|
|
90
|
+
} as unknown as Stage;
|
|
91
|
+
|
|
92
|
+
const promise = loadFont(stage, {
|
|
93
|
+
fontFamily: 'TestSdfFailFontLog',
|
|
94
|
+
atlasUrl: 'atlas.png',
|
|
95
|
+
atlasDataUrl: 'atlas.json',
|
|
96
|
+
} as Parameters<typeof loadFont>[1]);
|
|
97
|
+
|
|
98
|
+
await flush();
|
|
99
|
+
|
|
100
|
+
const error = new TextureError(
|
|
101
|
+
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
|
|
102
|
+
'boom',
|
|
103
|
+
);
|
|
104
|
+
const rejected = promise.catch(() => {});
|
|
105
|
+
tex.emit('failed', error);
|
|
106
|
+
await rejected;
|
|
107
|
+
|
|
108
|
+
const lastCall = errSpy.mock.calls[errSpy.mock.calls.length - 1]!;
|
|
109
|
+
expect(lastCall[1]).toBe(error);
|
|
110
|
+
expect(lastCall[1]).not.toBe(tex);
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/main-api/Renderer.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { Platform } from '../core/platforms/Platform.js';
|
|
|
28
28
|
* @category Events
|
|
29
29
|
* @example
|
|
30
30
|
* ```typescript
|
|
31
|
-
* renderer.on('fpsUpdate', (data) => {
|
|
31
|
+
* renderer.on('fpsUpdate', (_target, data) => {
|
|
32
32
|
* console.log(`Current FPS: ${data.fps}`);
|
|
33
33
|
* if (data.contextSpyData) {
|
|
34
34
|
* console.log('WebGL calls:', data.contextSpyData);
|
|
@@ -49,7 +49,7 @@ export interface RendererMainFpsUpdateEvent {
|
|
|
49
49
|
* @category Events
|
|
50
50
|
* @example
|
|
51
51
|
* ```typescript
|
|
52
|
-
* renderer.on('frameTick', (data) => {
|
|
52
|
+
* renderer.on('frameTick', (_target, data) => {
|
|
53
53
|
* console.log(`Frame time: ${data.time}ms, delta: ${data.delta}ms`);
|
|
54
54
|
* });
|
|
55
55
|
* ```
|
|
@@ -67,7 +67,7 @@ export interface RendererMainFrameTickEvent {
|
|
|
67
67
|
* @category Events
|
|
68
68
|
* @example
|
|
69
69
|
* ```typescript
|
|
70
|
-
* renderer.on('renderUpdate', (data) => {
|
|
70
|
+
* renderer.on('renderUpdate', (_target, data) => {
|
|
71
71
|
* console.log(`Rendered quads: ${data.quads}, renderOps: ${data.renderOps}`);
|
|
72
72
|
* });
|
|
73
73
|
* ```
|
|
@@ -110,7 +110,7 @@ export interface RendererMainIdleEvent {
|
|
|
110
110
|
* @category Events
|
|
111
111
|
* @example
|
|
112
112
|
* ```typescript
|
|
113
|
-
* renderer.on('criticalCleanup', (data) => {
|
|
113
|
+
* renderer.on('criticalCleanup', (_target, data) => {
|
|
114
114
|
* console.log(`Memory cleanup triggered!`);
|
|
115
115
|
* console.log(`Memory used: ${data.memUsed} bytes`);
|
|
116
116
|
* console.log(`Critical threshold: ${data.criticalThreshold} bytes`);
|
|
@@ -130,7 +130,7 @@ export interface RendererMainCriticalCleanupEvent {
|
|
|
130
130
|
* @category Events
|
|
131
131
|
* @example
|
|
132
132
|
* ```typescript
|
|
133
|
-
* renderer.on('criticalCleanupFailed', (data) => {
|
|
133
|
+
* renderer.on('criticalCleanupFailed', (_target, data) => {
|
|
134
134
|
* console.warn(`Memory cleanup failed!`);
|
|
135
135
|
* console.log(`Memory still used: ${data.memUsed} bytes`);
|
|
136
136
|
* console.log(`Critical threshold: ${data.criticalThreshold} bytes`);
|
|
@@ -167,6 +167,90 @@ export interface RendererMainContextLostEvent {
|
|
|
167
167
|
readonly __eventHasNoPayload?: never;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* GPU Out Of Memory Event Data
|
|
172
|
+
*
|
|
173
|
+
* @remarks
|
|
174
|
+
* Fired when the renderer detects a real `GL_OUT_OF_MEMORY` from the GPU (probed
|
|
175
|
+
* once per frame). This is the only certain signal that the texture memory
|
|
176
|
+
* estimate has overshot the device's real VRAM budget. At this point a texture
|
|
177
|
+
* upload has already failed and the driver may soon drop the WebGL context, so
|
|
178
|
+
* the supported recovery is for the application to reload with a lower
|
|
179
|
+
* `criticalThreshold`.
|
|
180
|
+
*
|
|
181
|
+
* `memUsed` is the estimated texture memory in use at the moment of the failure.
|
|
182
|
+
* Because the upload failed, the real GPU budget is at or below this value — so
|
|
183
|
+
* it is a good basis for the next `criticalThreshold`.
|
|
184
|
+
*
|
|
185
|
+
* The renderer deliberately does NOT persist, reload, or change the threshold
|
|
186
|
+
* itself — that is application policy. The recommended integration is to lower
|
|
187
|
+
* the threshold, persist it, and reload:
|
|
188
|
+
*
|
|
189
|
+
* @category Events
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* // --- on startup: read the calibrated threshold before creating the renderer
|
|
193
|
+
* //
|
|
194
|
+
* // Namespace the storage key per app. On TV devices that run from the
|
|
195
|
+
* // filesystem (file://) the origin is null/opaque, so a bare key can collide
|
|
196
|
+
* // across apps — including the path keeps each app's calibration separate.
|
|
197
|
+
* const STORAGE_KEY = `myapp:criticalThreshold:${location.pathname}`;
|
|
198
|
+
*
|
|
199
|
+
* // Never let calibration drive the threshold so low the UX breaks. Pick a
|
|
200
|
+
* // floor that matches your app (here, 70% of the default budget).
|
|
201
|
+
* const DEFAULT_CRITICAL = 200e6;
|
|
202
|
+
* const MIN_THRESHOLD = Math.round(DEFAULT_CRITICAL * 0.7);
|
|
203
|
+
*
|
|
204
|
+
* function readCriticalThreshold(): number {
|
|
205
|
+
* const raw =
|
|
206
|
+
* typeof localStorage !== 'undefined'
|
|
207
|
+
* ? localStorage.getItem(STORAGE_KEY)
|
|
208
|
+
* : null;
|
|
209
|
+
* const stored = raw !== null ? parseInt(raw, 10) : NaN;
|
|
210
|
+
* if (Number.isNaN(stored) === false && stored > 0) {
|
|
211
|
+
* return Math.max(stored, MIN_THRESHOLD);
|
|
212
|
+
* }
|
|
213
|
+
* return DEFAULT_CRITICAL;
|
|
214
|
+
* }
|
|
215
|
+
*
|
|
216
|
+
* const renderer = new RendererMain(
|
|
217
|
+
* { textureMemory: { criticalThreshold: readCriticalThreshold() } },
|
|
218
|
+
* 'app',
|
|
219
|
+
* );
|
|
220
|
+
*
|
|
221
|
+
* // --- at runtime: react to a real GPU out-of-memory
|
|
222
|
+
* let handlingOOM = false;
|
|
223
|
+
* renderer.on('outOfMemory', (_target, { memUsed, criticalThreshold }) => {
|
|
224
|
+
* if (handlingOOM === true) {
|
|
225
|
+
* return; // debounce — several uploads can fail in the same burst
|
|
226
|
+
* }
|
|
227
|
+
* handlingOOM = true;
|
|
228
|
+
*
|
|
229
|
+
* // The OOM proves the real budget is <= memUsed. Drop to 90% of the lower
|
|
230
|
+
* // of (estimate, current threshold), but never below the floor.
|
|
231
|
+
* const ceiling = Math.min(memUsed, criticalThreshold);
|
|
232
|
+
* const next = Math.max(Math.round(ceiling * 0.9), MIN_THRESHOLD);
|
|
233
|
+
*
|
|
234
|
+
* try {
|
|
235
|
+
* localStorage.setItem(STORAGE_KEY, String(next));
|
|
236
|
+
* } catch (e) {
|
|
237
|
+
* // storage may be blocked (e.g. file:// with storage disabled); the new
|
|
238
|
+
* // value just won't survive the reload.
|
|
239
|
+
* }
|
|
240
|
+
*
|
|
241
|
+
* // Reload so the renderer reinitializes with the lower budget. The engine
|
|
242
|
+
* // does not rebuild GPU resources in place, so reload is the clean recovery.
|
|
243
|
+
* location.reload();
|
|
244
|
+
* });
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export interface RendererMainOutOfMemoryEvent {
|
|
248
|
+
/** Estimated texture memory in use at the time of the failure (bytes) */
|
|
249
|
+
memUsed: number;
|
|
250
|
+
/** Critical threshold in effect at the time of the failure (bytes) */
|
|
251
|
+
criticalThreshold: number;
|
|
252
|
+
}
|
|
253
|
+
|
|
170
254
|
/**
|
|
171
255
|
* Settings for the Renderer that can be updated during runtime.
|
|
172
256
|
*/
|
|
@@ -532,11 +616,11 @@ export type RendererMainSettings = RendererRuntimeSettings & {
|
|
|
532
616
|
*
|
|
533
617
|
* Listen to events using the standard EventEmitter API:
|
|
534
618
|
* ```typescript
|
|
535
|
-
* renderer.on('fpsUpdate', (data: RendererMainFpsUpdateEvent) => {
|
|
619
|
+
* renderer.on('fpsUpdate', (_target, data: RendererMainFpsUpdateEvent) => {
|
|
536
620
|
* console.log(`FPS: ${data.fps}`);
|
|
537
621
|
* });
|
|
538
622
|
*
|
|
539
|
-
* renderer.on('idle', (
|
|
623
|
+
* renderer.on('idle', () => {
|
|
540
624
|
* // Renderer is idle - no scene changes
|
|
541
625
|
* });
|
|
542
626
|
* ```
|
|
@@ -548,6 +632,7 @@ export type RendererMainSettings = RendererRuntimeSettings & {
|
|
|
548
632
|
* @see {@link RendererMainCriticalCleanupEvent}
|
|
549
633
|
* @see {@link RendererMainCriticalCleanupFailedEvent}
|
|
550
634
|
* @see {@link RendererMainContextLostEvent}
|
|
635
|
+
* @see {@link RendererMainOutOfMemoryEvent}
|
|
551
636
|
*
|
|
552
637
|
* @fires RendererMain#fpsUpdate
|
|
553
638
|
* @fires RendererMain#frameTick
|
|
@@ -556,6 +641,7 @@ export type RendererMainSettings = RendererRuntimeSettings & {
|
|
|
556
641
|
* @fires RendererMain#criticalCleanup
|
|
557
642
|
* @fires RendererMain#criticalCleanupFailed
|
|
558
643
|
* @fires RendererMain#contextLost
|
|
644
|
+
* @fires RendererMain#outOfMemory
|
|
559
645
|
*/
|
|
560
646
|
export class RendererMain extends EventEmitter {
|
|
561
647
|
readonly root: INode;
|
|
@@ -717,11 +803,13 @@ export class RendererMain extends EventEmitter {
|
|
|
717
803
|
const currentTxSettings =
|
|
718
804
|
(this.stage && this.stage.options.textureMemory) || {};
|
|
719
805
|
|
|
806
|
+
const criticalThreshold =
|
|
807
|
+
textureMemory?.criticalThreshold ??
|
|
808
|
+
currentTxSettings?.criticalThreshold ??
|
|
809
|
+
200e6;
|
|
810
|
+
|
|
720
811
|
return {
|
|
721
|
-
criticalThreshold
|
|
722
|
-
textureMemory?.criticalThreshold ??
|
|
723
|
-
currentTxSettings?.criticalThreshold ??
|
|
724
|
-
200e6,
|
|
812
|
+
criticalThreshold,
|
|
725
813
|
targetThresholdLevel:
|
|
726
814
|
textureMemory?.targetThresholdLevel ??
|
|
727
815
|
currentTxSettings?.targetThresholdLevel ??
|