@lightningjs/renderer 3.0.2 → 3.0.3

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 (97) hide show
  1. package/README.md +56 -196
  2. package/dist/src/core/CoreNode.js +25 -4
  3. package/dist/src/core/CoreNode.js.map +1 -1
  4. package/dist/src/core/CoreTextNode.d.ts +9 -2
  5. package/dist/src/core/CoreTextNode.js +32 -11
  6. package/dist/src/core/CoreTextNode.js.map +1 -1
  7. package/dist/src/core/CoreTextureManager.d.ts +8 -0
  8. package/dist/src/core/CoreTextureManager.js +13 -1
  9. package/dist/src/core/CoreTextureManager.js.map +1 -1
  10. package/dist/src/core/Stage.d.ts +8 -0
  11. package/dist/src/core/Stage.js +23 -0
  12. package/dist/src/core/Stage.js.map +1 -1
  13. package/dist/src/core/TextureMemoryManager.d.ts +8 -13
  14. package/dist/src/core/TextureMemoryManager.js +22 -27
  15. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  16. package/dist/src/core/lib/ImageWorker.d.ts +2 -2
  17. package/dist/src/core/lib/ImageWorker.js +31 -12
  18. package/dist/src/core/lib/ImageWorker.js.map +1 -1
  19. package/dist/src/core/lib/WebGlContextWrapper.d.ts +105 -56
  20. package/dist/src/core/lib/WebGlContextWrapper.js +164 -158
  21. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  22. package/dist/src/core/lib/textureCompression.js +19 -10
  23. package/dist/src/core/lib/textureCompression.js.map +1 -1
  24. package/dist/src/core/lib/validateImageBitmap.d.ts +2 -1
  25. package/dist/src/core/lib/validateImageBitmap.js +4 -4
  26. package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
  27. package/dist/src/core/platform.js +2 -2
  28. package/dist/src/core/platform.js.map +1 -1
  29. package/dist/src/core/platforms/Platform.d.ts +4 -0
  30. package/dist/src/core/platforms/Platform.js.map +1 -1
  31. package/dist/src/core/platforms/web/WebPlatform.d.ts +2 -0
  32. package/dist/src/core/platforms/web/WebPlatform.js +13 -0
  33. package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
  34. package/dist/src/core/renderers/CoreRenderer.d.ts +6 -0
  35. package/dist/src/core/renderers/CoreRenderer.js +8 -0
  36. package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
  37. package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +1 -0
  38. package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -0
  39. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  40. package/dist/src/core/renderers/webgl/WebGlRenderOp.d.ts +45 -0
  41. package/dist/src/core/renderers/webgl/WebGlRenderOp.js +127 -0
  42. package/dist/src/core/renderers/webgl/WebGlRenderOp.js.map +1 -0
  43. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +2 -0
  44. package/dist/src/core/renderers/webgl/WebGlRenderer.js +30 -22
  45. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  46. package/dist/src/core/text-rendering/CanvasFont.d.ts +14 -0
  47. package/dist/src/core/text-rendering/CanvasFont.js +120 -0
  48. package/dist/src/core/text-rendering/CanvasFont.js.map +1 -0
  49. package/dist/src/core/text-rendering/CanvasTextRenderer.d.ts +1 -2
  50. package/dist/src/core/text-rendering/CanvasTextRenderer.js +11 -19
  51. package/dist/src/core/text-rendering/CanvasTextRenderer.js.map +1 -1
  52. package/dist/src/core/text-rendering/CoreFont.d.ts +33 -0
  53. package/dist/src/core/text-rendering/CoreFont.js +48 -0
  54. package/dist/src/core/text-rendering/CoreFont.js.map +1 -0
  55. package/dist/src/core/text-rendering/FontManager.d.ts +11 -0
  56. package/dist/src/core/text-rendering/FontManager.js +41 -0
  57. package/dist/src/core/text-rendering/FontManager.js.map +1 -0
  58. package/dist/src/core/text-rendering/SdfFont.d.ts +29 -0
  59. package/dist/src/core/text-rendering/SdfFont.js +142 -0
  60. package/dist/src/core/text-rendering/SdfFont.js.map +1 -0
  61. package/dist/src/core/text-rendering/SdfTextRenderer.d.ts +2 -2
  62. package/dist/src/core/text-rendering/SdfTextRenderer.js +141 -132
  63. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  64. package/dist/src/core/text-rendering/TextGenerator.d.ts +10 -0
  65. package/dist/src/core/text-rendering/TextGenerator.js +36 -0
  66. package/dist/src/core/text-rendering/TextGenerator.js.map +1 -0
  67. package/dist/src/core/text-rendering/TextRenderer.d.ts +26 -20
  68. package/dist/src/core/text-rendering/Utils.d.ts +2 -0
  69. package/dist/src/core/text-rendering/Utils.js +3 -0
  70. package/dist/src/core/text-rendering/Utils.js.map +1 -1
  71. package/dist/src/main-api/Renderer.d.ts +14 -0
  72. package/dist/src/main-api/Renderer.js +29 -3
  73. package/dist/src/main-api/Renderer.js.map +1 -1
  74. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  75. package/package.json +2 -1
  76. package/src/core/CoreNode.ts +29 -4
  77. package/src/core/CoreTextNode.test.ts +237 -0
  78. package/src/core/CoreTextNode.ts +53 -33
  79. package/src/core/CoreTextureManager.ts +14 -2
  80. package/src/core/Stage.ts +29 -0
  81. package/src/core/TextureMemoryManager.test.ts +134 -0
  82. package/src/core/TextureMemoryManager.ts +23 -30
  83. package/src/core/platforms/Platform.ts +5 -0
  84. package/src/core/platforms/web/WebPlatform.ts +13 -0
  85. package/src/core/renderers/CoreRenderer.ts +10 -0
  86. package/src/core/renderers/canvas/CanvasRenderer.ts +6 -0
  87. package/src/core/renderers/webgl/WebGlRenderer.rtt.test.ts +551 -0
  88. package/src/core/renderers/webgl/WebGlRenderer.ts +38 -28
  89. package/src/core/text-rendering/CanvasTextRenderer.ts +13 -41
  90. package/src/core/text-rendering/SdfTextRenderer.ts +166 -163
  91. package/src/core/text-rendering/TextRenderer.ts +23 -21
  92. package/src/core/text-rendering/Utils.ts +5 -1
  93. package/src/main-api/Renderer.test.ts +153 -0
  94. package/src/main-api/Renderer.ts +33 -3
  95. package/dist/src/core/renderers/webgl/WebGlCoreShader.destroy.d.ts +0 -1
  96. package/dist/src/core/renderers/webgl/WebGlCoreShader.destroy.js +0 -2
  97. package/dist/src/core/renderers/webgl/WebGlCoreShader.destroy.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/renderer",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "description": "Lightning 3 Renderer",
5
5
  "type": "module",
6
6
  "main": "./dist/exports/index.js",
@@ -40,6 +40,7 @@
40
40
  "concurrently": "^8.2.2",
41
41
  "eslint": "^9.36.0",
42
42
  "eslint-config-prettier": "^8.10.2",
43
+ "happy-dom": "17.6.3",
43
44
  "husky": "^8.0.3",
44
45
  "lint-staged": "^13.3.0",
45
46
  "prettier": "^2.8.8",
@@ -202,6 +202,26 @@ export enum UpdateType {
202
202
  All = 16383,
203
203
  }
204
204
 
205
+ /**
206
+ * Bitmask of UpdateType flags that represent a visually significant change
207
+ * within a node. Used to gate notifyParentRTTOfUpdate() so that RTT surfaces
208
+ * are only marked dirty when something actually visible changed, rather than
209
+ * on every update() cycle that merely propagates child traversal.
210
+ *
211
+ * Excluded flags (non-visual cascade/bookkeeping):
212
+ * Children, RenderBounds, RenderState, ParentRenderTexture, Autosize
213
+ */
214
+ const RTT_NOTIFY_MASK =
215
+ UpdateType.Local |
216
+ UpdateType.Global |
217
+ UpdateType.Clipping |
218
+ UpdateType.SortZIndexChildren |
219
+ UpdateType.PremultipliedColors |
220
+ UpdateType.WorldAlpha |
221
+ UpdateType.IsRenderable |
222
+ UpdateType.RenderTexture |
223
+ UpdateType.RecalcUniforms;
224
+
205
225
  /**
206
226
  * A custom data map which can be stored on an CoreNode
207
227
  *
@@ -1345,10 +1365,15 @@ export class CoreNode extends EventEmitter {
1345
1365
  }
1346
1366
  }
1347
1367
 
1348
- // If the node has an RTT parent and requires a texture re-render, inform the RTT parent
1349
- // if (this.parentHasRenderTexture && updateType & UpdateType.RenderTexture) {
1350
- // @TODO have a more scoped down updateType for RTT updates
1351
- if (parentHasRenderTexture === true) {
1368
+ // If the node has an RTT parent and a visually relevant change occurred (or a
1369
+ // nested RTT child already flagged this node via hasRTTupdates), notify the
1370
+ // nearest RTT ancestor so it re-renders its surface.
1371
+ // Guarded by RTT_NOTIFY_MASK to avoid redundant notifications on frames where
1372
+ // only child-traversal bookkeeping flags (Children, RenderBounds, etc.) are set.
1373
+ if (
1374
+ parentHasRenderTexture === true &&
1375
+ (this.hasRTTupdates === true || (updateType & RTT_NOTIFY_MASK) !== 0)
1376
+ ) {
1352
1377
  this.notifyParentRTTOfUpdate();
1353
1378
  }
1354
1379
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { describe, expect, it, vi, beforeEach } from 'vitest';
21
21
  import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js';
22
+ import { CoreNodeRenderState } from './CoreNode.js';
22
23
  import { Stage } from './Stage.js';
23
24
  import { CoreRenderer } from './renderers/CoreRenderer.js';
24
25
  import { mock } from 'vitest-mock-extended';
@@ -308,4 +309,240 @@ describe('CoreTextNode', () => {
308
309
  expect(node.isRenderable).toBe(false);
309
310
  });
310
311
  });
312
+
313
+ function makeStageWithDeleteBuffer(deleteBuffer: ReturnType<typeof vi.fn>) {
314
+ return mock<Stage>({
315
+ strictBound: createBound(0, 0, 1920, 1080),
316
+ preloadBound: createBound(0, 0, 1920, 1080),
317
+ defaultTexture: { state: 'loaded' },
318
+ renderer: { deleteBuffer } as unknown as CoreRenderer,
319
+ });
320
+ }
321
+
322
+ describe('updateRenderState – SDF buffer release on OutOfBounds', () => {
323
+ it('should call renderer.deleteBuffer and clear _sdfBufferRef when transitioning to OutOfBounds', () => {
324
+ const deleteBuffer = vi.fn();
325
+ const node = new CoreTextNode(
326
+ makeStageWithDeleteBuffer(deleteBuffer),
327
+ defaultTextProps,
328
+ mockTextRenderer,
329
+ );
330
+
331
+ // Simulate a live WebGLBuffer sitting in the ref
332
+ const fakeBuffer = {};
333
+ (node as any)._sdfBufferRef.current = fakeBuffer;
334
+
335
+ node.updateRenderState(CoreNodeRenderState.OutOfBounds);
336
+
337
+ expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
338
+ expect((node as any)._sdfBufferRef.current).toBeNull();
339
+ });
340
+
341
+ it('should not call renderer.deleteBuffer when _sdfBufferRef is already null', () => {
342
+ const deleteBuffer = vi.fn();
343
+ const node = new CoreTextNode(
344
+ makeStageWithDeleteBuffer(deleteBuffer),
345
+ defaultTextProps,
346
+ mockTextRenderer,
347
+ );
348
+
349
+ // _sdfBufferRef.current is null by default
350
+ node.updateRenderState(CoreNodeRenderState.OutOfBounds);
351
+
352
+ expect(deleteBuffer).not.toHaveBeenCalled();
353
+ });
354
+
355
+ it('should not release the buffer when transitioning to InBounds', () => {
356
+ const deleteBuffer = vi.fn();
357
+ const node = new CoreTextNode(
358
+ makeStageWithDeleteBuffer(deleteBuffer),
359
+ defaultTextProps,
360
+ mockTextRenderer,
361
+ );
362
+
363
+ const fakeBuffer = {};
364
+ (node as any)._sdfBufferRef.current = fakeBuffer;
365
+
366
+ node.updateRenderState(CoreNodeRenderState.InBounds);
367
+
368
+ expect(deleteBuffer).not.toHaveBeenCalled();
369
+ expect((node as any)._sdfBufferRef.current).toBe(fakeBuffer);
370
+ });
371
+
372
+ it('should not release the buffer for a canvas-type text node', () => {
373
+ const deleteBuffer = vi.fn();
374
+ const canvasTextRenderer = {
375
+ ...mockTextRenderer,
376
+ type: 'canvas' as const,
377
+ } as any;
378
+
379
+ const node = new CoreTextNode(
380
+ makeStageWithDeleteBuffer(deleteBuffer),
381
+ defaultTextProps,
382
+ canvasTextRenderer,
383
+ );
384
+
385
+ (node as any)._sdfBufferRef.current = {};
386
+
387
+ node.updateRenderState(CoreNodeRenderState.OutOfBounds);
388
+
389
+ expect(deleteBuffer).not.toHaveBeenCalled();
390
+ });
391
+ });
392
+
393
+ describe('SDF buffer release on layout regeneration', () => {
394
+ it('should call renderer.deleteBuffer before regenerating layout when font is already loaded', () => {
395
+ const deleteBuffer = vi.fn();
396
+ const props = { ...defaultTextProps, forceLoad: true };
397
+ const node = new CoreTextNode(
398
+ makeStageWithDeleteBuffer(deleteBuffer),
399
+ props,
400
+ mockTextRenderer,
401
+ );
402
+
403
+ const fakeBuffer = {} as WebGLBuffer;
404
+ (node as any)._sdfBufferRef.current = fakeBuffer;
405
+
406
+ node.update(16, clippingRect);
407
+
408
+ expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
409
+ expect((node as any)._sdfBufferRef.current).toBeNull();
410
+ });
411
+
412
+ it('should call renderer.deleteBuffer again on each subsequent layout regeneration', () => {
413
+ const deleteBuffer = vi.fn();
414
+ const props = { ...defaultTextProps, forceLoad: true };
415
+ const node = new CoreTextNode(
416
+ makeStageWithDeleteBuffer(deleteBuffer),
417
+ props,
418
+ mockTextRenderer,
419
+ );
420
+
421
+ node.update(16, clippingRect); // first layout – no buffer yet, no delete call
422
+ expect(deleteBuffer).not.toHaveBeenCalled();
423
+
424
+ // Trigger a second layout pass by invalidating the layout
425
+ node.fontSize = 24;
426
+ const secondBuffer = {} as WebGLBuffer;
427
+ (node as any)._sdfBufferRef.current = secondBuffer;
428
+
429
+ node.update(16, clippingRect);
430
+
431
+ expect(deleteBuffer).toHaveBeenCalledWith(secondBuffer);
432
+ expect((node as any)._sdfBufferRef.current).toBeNull();
433
+ });
434
+
435
+ it('should not call renderer.deleteBuffer when buffer is already null at regeneration time', () => {
436
+ const deleteBuffer = vi.fn();
437
+ const props = { ...defaultTextProps, forceLoad: true };
438
+ const node = new CoreTextNode(
439
+ makeStageWithDeleteBuffer(deleteBuffer),
440
+ props,
441
+ mockTextRenderer,
442
+ );
443
+
444
+ // _sdfBufferRef.current is null by default
445
+ node.update(16, clippingRect);
446
+
447
+ expect(deleteBuffer).not.toHaveBeenCalled();
448
+ });
449
+ });
450
+
451
+ describe('SDF buffer release when text becomes invalid', () => {
452
+ it('should call renderer.deleteBuffer when text is cleared during update', () => {
453
+ const deleteBuffer = vi.fn();
454
+ const props = { ...defaultTextProps, text: 'Hello', forceLoad: true };
455
+ const node = new CoreTextNode(
456
+ makeStageWithDeleteBuffer(deleteBuffer),
457
+ props,
458
+ mockTextRenderer,
459
+ );
460
+
461
+ // Prime the node with a cached buffer
462
+ const fakeBuffer = {} as WebGLBuffer;
463
+ (node as any)._sdfBufferRef.current = fakeBuffer;
464
+ (node as any)._layoutGenerated = true;
465
+
466
+ node.text = '';
467
+ node.update(16, clippingRect);
468
+
469
+ expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
470
+ expect((node as any)._sdfBufferRef.current).toBeNull();
471
+ });
472
+
473
+ it('should not call renderer.deleteBuffer when text is invalid and buffer is already null', () => {
474
+ const deleteBuffer = vi.fn();
475
+ const props = { ...defaultTextProps, text: '', forceLoad: true };
476
+ const node = new CoreTextNode(
477
+ makeStageWithDeleteBuffer(deleteBuffer),
478
+ props,
479
+ mockTextRenderer,
480
+ );
481
+
482
+ node.update(16, clippingRect);
483
+
484
+ expect(deleteBuffer).not.toHaveBeenCalled();
485
+ });
486
+
487
+ it('should also clear _cachedLayout when text becomes invalid', () => {
488
+ const deleteBuffer = vi.fn();
489
+ const props = { ...defaultTextProps, text: 'Hello', forceLoad: true };
490
+ const node = new CoreTextNode(
491
+ makeStageWithDeleteBuffer(deleteBuffer),
492
+ props,
493
+ mockTextRenderer,
494
+ );
495
+
496
+ (node as any)._cachedLayout = {};
497
+ (node as any)._layoutGenerated = true;
498
+
499
+ node.text = '';
500
+ node.update(16, clippingRect);
501
+
502
+ expect((node as any)._cachedLayout).toBeNull();
503
+ });
504
+ });
505
+
506
+ describe('SDF buffer release on destroy', () => {
507
+ it('should call renderer.deleteBuffer on destroy when a buffer is held', () => {
508
+ const deleteBuffer = vi.fn();
509
+ const node = new CoreTextNode(
510
+ makeStageWithDeleteBuffer(deleteBuffer),
511
+ defaultTextProps,
512
+ mockTextRenderer,
513
+ );
514
+
515
+ const fakeBuffer = {} as WebGLBuffer;
516
+ (node as any)._sdfBufferRef.current = fakeBuffer;
517
+
518
+ node.destroy();
519
+
520
+ expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
521
+ expect((node as any)._sdfBufferRef.current).toBeNull();
522
+ });
523
+
524
+ it('should not call renderer.deleteBuffer on destroy when buffer is already null', () => {
525
+ const deleteBuffer = vi.fn();
526
+ const node = new CoreTextNode(
527
+ makeStageWithDeleteBuffer(deleteBuffer),
528
+ defaultTextProps,
529
+ mockTextRenderer,
530
+ );
531
+
532
+ // _sdfBufferRef.current is null by default
533
+ node.destroy();
534
+
535
+ expect(deleteBuffer).not.toHaveBeenCalled();
536
+ });
537
+
538
+ it('should clear _cachedLayout on destroy', () => {
539
+ const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
540
+
541
+ (node as any)._cachedLayout = {};
542
+
543
+ node.destroy();
544
+
545
+ expect((node as any)._cachedLayout).toBeNull();
546
+ });
547
+ });
311
548
  });
@@ -65,7 +65,9 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
65
65
 
66
66
  // SDF layout caching for performance
67
67
  private _cachedLayout: TextLayout | null = null;
68
- private _lastVertexBuffer: Float32Array | null = null;
68
+ // Mutable ref box shared with SdfTextRenderer so the renderer can write the
69
+ // created WebGLBuffer back into it, allowing reuse across frames.
70
+ private _sdfBufferRef: { current: WebGLBuffer | null } = { current: null };
69
71
 
70
72
  // Text renderer properties - stored directly on the node
71
73
  private textProps: CoreTextNodeProps;
@@ -113,6 +115,18 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
113
115
  this.setUpdateType(UpdateType.IsRenderable);
114
116
  };
115
117
 
118
+ /**
119
+ * Delete the cached WebGLBuffer held by the SDF renderer ref and reset the
120
+ * ref so the next renderQuads call allocates a fresh one.
121
+ * Safe to call from destroy() or on text change.
122
+ */
123
+ private releaseSdfBuffer(): void {
124
+ const buf = this._sdfBufferRef.current;
125
+ if (buf === null) return;
126
+ this.stage.renderer.deleteBuffer(buf);
127
+ this._sdfBufferRef.current = null;
128
+ }
129
+
116
130
  allowTextGeneration() {
117
131
  const p = this.props.parent;
118
132
  if (p === null) {
@@ -196,7 +210,8 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
196
210
  * Override CoreNode's update method to handle text-specific updates
197
211
  */
198
212
  override update(delta: number, parentClippingRect: RectWithValid): void {
199
- const hasValidText = this.textProps.text && this.textProps.text.length > 0;
213
+ const hasValidText =
214
+ typeof this.textProps.text === 'string' && this.textProps.text.length > 0;
200
215
 
201
216
  if (
202
217
  hasValidText === true &&
@@ -207,7 +222,7 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
207
222
  if (this.fontHandler.isFontLoaded(this.textProps.fontFamily) === true) {
208
223
  this._waitingForFont = false;
209
224
  this._cachedLayout = null; // Invalidate cached layout
210
- this._lastVertexBuffer = null; // Invalidate last vertex buffer
225
+ this.releaseSdfBuffer(); // Free the cached WebGLBuffer
211
226
  const resp = this.textRenderer.renderText(this.textProps);
212
227
  this.handleRenderResult(resp);
213
228
  this._layoutGenerated = true;
@@ -216,11 +231,13 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
216
231
  this._waitingForFont = true;
217
232
  }
218
233
  } else if (hasValidText === false) {
234
+ this.props.w = 0;
235
+ this.props.h = 0;
219
236
  // If text is invalid, ensure node is not renderable
220
237
  this.setRenderable(false);
221
238
  this._layoutGenerated = false;
222
239
  this._cachedLayout = null;
223
- this._lastVertexBuffer = null;
240
+ this.releaseSdfBuffer(); // Free the cached WebGLBuffer
224
241
  }
225
242
 
226
243
  // First run the standard CoreNode update
@@ -232,7 +249,8 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
232
249
  */
233
250
  override updateIsRenderable(): void {
234
251
  // Guard: Text nodes are never renderable without valid text
235
- const hasValidText = this.textProps.text && this.textProps.text.length > 0;
252
+ const hasValidText =
253
+ typeof this.textProps.text === 'string' && this.textProps.text.length > 0;
236
254
  if (hasValidText === false) {
237
255
  this.setRenderable(false);
238
256
  return;
@@ -335,37 +353,39 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
335
353
  }
336
354
 
337
355
  // Early return if no cached data
338
- if (!this._cachedLayout) {
356
+ if (this._cachedLayout === null) {
339
357
  return;
340
358
  }
341
359
 
342
- if (this._lastVertexBuffer === null) {
343
- this._lastVertexBuffer = this.textRenderer.addQuads(this._cachedLayout);
344
- }
345
-
346
360
  const props = this.textProps;
347
- this.textRenderer.renderQuads(
348
- renderer,
349
- this._cachedLayout as TextLayout,
350
- this._lastVertexBuffer!,
351
- {
352
- fontFamily: this.textProps.fontFamily,
353
- fontSize: props.fontSize,
354
- color: this.props.color || 0xffffffff,
355
- offsetY: props.offsetY,
356
- worldAlpha: this.worldAlpha,
357
- globalTransform: this.globalTransform!.getFloatArr(),
358
- clippingRect: this.clippingRect,
359
- width: this.props.w,
360
- height: this.props.h,
361
- parentHasRenderTexture: this.parentHasRenderTexture,
362
- framebufferDimensions:
363
- this.parentHasRenderTexture === true
364
- ? this.parentFramebufferDimensions
365
- : null,
366
- stage: this.stage,
367
- },
368
- );
361
+ this.textRenderer.renderQuads(renderer, this._cachedLayout as TextLayout, {
362
+ fontFamily: this.textProps.fontFamily,
363
+ fontSize: props.fontSize,
364
+ color: this.props.color || 0xffffffff,
365
+ offsetY: props.offsetY,
366
+ worldAlpha: this.worldAlpha,
367
+ globalTransform: this.globalTransform!.getFloatArr(),
368
+ clippingRect: this.clippingRect,
369
+ width: this.props.w,
370
+ height: this.props.h,
371
+ parentHasRenderTexture: this.parentHasRenderTexture,
372
+ framebufferDimensions:
373
+ this.parentHasRenderTexture === true
374
+ ? this.parentFramebufferDimensions
375
+ : null,
376
+ stage: this.stage,
377
+ glBufferRef: this._sdfBufferRef,
378
+ });
379
+ }
380
+
381
+ override updateRenderState(renderState: CoreNodeRenderState): void {
382
+ super.updateRenderState(renderState);
383
+ if (
384
+ this._type === 'sdf' &&
385
+ renderState === CoreNodeRenderState.OutOfBounds
386
+ ) {
387
+ this.releaseSdfBuffer();
388
+ }
369
389
  }
370
390
 
371
391
  override destroy(): void {
@@ -375,7 +395,7 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
375
395
 
376
396
  // Clear cached layout and vertex buffer
377
397
  this._cachedLayout = null;
378
- this._lastVertexBuffer = null;
398
+ this.releaseSdfBuffer(); // Delete the cached WebGLBuffer before losing stage ref
379
399
 
380
400
  this.fontHandler = null!; // Clear reference to avoid memory leaks
381
401
  this.textRenderer = null!; // Clear reference to avoid memory leaks
@@ -289,8 +289,6 @@ export class CoreTextureManager extends EventEmitter {
289
289
  * @param immediate - Whether to prioritize the texture for immediate loading
290
290
  */
291
291
  async loadTexture(texture: Texture, priority?: boolean): Promise<void> {
292
- this.stage.txMemManager.removeFromOrphanedTextures(texture);
293
-
294
292
  if (texture.type === TextureType.subTexture) {
295
293
  // ignore subtextures - they get loaded through their parent
296
294
  return;
@@ -464,6 +462,20 @@ export class CoreTextureManager extends EventEmitter {
464
462
  }
465
463
  }
466
464
 
465
+ /**
466
+ * Destroy the CoreTextureManager and release all internal references.
467
+ *
468
+ * @remarks
469
+ * Clears the upload queue and key/inverse-key caches so that queued
470
+ * textures can be garbage-collected after a renderer teardown.
471
+ */
472
+ destroy(): void {
473
+ this.uploadTextureQueue = [];
474
+ this.keyCache.clear();
475
+ // inverseKeyCache is a WeakMap – entries will be GC'd automatically once
476
+ // the texture objects themselves are no longer referenced.
477
+ }
478
+
467
479
  /**
468
480
  * Resolve a parent texture from the cache or fallback to the provided texture.
469
481
  *
package/src/core/Stage.ts CHANGED
@@ -882,6 +882,35 @@ export class Stage {
882
882
  this.txMemManager.cleanup(full);
883
883
  }
884
884
 
885
+ /**
886
+ * Destroy the stage and release all resources.
887
+ *
888
+ * @remarks
889
+ * This method stops the render loop, destroys all nodes, releases all
890
+ * textures and GPU resources, and terminates any background workers.
891
+ */
892
+ destroy(): void {
893
+ // Stop the render loop and terminate workers
894
+ this.platform.stopLoop();
895
+
896
+ // Recursively destroy all nodes (includes CoreTextNode font/text cleanup)
897
+ this.root.destroy();
898
+
899
+ // Free all GPU-side textures and clear internal tracking arrays/caches
900
+ this.txMemManager.destroy();
901
+
902
+ // Clear the texture upload queue and key caches
903
+ this.txManager.destroy();
904
+
905
+ // Release the GPU context (WebGL) or canvas resources
906
+ this.renderer.destroy();
907
+
908
+ // Clear text renderer caches
909
+ for (const key in this.textRenderers) {
910
+ this.textRenderers[key]!.clearCache();
911
+ }
912
+ }
913
+
885
914
  set clearColor(value: number) {
886
915
  this.renderer.updateClearColor(value);
887
916
  this.renderRequested = true;
@@ -0,0 +1,134 @@
1
+ /*
2
+ * If not stated otherwise in this file or this component's LICENSE file the
3
+ * following copyright and licenses apply:
4
+ *
5
+ * Copyright 2024 Comcast Cable Communications Management, LLC.
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the License);
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { mock } from 'vitest-mock-extended';
22
+ import {
23
+ TextureMemoryManager,
24
+ type TextureMemoryManagerSettings,
25
+ } from './TextureMemoryManager.js';
26
+ import type { Stage } from './Stage.js';
27
+ import type { Texture } from './textures/Texture.js';
28
+ import { TextureType } from './textures/Texture.js';
29
+
30
+ function makeSettings(
31
+ overrides?: Partial<TextureMemoryManagerSettings>,
32
+ ): TextureMemoryManagerSettings {
33
+ return {
34
+ criticalThreshold: 124e6,
35
+ targetThresholdLevel: 0.5,
36
+ cleanupInterval: 5000,
37
+ debugLogging: false,
38
+ baselineMemoryAllocation: 25e6,
39
+ doNotExceedCriticalThreshold: false,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function makeTexture(memUsed = 1024): Texture {
45
+ const texture = mock<Texture>();
46
+ texture.memUsed = memUsed;
47
+ texture.type = TextureType.image;
48
+ texture.state = 'loaded';
49
+ (texture as { renderable: boolean }).renderable = false;
50
+ texture.preventCleanup = false;
51
+ texture.canBeCleanedUp.mockReturnValue(true);
52
+ return texture;
53
+ }
54
+
55
+ function makeStage() {
56
+ const stage = mock<Stage>();
57
+ const txManager = mock<Stage['txManager']>();
58
+ (stage as { txManager: Stage['txManager'] }).txManager = txManager;
59
+ return stage;
60
+ }
61
+
62
+ describe('TextureMemoryManager', () => {
63
+ describe('destroy()', () => {
64
+ it('calls destroyTexture for every loaded texture', () => {
65
+ const stage = makeStage();
66
+ const manager = new TextureMemoryManager(stage, makeSettings());
67
+
68
+ const textures = [makeTexture(512), makeTexture(1024), makeTexture(2048)];
69
+
70
+ for (const texture of textures) {
71
+ manager.setTextureMemUse(texture, texture.memUsed);
72
+ }
73
+
74
+ manager.destroy();
75
+
76
+ for (const texture of textures) {
77
+ expect(texture.destroy).toHaveBeenCalledOnce();
78
+ }
79
+ });
80
+
81
+ it('clears internal tracking so memUsed is 0 after destroy', () => {
82
+ const stage = makeStage();
83
+ const manager = new TextureMemoryManager(stage, makeSettings());
84
+
85
+ const texture = makeTexture(4096);
86
+ manager.setTextureMemUse(texture, texture.memUsed);
87
+
88
+ manager.destroy();
89
+
90
+ expect(manager.getMemoryInfo().memUsed).toBe(0);
91
+ });
92
+
93
+ it('reports no loaded textures after destroy', () => {
94
+ const stage = makeStage();
95
+ const manager = new TextureMemoryManager(stage, makeSettings());
96
+
97
+ manager.setTextureMemUse(makeTexture(512), 512);
98
+ manager.setTextureMemUse(makeTexture(1024), 1024);
99
+
100
+ manager.destroy();
101
+
102
+ expect(manager.getMemoryInfo().loadedTextures).toBe(0);
103
+ });
104
+
105
+ it('removes each texture from the cache via txManager', () => {
106
+ const stage = makeStage();
107
+ const manager = new TextureMemoryManager(stage, makeSettings());
108
+
109
+ const textures = [makeTexture(256), makeTexture(512)];
110
+ for (const texture of textures) {
111
+ manager.setTextureMemUse(texture, texture.memUsed);
112
+ }
113
+
114
+ manager.destroy();
115
+
116
+ expect(stage.txManager.removeTextureFromCache).toHaveBeenCalledTimes(
117
+ textures.length,
118
+ );
119
+
120
+ for (const texture of textures) {
121
+ expect(stage.txManager.removeTextureFromCache).toHaveBeenCalledWith(
122
+ texture,
123
+ );
124
+ }
125
+ });
126
+
127
+ it('handles an empty loadedTextures list without throwing', () => {
128
+ const stage = makeStage();
129
+ const manager = new TextureMemoryManager(stage, makeSettings());
130
+
131
+ expect(() => manager.destroy()).not.toThrow();
132
+ });
133
+ });
134
+ });