@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,79 @@
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
+ cleanupTextRenderers: vi.fn(),
23
+ eventBus: { emit: vi.fn() },
24
+ txMemManager: {
25
+ checkCleanup: vi.fn(() => false),
26
+ cleanup: vi.fn(),
27
+ handleOutOfMemory,
28
+ },
29
+ renderer: { checkForOutOfMemory },
30
+ } as unknown as Stage;
31
+ return { stage, checkForOutOfMemory, handleOutOfMemory };
32
+ }
33
+
34
+ describe('WebPlatform render loop — out-of-memory probe at idle', () => {
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ function runOneIdleFrame(stage: Stage) {
40
+ let capturedLoop: ((t?: number) => void) | null = null;
41
+ const raf = vi.fn((cb: (t?: number) => void) => {
42
+ capturedLoop = cb;
43
+ return 1;
44
+ });
45
+ vi.stubGlobal('requestAnimationFrame', raf);
46
+ vi.stubGlobal(
47
+ 'setTimeout',
48
+ vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
49
+ );
50
+
51
+ new WebPlatform().startLoop(stage);
52
+ capturedLoop!(0);
53
+ }
54
+
55
+ it('probes the renderer once when the scene goes idle', () => {
56
+ const { stage, checkForOutOfMemory } = makeIdleStage(false);
57
+ runOneIdleFrame(stage);
58
+ expect(checkForOutOfMemory).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it('handles OOM when the probe reports it at idle', () => {
62
+ const { stage, handleOutOfMemory } = makeIdleStage(true);
63
+ runOneIdleFrame(stage);
64
+ expect(handleOutOfMemory).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it('does not handle OOM when the probe reports none', () => {
68
+ const { stage, handleOutOfMemory } = makeIdleStage(false);
69
+ runOneIdleFrame(stage);
70
+ expect(handleOutOfMemory).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it('does not probe on an active (non-idle) frame', () => {
74
+ const { stage, checkForOutOfMemory } = makeIdleStage(false);
75
+ (stage.hasSceneUpdates as ReturnType<typeof vi.fn>).mockReturnValue(true);
76
+ runOneIdleFrame(stage);
77
+ expect(checkForOutOfMemory).not.toHaveBeenCalled();
78
+ });
79
+ });
@@ -77,7 +77,16 @@ 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();
89
+ stage.cleanupTextRenderers();
81
90
  stage.eventBus.emit('idle');
82
91
  isIdle = true;
83
92
  }
@@ -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
  }
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { CoreShaderNode } from './CoreShaderNode.js';
3
+ import type { Stage } from '../Stage.js';
4
+ import type { CoreShaderType } from './CoreShaderNode.js';
5
+
6
+ const makeNode = (mutate: ReturnType<typeof vi.fn>) => {
7
+ const stage = {
8
+ shManager: { mutateShaderValueUsage: mutate },
9
+ } as unknown as Stage;
10
+ return new CoreShaderNode(
11
+ 'test',
12
+ { time: undefined } as CoreShaderType,
13
+ stage,
14
+ );
15
+ };
16
+
17
+ describe('CoreShaderNode.detachNode', () => {
18
+ it('releases the held value-cache key so idle cleanup can evict it', () => {
19
+ const mutate = vi.fn();
20
+ const node = makeNode(mutate);
21
+ node.valueKey = 'color:1;node-width:10node-height:10';
22
+
23
+ node.detachNode();
24
+
25
+ expect(mutate).toHaveBeenCalledTimes(1);
26
+ expect(mutate).toHaveBeenCalledWith(
27
+ 'color:1;node-width:10node-height:10',
28
+ -1,
29
+ );
30
+ // Key is cleared so a double-detach can't decrement twice.
31
+ expect(node.valueKey).toBe('');
32
+ });
33
+
34
+ it('is a no-op when no value key is held', () => {
35
+ const mutate = vi.fn();
36
+ const node = makeNode(mutate);
37
+
38
+ node.detachNode();
39
+
40
+ expect(mutate).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it('does not double-decrement on repeated detach', () => {
44
+ const mutate = vi.fn();
45
+ const node = makeNode(mutate);
46
+ node.valueKey = 'k';
47
+
48
+ node.detachNode();
49
+ node.detachNode();
50
+
51
+ expect(mutate).toHaveBeenCalledTimes(1);
52
+ });
53
+ });
@@ -96,6 +96,12 @@ export class CoreShaderNode<Props extends object = Record<string, unknown>> {
96
96
  protected node: CoreNode | null = null;
97
97
  readonly time: CoreShaderType['time'] = undefined;
98
98
  update: (() => void) | undefined = undefined;
99
+ /**
100
+ * The shader-value cache key currently held by this node (set by the
101
+ * subclass `update()` after the most recent successful value resolution).
102
+ * Tracked on the base so {@link detachNode} can release it on teardown.
103
+ */
104
+ valueKey = '';
99
105
  private _valueKeyCache = '';
100
106
  private _valueKeyDirty = true;
101
107
  private _lastW = 0;
@@ -162,6 +168,23 @@ export class CoreShaderNode<Props extends object = Record<string, unknown>> {
162
168
  this.node = node;
163
169
  }
164
170
 
171
+ /**
172
+ * Release this node's currently-held shader-value cache entry so the shader
173
+ * manager's idle `cleanup()` can reclaim it, and detach from the owning node.
174
+ *
175
+ * Called from {@link CoreNode.destroy}. Mirrors the `prevKey` release done on
176
+ * every value-key change in the subclass `update()`; without it a destroyed
177
+ * node's last value-cache entry stays pinned at usage >= 1 forever and can
178
+ * never be evicted.
179
+ */
180
+ detachNode() {
181
+ if (this.valueKey.length > 0) {
182
+ this.stage.shManager.mutateShaderValueUsage(this.valueKey, -1);
183
+ this.valueKey = '';
184
+ }
185
+ this.node = null;
186
+ }
187
+
165
188
  createValueKey() {
166
189
  if (
167
190
  this._valueKeyDirty === false &&
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { CanvasRenderer } from './CanvasRenderer.js';
3
+ import { TextureType } from '../../textures/Texture.js';
4
+
5
+ describe('CanvasRenderer.renderContext', () => {
6
+ it('skips drawing when canvas texture image is undefined', () => {
7
+ const drawImage = vi.fn();
8
+
9
+ const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
10
+ (renderer as any).context = {
11
+ drawImage,
12
+ globalAlpha: 1,
13
+ };
14
+
15
+ const node = {
16
+ premultipliedColorTl: 0xffffffff,
17
+ globalTransform: { tx: 10, ty: 20 },
18
+ props: { w: 100, h: 50 },
19
+ worldAlpha: 1,
20
+ textureCoords: { x1: 0, y1: 0, x2: 1, y2: 1 },
21
+ } as any;
22
+
23
+ const texture = {
24
+ type: TextureType.image,
25
+ ctxTexture: {
26
+ getImage: () => undefined,
27
+ },
28
+ } as any;
29
+
30
+ expect(() => renderer.renderContext(node, texture)).not.toThrow();
31
+ expect(drawImage).not.toHaveBeenCalled();
32
+ expect((renderer as any).context.globalAlpha).toBe(1);
33
+ });
34
+ });
@@ -63,7 +63,19 @@ export class CanvasRenderer extends CoreRenderer {
63
63
  }
64
64
 
65
65
  const hasTransform = ta !== 1;
66
- const hasClipping = clippingRect.w !== 0 && clippingRect.h !== 0;
66
+ const clippingValid = clippingRect.valid === true;
67
+
68
+ // If the clipping rect is valid but zero-area, the node is fully clipped — skip rendering
69
+ if (
70
+ clippingValid === true &&
71
+ clippingRect.w === 0 &&
72
+ clippingRect.h === 0
73
+ ) {
74
+ return;
75
+ }
76
+
77
+ const hasClipping =
78
+ clippingValid === true && clippingRect.w !== 0 && clippingRect.h !== 0;
67
79
  const shader = node.props.shader;
68
80
  const hasShader = shader !== null;
69
81
 
@@ -124,7 +136,7 @@ export class CanvasRenderer extends CoreRenderer {
124
136
 
125
137
  if (textureType !== TextureType.color) {
126
138
  const tintColor = parseColor(color);
127
- let image: ImageBitmap | HTMLCanvasElement | HTMLImageElement;
139
+ let image: ImageBitmap | HTMLCanvasElement | HTMLImageElement | null;
128
140
 
129
141
  if (textureType === TextureType.subTexture) {
130
142
  image = (
@@ -134,12 +146,29 @@ export class CanvasRenderer extends CoreRenderer {
134
146
  image = (texture.ctxTexture as CanvasTexture).getImage(tintColor);
135
147
  }
136
148
 
149
+ // The texture can disappear while an async load/fetch is racing (e.g. 404);
150
+ // skip drawing this frame instead of dereferencing an invalid image object.
151
+ if (image === null || image === undefined) {
152
+ return;
153
+ }
154
+
155
+ const imageWidth = image.width;
156
+ const imageHeight = image.height;
157
+ if (
158
+ typeof imageWidth !== 'number' ||
159
+ typeof imageHeight !== 'number' ||
160
+ imageWidth <= 0 ||
161
+ imageHeight <= 0
162
+ ) {
163
+ return;
164
+ }
165
+
137
166
  this.context.globalAlpha = tintColor.a ?? node.worldAlpha;
138
167
 
139
168
  const txCoords = node.textureCoords;
140
169
  if (txCoords) {
141
- const ix = image.width;
142
- const iy = image.height;
170
+ const ix = imageWidth;
171
+ const iy = imageHeight;
143
172
 
144
173
  let sx = txCoords.x1 * ix;
145
174
  let sy = txCoords.y1 * iy;
@@ -264,6 +293,11 @@ export class CanvasRenderer extends CoreRenderer {
264
293
  return null;
265
294
  }
266
295
 
296
+ // Canvas2D has no GPU out-of-memory signal to probe.
297
+ checkForOutOfMemory(): boolean {
298
+ return false;
299
+ }
300
+
267
301
  /**
268
302
  * Updates the clear color of the canvas renderer.
269
303
  *
@@ -26,7 +26,6 @@ export class CanvasShaderNode<
26
26
  > extends CoreShaderNode<Props> {
27
27
  private updater: ((node: CoreNode, props?: Props) => void) | undefined =
28
28
  undefined;
29
- private valueKey: string = '';
30
29
  computed: Partial<Computed> = {};
31
30
  applySNR: boolean;
32
31
  render: CanvasShaderType<Props>['render'];
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CanvasTexture } from './CanvasTexture.js';
3
+
4
+ describe('CanvasTexture.load', () => {
5
+ it('fails gracefully when textureData.data is null', async () => {
6
+ const textureSource = {
7
+ textureData: { data: null },
8
+ state: 'initial',
9
+ dimensions: null,
10
+ setState(nextState: string) {
11
+ this.state = nextState;
12
+ },
13
+ freeTextureData() {
14
+ // no-op
15
+ },
16
+ } as any;
17
+
18
+ const memManager = {
19
+ setTextureMemUse() {
20
+ // no-op
21
+ },
22
+ } as any;
23
+
24
+ const ctxTexture = new CanvasTexture(memManager, textureSource);
25
+
26
+ await expect(ctxTexture.load()).rejects.toThrow(
27
+ 'CanvasTexture: Texture data is null',
28
+ );
29
+ expect(textureSource.state).toBe('failed');
30
+ });
31
+ });
@@ -2,6 +2,7 @@ import type { Dimensions } from '../../../common/CommonTypes.js';
2
2
  import { assertTruthy } from '../../../utils.js';
3
3
  import { formatRgba, type IParsedColor } from '../../lib/colorParser.js';
4
4
  import { CoreContextTexture } from '../CoreContextTexture.js';
5
+ import type { Texture } from '../../textures/Texture.js';
5
6
 
6
7
  export class CanvasTexture extends CoreContextTexture {
7
8
  protected image:
@@ -17,10 +18,23 @@ export class CanvasTexture extends CoreContextTexture {
17
18
  | undefined;
18
19
 
19
20
  async load(): Promise<void> {
21
+ // Capture textureData synchronously before any await - a pending
22
+ // freeTextureDataTask microtask could null textureSource.textureData
23
+ // during the first async suspension, causing onLoadRequest to fail.
24
+ const textureData = this.textureSource.textureData;
25
+ assertTruthy(textureData?.data, 'Texture data is null before load');
26
+
20
27
  this.textureSource.setState('loading');
21
28
 
22
29
  try {
23
- const size = await this.onLoadRequest();
30
+ const size = await this.onLoadRequest(textureData.data);
31
+
32
+ // Guard against the texture being freed while the load was in flight
33
+ if (this.textureSource.state === 'freed') {
34
+ this.image = undefined;
35
+ return;
36
+ }
37
+
24
38
  this.textureSource.setState('loaded', size);
25
39
  this.textureSource.freeTextureData();
26
40
  this.updateMemSize();
@@ -63,9 +77,11 @@ export class CanvasTexture extends CoreContextTexture {
63
77
 
64
78
  getImage(
65
79
  color: IParsedColor,
66
- ): ImageBitmap | HTMLCanvasElement | HTMLImageElement {
80
+ ): ImageBitmap | HTMLCanvasElement | HTMLImageElement | null {
67
81
  const image = this.image;
68
- assertTruthy(image, 'Attempt to get unloaded image texture');
82
+ if (image === undefined) {
83
+ return null;
84
+ }
69
85
 
70
86
  if (color.isWhite) {
71
87
  if (this.tintCache) {
@@ -114,9 +130,21 @@ export class CanvasTexture extends CoreContextTexture {
114
130
  return canvas;
115
131
  }
116
132
 
117
- private async onLoadRequest(): Promise<Dimensions> {
118
- assertTruthy(this.textureSource?.textureData?.data, 'Texture data is null');
119
- const { data } = this.textureSource.textureData;
133
+ private async onLoadRequest(
134
+ data: NonNullable<Texture['textureData']>['data'],
135
+ ): Promise<Dimensions> {
136
+ if (data === null) {
137
+ throw new Error('CanvasTexture: Texture data is null');
138
+ }
139
+
140
+ // CompressedData objects (KTX, PVR, ASTC) carry GPU-format mipmap buffers
141
+ // that cannot be decoded by Canvas2D. Reject explicitly rather than falling
142
+ // through silently and leaving this.image unassigned.
143
+ if (typeof data === 'object' && 'mipmaps' in data) {
144
+ throw new Error(
145
+ 'CanvasTexture: Compressed texture data is not supported in Canvas2D render mode',
146
+ );
147
+ }
120
148
 
121
149
  // TODO: canvas from text renderer should be able to provide the canvas directly
122
150
  // instead of having to re-draw it into a new canvas...
@@ -125,7 +153,7 @@ export class CanvasTexture extends CoreContextTexture {
125
153
  canvas.width = data.width;
126
154
  canvas.height = data.height;
127
155
  const ctx = canvas.getContext('2d');
128
- if (ctx) ctx.putImageData(data, 0, 0);
156
+ if (ctx !== null) ctx.putImageData(data, 0, 0);
129
157
  this.image = canvas;
130
158
  return { w: data.width, h: data.height };
131
159
  } else if (
@@ -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;
@@ -53,7 +53,6 @@ export class WebGlShaderNode<
53
53
  readonly program: WebGlShaderProgram;
54
54
  private updater: ((node: CoreNode, props?: Props) => void) | undefined =
55
55
  undefined;
56
- private valueKey: string = '';
57
56
  uniforms: UniformCollection = {
58
57
  single: {},
59
58
  vec2: {},
@@ -40,10 +40,22 @@ const layoutCache = new Map<
40
40
  }
41
41
  >();
42
42
 
43
+ // Upper bound on layoutCache entries, enforced on idle via `cleanup`.
44
+ // Overridden from stage options in `init`. Note: the Canvas path does not
45
+ // currently populate `layoutCache`, so this is effectively inert today and
46
+ // exists to keep the eviction policy uniform with the SDF backend should
47
+ // Canvas layout caching be wired up later.
48
+ let maxLayoutCacheSize = 250;
49
+
43
50
  // Initialize the Text Renderer
44
51
  const init = (stage: Stage): void => {
45
52
  const dpr = stage.options.devicePhysicalPixelRatio;
46
53
 
54
+ const configuredCacheSize = stage.options.textLayoutCacheSize;
55
+ if (configuredCacheSize !== undefined) {
56
+ maxLayoutCacheSize = configuredCacheSize;
57
+ }
58
+
47
59
  // Drawing canvas and context
48
60
  canvas = stage.platform.createCanvas() as HTMLCanvasElement | OffscreenCanvas;
49
61
  context = canvas.getContext('2d', { willReadFrequently: true }) as
@@ -224,6 +236,19 @@ const clearLayoutCache = (): void => {
224
236
  layoutCache.clear();
225
237
  };
226
238
 
239
+ /**
240
+ * Trim the layout cache back down to `maxLayoutCacheSize`, evicting the
241
+ * least-recently-used entries first. Called when the stage goes idle. The
242
+ * Canvas path does not currently populate `layoutCache`, so this is a no-op in
243
+ * practice today; it mirrors the SDF backend's eviction policy.
244
+ */
245
+ const cleanup = (): void => {
246
+ while (layoutCache.size > maxLayoutCacheSize) {
247
+ const oldest = layoutCache.keys().next().value as string;
248
+ layoutCache.delete(oldest);
249
+ }
250
+ };
251
+
227
252
  /**
228
253
  * Add quads for rendering (Canvas doesn't use quads)
229
254
  */
@@ -252,6 +277,7 @@ const CanvasTextRenderer = {
252
277
  renderQuads,
253
278
  init,
254
279
  clearLayoutCache,
280
+ cleanup,
255
281
  };
256
282
 
257
283
  export default CanvasTextRenderer;
@@ -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
- atlasTexture.on('failed', (error: Error) => {
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]) {