@solidtv/renderer 1.3.0 → 1.3.2

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.
Files changed (83) hide show
  1. package/dist/src/core/CoreNode.d.ts +0 -8
  2. package/dist/src/core/CoreNode.js +7 -14
  3. package/dist/src/core/CoreNode.js.map +1 -1
  4. package/dist/src/core/Stage.d.ts +7 -15
  5. package/dist/src/core/Stage.js +22 -39
  6. package/dist/src/core/Stage.js.map +1 -1
  7. package/dist/src/core/TextureMemoryManager.d.ts +16 -0
  8. package/dist/src/core/TextureMemoryManager.js +30 -5
  9. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  10. package/dist/src/core/lib/utils.d.ts +0 -1
  11. package/dist/src/core/lib/utils.js +0 -3
  12. package/dist/src/core/lib/utils.js.map +1 -1
  13. package/dist/src/core/platforms/web/WebPlatform.js +9 -0
  14. package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
  15. package/dist/src/core/renderers/CoreRenderer.d.ts +11 -0
  16. package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
  17. package/dist/src/core/renderers/CoreShaderNode.d.ts +16 -0
  18. package/dist/src/core/renderers/CoreShaderNode.js +22 -0
  19. package/dist/src/core/renderers/CoreShaderNode.js.map +1 -1
  20. package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +1 -0
  21. package/dist/src/core/renderers/canvas/CanvasRenderer.js +27 -3
  22. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  23. package/dist/src/core/renderers/canvas/CanvasShaderNode.d.ts +0 -1
  24. package/dist/src/core/renderers/canvas/CanvasShaderNode.js +0 -1
  25. package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
  26. package/dist/src/core/renderers/canvas/CanvasTexture.d.ts +1 -1
  27. package/dist/src/core/renderers/canvas/CanvasTexture.js +25 -6
  28. package/dist/src/core/renderers/canvas/CanvasTexture.js.map +1 -1
  29. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +13 -0
  30. package/dist/src/core/renderers/webgl/WebGlRenderer.js +33 -0
  31. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  32. package/dist/src/core/renderers/webgl/WebGlShaderNode.d.ts +0 -1
  33. package/dist/src/core/renderers/webgl/WebGlShaderNode.js +0 -1
  34. package/dist/src/core/renderers/webgl/WebGlShaderNode.js.map +1 -1
  35. package/dist/src/core/text-rendering/CanvasTextRenderer.d.ts +1 -0
  36. package/dist/src/core/text-rendering/CanvasTextRenderer.js +23 -0
  37. package/dist/src/core/text-rendering/CanvasTextRenderer.js.map +1 -1
  38. package/dist/src/core/text-rendering/SdfFontHandler.js +5 -1
  39. package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
  40. package/dist/src/core/text-rendering/SdfTextRenderer.d.ts +1 -0
  41. package/dist/src/core/text-rendering/SdfTextRenderer.js +27 -0
  42. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  43. package/dist/src/core/text-rendering/TextRenderer.d.ts +7 -0
  44. package/dist/src/core/textures/ImageTexture.js +11 -0
  45. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  46. package/dist/src/core/textures/SubTexture.d.ts +1 -0
  47. package/dist/src/core/textures/SubTexture.js +12 -0
  48. package/dist/src/core/textures/SubTexture.js.map +1 -1
  49. package/dist/src/core/textures/Texture.js +10 -0
  50. package/dist/src/core/textures/Texture.js.map +1 -1
  51. package/dist/src/main-api/Renderer.d.ts +108 -7
  52. package/dist/src/main-api/Renderer.js +10 -5
  53. package/dist/src/main-api/Renderer.js.map +1 -1
  54. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  55. package/package.json +1 -1
  56. package/src/core/CoreNode.ts +8 -23
  57. package/src/core/Stage.ts +24 -47
  58. package/src/core/TextureMemoryManager.test.ts +98 -0
  59. package/src/core/TextureMemoryManager.ts +32 -5
  60. package/src/core/lib/utils.ts +0 -4
  61. package/src/core/platforms/web/WebPlatform.outOfMemory.test.ts +79 -0
  62. package/src/core/platforms/web/WebPlatform.ts +9 -0
  63. package/src/core/renderers/CoreRenderer.ts +12 -0
  64. package/src/core/renderers/CoreShaderNode.test.ts +53 -0
  65. package/src/core/renderers/CoreShaderNode.ts +23 -0
  66. package/src/core/renderers/canvas/CanvasRenderer.test.ts +34 -0
  67. package/src/core/renderers/canvas/CanvasRenderer.ts +38 -4
  68. package/src/core/renderers/canvas/CanvasShaderNode.ts +0 -1
  69. package/src/core/renderers/canvas/CanvasTexture.test.ts +31 -0
  70. package/src/core/renderers/canvas/CanvasTexture.ts +35 -7
  71. package/src/core/renderers/webgl/WebGlRenderer.ts +36 -0
  72. package/src/core/renderers/webgl/WebGlShaderNode.ts +0 -1
  73. package/src/core/text-rendering/CanvasTextRenderer.ts +26 -0
  74. package/src/core/text-rendering/SdfFontHandler.ts +6 -1
  75. package/src/core/text-rendering/SdfTextRenderer.test.ts +133 -0
  76. package/src/core/text-rendering/SdfTextRenderer.ts +30 -0
  77. package/src/core/text-rendering/TextRenderer.ts +7 -0
  78. package/src/core/text-rendering/tests/SdfFontHandler.test.ts +112 -0
  79. package/src/core/textures/ImageTexture.ts +18 -0
  80. package/src/core/textures/SubTexture.test.ts +45 -0
  81. package/src/core/textures/SubTexture.ts +13 -0
  82. package/src/core/textures/Texture.ts +12 -0
  83. package/src/main-api/Renderer.ts +118 -11
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { CoreTextNodeProps } from '../CoreTextNode.js';
3
+
4
+ // Mock the font handler so renderText/generateTextLayout can run without a
5
+ // real loaded font. getFontData is only invoked on a layout-cache MISS, so the
6
+ // call count is our probe for cache hits vs misses.
7
+ vi.mock('./SdfFontHandler.js', () => {
8
+ const fontData = {
9
+ data: {
10
+ common: { base: 0, scaleW: 512, scaleH: 512, lineHeight: 50 },
11
+ info: { size: 42 },
12
+ distanceField: { distanceRange: 4 },
13
+ },
14
+ glyphMap: new Map(),
15
+ kernings: {},
16
+ atlasTexture: {},
17
+ metrics: {},
18
+ maxCharHeight: 50,
19
+ };
20
+ const metrics = {
21
+ ascender: 40,
22
+ descender: -10,
23
+ lineGap: 0,
24
+ capHeight: 30,
25
+ xHeight: 20,
26
+ };
27
+ return {
28
+ type: 'sdf',
29
+ init: vi.fn(),
30
+ getFontData: vi.fn(() => fontData),
31
+ getFontMetrics: vi.fn(() => metrics),
32
+ measureText: vi.fn((text: string) => text.length * 10),
33
+ getAtlas: vi.fn(() => null),
34
+ };
35
+ });
36
+
37
+ import SdfTextRenderer from './SdfTextRenderer.js';
38
+ import * as SdfFontHandler from './SdfFontHandler.js';
39
+
40
+ const makeProps = (text: string): CoreTextNodeProps =>
41
+ ({
42
+ text,
43
+ fontFamily: 'Test',
44
+ fontStyle: 'normal',
45
+ fontSize: 42,
46
+ letterSpacing: 0,
47
+ lineHeight: 0,
48
+ maxHeight: 0,
49
+ maxWidth: 100000,
50
+ maxLines: 0,
51
+ textAlign: 'left',
52
+ wordBreak: 'normal',
53
+ overflowSuffix: '',
54
+ } as unknown as CoreTextNodeProps);
55
+
56
+ const initRenderer = (cacheSize: number): void => {
57
+ const fakeStage = {
58
+ options: { textLayoutCacheSize: cacheSize },
59
+ shManager: {
60
+ registerShaderType: vi.fn(),
61
+ createShader: vi.fn(() => ({})),
62
+ },
63
+ };
64
+ SdfTextRenderer.init(fakeStage as never);
65
+ };
66
+
67
+ const render = (text: string): void => {
68
+ SdfTextRenderer.renderText(makeProps(text));
69
+ };
70
+
71
+ describe('SdfTextRenderer layout cache', () => {
72
+ beforeEach(() => {
73
+ // Empty the module-level cache between tests, then reset call counts.
74
+ initRenderer(0);
75
+ SdfTextRenderer.cleanup();
76
+ vi.clearAllMocks();
77
+ });
78
+
79
+ it('reuses the cached layout for identical strings', () => {
80
+ initRenderer(10);
81
+
82
+ render('Badge');
83
+ render('Badge');
84
+
85
+ // Second render is a cache hit: no fresh layout generation.
86
+ expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it('caches long strings too (no length-based skip)', () => {
90
+ initRenderer(10);
91
+ const long = 'x'.repeat(500);
92
+
93
+ render(long);
94
+ render(long);
95
+
96
+ // Bounded purely by the LRU cap, not by length.
97
+ expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it('cleanup trims to the cap and evicts least-recently-used first', () => {
101
+ initRenderer(2);
102
+
103
+ render('A');
104
+ render('B');
105
+ render('C');
106
+ // Re-access 'A' so it becomes most-recently-used; 'B' is now the LRU.
107
+ render('A');
108
+
109
+ SdfTextRenderer.cleanup();
110
+
111
+ vi.clearAllMocks();
112
+ render('B'); // evicted -> miss
113
+ render('A'); // survived -> hit
114
+ render('C'); // survived -> hit
115
+
116
+ expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
117
+ });
118
+
119
+ it('cleanup is a no-op while under the cap', () => {
120
+ initRenderer(10);
121
+
122
+ render('one');
123
+ render('two');
124
+
125
+ SdfTextRenderer.cleanup();
126
+
127
+ vi.clearAllMocks();
128
+ render('one'); // still cached -> hit
129
+ render('two'); // still cached -> hit
130
+
131
+ expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(0);
132
+ });
133
+ });
@@ -24,10 +24,20 @@ const type = 'sdf' as const;
24
24
 
25
25
  let sdfShader: WebGlShaderNode | null = null;
26
26
 
27
+ // Upper bound on layoutCache entries, enforced on idle via `cleanup`.
28
+ // Overridden from stage options in `init`. The cache is allowed to grow past
29
+ // this during active rendering and is trimmed back to it when the stage idles.
30
+ let maxLayoutCacheSize = 250;
31
+
27
32
  // Initialize the SDF text renderer
28
33
  const init = (stage: Stage): void => {
29
34
  SdfFontHandler.init();
30
35
 
36
+ const configuredCacheSize = stage.options.textLayoutCacheSize;
37
+ if (configuredCacheSize !== undefined) {
38
+ maxLayoutCacheSize = configuredCacheSize;
39
+ }
40
+
31
41
  // Register SDF shader with the shader manager
32
42
  stage.shManager.registerShaderType('Sdf', Sdf);
33
43
  sdfShader = stage.shManager.createShader('Sdf') as WebGlShaderNode;
@@ -58,6 +68,11 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
58
68
  const cacheKey = getLayoutCacheKey(props);
59
69
  let layout = layoutCache.get(cacheKey);
60
70
  if (layout !== undefined) {
71
+ // Refresh LRU recency: re-insert moves the key to the most-recently-used
72
+ // end so idle `cleanup` evicts genuinely cold entries first. renderText
73
+ // runs on text/layout change, not per frame, so this re-insert is cheap.
74
+ layoutCache.delete(cacheKey);
75
+ layoutCache.set(cacheKey, layout);
61
76
  return {
62
77
  remainingLines: 0,
63
78
  hasRemainingText: false,
@@ -357,6 +372,20 @@ const generateTextLayout = (
357
372
  };
358
373
  };
359
374
 
375
+ /**
376
+ * Trim the layout cache back down to `maxLayoutCacheSize`, evicting the
377
+ * least-recently-used entries first. Called when the stage goes idle so this
378
+ * never competes with active rendering. A fresh iterator is taken each step so
379
+ * we always delete the current front (oldest) key without iterator-invalidation
380
+ * concerns; this runs at most once per idle transition and only when over cap.
381
+ */
382
+ const cleanup = (): void => {
383
+ while (layoutCache.size > maxLayoutCacheSize) {
384
+ const oldest = layoutCache.keys().next().value as string;
385
+ layoutCache.delete(oldest);
386
+ }
387
+ };
388
+
360
389
  /**
361
390
  * SDF Text Renderer - implements TextRenderer interface
362
391
  */
@@ -367,6 +396,7 @@ const SdfTextRenderer = {
367
396
  addQuads,
368
397
  renderQuads,
369
398
  init,
399
+ cleanup,
370
400
  };
371
401
 
372
402
  export default SdfTextRenderer;
@@ -435,6 +435,13 @@ export interface TextRenderer {
435
435
  renderProps: TextRenderProps,
436
436
  ) => void | SdfRenderOp | null;
437
437
  init: (stage: Stage) => void;
438
+ /**
439
+ * Trim internal caches back down to their configured limits.
440
+ * Called when the stage goes idle so cache eviction never competes with
441
+ * active rendering. Backends with no bounded cache may implement this as a
442
+ * no-op.
443
+ */
444
+ cleanup: () => void;
438
445
  }
439
446
 
440
447
  /**
@@ -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
+ });
@@ -269,6 +269,24 @@ export class ImageTexture extends Texture {
269
269
  }
270
270
 
271
271
  override async getTextureSource(): Promise<TextureData> {
272
+ // Compressed textures are not supported by the Canvas2D renderer.
273
+ // Fail fast here before incurring a network fetch or binary decode.
274
+ if (this.txManager.renderer?.mode === 'canvas') {
275
+ const { src, type } = this.props;
276
+ if (
277
+ type === 'compressed' ||
278
+ (typeof src === 'string' && isCompressedTextureContainer(src) === true)
279
+ ) {
280
+ const err = new Error(
281
+ `ImageTexture: Compressed textures are not supported in Canvas2D render mode (src: ${String(
282
+ src,
283
+ )})`,
284
+ );
285
+ this.setState('failed', err);
286
+ return { data: null };
287
+ }
288
+ }
289
+
272
290
  let resp: TextureData;
273
291
  try {
274
292
  resp = await this.determineImageTypeAndLoadImage();
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { SubTexture } from './SubTexture.js';
3
+ import { ImageTexture } from './ImageTexture.js';
4
+ import type { CoreTextureManager } from '../CoreTextureManager.js';
5
+
6
+ const flushMicrotasks = () => Promise.resolve();
7
+
8
+ describe('SubTexture lifecycle', () => {
9
+ it('detaches its parent-texture listeners on destroy', async () => {
10
+ // 'initial' state means the constructor's microtask attaches listeners
11
+ // without synchronously firing any of the state handlers.
12
+ const parent = {
13
+ state: 'initial',
14
+ dimensions: null,
15
+ error: null,
16
+ on: vi.fn(),
17
+ off: vi.fn(),
18
+ };
19
+ const txManager = {
20
+ maxRetryCount: 0,
21
+ platform: {},
22
+ resolveParentTexture: () => parent,
23
+ } as unknown as CoreTextureManager;
24
+
25
+ const parentImage = new ImageTexture(txManager, {} as never);
26
+ const sub = new SubTexture(txManager, {
27
+ texture: parentImage,
28
+ x: 0,
29
+ y: 0,
30
+ w: 10,
31
+ h: 10,
32
+ });
33
+
34
+ // Listeners are attached in a microtask after construction.
35
+ await flushMicrotasks();
36
+ expect(parent.on).toHaveBeenCalledTimes(4);
37
+
38
+ sub.destroy();
39
+
40
+ const offEvents = (parent.off.mock.calls as Array<[string]>)
41
+ .map((c) => c[0])
42
+ .sort();
43
+ expect(offEvents).toEqual(['failed', 'freed', 'loaded', 'loading']);
44
+ });
45
+ });
@@ -133,6 +133,19 @@ export class SubTexture extends Texture {
133
133
  this.parentTexture.setRenderableOwner(this.subtextureId, isRenderable);
134
134
  }
135
135
 
136
+ override destroy(): void {
137
+ // Detach from the parent texture's event emitter. The parent is typically a
138
+ // long-lived shared atlas (preventCleanup), so without this each destroyed
139
+ // SubTexture — and everything its handlers capture — would be retained by
140
+ // the parent's listener lists for the rest of the session.
141
+ const parentTx = this.parentTexture;
142
+ parentTx.off('loading', this.onParentTxLoading);
143
+ parentTx.off('loaded', this.onParentTxLoaded);
144
+ parentTx.off('failed', this.onParentTxFailed);
145
+ parentTx.off('freed', this.onParentTxFreed);
146
+ super.destroy();
147
+ }
148
+
136
149
  override async getTextureSource(): Promise<TextureData> {
137
150
  // Check if parent texture is loaded
138
151
  return new Promise((resolve, reject) => {
@@ -240,6 +240,11 @@ export abstract class Texture extends EventEmitter {
240
240
  return false;
241
241
  }
242
242
 
243
+ // Don't cleanup a texture that is in the process of loading
244
+ if (this.state === 'loading') {
245
+ return false;
246
+ }
247
+
243
248
  // Don't cleanup if not renderable
244
249
  if (this.renderable === true) {
245
250
  return false;
@@ -341,6 +346,9 @@ export abstract class Texture extends EventEmitter {
341
346
  */
342
347
  free(): void {
343
348
  this.ctxTexture?.free();
349
+ // Null out the freed ctxTexture so a subsequent reload re-creates it via
350
+ // getCtxTexture() instead of reusing a stale, already-freed reference.
351
+ this.ctxTexture = undefined;
344
352
  }
345
353
 
346
354
  /**
@@ -370,6 +378,10 @@ export abstract class Texture extends EventEmitter {
370
378
 
371
379
  // Always free texture data regardless of state
372
380
  this.freeTextureData();
381
+
382
+ // Drop any remaining subscribers so a texture destroyed while something
383
+ // still holds a listener does not retain it (and its captures).
384
+ this.removeAllListeners();
373
385
  }
374
386
 
375
387
  /**
@@ -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
  */
@@ -309,6 +393,23 @@ export interface RendererRuntimeSettings {
309
393
  * Configuration settings for {@link RendererMain}
310
394
  */
311
395
  export type RendererMainSettings = RendererRuntimeSettings & {
396
+ /**
397
+ * Maximum number of entries kept in the SDF text layout cache
398
+ *
399
+ * @remarks
400
+ * The SDF text renderer caches the computed glyph layout for a given
401
+ * `text` + font + layout-prop combination so that identical strings (e.g.
402
+ * repeated badges/labels) are not re-laid-out. The cache is content-keyed
403
+ * and shared across nodes, and is trimmed down to this many (most recently
404
+ * used) entries whenever the stage goes idle.
405
+ *
406
+ * Set this higher for content-dense UIs with many simultaneous unique
407
+ * strings, or lower to cap memory more aggressively.
408
+ *
409
+ * @defaultValue `250`
410
+ */
411
+ textLayoutCacheSize: number;
412
+
312
413
  /**
313
414
  * Include context call (i.e. WebGL) information in FPS updates
314
415
  *
@@ -532,11 +633,11 @@ export type RendererMainSettings = RendererRuntimeSettings & {
532
633
  *
533
634
  * Listen to events using the standard EventEmitter API:
534
635
  * ```typescript
535
- * renderer.on('fpsUpdate', (data: RendererMainFpsUpdateEvent) => {
636
+ * renderer.on('fpsUpdate', (_target, data: RendererMainFpsUpdateEvent) => {
536
637
  * console.log(`FPS: ${data.fps}`);
537
638
  * });
538
639
  *
539
- * renderer.on('idle', (data: RendererMainIdleEvent) => {
640
+ * renderer.on('idle', () => {
540
641
  * // Renderer is idle - no scene changes
541
642
  * });
542
643
  * ```
@@ -548,6 +649,7 @@ export type RendererMainSettings = RendererRuntimeSettings & {
548
649
  * @see {@link RendererMainCriticalCleanupEvent}
549
650
  * @see {@link RendererMainCriticalCleanupFailedEvent}
550
651
  * @see {@link RendererMainContextLostEvent}
652
+ * @see {@link RendererMainOutOfMemoryEvent}
551
653
  *
552
654
  * @fires RendererMain#fpsUpdate
553
655
  * @fires RendererMain#frameTick
@@ -556,6 +658,7 @@ export type RendererMainSettings = RendererRuntimeSettings & {
556
658
  * @fires RendererMain#criticalCleanup
557
659
  * @fires RendererMain#criticalCleanupFailed
558
660
  * @fires RendererMain#contextLost
661
+ * @fires RendererMain#outOfMemory
559
662
  */
560
663
  export class RendererMain extends EventEmitter {
561
664
  readonly root: INode;
@@ -592,6 +695,7 @@ export class RendererMain extends EventEmitter {
592
695
  fpsUpdateInterval: settings.fpsUpdateInterval || 0,
593
696
  enableClear: settings.enableClear ?? true,
594
697
  targetFPS: settings.targetFPS || 0,
698
+ textLayoutCacheSize: settings.textLayoutCacheSize ?? 250,
595
699
  numImageWorkers:
596
700
  settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2,
597
701
  enableContextSpy: settings.enableContextSpy ?? false,
@@ -669,6 +773,7 @@ export class RendererMain extends EventEmitter {
669
773
  textBaselineMode: settings.textBaselineMode!,
670
774
  inspector: settings.inspector !== null,
671
775
  targetFPS: settings.targetFPS!,
776
+ textLayoutCacheSize: settings.textLayoutCacheSize!,
672
777
  textureProcessingTimeLimit: settings.textureProcessingTimeLimit!,
673
778
  createImageBitmapSupport: settings.createImageBitmapSupport!,
674
779
  premultiplyAlphaHonored: settings.premultiplyAlphaHonored,
@@ -717,11 +822,13 @@ export class RendererMain extends EventEmitter {
717
822
  const currentTxSettings =
718
823
  (this.stage && this.stage.options.textureMemory) || {};
719
824
 
825
+ const criticalThreshold =
826
+ textureMemory?.criticalThreshold ??
827
+ currentTxSettings?.criticalThreshold ??
828
+ 200e6;
829
+
720
830
  return {
721
- criticalThreshold:
722
- textureMemory?.criticalThreshold ??
723
- currentTxSettings?.criticalThreshold ??
724
- 200e6,
831
+ criticalThreshold,
725
832
  targetThresholdLevel:
726
833
  textureMemory?.targetThresholdLevel ??
727
834
  currentTxSettings?.targetThresholdLevel ??