@solidtv/renderer 1.5.5 → 1.6.0

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/Stage.js +9 -1
  4. package/dist/src/core/Stage.js.map +1 -1
  5. package/dist/src/core/lib/WebGlContextWrapper.d.ts +17 -1
  6. package/dist/src/core/lib/WebGlContextWrapper.js +21 -0
  7. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  8. package/dist/src/core/lib/colorParser.d.ts +4 -1
  9. package/dist/src/core/lib/colorParser.js +16 -6
  10. package/dist/src/core/lib/colorParser.js.map +1 -1
  11. package/dist/src/core/lib/validateImageBitmap.js +7 -0
  12. package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
  13. package/dist/src/core/platforms/web/WebPlatform.js +39 -2
  14. package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
  15. package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +4 -0
  16. package/dist/src/core/renderers/canvas/CanvasRenderer.js +28 -13
  17. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  18. package/dist/src/core/renderers/canvas/CanvasShaderNode.js +1 -0
  19. package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
  20. package/dist/src/core/renderers/webgl/SdfRenderOp.js +2 -2
  21. package/dist/src/core/renderers/webgl/SdfRenderOp.js.map +1 -1
  22. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +43 -0
  23. package/dist/src/core/renderers/webgl/WebGlRenderer.js +109 -10
  24. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  25. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +21 -13
  26. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  27. package/dist/src/core/renderers/webgl/internal/ShaderUtils.d.ts +8 -3
  28. package/dist/src/core/renderers/webgl/internal/ShaderUtils.js +12 -11
  29. package/dist/src/core/renderers/webgl/internal/ShaderUtils.js.map +1 -1
  30. package/dist/src/core/shaders/canvas/Rounded.js +3 -3
  31. package/dist/src/core/shaders/canvas/Rounded.js.map +1 -1
  32. package/dist/src/core/shaders/canvas/RoundedWithShadow.js +3 -3
  33. package/dist/src/core/shaders/canvas/RoundedWithShadow.js.map +1 -1
  34. package/dist/src/core/shaders/canvas/utils/render.js +3 -3
  35. package/dist/src/core/shaders/canvas/utils/render.js.map +1 -1
  36. package/dist/src/core/shaders/webgl/Border.js +12 -11
  37. package/dist/src/core/shaders/webgl/Border.js.map +1 -1
  38. package/dist/src/core/shaders/webgl/HolePunch.js +12 -5
  39. package/dist/src/core/shaders/webgl/HolePunch.js.map +1 -1
  40. package/dist/src/core/shaders/webgl/LinearGradient.js +3 -19
  41. package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
  42. package/dist/src/core/shaders/webgl/RadialGradient.js +3 -20
  43. package/dist/src/core/shaders/webgl/RadialGradient.js.map +1 -1
  44. package/dist/src/core/shaders/webgl/RadialProgress.js +15 -23
  45. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
  46. package/dist/src/core/shaders/webgl/RoundedWithBorder.js +35 -32
  47. package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
  48. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +58 -45
  49. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
  50. package/dist/src/core/shaders/webgl/RoundedWithShadow.js +15 -13
  51. package/dist/src/core/shaders/webgl/RoundedWithShadow.js.map +1 -1
  52. package/dist/src/core/shaders/webgl/Shadow.js +4 -5
  53. package/dist/src/core/shaders/webgl/Shadow.js.map +1 -1
  54. package/dist/src/core/text-rendering/SdfTextRenderer.js +23 -10
  55. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  56. package/dist/src/core/text-rendering/TextLayoutEngine.js +14 -2
  57. package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
  58. package/dist/src/main-api/Renderer.d.ts +20 -0
  59. package/dist/src/main-api/Renderer.js +6 -0
  60. package/dist/src/main-api/Renderer.js.map +1 -1
  61. package/dist/src/utils.js +1 -1
  62. package/dist/src/utils.js.map +1 -1
  63. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  64. package/package.json +1 -1
  65. package/src/core/CoreNode.ts +2 -2
  66. package/src/core/Stage.renderListUpdate.test.ts +73 -0
  67. package/src/core/Stage.ts +11 -1
  68. package/src/core/lib/WebGlContextWrapper.test.ts +23 -1
  69. package/src/core/lib/WebGlContextWrapper.ts +23 -1
  70. package/src/core/lib/colorParser.test.ts +46 -0
  71. package/src/core/lib/colorParser.ts +17 -6
  72. package/src/core/lib/validateImageBitmap.test.ts +15 -0
  73. package/src/core/lib/validateImageBitmap.ts +8 -0
  74. package/src/core/platforms/web/WebPlatform.loopError.test.ts +145 -0
  75. package/src/core/platforms/web/WebPlatform.ts +39 -2
  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 +28 -0
  106. package/src/utils.ts +1 -1
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tests the `handleLoopError` escape hatch: a synchronous throw inside a frame
3
+ * (event subscriber, animation step, draw) must never propagate out of the
4
+ * `requestAnimationFrame` callback and freeze the loop. By default the error is
5
+ * swallowed and the loop keeps running; a registered handler is invoked with the
6
+ * error and the loop still survives.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+ import { WebPlatform } from './WebPlatform.js';
10
+ import type { Stage } from '../../Stage.js';
11
+
12
+ interface MakeStageOptions {
13
+ idle?: boolean;
14
+ handleLoopError?: (error: unknown) => void;
15
+ throwIn?: 'drawFrame' | 'emitIdle';
16
+ error?: unknown;
17
+ }
18
+
19
+ function makeStage(opts: MakeStageOptions = {}) {
20
+ const idle = opts.idle ?? false;
21
+ const error = opts.error ?? new Error('frame boom');
22
+
23
+ const drawFrame = vi.fn(() => {
24
+ if (opts.throwIn === 'drawFrame') {
25
+ throw error;
26
+ }
27
+ });
28
+ const emit = vi.fn(() => {
29
+ if (opts.throwIn === 'emitIdle') {
30
+ throw error;
31
+ }
32
+ });
33
+
34
+ const stage = {
35
+ isContextLost: false,
36
+ targetFrameTime: 0,
37
+ updateFrameTime: vi.fn(),
38
+ updateAnimations: vi.fn(() => false),
39
+ hasSceneUpdates: vi.fn(() => !idle),
40
+ calculateFps: vi.fn(),
41
+ drawFrame,
42
+ flushFrameEvents: vi.fn(),
43
+ shManager: { cleanup: vi.fn() },
44
+ cleanupTextRenderers: vi.fn(),
45
+ eventBus: { emit },
46
+ txMemManager: {
47
+ checkCleanup: vi.fn(() => false),
48
+ cleanup: vi.fn(),
49
+ handleOutOfMemory: vi.fn(),
50
+ },
51
+ renderer: { checkForOutOfMemory: vi.fn(() => false) },
52
+ options: { handleLoopError: opts.handleLoopError },
53
+ } as unknown as Stage;
54
+
55
+ return { stage, drawFrame, emit };
56
+ }
57
+
58
+ describe('WebPlatform render loop — handleLoopError escape hatch', () => {
59
+ afterEach(() => {
60
+ vi.unstubAllGlobals();
61
+ });
62
+
63
+ // Stubs raf/setTimeout, starts the loop, and returns a `runFrame` that drives
64
+ // exactly one frame plus the raf/setTimeout call counts so far.
65
+ function harness(stage: Stage) {
66
+ let capturedLoop: ((t?: number) => void) | null = null;
67
+ const raf = vi.fn((cb: (t?: number) => void) => {
68
+ capturedLoop = cb;
69
+ return 1;
70
+ });
71
+ const timeout = vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>);
72
+ vi.stubGlobal('requestAnimationFrame', raf);
73
+ vi.stubGlobal('setTimeout', timeout);
74
+
75
+ new WebPlatform().startLoop(stage);
76
+
77
+ return {
78
+ runFrame: () => capturedLoop!(0),
79
+ rafCount: () => raf.mock.calls.length,
80
+ timeoutCount: () => timeout.mock.calls.length,
81
+ };
82
+ }
83
+
84
+ it('routes an active-frame error to the handler and keeps the loop alive', () => {
85
+ const error = new Error('draw boom');
86
+ const handleLoopError = vi.fn();
87
+ const { stage } = makeStage({
88
+ throwIn: 'drawFrame',
89
+ error,
90
+ handleLoopError,
91
+ });
92
+
93
+ const h = harness(stage);
94
+ expect(h.rafCount()).toBe(1); // initial schedule from startLoop
95
+
96
+ expect(() => h.runFrame()).not.toThrow();
97
+
98
+ expect(handleLoopError).toHaveBeenCalledTimes(1);
99
+ expect(handleLoopError).toHaveBeenCalledWith(error);
100
+ expect(h.rafCount()).toBe(2); // rescheduled the next frame
101
+ });
102
+
103
+ it('swallows the error and keeps the loop alive when no handler is registered', () => {
104
+ const error = new Error('draw boom');
105
+ const { stage } = makeStage({ throwIn: 'drawFrame', error });
106
+
107
+ const h = harness(stage);
108
+
109
+ // Default behaviour: the error is swallowed, never propagated, and the loop
110
+ // reschedules so the app does not freeze.
111
+ expect(() => h.runFrame()).not.toThrow();
112
+ expect(h.rafCount()).toBe(2); // rescheduled despite the error
113
+ });
114
+
115
+ it('does not double-schedule when an idle-path error follows the idle reschedule', () => {
116
+ const handleLoopError = vi.fn();
117
+ const { stage } = makeStage({
118
+ idle: true,
119
+ throwIn: 'emitIdle',
120
+ handleLoopError,
121
+ });
122
+
123
+ const h = harness(stage);
124
+
125
+ expect(() => h.runFrame()).not.toThrow();
126
+
127
+ expect(handleLoopError).toHaveBeenCalledTimes(1);
128
+ // The idle path already queued the next tick via setTimeout before the throw,
129
+ // so the catch must NOT queue an extra rAF.
130
+ expect(h.timeoutCount()).toBe(1);
131
+ expect(h.rafCount()).toBe(1);
132
+ });
133
+
134
+ it('does not touch the handler on a clean frame and reschedules normally', () => {
135
+ const handleLoopError = vi.fn();
136
+ const { stage, drawFrame } = makeStage({ handleLoopError });
137
+
138
+ const h = harness(stage);
139
+ h.runFrame();
140
+
141
+ expect(handleLoopError).not.toHaveBeenCalled();
142
+ expect(drawFrame).toHaveBeenCalledTimes(1);
143
+ expect(h.rafCount()).toBe(2);
144
+ });
145
+ });
@@ -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
 
@@ -75,6 +91,7 @@ export class WebPlatform extends Platform {
75
91
  // This ensures we wake up slightly before the next frame to check for updates,
76
92
  // preventing us from missing a frame due to timer variances.
77
93
  setTimeout(requestLoop, Math.max(targetFrameTime, 15));
94
+ scheduled = true;
78
95
 
79
96
  if (isIdle === false) {
80
97
  // The render burst has settled. Probe for a GPU out-of-memory now
@@ -105,9 +122,29 @@ export class WebPlatform extends Platform {
105
122
 
106
123
  // Schedule next frame
107
124
  requestAnimationFrame(runLoop);
125
+ scheduled = true;
108
126
  };
109
127
 
110
- const requestLoop = () => requestAnimationFrame(runLoop);
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);
138
+ } catch (error: unknown) {
139
+ const handleLoopError = stage.options.handleLoopError;
140
+ if (handleLoopError !== undefined) {
141
+ handleLoopError(error);
142
+ }
143
+ if (scheduled === false) {
144
+ requestAnimationFrame(runLoop);
145
+ }
146
+ }
147
+ };
111
148
 
112
149
  requestAnimationFrame(runLoop);
113
150
  }
@@ -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;
@@ -17,8 +17,16 @@ export class CanvasRenderer extends CoreRenderer {
17
17
  private canvas: HTMLCanvasElement;
18
18
  private pixelRatio: number;
19
19
  private clearColor: string;
20
+ private clearColorAlpha: number;
20
21
  public renderToTextureActive = false;
21
22
  activeRttNode: CoreNode | null = null;
23
+ // Scratch state for the preallocated shader renderContext callback below —
24
+ // valid only for the duration of the shader render() call in addQuad.
25
+ private shaderContextNode: CoreNode | null = null;
26
+ private shaderContextTexture: Texture | null = null;
27
+ private shaderRenderContext = () => {
28
+ this.renderContext(this.shaderContextNode!, this.shaderContextTexture!);
29
+ };
22
30
 
23
31
  constructor(options: CoreRendererOptions) {
24
32
  super(options);
@@ -29,16 +37,23 @@ export class CanvasRenderer extends CoreRenderer {
29
37
  this.context = canvas.getContext('2d') as CanvasRenderingContext2D;
30
38
  this.pixelRatio = this.stage.pixelRatio;
31
39
  this.clearColor = normalizeCanvasColor(this.stage.clearColor);
40
+ this.clearColorAlpha = (this.stage.clearColor >>> 24) & 0xff;
32
41
  }
33
42
 
34
43
  reset(): void {
35
- this.canvas.width = this.canvas.width; // quick reset canvas
36
-
37
44
  const ctx = this.context;
45
+ const w = this.canvas.width;
46
+ const h = this.canvas.height;
47
+
48
+ // The frame's save/restore pairs are balanced, so resetting the transform
49
+ // and clearing is equivalent to the `canvas.width = canvas.width` trick
50
+ // without tearing down the backing store every frame.
51
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
52
+ ctx.clearRect(0, 0, w, h);
38
53
 
39
- if (this.clearColor) {
54
+ if (this.clearColorAlpha !== 0) {
40
55
  ctx.fillStyle = this.clearColor;
41
- ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
56
+ ctx.fillRect(0, 0, w, h);
42
57
  }
43
58
 
44
59
  ctx.scale(this.pixelRatio, this.pixelRatio);
@@ -72,7 +87,7 @@ export class CanvasRenderer extends CoreRenderer {
72
87
  }
73
88
  }
74
89
 
75
- const hasTransform = ta !== 1;
90
+ const hasTransform = ta !== 1 || td !== 1 || tb !== 0 || tc !== 0;
76
91
  const clippingValid = clippingRect.valid === true;
77
92
 
78
93
  // If the clipping rect is valid but zero-area, the node is fully clipped — skip rendering
@@ -99,10 +114,9 @@ export class CanvasRenderer extends CoreRenderer {
99
114
  }
100
115
 
101
116
  if (hasClipping === true) {
102
- const path = new Path2D();
103
- const { x, y, w, h } = clippingRect;
104
- path.rect(x, y, w, h);
105
- ctx.clip(path);
117
+ ctx.beginPath();
118
+ ctx.rect(clippingRect.x, clippingRect.y, clippingRect.w, clippingRect.h);
119
+ ctx.clip();
106
120
  }
107
121
 
108
122
  if (hasTransform === true) {
@@ -121,12 +135,11 @@ export class CanvasRenderer extends CoreRenderer {
121
135
  }
122
136
 
123
137
  if (hasShader === true) {
124
- let renderContext: (() => void) | null = () => {
125
- this.renderContext(node, texture);
126
- };
127
-
128
- (shader as CanvasShaderNode).render(ctx, node, renderContext);
129
- renderContext = null;
138
+ this.shaderContextNode = node;
139
+ this.shaderContextTexture = texture;
140
+ (shader as CanvasShaderNode).render(ctx, node, this.shaderRenderContext);
141
+ this.shaderContextNode = null;
142
+ this.shaderContextTexture = null;
130
143
  } else {
131
144
  this.renderContext(node, texture);
132
145
  }
@@ -345,6 +358,7 @@ export class CanvasRenderer extends CoreRenderer {
345
358
  */
346
359
  updateClearColor(color: number) {
347
360
  this.clearColor = normalizeCanvasColor(color);
361
+ this.clearColorAlpha = (color >>> 24) & 0xff;
348
362
  }
349
363
 
350
364
  override getTextureCoords(
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { CanvasShaderNode, type CanvasShaderType } from './CanvasShaderNode.js';
3
+ import type { CoreNode } from '../../CoreNode.js';
4
+ import type { Stage } from '../../Stage.js';
5
+
6
+ type TestProps = { radius: number };
7
+
8
+ const makeShaderNode = (props: TestProps) => {
9
+ const shManager = {
10
+ getShaderValues: vi.fn(),
11
+ setShaderValues: vi.fn(),
12
+ mutateShaderValueUsage: vi.fn(),
13
+ };
14
+ const stage = { shManager } as unknown as Stage;
15
+ const updater = vi.fn(function (this: CanvasShaderNode<TestProps>) {
16
+ (this.computed as Record<string, unknown>).value = 'computed';
17
+ });
18
+ const config = {
19
+ props: { radius: 0 },
20
+ render: vi.fn(),
21
+ update: updater,
22
+ } as unknown as CanvasShaderType<TestProps>;
23
+ const shaderNode = new CanvasShaderNode<TestProps>(
24
+ 'test',
25
+ config,
26
+ stage,
27
+ props,
28
+ );
29
+ shaderNode.attachNode({ w: 100, h: 50 } as CoreNode);
30
+ return { shaderNode, shManager, updater };
31
+ };
32
+
33
+ describe('CanvasShaderNode.update value cache', () => {
34
+ it('computes and registers values on a cache miss', () => {
35
+ const { shaderNode, shManager, updater } = makeShaderNode({ radius: 4 });
36
+ shManager.getShaderValues.mockReturnValue(undefined);
37
+
38
+ shaderNode.update!();
39
+
40
+ expect(updater).toHaveBeenCalledTimes(1);
41
+ expect(shManager.setShaderValues).toHaveBeenCalledTimes(1);
42
+ expect(shManager.setShaderValues).toHaveBeenCalledWith(
43
+ shaderNode.valueKey,
44
+ shaderNode.computed,
45
+ );
46
+ expect((shaderNode.computed as Record<string, unknown>).value).toBe(
47
+ 'computed',
48
+ );
49
+ });
50
+
51
+ it('reuses cached values on a cache hit without recomputing', () => {
52
+ const { shaderNode, shManager, updater } = makeShaderNode({ radius: 4 });
53
+ const cached = { value: 'cached' };
54
+ shManager.getShaderValues.mockReturnValue(cached);
55
+
56
+ shaderNode.update!();
57
+
58
+ expect(shaderNode.computed).toBe(cached);
59
+ expect(updater).not.toHaveBeenCalled();
60
+ expect(shManager.setShaderValues).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('skips all work when the value key is unchanged', () => {
64
+ const { shaderNode, shManager, updater } = makeShaderNode({ radius: 4 });
65
+ shManager.getShaderValues.mockReturnValue(undefined);
66
+
67
+ shaderNode.update!();
68
+ shaderNode.update!();
69
+
70
+ expect(updater).toHaveBeenCalledTimes(1);
71
+ expect(shManager.getShaderValues).toHaveBeenCalledTimes(1);
72
+ });
73
+ });
@@ -63,6 +63,7 @@ export class CanvasShaderNode<
63
63
  ) as Record<string, unknown>;
64
64
  if (computed !== undefined) {
65
65
  this.computed = computed as Computed;
66
+ return;
66
67
  }
67
68
  this.computed = {};
68
69
  this.updater!(this.node as CoreNode);
@@ -60,7 +60,7 @@ export class SdfRenderOp extends CoreRenderOp {
60
60
  }
61
61
 
62
62
  draw() {
63
- const { glw, options, stage } = this.renderer;
63
+ const { glw, stage } = this.renderer;
64
64
 
65
65
  stage.shManager.useShader(this.shader.program);
66
66
  this.shader.program.bindRenderOp(this);
@@ -73,7 +73,7 @@ export class SdfRenderOp extends CoreRenderOp {
73
73
  const clipWidth = Math.round(this.clippingRect.w * pixelRatio);
74
74
  const clipHeight = Math.round(this.clippingRect.h * pixelRatio);
75
75
  let clipY = Math.round(
76
- options.canvas.height - clipHeight - this.clippingRect.y * pixelRatio,
76
+ glw.canvasH - clipHeight - this.clippingRect.y * pixelRatio,
77
77
  );
78
78
  // if parent has render texture, we need to adjust the scissor rect
79
79
  // to be relative to the parent's framebuffer