@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,122 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { WebGlRenderer } from './WebGlRenderer.js';
3
+
4
+ /**
5
+ * Tests for the shader-node batching gate in WebGlRenderer.reuseRenderOp.
6
+ *
7
+ * A full renderer needs a live GL context, so the method is exercised on a
8
+ * minimal fake `this` holding only curRenderOp. Nodes and shader nodes are
9
+ * plain objects with the fields reuseRenderOp reads.
10
+ */
11
+
12
+ const clippingRect = { x: 0, y: 0, w: 0, h: 0, valid: false };
13
+
14
+ type FakeShaderNode = {
15
+ shaderKey: string;
16
+ program: { reuseRenderOp: ReturnType<typeof vi.fn> };
17
+ uniforms: object;
18
+ resolvedProps: Record<string, unknown>;
19
+ };
20
+
21
+ const makeShaderNode = (
22
+ program: FakeShaderNode['program'],
23
+ uniforms: object,
24
+ shaderKey = 'Rounded',
25
+ ): FakeShaderNode => ({
26
+ shaderKey,
27
+ program,
28
+ uniforms,
29
+ resolvedProps: { radius: [16, 16, 16, 16] },
30
+ });
31
+
32
+ const makeProgram = (batches = true) => ({
33
+ reuseRenderOp: vi.fn(() => batches),
34
+ });
35
+
36
+ // Node shape shared by the op anchor and the incoming quad
37
+ const makeNode = (shader: FakeShaderNode) => ({
38
+ isCoreNode: true,
39
+ parentHasRenderTexture: false,
40
+ parentFramebufferDimensions: null,
41
+ rtt: false,
42
+ clippingRect,
43
+ props: { rtt: false, shader },
44
+ shader,
45
+ });
46
+
47
+ const reuse = (curRenderOp: unknown, node: unknown): boolean =>
48
+ WebGlRenderer.prototype.reuseRenderOp.call(
49
+ { curRenderOp } as WebGlRenderer,
50
+ node as never,
51
+ );
52
+
53
+ describe('WebGlRenderer.reuseRenderOp shader batching', () => {
54
+ it('rejects when there is no current render op', () => {
55
+ const shader = makeShaderNode(makeProgram(), {});
56
+ expect(reuse(null, makeNode(shader))).toBe(false);
57
+ });
58
+
59
+ it('batches two quads using the same shader node', () => {
60
+ const shader = makeShaderNode(makeProgram(), {});
61
+
62
+ expect(reuse(makeNode(shader), makeNode(shader))).toBe(true);
63
+ expect(shader.program.reuseRenderOp.mock.calls.length).toBe(1);
64
+ });
65
+
66
+ it('batches distinct shader nodes sharing program and uniform collection', () => {
67
+ const program = makeProgram();
68
+ const uniforms = {};
69
+ const shaderA = makeShaderNode(program, uniforms);
70
+ const shaderB = makeShaderNode(program, uniforms);
71
+
72
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(true);
73
+ });
74
+
75
+ it('rejects distinct shader nodes with different uniform collections', () => {
76
+ const program = makeProgram();
77
+ const shaderA = makeShaderNode(program, {});
78
+ const shaderB = makeShaderNode(program, {});
79
+
80
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
81
+ // Gate rejects before the program-level prop compare runs
82
+ expect(program.reuseRenderOp.mock.calls.length).toBe(0);
83
+ });
84
+
85
+ it('rejects equal uniform collections from different programs', () => {
86
+ // The shader value key does not include the shader type, so a collection
87
+ // shared across programs (key collision) must never merge draw calls.
88
+ const uniforms = {};
89
+ const shaderA = makeShaderNode(makeProgram(), uniforms, 'TypeA');
90
+ const shaderB = makeShaderNode(makeProgram(), uniforms, 'TypeB');
91
+
92
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
93
+ });
94
+
95
+ it('batches default-shader quads without consulting the program', () => {
96
+ const shaderA = makeShaderNode(makeProgram(), {}, 'default');
97
+ const shaderB = makeShaderNode(makeProgram(), {}, 'default');
98
+
99
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(true);
100
+ expect(shaderA.program.reuseRenderOp.mock.calls.length).toBe(0);
101
+ });
102
+
103
+ it('rejects when clipping rects differ', () => {
104
+ const shader = makeShaderNode(makeProgram(), {});
105
+ const node = makeNode(shader);
106
+ node.clippingRect = { x: 0, y: 0, w: 100, h: 100, valid: true };
107
+
108
+ expect(reuse(makeNode(shader), node)).toBe(false);
109
+ });
110
+
111
+ it('defers to the program-level check for batched shader nodes', () => {
112
+ const program = makeProgram(false);
113
+ const uniforms = {};
114
+ const shaderA = makeShaderNode(program, uniforms);
115
+ const shaderB = makeShaderNode(program, uniforms);
116
+
117
+ // Same program + shared collection passes the gate, but the program can
118
+ // still veto (e.g. worldAlpha mismatch on a u_alpha shader).
119
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
120
+ expect(program.reuseRenderOp.mock.calls.length).toBe(1);
121
+ });
122
+ });
@@ -0,0 +1,302 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { WebGlRenderer } from './WebGlRenderer.js';
3
+
4
+ /**
5
+ * Tests for the SDF buffer write paths and the upload skip.
6
+ *
7
+ * A full renderer needs a live GL context, so methods are exercised on a
8
+ * minimal fake `this` via the prototype, holding only the fields each method
9
+ * touches.
10
+ */
11
+
12
+ const FLOATS_PER_GLYPH = 24; // 4 vertices x 6 floats
13
+
14
+ /**
15
+ * Build one glyph (4 vertices) of cached vertex data with a bit-pattern color.
16
+ * Layout per vertex: [x, y, u, v, color(uint32), distRange].
17
+ */
18
+ const makeCachedGlyph = (color: number): Float32Array => {
19
+ const buf = new ArrayBuffer(FLOATS_PER_GLYPH * 4);
20
+ const f = new Float32Array(buf);
21
+ const u = new Uint32Array(buf);
22
+ const corners = [
23
+ [10, 20, 0.1, 0.2],
24
+ [30, 20, 0.3, 0.2],
25
+ [10, 40, 0.1, 0.4],
26
+ [30, 40, 0.3, 0.4],
27
+ ];
28
+ for (let v = 0; v < 4; v++) {
29
+ const i = v * 6;
30
+ f[i] = corners[v]![0]!;
31
+ f[i + 1] = corners[v]![1]!;
32
+ f[i + 2] = corners[v]![2]!;
33
+ f[i + 3] = corners[v]![3]!;
34
+ u[i + 4] = color;
35
+ f[i + 5] = 4; // distanceRange
36
+ }
37
+ return f;
38
+ };
39
+
40
+ type SdfWriterFake = {
41
+ sdfBufferIdx: number;
42
+ sdfQuadCount: number;
43
+ sdfBuffer: ArrayBuffer;
44
+ fSdfBuffer: Float32Array;
45
+ uiSdfBuffer: Uint32Array;
46
+ sdfBufferChanged: boolean;
47
+ ensureSdfBufferCapacity: ReturnType<typeof vi.fn>;
48
+ finalizeSdfBatch: ReturnType<typeof vi.fn>;
49
+ };
50
+
51
+ const makeWriterFake = (): SdfWriterFake => {
52
+ const sdfBuffer = new ArrayBuffer(1024 * 4);
53
+ return {
54
+ sdfBufferIdx: 0,
55
+ sdfQuadCount: 0,
56
+ sdfBuffer,
57
+ fSdfBuffer: new Float32Array(sdfBuffer),
58
+ uiSdfBuffer: new Uint32Array(sdfBuffer),
59
+ sdfBufferChanged: false,
60
+ ensureSdfBufferCapacity: vi.fn(),
61
+ finalizeSdfBatch: vi.fn(),
62
+ };
63
+ };
64
+
65
+ const finalizeArgs = [
66
+ {} as never, // atlasTexture
67
+ { x: 0, y: 0, w: 0, h: 0, valid: false }, // clippingRect
68
+ 1, // worldAlpha
69
+ 100, // width
70
+ 50, // height
71
+ false, // parentHasRenderTexture
72
+ null, // framebufferDimensions
73
+ {} as never, // sdfShader
74
+ ] as const;
75
+
76
+ describe('WebGlRenderer.addSdfTranslatedQuads', () => {
77
+ it('copies cached vertices with the position delta applied', () => {
78
+ const fake = makeWriterFake();
79
+ const cached = makeCachedGlyph(0x00ff00ff);
80
+
81
+ WebGlRenderer.prototype.addSdfTranslatedQuads.call(
82
+ fake as never,
83
+ cached,
84
+ 1,
85
+ 5,
86
+ -3,
87
+ ...finalizeArgs,
88
+ );
89
+
90
+ const f = fake.fSdfBuffer;
91
+ // Positions shifted by (5, -3)
92
+ expect([f[0], f[1]]).toEqual([15, 17]);
93
+ expect([f[6], f[7]]).toEqual([35, 17]);
94
+ expect([f[12], f[13]]).toEqual([15, 37]);
95
+ expect([f[18], f[19]]).toEqual([35, 37]);
96
+ // UVs and distanceRange untouched
97
+ expect([f[2], f[3], f[5]]).toEqual([Math.fround(0.1), Math.fround(0.2), 4]);
98
+ expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH);
99
+ expect(fake.sdfQuadCount).toBe(1);
100
+ expect(fake.sdfBufferChanged).toBe(true);
101
+ expect(fake.finalizeSdfBatch.mock.calls.length).toBe(1);
102
+ // startQuad 0, numGlyphs 1
103
+ expect(fake.finalizeSdfBatch.mock.calls[0]![0]).toBe(0);
104
+ expect(fake.finalizeSdfBatch.mock.calls[0]![1]).toBe(1);
105
+ // Source cache untouched
106
+ expect(cached[0]).toBe(10);
107
+ expect(cached[1]).toBe(20);
108
+ });
109
+
110
+ it('preserves packed color bits exactly, including float32 NaN patterns', () => {
111
+ const fake = makeWriterFake();
112
+ // 0xFFFFFFFF (white, full alpha) is a float32 NaN bit pattern — an
113
+ // element-wise float copy could canonicalize it. The memcpy must not.
114
+ const cached = makeCachedGlyph(0xffffffff);
115
+
116
+ WebGlRenderer.prototype.addSdfTranslatedQuads.call(
117
+ fake as never,
118
+ cached,
119
+ 1,
120
+ 100,
121
+ 200,
122
+ ...finalizeArgs,
123
+ );
124
+
125
+ for (let v = 0; v < 4; v++) {
126
+ expect(fake.uiSdfBuffer[v * 6 + 4]).toBe(0xffffffff);
127
+ }
128
+ });
129
+
130
+ it('appends after existing content and is a no-op for zero glyphs', () => {
131
+ const fake = makeWriterFake();
132
+ fake.sdfBufferIdx = FLOATS_PER_GLYPH;
133
+ fake.sdfQuadCount = 1;
134
+ const cached = makeCachedGlyph(0x000000ff);
135
+
136
+ WebGlRenderer.prototype.addSdfTranslatedQuads.call(
137
+ fake as never,
138
+ cached,
139
+ 0,
140
+ 5,
141
+ 5,
142
+ ...finalizeArgs,
143
+ );
144
+ expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH);
145
+ expect(fake.sdfBufferChanged).toBe(false);
146
+ expect(fake.finalizeSdfBatch.mock.calls.length).toBe(0);
147
+
148
+ WebGlRenderer.prototype.addSdfTranslatedQuads.call(
149
+ fake as never,
150
+ cached,
151
+ 1,
152
+ 5,
153
+ 5,
154
+ ...finalizeArgs,
155
+ );
156
+ expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH * 2);
157
+ expect(fake.fSdfBuffer[FLOATS_PER_GLYPH]).toBe(15);
158
+ // startQuad continues from the existing quad count
159
+ expect(fake.finalizeSdfBatch.mock.calls[0]![0]).toBe(1);
160
+ });
161
+ });
162
+
163
+ type UploadFake = {
164
+ sdfBufferIdx: number;
165
+ sdfBufferChanged: boolean;
166
+ lastUploadedSdfSize: number;
167
+ sdfBuffer: ArrayBuffer;
168
+ sdfQuadBufferCollection: { getBuffer: ReturnType<typeof vi.fn> };
169
+ glw: { arrayBufferData: ReturnType<typeof vi.fn>; DYNAMIC_DRAW: number };
170
+ };
171
+
172
+ const makeUploadFake = (): UploadFake => ({
173
+ sdfBufferIdx: 0,
174
+ sdfBufferChanged: true,
175
+ lastUploadedSdfSize: 0,
176
+ sdfBuffer: new ArrayBuffer(1024),
177
+ sdfQuadBufferCollection: { getBuffer: vi.fn(() => ({})) },
178
+ glw: { arrayBufferData: vi.fn(), DYNAMIC_DRAW: 35048 },
179
+ });
180
+
181
+ const upload = (fake: UploadFake) =>
182
+ (
183
+ WebGlRenderer.prototype as unknown as {
184
+ uploadSdfBuffer: (this: unknown) => void;
185
+ }
186
+ ).uploadSdfBuffer.call(fake);
187
+
188
+ describe('WebGlRenderer.uploadSdfBuffer skip', () => {
189
+ it('does nothing when no SDF glyphs were written', () => {
190
+ const fake = makeUploadFake();
191
+
192
+ upload(fake);
193
+
194
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(0);
195
+ // Flag is kept so a later frame with content still uploads
196
+ expect(fake.sdfBufferChanged).toBe(true);
197
+ });
198
+
199
+ it('uploads when changed, then skips identical follow-up frames', () => {
200
+ const fake = makeUploadFake();
201
+ fake.sdfBufferIdx = 48;
202
+
203
+ upload(fake);
204
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(1);
205
+ expect(fake.sdfBufferChanged).toBe(false);
206
+ expect(fake.lastUploadedSdfSize).toBe(48);
207
+
208
+ // Next frame: exact cache hits rewrote identical bytes, same size
209
+ upload(fake);
210
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(1);
211
+ });
212
+
213
+ it('uploads again when the changed flag is raised', () => {
214
+ const fake = makeUploadFake();
215
+ fake.sdfBufferIdx = 48;
216
+ upload(fake);
217
+
218
+ fake.sdfBufferChanged = true;
219
+ upload(fake);
220
+
221
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(2);
222
+ });
223
+
224
+ it('uploads when the size differs even if the flag is clear', () => {
225
+ const fake = makeUploadFake();
226
+ fake.sdfBufferIdx = 48;
227
+ upload(fake);
228
+
229
+ fake.sdfBufferIdx = 24;
230
+ upload(fake);
231
+
232
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(2);
233
+ expect(fake.lastUploadedSdfSize).toBe(24);
234
+ });
235
+ });
236
+
237
+ describe('sdfBufferChanged raisers', () => {
238
+ it('addSdfQuads marks the buffer changed', () => {
239
+ const fake = makeWriterFake();
240
+ // One glyph record: [x, y, w, h, u, v, uw, vh]
241
+ const glyphs = new Float32Array([0, 0, 10, 10, 0, 0, 0.1, 0.1]);
242
+ const transform = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
243
+
244
+ WebGlRenderer.prototype.addSdfQuads.call(
245
+ fake as never,
246
+ glyphs,
247
+ 1, // glyphCount
248
+ 1, // fontScale
249
+ transform,
250
+ 0xffffffff, // color
251
+ 1, // worldAlpha
252
+ 4, // distanceRange
253
+ {} as never, // atlasTexture
254
+ { x: 0, y: 0, w: 0, h: 0, valid: false }, // clippingRect
255
+ 100, // width
256
+ 50, // height
257
+ false, // parentHasRenderTexture
258
+ null, // framebufferDimensions
259
+ {} as never, // sdfShader
260
+ );
261
+
262
+ expect(fake.sdfBufferChanged).toBe(true);
263
+ expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH);
264
+ });
265
+
266
+ it('invalidateQuadBuffer marks the buffer changed (render-list rebuild)', () => {
267
+ const fake = {
268
+ sdfBufferChanged: false,
269
+ curBufferIdx: 99,
270
+ lastUploadedBufferSize: 99,
271
+ needsFullUpload: false,
272
+ stage: { renderList: [] },
273
+ };
274
+
275
+ WebGlRenderer.prototype.invalidateQuadBuffer.call(fake as never);
276
+
277
+ expect(fake.sdfBufferChanged).toBe(true);
278
+ });
279
+
280
+ it('ensureSdfBufferCapacity marks the buffer changed on growth only', () => {
281
+ const sdfBuffer = new ArrayBuffer(FLOATS_PER_GLYPH * 4);
282
+ const fake = {
283
+ sdfBuffer,
284
+ fSdfBuffer: new Float32Array(sdfBuffer),
285
+ uiSdfBuffer: new Uint32Array(sdfBuffer),
286
+ sdfBufferChanged: false,
287
+ };
288
+ const ensure = (size: number) =>
289
+ (
290
+ WebGlRenderer.prototype as unknown as {
291
+ ensureSdfBufferCapacity: (this: unknown, n: number) => void;
292
+ }
293
+ ).ensureSdfBufferCapacity.call(fake, size);
294
+
295
+ ensure(FLOATS_PER_GLYPH); // fits — no realloc
296
+ expect(fake.sdfBufferChanged).toBe(false);
297
+
298
+ ensure(FLOATS_PER_GLYPH * 8); // grows
299
+ expect(fake.sdfBufferChanged).toBe(true);
300
+ expect(fake.fSdfBuffer.length).toBeGreaterThanOrEqual(FLOATS_PER_GLYPH * 8);
301
+ });
302
+ });
@@ -127,6 +127,23 @@ export class WebGlRenderer extends CoreRenderer {
127
127
  sdfQuadCount = 0;
128
128
  sdfQuadBufferCollection: BufferCollection;
129
129
  curSdfRenderOp: SdfRenderOp | null = null;
130
+ /**
131
+ * Whether the shared SDF buffer's bytes may differ from what the GPU
132
+ * currently holds. Set by every write path that produces fresh bytes
133
+ * (cache-miss recompute, translated copy), by anything that can shift
134
+ * offsets or resize the buffer (render-list rebuild, RTT partial upload,
135
+ * backing-store growth), and consumed by {@link uploadSdfBuffer}. The
136
+ * exact cache-hit mem-copy path deliberately does NOT set it — it writes
137
+ * byte-identical data at identical offsets. Conservative direction: when
138
+ * in doubt, set it — a redundant upload is correct, a wrong skip is a
139
+ * glitch.
140
+ */
141
+ sdfBufferChanged = true;
142
+ /**
143
+ * Float32 length of the last main-pass SDF upload — the size half of the
144
+ * skip test in {@link uploadSdfBuffer}.
145
+ */
146
+ lastUploadedSdfSize = 0;
130
147
 
131
148
  /**
132
149
  * When true, the entire quad buffer is re-uploaded to the GPU via bufferData
@@ -212,7 +229,7 @@ export class WebGlRenderer extends CoreRenderer {
212
229
  gl,
213
230
  options.disableVertexArrayObject,
214
231
  ));
215
- glw.viewport(0, 0, options.canvas.width, options.canvas.height);
232
+ glw.viewport(0, 0, glw.canvasW, glw.canvasH);
216
233
 
217
234
  this.attachContextLossListeners(options.canvas);
218
235
 
@@ -640,9 +657,21 @@ export class WebGlRenderer extends CoreRenderer {
640
657
  return true;
641
658
  }
642
659
 
643
- // Check if the shader is the same
660
+ // Distinct shader nodes can still batch when they resolve to the same
661
+ // program and the value-key cache handed both the same uniform collection:
662
+ // collections are immutable after fill and shared by reference across
663
+ // equal value keys, so reference equality implies value equality. This is
664
+ // the common TV-rail case — many same-size cards, each app-created with
665
+ // its own shader node but equal props. Program identity is required
666
+ // because the value key does not include the shader type, so equal
667
+ // collections from different programs must not merge.
644
668
  if (curShader !== shader) {
645
- return false;
669
+ if (
670
+ curShader.program !== shader.program ||
671
+ curShader.uniforms !== shader.uniforms
672
+ ) {
673
+ return false;
674
+ }
646
675
  }
647
676
 
648
677
  if (
@@ -729,6 +758,9 @@ export class WebGlRenderer extends CoreRenderer {
729
758
  return;
730
759
  }
731
760
 
761
+ // Full recompute writes fresh bytes — the GPU copy is now stale.
762
+ this.sdfBufferChanged = true;
763
+
732
764
  let idx = this.sdfBufferIdx;
733
765
  this.ensureSdfBufferCapacity(idx + glyphCount * 24);
734
766
 
@@ -891,6 +923,76 @@ export class WebGlRenderer extends CoreRenderer {
891
923
  );
892
924
  }
893
925
 
926
+ /**
927
+ * Append cached SDF vertices translated by (dx, dy) to the shared buffer.
928
+ *
929
+ * @remarks
930
+ * The scroll fast path: a text node whose transform changed by pure
931
+ * translation reuses its world-space vertex cache — one mem-copy plus two
932
+ * adds per vertex instead of full per-glyph matrix math, and the cache
933
+ * keeps its original base so nothing is re-snapshotted per frame.
934
+ *
935
+ * The copy MUST stay a typed-array `set` (bit-exact memcpy): packed ABGR
936
+ * colors live in the same Float32Array and some bit patterns are float32
937
+ * NaNs, which element-wise float reads/writes may canonicalize and corrupt.
938
+ * Only the two position floats of each vertex are touched after the copy.
939
+ */
940
+ addSdfTranslatedQuads(
941
+ cachedVertices: Float32Array,
942
+ numGlyphs: number,
943
+ dx: number,
944
+ dy: number,
945
+ atlasTexture: WebGlCtxTexture,
946
+ clippingRect: import('../../lib/utils.js').RectWithValid,
947
+ worldAlpha: number,
948
+ width: number,
949
+ height: number,
950
+ parentHasRenderTexture: boolean,
951
+ framebufferDimensions:
952
+ | import('../../../common/CommonTypes.js').Dimensions
953
+ | null,
954
+ sdfShader: WebGlShaderNode,
955
+ ): void {
956
+ if (numGlyphs === 0) {
957
+ return;
958
+ }
959
+
960
+ // Translated positions are fresh bytes — the GPU copy is now stale.
961
+ this.sdfBufferChanged = true;
962
+
963
+ const startQuad = this.sdfQuadCount;
964
+ const idx = this.sdfBufferIdx;
965
+
966
+ this.ensureSdfBufferCapacity(idx + cachedVertices.length);
967
+
968
+ // Read the buffer reference only after ensureSdfBufferCapacity — growth
969
+ // swaps the backing store.
970
+ const f = this.fSdfBuffer;
971
+ f.set(cachedVertices, idx);
972
+
973
+ const end = idx + cachedVertices.length;
974
+ for (let i = idx; i < end; i += 6) {
975
+ f[i] = f[i]! + dx;
976
+ f[i + 1] = f[i + 1]! + dy;
977
+ }
978
+
979
+ this.sdfBufferIdx = end;
980
+ this.sdfQuadCount += numGlyphs;
981
+
982
+ this.finalizeSdfBatch(
983
+ startQuad,
984
+ numGlyphs,
985
+ atlasTexture,
986
+ clippingRect,
987
+ worldAlpha,
988
+ width,
989
+ height,
990
+ parentHasRenderTexture,
991
+ framebufferDimensions,
992
+ sdfShader,
993
+ );
994
+ }
995
+
894
996
  /**
895
997
  * Shared batching logic for SDF render ops.
896
998
  * Called by both `addSdfQuads` (full compute) and `addSdfCachedQuads` (fast copy).
@@ -992,6 +1094,9 @@ export class WebGlRenderer extends CoreRenderer {
992
1094
  this.sdfBuffer = newBuffer;
993
1095
  this.fSdfBuffer = newFSdfBuffer;
994
1096
  this.uiSdfBuffer = newUiSdfBuffer;
1097
+
1098
+ // New backing store — never skip the next upload.
1099
+ this.sdfBufferChanged = true;
995
1100
  }
996
1101
 
997
1102
  /**
@@ -1068,12 +1173,7 @@ export class WebGlRenderer extends CoreRenderer {
1068
1173
  }
1069
1174
 
1070
1175
  // Upload the shared SDF buffer if any SDF glyphs were written this frame.
1071
- if (this.sdfBufferIdx > 0) {
1072
- const sdfBuf =
1073
- this.sdfQuadBufferCollection.getBuffer('a_position') || null;
1074
- const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx);
1075
- glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW);
1076
- }
1176
+ this.uploadSdfBuffer();
1077
1177
 
1078
1178
  for (let i = 0, length = this.renderOps.length; i < length; i++) {
1079
1179
  this.renderOps[i]!.draw(this);
@@ -1087,6 +1187,34 @@ export class WebGlRenderer extends CoreRenderer {
1087
1187
  this.numQuadsRendered = this.quadBufferUsage / QUAD_SIZE_IN_BYTES;
1088
1188
  }
1089
1189
 
1190
+ /**
1191
+ * Upload the shared SDF buffer for the main pass, skipping the driver-side
1192
+ * `bufferData` copy when the bytes provably match what the GPU already
1193
+ * holds: every write this frame was an exact cache-hit mem-copy
1194
+ * (`sdfBufferChanged` false) and the total size matches the previous
1195
+ * upload. Exact hits write byte-identical data, and identical offsets are
1196
+ * guaranteed because every source of reorder or resize — cache-miss
1197
+ * recompute, translated copy, render-list rebuild, RTT partial upload,
1198
+ * backing-store growth — sets `sdfBufferChanged`.
1199
+ */
1200
+ private uploadSdfBuffer(): void {
1201
+ if (this.sdfBufferIdx === 0) {
1202
+ return;
1203
+ }
1204
+ if (
1205
+ this.sdfBufferChanged === false &&
1206
+ this.sdfBufferIdx === this.lastUploadedSdfSize
1207
+ ) {
1208
+ return;
1209
+ }
1210
+ const glw = this.glw;
1211
+ const sdfBuf = this.sdfQuadBufferCollection.getBuffer('a_position') || null;
1212
+ const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx);
1213
+ glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW);
1214
+ this.lastUploadedSdfSize = this.sdfBufferIdx;
1215
+ this.sdfBufferChanged = false;
1216
+ }
1217
+
1090
1218
  getQuadCount(): number {
1091
1219
  return this.numQuadsRendered;
1092
1220
  }
@@ -1264,7 +1392,7 @@ export class WebGlRenderer extends CoreRenderer {
1264
1392
  // Bind the default framebuffer
1265
1393
  glw.bindFramebuffer(null);
1266
1394
 
1267
- glw.viewport(0, 0, this.glw.canvas.width, this.glw.canvas.height);
1395
+ glw.viewport(0, 0, glw.canvasW, glw.canvasH);
1268
1396
  this.renderToTextureActive = false;
1269
1397
  }
1270
1398
 
@@ -1323,6 +1451,9 @@ export class WebGlRenderer extends CoreRenderer {
1323
1451
  this.sdfQuadBufferCollection.getBuffer('a_position') || null;
1324
1452
  const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx);
1325
1453
  glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW);
1454
+ // This partial upload replaced the GL buffer's contents and size — the
1455
+ // main pass upload can no longer be skipped this frame.
1456
+ this.sdfBufferChanged = true;
1326
1457
  }
1327
1458
 
1328
1459
  for (let i = 0, length = this.renderOps.length; i < length; i++) {
@@ -1331,7 +1462,10 @@ export class WebGlRenderer extends CoreRenderer {
1331
1462
  }
1332
1463
 
1333
1464
  updateViewport(): void {
1334
- this.glw.viewport(0, 0, this.glw.canvas.width, this.glw.canvas.height);
1465
+ // Called after the canvas is resized (Renderer.updateAppDimensions)
1466
+ // refresh the wrapper's cached dimensions before they're used again.
1467
+ this.glw.updateCanvasDimensions();
1468
+ this.glw.viewport(0, 0, this.glw.canvasW, this.glw.canvasH);
1335
1469
  }
1336
1470
 
1337
1471
  removeRTTNode(node: CoreNode) {
@@ -1486,6 +1620,12 @@ export class WebGlRenderer extends CoreRenderer {
1486
1620
  * next addQuad() pass will reassign compact, contiguous slots starting from 0.
1487
1621
  */
1488
1622
  override invalidateQuadBuffer(): void {
1623
+ // A render-list rebuild can reorder text nodes without touching their
1624
+ // SDF vertex caches; byte-identical glyph data would then land at
1625
+ // different offsets, so the upload skip must not fire on the rebuild
1626
+ // frame.
1627
+ this.sdfBufferChanged = true;
1628
+
1489
1629
  if (!DIRTY_QUAD_BUFFER) {
1490
1630
  return;
1491
1631
  }