@solidtv/renderer 1.5.6 → 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 (97) 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/platforms/web/WebPlatform.js +60 -50
  12. package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
  13. package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +4 -0
  14. package/dist/src/core/renderers/canvas/CanvasRenderer.js +28 -13
  15. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  16. package/dist/src/core/renderers/canvas/CanvasShaderNode.js +1 -0
  17. package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
  18. package/dist/src/core/renderers/webgl/SdfRenderOp.js +2 -2
  19. package/dist/src/core/renderers/webgl/SdfRenderOp.js.map +1 -1
  20. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +43 -0
  21. package/dist/src/core/renderers/webgl/WebGlRenderer.js +109 -10
  22. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  23. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +21 -13
  24. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  25. package/dist/src/core/renderers/webgl/internal/ShaderUtils.d.ts +8 -3
  26. package/dist/src/core/renderers/webgl/internal/ShaderUtils.js +12 -11
  27. package/dist/src/core/renderers/webgl/internal/ShaderUtils.js.map +1 -1
  28. package/dist/src/core/shaders/canvas/Rounded.js +3 -3
  29. package/dist/src/core/shaders/canvas/Rounded.js.map +1 -1
  30. package/dist/src/core/shaders/canvas/RoundedWithShadow.js +3 -3
  31. package/dist/src/core/shaders/canvas/RoundedWithShadow.js.map +1 -1
  32. package/dist/src/core/shaders/canvas/utils/render.js +3 -3
  33. package/dist/src/core/shaders/canvas/utils/render.js.map +1 -1
  34. package/dist/src/core/shaders/webgl/Border.js +12 -11
  35. package/dist/src/core/shaders/webgl/Border.js.map +1 -1
  36. package/dist/src/core/shaders/webgl/HolePunch.js +12 -5
  37. package/dist/src/core/shaders/webgl/HolePunch.js.map +1 -1
  38. package/dist/src/core/shaders/webgl/LinearGradient.js +3 -19
  39. package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
  40. package/dist/src/core/shaders/webgl/RadialGradient.js +3 -20
  41. package/dist/src/core/shaders/webgl/RadialGradient.js.map +1 -1
  42. package/dist/src/core/shaders/webgl/RadialProgress.js +15 -23
  43. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
  44. package/dist/src/core/shaders/webgl/RoundedWithBorder.js +35 -32
  45. package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
  46. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +58 -45
  47. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
  48. package/dist/src/core/shaders/webgl/RoundedWithShadow.js +15 -13
  49. package/dist/src/core/shaders/webgl/RoundedWithShadow.js.map +1 -1
  50. package/dist/src/core/shaders/webgl/Shadow.js +4 -5
  51. package/dist/src/core/shaders/webgl/Shadow.js.map +1 -1
  52. package/dist/src/core/text-rendering/SdfTextRenderer.js +23 -10
  53. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  54. package/dist/src/core/text-rendering/TextLayoutEngine.js +14 -2
  55. package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
  56. package/dist/src/utils.js +1 -1
  57. package/dist/src/utils.js.map +1 -1
  58. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  59. package/package.json +1 -1
  60. package/src/core/CoreNode.ts +2 -2
  61. package/src/core/Stage.renderListUpdate.test.ts +73 -0
  62. package/src/core/Stage.ts +11 -1
  63. package/src/core/lib/WebGlContextWrapper.test.ts +23 -1
  64. package/src/core/lib/WebGlContextWrapper.ts +23 -1
  65. package/src/core/lib/colorParser.test.ts +46 -0
  66. package/src/core/lib/colorParser.ts +17 -6
  67. package/src/core/platforms/web/WebPlatform.ts +63 -52
  68. package/src/core/renderers/canvas/CanvasRenderer.test.ts +230 -0
  69. package/src/core/renderers/canvas/CanvasRenderer.ts +29 -15
  70. package/src/core/renderers/canvas/CanvasShaderNode.test.ts +73 -0
  71. package/src/core/renderers/canvas/CanvasShaderNode.ts +1 -0
  72. package/src/core/renderers/webgl/SdfRenderOp.ts +2 -2
  73. package/src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts +122 -0
  74. package/src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts +302 -0
  75. package/src/core/renderers/webgl/WebGlRenderer.ts +151 -11
  76. package/src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts +150 -0
  77. package/src/core/renderers/webgl/WebGlShaderProgram.ts +27 -15
  78. package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +4 -2
  79. package/src/core/renderers/webgl/internal/ShaderUtils.test.ts +124 -0
  80. package/src/core/renderers/webgl/internal/ShaderUtils.ts +14 -13
  81. package/src/core/shaders/canvas/Rounded.ts +3 -3
  82. package/src/core/shaders/canvas/RoundedWithShadow.ts +3 -3
  83. package/src/core/shaders/canvas/utils/render.ts +3 -3
  84. package/src/core/shaders/webgl/Border.ts +12 -11
  85. package/src/core/shaders/webgl/HolePunch.ts +12 -5
  86. package/src/core/shaders/webgl/LinearGradient.ts +3 -19
  87. package/src/core/shaders/webgl/RadialGradient.ts +3 -20
  88. package/src/core/shaders/webgl/RadialProgress.ts +15 -23
  89. package/src/core/shaders/webgl/RoundedWithBorder.ts +35 -32
  90. package/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +58 -45
  91. package/src/core/shaders/webgl/RoundedWithShadow.ts +15 -13
  92. package/src/core/shaders/webgl/Shadow.ts +4 -5
  93. package/src/core/text-rendering/SdfTextRenderer.test.ts +177 -0
  94. package/src/core/text-rendering/SdfTextRenderer.ts +38 -10
  95. package/src/core/text-rendering/TextLayoutEngine.ts +15 -2
  96. package/src/core/text-rendering/tests/TextLayoutEngine.test.ts +99 -0
  97. package/src/utils.ts +1 -1
@@ -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
  }
@@ -0,0 +1,150 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { WebGlShaderProgram } from './WebGlShaderProgram.js';
3
+
4
+ /**
5
+ * Tests for the batch test in WebGlShaderProgram.reuseRenderOp.
6
+ *
7
+ * The program instance is created without running the constructor (which
8
+ * compiles GLSL against a live GL context); only the fields reuseRenderOp
9
+ * touches are populated manually.
10
+ */
11
+
12
+ type ProgramFlags = {
13
+ useSystemAlpha?: boolean;
14
+ useSystemDimensions?: boolean;
15
+ useTimeValue?: boolean;
16
+ canBatch?: (node: unknown, op: unknown) => boolean;
17
+ };
18
+
19
+ const makeProgram = (flags: ProgramFlags = {}): WebGlShaderProgram => {
20
+ const program = Object.create(
21
+ WebGlShaderProgram.prototype,
22
+ ) as WebGlShaderProgram;
23
+ const p = program as unknown as Record<string, unknown>;
24
+ p['useSystemAlpha'] = flags.useSystemAlpha === true;
25
+ p['useSystemDimensions'] = flags.useSystemDimensions === true;
26
+ p['useTimeValue'] = flags.useTimeValue === true;
27
+ p['lifecycle'] = { canBatch: flags.canBatch, update: undefined };
28
+ return program;
29
+ };
30
+
31
+ type FakeShaderNode = {
32
+ uniforms: object;
33
+ resolvedProps: Record<string, unknown> | undefined;
34
+ };
35
+
36
+ const makeShaderNode = (
37
+ uniforms: object,
38
+ resolvedProps?: Record<string, unknown>,
39
+ ): FakeShaderNode => ({ uniforms, resolvedProps });
40
+
41
+ const makeNode = (
42
+ shader: FakeShaderNode,
43
+ overrides?: Record<string, unknown>,
44
+ ) => ({
45
+ worldAlpha: 1,
46
+ w: 200,
47
+ h: 100,
48
+ props: { shader },
49
+ ...overrides,
50
+ });
51
+
52
+ const makeOp = (shader: FakeShaderNode, overrides?: Record<string, unknown>) =>
53
+ ({
54
+ time: 0,
55
+ worldAlpha: 1,
56
+ w: 200,
57
+ h: 100,
58
+ shader,
59
+ ...overrides,
60
+ } as never);
61
+
62
+ describe('WebGlShaderProgram.reuseRenderOp', () => {
63
+ it('batches the same shader node without comparing props', () => {
64
+ const program = makeProgram();
65
+ const shader = makeShaderNode({}, { radius: 16 });
66
+
67
+ expect(
68
+ program.reuseRenderOp(makeNode(shader) as never, makeOp(shader)),
69
+ ).toBe(true);
70
+ });
71
+
72
+ it('batches distinct shader nodes that share a uniform collection', () => {
73
+ const program = makeProgram();
74
+ const uniforms = {};
75
+ // Distinct resolvedProps objects — reference-inequal props must not matter
76
+ // when the value-key cache handed both nodes the same collection.
77
+ const shaderA = makeShaderNode(uniforms, { radius: [16, 16, 16, 16] });
78
+ const shaderB = makeShaderNode(uniforms, { radius: [16, 16, 16, 16] });
79
+
80
+ expect(
81
+ program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderB)),
82
+ ).toBe(true);
83
+ });
84
+
85
+ it('falls back to a prop-value compare for distinct collections', () => {
86
+ const program = makeProgram();
87
+ const shaderA = makeShaderNode({}, { radius: 16, gap: 2 });
88
+ const shaderB = makeShaderNode({}, { radius: 16, gap: 2 });
89
+ const shaderC = makeShaderNode({}, { radius: 16, gap: 4 });
90
+
91
+ expect(
92
+ program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderB)),
93
+ ).toBe(true);
94
+ expect(
95
+ program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderC)),
96
+ ).toBe(false);
97
+ });
98
+
99
+ it('rejects on system alpha mismatch before any shader compare', () => {
100
+ const program = makeProgram({ useSystemAlpha: true });
101
+ const shader = makeShaderNode({});
102
+
103
+ expect(
104
+ program.reuseRenderOp(
105
+ makeNode(shader, { worldAlpha: 0.5 }) as never,
106
+ makeOp(shader),
107
+ ),
108
+ ).toBe(false);
109
+ });
110
+
111
+ it('rejects on dimension mismatch when the program uses u_dimensions', () => {
112
+ const program = makeProgram({ useSystemDimensions: true });
113
+ const shader = makeShaderNode({});
114
+
115
+ expect(
116
+ program.reuseRenderOp(
117
+ makeNode(shader, { w: 300 }) as never,
118
+ makeOp(shader),
119
+ ),
120
+ ).toBe(false);
121
+ });
122
+
123
+ it('does not read node.time when the program has no time uniform', () => {
124
+ const program = makeProgram();
125
+ const shader = makeShaderNode({});
126
+ const node = makeNode(shader);
127
+ let timeReads = 0;
128
+ Object.defineProperty(node, 'time', {
129
+ get: () => {
130
+ timeReads++;
131
+ return 0;
132
+ },
133
+ });
134
+
135
+ program.reuseRenderOp(node as never, makeOp(shader));
136
+
137
+ expect(timeReads).toBe(0);
138
+ });
139
+
140
+ it('delegates to the shader type canBatch hook when defined', () => {
141
+ const canBatch = vi.fn(() => false);
142
+ const program = makeProgram({ canBatch });
143
+ const shader = makeShaderNode({});
144
+
145
+ expect(
146
+ program.reuseRenderOp(makeNode(shader) as never, makeOp(shader)),
147
+ ).toBe(false);
148
+ expect(canBatch.mock.calls.length).toBe(1);
149
+ });
150
+ });
@@ -158,40 +158,52 @@ export class WebGlShaderProgram implements CoreShaderProgram {
158
158
  return this.lifecycle.canBatch(node, currentRenderOp);
159
159
  }
160
160
 
161
- const { time, worldAlpha, w, h } = node;
162
-
161
+ // Read node getters only for the system uniforms this program actually
162
+ // uses — node.time in particular is a getter that runs per call.
163
163
  if (this.useTimeValue === true) {
164
- if (time !== currentRenderOp.time) {
164
+ if (node.time !== currentRenderOp.time) {
165
165
  return false;
166
166
  }
167
167
  }
168
168
 
169
169
  if (this.useSystemAlpha === true) {
170
- if (worldAlpha !== currentRenderOp.worldAlpha) {
170
+ if (node.worldAlpha !== currentRenderOp.worldAlpha) {
171
171
  return false;
172
172
  }
173
173
  }
174
174
 
175
175
  if (this.useSystemDimensions === true) {
176
- if (w !== currentRenderOp.w || h !== currentRenderOp.h) {
176
+ if (node.w !== currentRenderOp.w || node.h !== currentRenderOp.h) {
177
177
  return false;
178
178
  }
179
179
  }
180
180
 
181
- let shaderPropsA: Record<string, unknown> | undefined = undefined;
182
- let shaderPropsB: Record<string, unknown> | undefined = undefined;
181
+ const shader = node.props.shader as WebGlShaderNode | null;
182
+ const opShader = currentRenderOp.shader as WebGlShaderNode | null;
183
183
 
184
- const shader = node.props.shader;
184
+ // Same shader node — same resolved props by definition.
185
+ if (shader === opShader) {
186
+ return true;
187
+ }
185
188
 
186
- if (shader !== null) {
187
- shaderPropsA = (shader as WebGlShaderNode).resolvedProps;
189
+ if (shader === null || opShader === null) {
190
+ return false;
188
191
  }
189
192
 
190
- const opShader = currentRenderOp.shader;
191
- if (opShader !== null) {
192
- shaderPropsB = (opShader as WebGlShaderNode).resolvedProps;
193
+ // Shader nodes with equal value keys share their uniform collection by
194
+ // reference (see WebGlShaderNode.update); reference equality implies the
195
+ // resolved prop values match without a key-by-key compare.
196
+ if (shader.uniforms === opShader.uniforms) {
197
+ return true;
193
198
  }
194
199
 
200
+ const shaderPropsA = shader.resolvedProps as
201
+ | Record<string, unknown>
202
+ | undefined;
203
+ const shaderPropsB = opShader.resolvedProps as
204
+ | Record<string, unknown>
205
+ | undefined;
206
+
195
207
  if (
196
208
  (shaderPropsA === undefined && shaderPropsB !== undefined) ||
197
209
  (shaderPropsA !== undefined && shaderPropsB === undefined)
@@ -243,8 +255,8 @@ export class WebGlShaderProgram implements CoreShaderProgram {
243
255
  resolutionH = framebufferDimensions.h;
244
256
  } else {
245
257
  pixelRatio = renderOp.stage.pixelRatio;
246
- resolutionW = this.glw.canvas.width;
247
- resolutionH = this.glw.canvas.height;
258
+ resolutionW = this.glw.canvasW;
259
+ resolutionH = this.glw.canvasH;
248
260
  }
249
261
 
250
262
  if (pixelRatio !== this.lastPixelRatio) {
@@ -13,7 +13,8 @@ type FakeGlw = {
13
13
  uniform1f: ReturnType<typeof vi.fn>;
14
14
  uniform2f: ReturnType<typeof vi.fn>;
15
15
  uniform4f: ReturnType<typeof vi.fn>;
16
- canvas: { width: number; height: number };
16
+ canvasW: number;
17
+ canvasH: number;
17
18
  };
18
19
 
19
20
  const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => {
@@ -21,7 +22,8 @@ const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => {
21
22
  uniform1f: vi.fn(),
22
23
  uniform2f: vi.fn(),
23
24
  uniform4f: vi.fn(),
24
- canvas: { width: 1920, height: 1080 },
25
+ canvasW: 1920,
26
+ canvasH: 1080,
25
27
  };
26
28
 
27
29
  const program = Object.create(
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { genGradientColors } from './ShaderUtils.js';
3
+
4
+ type Vec4 = [number, number, number, number];
5
+
6
+ const smoothstep = (e0: number, e1: number, x: number): number => {
7
+ const t = Math.min(Math.max((x - e0) / (e1 - e0), 0), 1);
8
+ return t * t * (3 - 2 * t);
9
+ };
10
+
11
+ const mix = (a: Vec4, b: Vec4, t: number): Vec4 => [
12
+ a[0] + (b[0] - a[0]) * t,
13
+ a[1] + (b[1] - a[1]) * t,
14
+ a[2] + (b[2] - a[2]) * t,
15
+ a[3] + (b[3] - a[3]) * t,
16
+ ];
17
+
18
+ /**
19
+ * Evaluate the generated GLSL statements as JS. The generated code only uses
20
+ * `mix`, `smoothstep`, `u_colors[i]`, `u_stops[i]` and `dist`, so shimming
21
+ * those makes the string directly executable.
22
+ */
23
+ function evalGradient(stops: number[], colors: Vec4[], dist: number): Vec4 {
24
+ const src = genGradientColors(stops.length).replace(
25
+ 'vec4 colorOut =',
26
+ 'let colorOut =',
27
+ );
28
+ const fn = new Function(
29
+ 'mix',
30
+ 'smoothstep',
31
+ 'u_stops',
32
+ 'u_colors',
33
+ 'dist',
34
+ `${src}; return colorOut;`,
35
+ );
36
+ return fn(mix, smoothstep, stops, colors, dist) as Vec4;
37
+ }
38
+
39
+ /**
40
+ * Reference implementation: the original branchy segment select
41
+ * (below first stop / above last stop / smoothstep within the segment).
42
+ */
43
+ function referenceGradient(
44
+ stops: number[],
45
+ colors: Vec4[],
46
+ dist: number,
47
+ ): Vec4 {
48
+ dist = Math.min(Math.max(dist, 0), 1);
49
+ if (dist <= stops[0]!) return colors[0]!;
50
+ const last = stops.length - 1;
51
+ if (dist >= stops[last]!) return colors[last]!;
52
+ for (let i = 0; i < last; i++) {
53
+ if (dist >= stops[i]! && dist <= stops[i + 1]!) {
54
+ return mix(
55
+ colors[i]!,
56
+ colors[i + 1]!,
57
+ smoothstep(stops[i]!, stops[i + 1]!, dist),
58
+ );
59
+ }
60
+ }
61
+ return colors[last]!;
62
+ }
63
+
64
+ const RED: Vec4 = [1, 0, 0, 1];
65
+ const GREEN: Vec4 = [0, 1, 0, 1];
66
+ const BLUE: Vec4 = [0, 0, 1, 0.5];
67
+ const WHITE: Vec4 = [1, 1, 1, 1];
68
+
69
+ describe('genGradientColors', () => {
70
+ it('emits no branches (no if / ternary / return)', () => {
71
+ const src = genGradientColors(4);
72
+ expect(src.includes('if')).toBe(false);
73
+ expect(src.includes('?')).toBe(false);
74
+ expect(src.includes('return')).toBe(false);
75
+ });
76
+
77
+ it('with a single stop resolves to the first color', () => {
78
+ const out = evalGradient([0], [RED], 0.7);
79
+ expect(out).toEqual(RED);
80
+ });
81
+
82
+ it('clamps to the first color below the first stop', () => {
83
+ const out = evalGradient([0.25, 0.75], [RED, GREEN], 0.1);
84
+ expect(out).toEqual(RED);
85
+ });
86
+
87
+ it('clamps to the last color above the last stop', () => {
88
+ const out = evalGradient([0.25, 0.75], [RED, GREEN], 0.9);
89
+ expect(out).toEqual(GREEN);
90
+ });
91
+
92
+ it('interpolates with smoothstep inside a segment', () => {
93
+ const dist = 0.5;
94
+ const out = evalGradient([0.25, 0.75], [RED, GREEN], dist);
95
+ const expected = mix(RED, GREEN, smoothstep(0.25, 0.75, dist));
96
+ for (let i = 0; i < 4; i++) {
97
+ expect(out[i]).toBeCloseTo(expected[i]!, 6);
98
+ }
99
+ });
100
+
101
+ it('matches the reference segment select across a multi-stop ramp', () => {
102
+ const stops = [0, 0.3, 0.6, 1];
103
+ const colors = [RED, GREEN, BLUE, WHITE];
104
+ for (let d = 0; d <= 100; d++) {
105
+ const dist = d / 100;
106
+ const out = evalGradient(stops, colors, dist);
107
+ const ref = referenceGradient(stops, colors, dist);
108
+ for (let i = 0; i < 4; i++) {
109
+ expect(out[i]).toBeCloseTo(ref[i]!, 6);
110
+ }
111
+ }
112
+ });
113
+
114
+ it('is exact at stop boundaries', () => {
115
+ const stops = [0, 0.5, 1];
116
+ const colors = [RED, GREEN, BLUE];
117
+ expect(evalGradient(stops, colors, 0)).toEqual(RED);
118
+ const mid = evalGradient(stops, colors, 0.5);
119
+ for (let i = 0; i < 4; i++) {
120
+ expect(mid[i]).toBeCloseTo(GREEN[i]!, 6);
121
+ }
122
+ expect(evalGradient(stops, colors, 1)).toEqual(BLUE);
123
+ });
124
+ });
@@ -243,21 +243,22 @@ export const DefaultVertexSource = `
243
243
  `;
244
244
 
245
245
  /**
246
- * generate fragment source for
247
- * @param stops
248
- * @returns
246
+ * Generates branchless gradient-stop evaluation statements for a fragment
247
+ * shader. Expects a `float dist` in scope and `u_stops`/`u_colors` uniform
248
+ * arrays of length `stops`; leaves the result in `vec4 colorOut`.
249
+ *
250
+ * The accumulated `mix()` chain is exactly equivalent to selecting the
251
+ * segment containing `dist` (for ascending stops): below a segment the
252
+ * smoothstep is 0 (no-op), above it is 1 (fully replaced by the next color).
253
+ * No `if`/ternary — Mali 400-class fragment pipelines serialize any branch.
249
254
  */
250
255
  export function genGradientColors(stops: number): string {
251
- let result = `
252
- float stopCalc = (dist - u_stops[0]) / (u_stops[1] - u_stops[0]);
253
- vec4 colorOut = mix(u_colors[0], u_colors[1], stopCalc);
254
- `;
255
- if (stops > 2) {
256
- for (let i = 2; i < stops; i++) {
257
- result += `colorOut = mix(colorOut, u_colors[${i}], clamp((dist - u_stops[${
258
- i - 1
259
- }]) / (u_stops[${i}] - u_stops[${i - 1}]), 0.0, 1.0));`;
260
- }
256
+ let result = `vec4 colorOut = u_colors[0];`;
257
+ for (let i = 1; i < stops; i++) {
258
+ result += `
259
+ colorOut = mix(colorOut, u_colors[${i}], smoothstep(u_stops[${
260
+ i - 1
261
+ }], u_stops[${i}], dist));`;
261
262
  }
262
263
  return result;
263
264
  }
@@ -22,16 +22,16 @@ export const Rounded: CanvasShaderType<RoundedProps, ComputedRoundedValues> = {
22
22
  );
23
23
  },
24
24
  render(ctx, node, renderContext) {
25
- const path = new Path2D();
25
+ ctx.beginPath();
26
26
  roundRect(
27
- path,
27
+ ctx,
28
28
  node.globalTransform!.tx,
29
29
  node.globalTransform!.ty,
30
30
  node.props.w,
31
31
  node.props.h,
32
32
  this.computed.radius!,
33
33
  );
34
- ctx.clip(path);
34
+ ctx.clip();
35
35
 
36
36
  renderContext();
37
37
  },
@@ -49,9 +49,9 @@ export const RoundedWithShadow: CanvasShaderType<
49
49
  );
50
50
  }
51
51
 
52
- const path = new Path2D();
53
- render.roundRect(path, tx, ty, w, h, computed.radius);
54
- ctx.clip(path);
52
+ ctx.beginPath();
53
+ render.roundRect(ctx, tx, ty, w, h, computed.radius);
54
+ ctx.clip();
55
55
  renderContext();
56
56
  },
57
57
  };