@solidtv/renderer 1.5.6 → 1.6.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.
Files changed (106) hide show
  1. package/dist/src/core/CoreNode.js +2 -2
  2. package/dist/src/core/CoreNode.js.map +1 -1
  3. package/dist/src/core/CoreTextureManager.d.ts +31 -0
  4. package/dist/src/core/CoreTextureManager.js +87 -6
  5. package/dist/src/core/CoreTextureManager.js.map +1 -1
  6. package/dist/src/core/Stage.js +11 -2
  7. package/dist/src/core/Stage.js.map +1 -1
  8. package/dist/src/core/lib/WebGlContextWrapper.d.ts +17 -1
  9. package/dist/src/core/lib/WebGlContextWrapper.js +21 -0
  10. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  11. package/dist/src/core/lib/colorParser.d.ts +4 -1
  12. package/dist/src/core/lib/colorParser.js +16 -6
  13. package/dist/src/core/lib/colorParser.js.map +1 -1
  14. package/dist/src/core/platforms/web/WebPlatform.js +60 -50
  15. package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
  16. package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +4 -0
  17. package/dist/src/core/renderers/canvas/CanvasRenderer.js +28 -13
  18. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  19. package/dist/src/core/renderers/canvas/CanvasShaderNode.js +1 -0
  20. package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
  21. package/dist/src/core/renderers/webgl/SdfRenderOp.js +2 -2
  22. package/dist/src/core/renderers/webgl/SdfRenderOp.js.map +1 -1
  23. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +43 -0
  24. package/dist/src/core/renderers/webgl/WebGlRenderer.js +109 -10
  25. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  26. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +21 -13
  27. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  28. package/dist/src/core/renderers/webgl/internal/ShaderUtils.d.ts +8 -3
  29. package/dist/src/core/renderers/webgl/internal/ShaderUtils.js +12 -11
  30. package/dist/src/core/renderers/webgl/internal/ShaderUtils.js.map +1 -1
  31. package/dist/src/core/shaders/canvas/Rounded.js +3 -3
  32. package/dist/src/core/shaders/canvas/Rounded.js.map +1 -1
  33. package/dist/src/core/shaders/canvas/RoundedWithShadow.js +3 -3
  34. package/dist/src/core/shaders/canvas/RoundedWithShadow.js.map +1 -1
  35. package/dist/src/core/shaders/canvas/utils/render.js +3 -3
  36. package/dist/src/core/shaders/canvas/utils/render.js.map +1 -1
  37. package/dist/src/core/shaders/webgl/Border.js +12 -11
  38. package/dist/src/core/shaders/webgl/Border.js.map +1 -1
  39. package/dist/src/core/shaders/webgl/HolePunch.js +12 -5
  40. package/dist/src/core/shaders/webgl/HolePunch.js.map +1 -1
  41. package/dist/src/core/shaders/webgl/LinearGradient.js +3 -19
  42. package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
  43. package/dist/src/core/shaders/webgl/RadialGradient.js +3 -20
  44. package/dist/src/core/shaders/webgl/RadialGradient.js.map +1 -1
  45. package/dist/src/core/shaders/webgl/RadialProgress.js +15 -23
  46. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
  47. package/dist/src/core/shaders/webgl/RoundedWithBorder.js +35 -32
  48. package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
  49. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +58 -45
  50. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
  51. package/dist/src/core/shaders/webgl/RoundedWithShadow.js +15 -13
  52. package/dist/src/core/shaders/webgl/RoundedWithShadow.js.map +1 -1
  53. package/dist/src/core/shaders/webgl/Shadow.js +4 -5
  54. package/dist/src/core/shaders/webgl/Shadow.js.map +1 -1
  55. package/dist/src/core/text-rendering/SdfTextRenderer.js +23 -10
  56. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  57. package/dist/src/core/text-rendering/TextLayoutEngine.js +14 -2
  58. package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
  59. package/dist/src/main-api/Renderer.d.ts +28 -5
  60. package/dist/src/main-api/Renderer.js +5 -1
  61. package/dist/src/main-api/Renderer.js.map +1 -1
  62. package/dist/src/utils.js +1 -1
  63. package/dist/src/utils.js.map +1 -1
  64. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  65. package/package.json +1 -1
  66. package/src/core/CoreNode.ts +2 -2
  67. package/src/core/CoreTextureManager.test.ts +147 -0
  68. package/src/core/CoreTextureManager.ts +97 -8
  69. package/src/core/Stage.renderListUpdate.test.ts +73 -0
  70. package/src/core/Stage.ts +13 -1
  71. package/src/core/lib/WebGlContextWrapper.test.ts +23 -1
  72. package/src/core/lib/WebGlContextWrapper.ts +23 -1
  73. package/src/core/lib/colorParser.test.ts +46 -0
  74. package/src/core/lib/colorParser.ts +17 -6
  75. package/src/core/platforms/web/WebPlatform.ts +63 -52
  76. package/src/core/renderers/canvas/CanvasRenderer.test.ts +230 -0
  77. package/src/core/renderers/canvas/CanvasRenderer.ts +29 -15
  78. package/src/core/renderers/canvas/CanvasShaderNode.test.ts +73 -0
  79. package/src/core/renderers/canvas/CanvasShaderNode.ts +1 -0
  80. package/src/core/renderers/webgl/SdfRenderOp.ts +2 -2
  81. package/src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts +122 -0
  82. package/src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts +302 -0
  83. package/src/core/renderers/webgl/WebGlRenderer.ts +151 -11
  84. package/src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts +150 -0
  85. package/src/core/renderers/webgl/WebGlShaderProgram.ts +27 -15
  86. package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +4 -2
  87. package/src/core/renderers/webgl/internal/ShaderUtils.test.ts +124 -0
  88. package/src/core/renderers/webgl/internal/ShaderUtils.ts +14 -13
  89. package/src/core/shaders/canvas/Rounded.ts +3 -3
  90. package/src/core/shaders/canvas/RoundedWithShadow.ts +3 -3
  91. package/src/core/shaders/canvas/utils/render.ts +3 -3
  92. package/src/core/shaders/webgl/Border.ts +12 -11
  93. package/src/core/shaders/webgl/HolePunch.ts +12 -5
  94. package/src/core/shaders/webgl/LinearGradient.ts +3 -19
  95. package/src/core/shaders/webgl/RadialGradient.ts +3 -20
  96. package/src/core/shaders/webgl/RadialProgress.ts +15 -23
  97. package/src/core/shaders/webgl/RoundedWithBorder.ts +35 -32
  98. package/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +58 -45
  99. package/src/core/shaders/webgl/RoundedWithShadow.ts +15 -13
  100. package/src/core/shaders/webgl/Shadow.ts +4 -5
  101. package/src/core/text-rendering/SdfTextRenderer.test.ts +177 -0
  102. package/src/core/text-rendering/SdfTextRenderer.ts +38 -10
  103. package/src/core/text-rendering/TextLayoutEngine.ts +15 -2
  104. package/src/core/text-rendering/tests/TextLayoutEngine.test.ts +99 -0
  105. package/src/main-api/Renderer.ts +35 -6
  106. package/src/utils.ts +1 -1
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Stage } from './Stage.js';
3
+
4
+ /**
5
+ * Tests for the requestRenderListUpdate dedupe.
6
+ *
7
+ * A full Stage needs a live renderer, so the method is exercised on a minimal
8
+ * fake via the prototype. Only the fields requestRenderListUpdate touches are
9
+ * provided: renderer.invalidateQuadBuffer, renderListDirty, requestRender.
10
+ */
11
+
12
+ type FakeStage = {
13
+ renderer: { invalidateQuadBuffer?: ReturnType<typeof vi.fn> };
14
+ renderListDirty: boolean;
15
+ requestRender: ReturnType<typeof vi.fn>;
16
+ };
17
+
18
+ const makeStage = (hasInvalidate = true): FakeStage => ({
19
+ renderer: hasInvalidate ? { invalidateQuadBuffer: vi.fn() } : {},
20
+ renderListDirty: false,
21
+ requestRender: vi.fn(),
22
+ });
23
+
24
+ const requestRenderListUpdate = (stage: FakeStage) =>
25
+ Stage.prototype.requestRenderListUpdate.call(stage as unknown as Stage);
26
+
27
+ describe('Stage.requestRenderListUpdate dedupe', () => {
28
+ it('invalidates the quad buffer and marks the list dirty on first call', () => {
29
+ const stage = makeStage();
30
+
31
+ requestRenderListUpdate(stage);
32
+
33
+ expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(1);
34
+ expect(stage.renderListDirty).toBe(true);
35
+ expect(stage.requestRender.mock.calls.length).toBe(1);
36
+ });
37
+
38
+ it('skips repeat invalidations while a rebuild is already pending', () => {
39
+ const stage = makeStage();
40
+
41
+ requestRenderListUpdate(stage);
42
+ requestRenderListUpdate(stage);
43
+ requestRenderListUpdate(stage);
44
+
45
+ // One structural invalidation for the whole burst (e.g. a row of cards
46
+ // flipping renderable in the same frame), but a render is requested each
47
+ // time so no frame is ever dropped.
48
+ expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(1);
49
+ expect(stage.requestRender.mock.calls.length).toBe(3);
50
+ expect(stage.renderListDirty).toBe(true);
51
+ });
52
+
53
+ it('invalidates again after drawFrame clears the dirty flag', () => {
54
+ const stage = makeStage();
55
+
56
+ requestRenderListUpdate(stage);
57
+ // drawFrame rebuilds the render list and clears the flag
58
+ stage.renderListDirty = false;
59
+ requestRenderListUpdate(stage);
60
+
61
+ expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(2);
62
+ expect(stage.renderListDirty).toBe(true);
63
+ });
64
+
65
+ it('handles renderers without invalidateQuadBuffer (canvas backend)', () => {
66
+ const stage = makeStage(false);
67
+
68
+ requestRenderListUpdate(stage);
69
+
70
+ expect(stage.renderListDirty).toBe(true);
71
+ expect(stage.requestRender.mock.calls.length).toBe(1);
72
+ });
73
+ });
package/src/core/Stage.ts CHANGED
@@ -200,6 +200,7 @@ export class Stage {
200
200
  premultiplyAlphaHonored,
201
201
  platform,
202
202
  maxRetryCount,
203
+ imageDecodeConcurrency,
203
204
  } = options;
204
205
 
205
206
  assertTruthy(
@@ -229,6 +230,7 @@ export class Stage {
229
230
  // undefined -> true (default: assume honored, no probe)
230
231
  premultiplyAlphaHonored: premultiplyAlphaHonored ?? true,
231
232
  maxRetryCount,
233
+ imageDecodeConcurrency,
232
234
  });
233
235
 
234
236
  // Wait for the Texture Manager to initialize
@@ -737,6 +739,17 @@ export class Stage {
737
739
  }
738
740
 
739
741
  requestRenderListUpdate() {
742
+ this.requestRender();
743
+
744
+ // A rebuild is already pending: the invalidation below ran when the flag
745
+ // was first raised, and the render list doesn't change until drawFrame
746
+ // rebuilds it. Skipping the repeat matters when many nodes flip renderable
747
+ // state in one frame (e.g. a whole row scrolling out of the viewport) —
748
+ // invalidateQuadBuffer walks the entire render list on every call.
749
+ if (this.renderListDirty === true) {
750
+ return;
751
+ }
752
+
740
753
  // Notify the renderer that the render list is structurally changing.
741
754
  // For the WebGL renderer this resets per-node buffer slot assignments
742
755
  // and schedules a full GPU buffer re-upload on the next frame.
@@ -744,7 +757,6 @@ export class Stage {
744
757
  this.renderer.invalidateQuadBuffer();
745
758
  }
746
759
  this.renderListDirty = true;
747
- this.requestRender();
748
760
  }
749
761
 
750
762
  buildRenderList(node: CoreNode) {
@@ -19,7 +19,7 @@ function mockWebGl1(hasVaoExtension: boolean): WebGLRenderingContext {
19
19
  deleteVertexArrayOES: (_vao: unknown) => undefined,
20
20
  };
21
21
  const target: Record<string, unknown> = {
22
- canvas: {},
22
+ canvas: { width: 1920, height: 1080 },
23
23
  drawingBufferWidth: 100,
24
24
  drawingBufferHeight: 100,
25
25
  getParameter: () => 8,
@@ -56,3 +56,25 @@ describe('WebGlContextWrapper VAO support', () => {
56
56
  expect(glw.isWebGl2).toBe(false);
57
57
  });
58
58
  });
59
+
60
+ describe('WebGlContextWrapper canvas dimension cache', () => {
61
+ it('seeds canvasW/canvasH from the canvas at construction', () => {
62
+ const glw = new WebGlContextWrapper(mockWebGl1(true));
63
+ expect(glw.canvasW).toBe(1920);
64
+ expect(glw.canvasH).toBe(1080);
65
+ });
66
+
67
+ it('does not track canvas resizes until updateCanvasDimensions is called', () => {
68
+ const glw = new WebGlContextWrapper(mockWebGl1(true));
69
+ const canvas = glw.canvas as { width: number; height: number };
70
+
71
+ canvas.width = 1280;
72
+ canvas.height = 720;
73
+ expect(glw.canvasW).toBe(1920);
74
+ expect(glw.canvasH).toBe(1080);
75
+
76
+ glw.updateCanvasDimensions();
77
+ expect(glw.canvasW).toBe(1280);
78
+ expect(glw.canvasH).toBe(720);
79
+ });
80
+ });
@@ -69,7 +69,18 @@ export class WebGlContextWrapper {
69
69
  //#endregion Vertex Array Objects
70
70
 
71
71
  //#region Canvas
72
- public readonly canvas;
72
+ public readonly canvas: HTMLCanvasElement | OffscreenCanvas;
73
+ /**
74
+ * Cached copies of `canvas.width`/`canvas.height`.
75
+ *
76
+ * @remarks
77
+ * On HTMLCanvasElement these are DOM getters that cross into Blink — too
78
+ * slow to read per render op per frame on embedded targets. The canvas is
79
+ * only resized via Renderer.updateAppDimensions, which refreshes this cache
80
+ * through {@link updateCanvasDimensions}.
81
+ */
82
+ public canvasW: number;
83
+ public canvasH: number;
73
84
  //#endregion Canvas
74
85
 
75
86
  //#region WebGL Enums
@@ -170,6 +181,8 @@ export class WebGlContextWrapper {
170
181
  this.isWebGl2 = this.gl2 !== null;
171
182
 
172
183
  this.canvas = gl.canvas;
184
+ this.canvasW = gl.canvas.width;
185
+ this.canvasH = gl.canvas.height;
173
186
 
174
187
  // Extract GLenums
175
188
  this.MAX_RENDERBUFFER_SIZE = gl.MAX_RENDERBUFFER_SIZE;
@@ -447,6 +460,15 @@ export class WebGlContextWrapper {
447
460
  this.gl.viewport(x, y, width, height);
448
461
  }
449
462
 
463
+ /**
464
+ * Refresh {@link canvasW}/{@link canvasH} from the canvas element.
465
+ * Must be called after the canvas is resized.
466
+ */
467
+ updateCanvasDimensions() {
468
+ this.canvasW = this.canvas.width;
469
+ this.canvasH = this.canvas.height;
470
+ }
471
+
450
472
  /**
451
473
  * ```
452
474
  * gl.clearColor(red, green, blue, alpha);
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseColor } from './colorParser.js';
3
+
4
+ describe('parseColor', () => {
5
+ it('returns the white singleton for opaque white', () => {
6
+ const first = parseColor(0xffffffff);
7
+ const second = parseColor(0xffffffff);
8
+
9
+ expect(first).toBe(second);
10
+ expect(first.isWhite).toBe(true);
11
+ expect(first.a).toBe(1);
12
+ expect(first.r).toBe(0xff);
13
+ expect(first.g).toBe(0xff);
14
+ expect(first.b).toBe(0xff);
15
+ });
16
+
17
+ it('extracts abgr components', () => {
18
+ const color = parseColor(0x80102030);
19
+
20
+ expect(color.isWhite).toBe(false);
21
+ expect(color.a).toBe(0x80 / 255);
22
+ expect(color.b).toBe(0x10);
23
+ expect(color.g).toBe(0x20);
24
+ expect(color.r).toBe(0x30);
25
+ });
26
+
27
+ it('reuses one scratch object across calls', () => {
28
+ const first = parseColor(0x80102030);
29
+ const second = parseColor(0xff405060);
30
+
31
+ expect(second).toBe(first);
32
+ expect(second.a).toBe(1);
33
+ expect(second.b).toBe(0x40);
34
+ expect(second.g).toBe(0x50);
35
+ expect(second.r).toBe(0x60);
36
+ });
37
+
38
+ it('never returns the white singleton as the scratch object', () => {
39
+ const white = parseColor(0xffffffff);
40
+ const tinted = parseColor(0x80102030);
41
+
42
+ expect(tinted).not.toBe(white);
43
+ expect(white.isWhite).toBe(true);
44
+ expect(white.r).toBe(0xff);
45
+ });
46
+ });
@@ -14,18 +14,29 @@ const WHITE: IParsedColor = {
14
14
  b: 0xff,
15
15
  };
16
16
 
17
+ const SCRATCH: IParsedColor = {
18
+ isWhite: false,
19
+ a: 1,
20
+ r: 0,
21
+ g: 0,
22
+ b: 0,
23
+ };
24
+
17
25
  /**
18
- * Extract color components
26
+ * Extract color components.
27
+ *
28
+ * Returns a shared scratch object to avoid a per-call allocation in the
29
+ * canvas frame loop — consume it synchronously, never retain it.
19
30
  */
20
31
  export function parseColor(abgr: number): IParsedColor {
21
32
  if (abgr === 0xffffffff) {
22
33
  return WHITE;
23
34
  }
24
- const a = ((abgr >>> 24) & 0xff) / 255;
25
- const b = (abgr >>> 16) & 0xff & 0xff;
26
- const g = (abgr >>> 8) & 0xff & 0xff;
27
- const r = abgr & 0xff & 0xff;
28
- return { isWhite: false, a, r, g, b };
35
+ SCRATCH.a = ((abgr >>> 24) & 0xff) / 255;
36
+ SCRATCH.b = (abgr >>> 16) & 0xff;
37
+ SCRATCH.g = (abgr >>> 8) & 0xff;
38
+ SCRATCH.r = abgr & 0xff;
39
+ return SCRATCH;
29
40
  }
30
41
 
31
42
  export function parseToAbgrString(abgr: number): string {
@@ -29,9 +29,24 @@ export class WebPlatform extends Platform {
29
29
  override startLoop(stage: Stage): void {
30
30
  let isIdle = false;
31
31
  let lastFrameTime = 0;
32
+ // Set by `tick` once it has queued the next frame; read by `runLoop`'s catch
33
+ // so a throw after scheduling never double-schedules (which would compound
34
+ // into runaway frames if it threw every frame). Reset at the top of each frame.
35
+ let scheduled = false;
32
36
  const buffer = 4;
33
37
 
34
- const runLoop = (currentTime: number = 0) => {
38
+ const requestLoop = () => requestAnimationFrame(runLoop);
39
+
40
+ // All per-frame work lives in `tick`, deliberately split out of `runLoop`.
41
+ // `runLoop` carries the try/catch that keeps the loop alive, and some
42
+ // optimizing compilers — notably Chrome 38's Crankshaft, our language floor —
43
+ // refuse to optimize any function that contains a try/catch. Keeping that
44
+ // guard in a near-empty shell confines the de-opt to the shell: every hot
45
+ // operation (the `stage.*` calls below, run every frame) stays in fully
46
+ // optimizable functions. `tick` is also where all client-supplied code runs
47
+ // (synchronous event subscribers, the events drained by `flushFrameEvents`,
48
+ // and animation steps) — i.e. everything that can actually throw.
49
+ const tick = (currentTime: number) => {
35
50
  // The GL context is lost and the engine does not rebuild it in place.
36
51
  // Stop the loop entirely (no reschedule) so we issue no GL calls against
37
52
  // a dead context. Recovery is via app reload (see the `contextLost` event).
@@ -55,6 +70,7 @@ export class WebPlatform extends Platform {
55
70
  } else {
56
71
  requestAnimationFrame(runLoop);
57
72
  }
73
+ scheduled = true;
58
74
  return;
59
75
  }
60
76
 
@@ -64,65 +80,62 @@ export class WebPlatform extends Platform {
64
80
  lastFrameTime = currentTime;
65
81
  }
66
82
 
67
- // From here on the frame runs client-supplied code: synchronous event
68
- // subscribers (`frameTick`, `idle`, and the queued events drained by
69
- // `flushFrameEvents`) and animation steps. A throw in any of these would
70
- // otherwise propagate out of the rAF callback and permanently stop the
71
- // render loop — the whole app freezes until reload. Guard the body and
72
- // always keep the loop alive: hand the error to `handleLoopError` (a
73
- // no-op by default; the app can override it to log/report) and reschedule.
74
- // `scheduled` tracks whether the next tick was already queued before the
75
- // throw so we never double-schedule (which would compound into runaway
76
- // frames if it threw every frame).
77
- let scheduled = false;
78
- try {
79
- stage.updateFrameTime();
80
- const hasActiveAnimations = stage.updateAnimations();
81
-
82
- if (!stage.hasSceneUpdates()) {
83
- // We still need to calculate the fps else it looks like the app is frozen
84
- stage.calculateFps();
83
+ stage.updateFrameTime();
84
+ const hasActiveAnimations = stage.updateAnimations();
85
85
 
86
- // We use 15ms instead of 16.6ms to provide a safety buffer.
87
- // This ensures we wake up slightly before the next frame to check for updates,
88
- // preventing us from missing a frame due to timer variances.
89
- setTimeout(requestLoop, Math.max(targetFrameTime, 15));
90
- scheduled = true;
86
+ if (!stage.hasSceneUpdates()) {
87
+ // We still need to calculate the fps else it looks like the app is frozen
88
+ stage.calculateFps();
91
89
 
92
- if (isIdle === false) {
93
- // The render burst has settled. Probe for a GPU out-of-memory now
94
- // rather than every frame: GL errors accumulate and persist until
95
- // drained, so a single check here still catches any OOM raised during
96
- // the active frames, without paying the getError() CPU/GPU sync on
97
- // every frame. Queues the `outOfMemory` event, flushed below.
98
- if (stage.renderer.checkForOutOfMemory() === true) {
99
- stage.txMemManager.handleOutOfMemory();
100
- }
101
- stage.shManager.cleanup();
102
- stage.cleanupTextRenderers();
103
- stage.eventBus.emit('idle');
104
- isIdle = true;
105
- }
90
+ // We use 15ms instead of 16.6ms to provide a safety buffer.
91
+ // This ensures we wake up slightly before the next frame to check for updates,
92
+ // preventing us from missing a frame due to timer variances.
93
+ setTimeout(requestLoop, Math.max(targetFrameTime, 15));
94
+ scheduled = true;
106
95
 
107
- if (stage.txMemManager.checkCleanup() === true) {
108
- stage.txMemManager.cleanup();
96
+ if (isIdle === false) {
97
+ // The render burst has settled. Probe for a GPU out-of-memory now
98
+ // rather than every frame: GL errors accumulate and persist until
99
+ // drained, so a single check here still catches any OOM raised during
100
+ // the active frames, without paying the getError() CPU/GPU sync on
101
+ // every frame. Queues the `outOfMemory` event, flushed below.
102
+ if (stage.renderer.checkForOutOfMemory() === true) {
103
+ stage.txMemManager.handleOutOfMemory();
109
104
  }
105
+ stage.shManager.cleanup();
106
+ stage.cleanupTextRenderers();
107
+ stage.eventBus.emit('idle');
108
+ isIdle = true;
109
+ }
110
110
 
111
- stage.flushFrameEvents();
112
- return;
111
+ if (stage.txMemManager.checkCleanup() === true) {
112
+ stage.txMemManager.cleanup();
113
113
  }
114
114
 
115
- isIdle = false;
116
- stage.drawFrame(hasActiveAnimations);
117
115
  stage.flushFrameEvents();
116
+ return;
117
+ }
118
118
 
119
- // Schedule next frame
120
- requestAnimationFrame(runLoop);
121
- scheduled = true;
119
+ isIdle = false;
120
+ stage.drawFrame(hasActiveAnimations);
121
+ stage.flushFrameEvents();
122
+
123
+ // Schedule next frame
124
+ requestAnimationFrame(runLoop);
125
+ scheduled = true;
126
+ };
127
+
128
+ const runLoop = (currentTime: number = 0) => {
129
+ // `tick` runs client-supplied code every frame; a throw there would
130
+ // otherwise propagate out of the rAF callback and permanently stop the
131
+ // render loop — the whole app freezes until reload. Guard it and always
132
+ // keep the loop alive: hand the error to `handleLoopError` (a no-op by
133
+ // default; the app can override it to log/report) and reschedule unless
134
+ // `tick` already queued the next frame before throwing.
135
+ scheduled = false;
136
+ try {
137
+ tick(currentTime);
122
138
  } catch (error: unknown) {
123
- // Report the error (default handler is a no-op), then keep the loop
124
- // alive — a single bad frame must never freeze the app. Skip the
125
- // reschedule if this frame already queued the next tick before throwing.
126
139
  const handleLoopError = stage.options.handleLoopError;
127
140
  if (handleLoopError !== undefined) {
128
141
  handleLoopError(error);
@@ -133,8 +146,6 @@ export class WebPlatform extends Platform {
133
146
  }
134
147
  };
135
148
 
136
- const requestLoop = () => requestAnimationFrame(runLoop);
137
-
138
149
  requestAnimationFrame(runLoop);
139
150
  }
140
151
 
@@ -33,6 +33,236 @@ describe('CanvasRenderer.renderContext', () => {
33
33
  });
34
34
  });
35
35
 
36
+ describe('CanvasRenderer.addQuad transform detection', () => {
37
+ const makeRenderer = () => {
38
+ const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
39
+ const context = {
40
+ save: vi.fn(),
41
+ restore: vi.fn(),
42
+ setTransform: vi.fn(),
43
+ scale: vi.fn(),
44
+ translate: vi.fn(),
45
+ fillRect: vi.fn(),
46
+ clip: vi.fn(),
47
+ fillStyle: '',
48
+ globalAlpha: 1,
49
+ };
50
+ (renderer as any).context = context;
51
+ (renderer as any).pixelRatio = 1;
52
+ (renderer as any).stage = { defaultTexture: null };
53
+ return { renderer, context };
54
+ };
55
+
56
+ const makeColorNode = (transform: {
57
+ tx: number;
58
+ ty: number;
59
+ ta: number;
60
+ tb: number;
61
+ tc: number;
62
+ td: number;
63
+ }) =>
64
+ ({
65
+ globalTransform: transform,
66
+ clippingRect: { valid: false, x: 0, y: 0, w: 0, h: 0 },
67
+ placeholderActive: false,
68
+ premultipliedColorTl: 0xff0000ff,
69
+ premultipliedColorTr: 0xff0000ff,
70
+ premultipliedColorBr: 0xff0000ff,
71
+ worldAlpha: 1,
72
+ props: {
73
+ texture: { type: TextureType.color },
74
+ shader: null,
75
+ w: 100,
76
+ h: 50,
77
+ },
78
+ } as any);
79
+
80
+ it('applies the transform for a scaleY-only transform (td !== 1)', () => {
81
+ const { renderer, context } = makeRenderer();
82
+ const node = makeColorNode({ tx: 5, ty: 10, ta: 1, tb: 0, tc: 0, td: 2 });
83
+
84
+ renderer.addQuad(node);
85
+
86
+ expect(context.setTransform).toHaveBeenCalledWith(1, 0, 0, 2, 5, 10);
87
+ expect(context.save).toHaveBeenCalledTimes(1);
88
+ expect(context.restore).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it('applies the transform for a rotation-only transform (tb/tc !== 0)', () => {
92
+ const { renderer, context } = makeRenderer();
93
+ const node = makeColorNode({ tx: 0, ty: 0, ta: 1, tb: -1, tc: 1, td: 1 });
94
+
95
+ renderer.addQuad(node);
96
+
97
+ expect(context.setTransform).toHaveBeenCalledWith(1, 1, -1, 1, 0, 0);
98
+ });
99
+
100
+ it('skips the transform for an identity transform', () => {
101
+ const { renderer, context } = makeRenderer();
102
+ const node = makeColorNode({ tx: 5, ty: 10, ta: 1, tb: 0, tc: 0, td: 1 });
103
+
104
+ renderer.addQuad(node);
105
+
106
+ expect(context.setTransform).not.toHaveBeenCalled();
107
+ expect(context.save).not.toHaveBeenCalled();
108
+ expect(context.fillRect).toHaveBeenCalledWith(5, 10, 100, 50);
109
+ });
110
+ });
111
+
112
+ describe('CanvasRenderer.reset', () => {
113
+ const makeRenderer = (clearColorAlpha: number) => {
114
+ const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
115
+ const context = {
116
+ setTransform: vi.fn(),
117
+ clearRect: vi.fn(),
118
+ fillRect: vi.fn(),
119
+ scale: vi.fn(),
120
+ fillStyle: '',
121
+ };
122
+ let widthWrites = 0;
123
+ const canvas = {
124
+ height: 100,
125
+ get width() {
126
+ return 200;
127
+ },
128
+ set width(_v: number) {
129
+ widthWrites++;
130
+ },
131
+ };
132
+ (renderer as any).context = context;
133
+ (renderer as any).canvas = canvas;
134
+ (renderer as any).pixelRatio = 2;
135
+ (renderer as any).clearColor = 'rgba(0,0,0,0)';
136
+ (renderer as any).clearColorAlpha = clearColorAlpha;
137
+ return { renderer, context, getWidthWrites: () => widthWrites };
138
+ };
139
+
140
+ it('clears via setTransform + clearRect without reallocating the canvas', () => {
141
+ const { renderer, context, getWidthWrites } = makeRenderer(0);
142
+
143
+ renderer.reset();
144
+
145
+ expect(getWidthWrites()).toBe(0);
146
+ expect(context.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0);
147
+ expect(context.clearRect).toHaveBeenCalledWith(0, 0, 200, 100);
148
+ expect(context.scale).toHaveBeenCalledWith(2, 2);
149
+ });
150
+
151
+ it('skips the clear-color fill when the clear color is fully transparent', () => {
152
+ const { renderer, context } = makeRenderer(0);
153
+
154
+ renderer.reset();
155
+
156
+ expect(context.fillRect).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('fills with the clear color when its alpha is non-zero', () => {
160
+ const { renderer, context } = makeRenderer(0xff);
161
+ (renderer as any).clearColor = 'rgba(16,32,48,1)';
162
+
163
+ renderer.reset();
164
+
165
+ expect(context.fillRect).toHaveBeenCalledWith(0, 0, 200, 100);
166
+ expect(context.fillStyle).toBe('rgba(16,32,48,1)');
167
+ });
168
+
169
+ it('updateClearColor keeps the alpha skip in sync', () => {
170
+ const { renderer, context } = makeRenderer(0xff);
171
+
172
+ renderer.updateClearColor(0x00000000);
173
+ renderer.reset();
174
+
175
+ expect(context.fillRect).not.toHaveBeenCalled();
176
+ });
177
+ });
178
+
179
+ describe('CanvasRenderer.addQuad allocation-free paths', () => {
180
+ const makeRenderer = () => {
181
+ const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
182
+ const context = {
183
+ save: vi.fn(),
184
+ restore: vi.fn(),
185
+ setTransform: vi.fn(),
186
+ scale: vi.fn(),
187
+ translate: vi.fn(),
188
+ fillRect: vi.fn(),
189
+ beginPath: vi.fn(),
190
+ rect: vi.fn(),
191
+ clip: vi.fn(),
192
+ fillStyle: '',
193
+ globalAlpha: 1,
194
+ };
195
+ (renderer as any).context = context;
196
+ (renderer as any).pixelRatio = 1;
197
+ (renderer as any).stage = { defaultTexture: null };
198
+ return { renderer, context };
199
+ };
200
+
201
+ const makeColorNode = (shader: unknown = null) =>
202
+ ({
203
+ globalTransform: { tx: 5, ty: 10, ta: 1, tb: 0, tc: 0, td: 1 },
204
+ clippingRect: { valid: true, x: 0, y: 0, w: 300, h: 200 },
205
+ placeholderActive: false,
206
+ premultipliedColorTl: 0xff0000ff,
207
+ premultipliedColorTr: 0xff0000ff,
208
+ premultipliedColorBr: 0xff0000ff,
209
+ worldAlpha: 1,
210
+ props: {
211
+ texture: { type: TextureType.color },
212
+ shader,
213
+ w: 100,
214
+ h: 50,
215
+ },
216
+ } as any);
217
+
218
+ it('clips with beginPath/rect/clip instead of a Path2D', () => {
219
+ const { renderer, context } = makeRenderer();
220
+
221
+ renderer.addQuad(makeColorNode());
222
+
223
+ expect(context.beginPath).toHaveBeenCalledTimes(1);
224
+ expect(context.rect).toHaveBeenCalledWith(0, 0, 300, 200);
225
+ expect(context.clip).toHaveBeenCalledWith();
226
+ expect(context.fillRect).toHaveBeenCalledWith(5, 10, 100, 50);
227
+ });
228
+
229
+ it('passes the same preallocated renderContext callback to every shader', () => {
230
+ // Real construction (not Object.create) so class-field initializers run —
231
+ // the preallocated callback is a field arrow function.
232
+ const context = {
233
+ save: vi.fn(),
234
+ restore: vi.fn(),
235
+ fillRect: vi.fn(),
236
+ beginPath: vi.fn(),
237
+ rect: vi.fn(),
238
+ clip: vi.fn(),
239
+ fillStyle: '',
240
+ globalAlpha: 1,
241
+ };
242
+ const renderer = new CanvasRenderer({
243
+ stage: { pixelRatio: 1, clearColor: 0x00000000, defaultTexture: null },
244
+ canvas: { getContext: () => context },
245
+ } as any);
246
+ const seen: Array<() => void> = [];
247
+ const shader = {
248
+ applySNR: false,
249
+ render: vi.fn((_ctx: unknown, _node: unknown, rc: () => void) => {
250
+ seen.push(rc);
251
+ rc();
252
+ }),
253
+ };
254
+
255
+ renderer.addQuad(makeColorNode(shader));
256
+ renderer.addQuad(makeColorNode(shader));
257
+
258
+ expect(seen.length).toBe(2);
259
+ expect(seen[0]).toBe(seen[1]);
260
+ // The callback drew each node's content when invoked
261
+ expect(context.fillRect).toHaveBeenCalledTimes(2);
262
+ expect(context.fillRect).toHaveBeenCalledWith(5, 10, 100, 50);
263
+ });
264
+ });
265
+
36
266
  describe('CanvasRenderer.getCapabilities', () => {
37
267
  it('reports the canvas backend with no WebGL or VAO support', () => {
38
268
  const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;