@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
@@ -6,6 +6,7 @@ import {
6
6
  } from '../templates/RadialProgressTemplate.js';
7
7
  import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js';
8
8
  import type { WebGlRenderer } from '../../renderers/webgl/WebGlRenderer.js';
9
+ import { genGradientColors } from '../../renderers/webgl/internal/ShaderUtils.js';
9
10
 
10
11
  export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
11
12
  props: RadialProgressTemplate.props,
@@ -57,7 +58,6 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
57
58
  # endif
58
59
 
59
60
  #define MAX_STOPS ${maxStops}
60
- #define LAST_STOP ${maxStops - 1}
61
61
  #define CAP_ROUND ${props.cap}
62
62
  #define HAS_TRACK ${props.trackColor !== 0 ? 1 : 0}
63
63
 
@@ -87,22 +87,8 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
87
87
 
88
88
  vec4 getGradientColor(float dist) {
89
89
  dist = clamp(dist, 0.0, 1.0);
90
-
91
- if (dist <= u_stops[0]) {
92
- return u_colors[0];
93
- }
94
- if (dist >= u_stops[LAST_STOP]) {
95
- return u_colors[LAST_STOP];
96
- }
97
- for (int i = 0; i < LAST_STOP; i++) {
98
- float left = u_stops[i];
99
- float right = u_stops[i + 1];
100
- if (dist >= left && dist <= right) {
101
- float lDist = smoothstep(left, right, dist);
102
- return mix(u_colors[i], u_colors[i + 1], lDist);
103
- }
104
- }
105
- return u_colors[LAST_STOP];
90
+ ${genGradientColors(maxStops)}
91
+ return colorOut;
106
92
  }
107
93
 
108
94
  // Coverage of a disc centered at \`c\` with radius \`r\` at pixel \`p\` (with 1px AA)
@@ -116,9 +102,13 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
116
102
  // Effective progress: when u_duration > 0 the shader self-animates from
117
103
  // u_time, otherwise we use the static u_progress prop. countdown == 1
118
104
  // drains (1 -> 0), countdown == 0 fills (0 -> 1).
119
- float cyclePos = u_duration > 0.0 ? fract(u_time / u_duration) : 0.0;
120
- float animProgress = u_countdown > 0.5 ? 1.0 - cyclePos : cyclePos;
121
- float progress = u_duration > 0.0 ? animProgress : u_progress;
105
+ // Branchless (Mali 400 serializes uniform branches): the mix()-guarded
106
+ // denominator keeps the division finite when u_duration == 0 -- a
107
+ // max()-clamped epsilon would overflow fp16 mediump to Inf/NaN.
108
+ float hasDuration = step(0.0001, u_duration);
109
+ float cyclePos = fract(u_time / mix(1.0, u_duration, hasDuration)) * hasDuration;
110
+ float animProgress = mix(cyclePos, 1.0 - cyclePos, step(0.5, u_countdown));
111
+ float progress = mix(u_progress, animProgress, hasDuration);
122
112
 
123
113
  vec2 p = v_nodeCoords.xy * u_dimensions - u_center;
124
114
  float dist = length(p);
@@ -135,7 +125,7 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
135
125
 
136
126
  // Filled arc coverage (1 if in filled arc, else 0). When progress >= 1 the
137
127
  // whole ring is filled regardless of \`t\` -- guards against the mod() seam.
138
- float arcCoverage = progress >= 1.0 ? 1.0 : step(t, progress);
128
+ float arcCoverage = max(step(1.0, progress), step(t, progress));
139
129
  float fillCoverage = ringCoverage * arcCoverage;
140
130
 
141
131
  #if CAP_ROUND
@@ -151,8 +141,10 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
151
141
  #endif
152
142
 
153
143
  // Sample gradient. Normalize \`t\` to the *filled* portion so the gradient
154
- // spans the visible arc end-to-end regardless of progress.
155
- float gradT = progress > 0.0 ? clamp(t / progress, 0.0, 1.0) : 0.0;
144
+ // spans the visible arc end-to-end regardless of progress. mix()-guarded
145
+ // denominator: safe at progress == 0 without fp16 overflow.
146
+ float hasProgress = step(0.000001, progress);
147
+ float gradT = clamp(t / mix(1.0, progress, hasProgress), 0.0, 1.0) * hasProgress;
156
148
  vec4 fillCol = getGradientColor(gradT);
157
149
 
158
150
  // Composite: track under fill (if track enabled), both gated by ringCoverage.
@@ -64,6 +64,15 @@ export const RoundedWithBorder: WebGlShaderType<RoundedWithBorderProps> = {
64
64
  v_innerSize = vec2(0.0);
65
65
  v_outerSize = vec2(0.0);
66
66
 
67
+ // Defaults for the zero-border case: the fragment shader is branchless
68
+ // and always evaluates the border SDFs, so every varying must carry a
69
+ // finite value (an unwritten varying is undefined and can poison the
70
+ // final mix() with NaN).
71
+ v_outerBorderUv = vec2(0.0);
72
+ v_innerBorderUv = vec2(0.0);
73
+ v_outerBorderRadius = u_radius;
74
+ v_innerBorderRadius = u_radius;
75
+
67
76
  if(borderZero == 0.0) {
68
77
  vec4 adjustedBorderWidth = u_borderWidth - 1.0 + clamp(u_borderWidth, -1.0, 1.0);
69
78
 
@@ -72,9 +81,6 @@ export const RoundedWithBorder: WebGlShaderType<RoundedWithBorderProps> = {
72
81
  float borderBottom = adjustedBorderWidth.z;
73
82
  float borderLeft = adjustedBorderWidth.w;
74
83
 
75
- v_outerBorderUv = vec2(0.0);
76
- v_innerBorderUv = vec2(0.0);
77
-
78
84
  vec2 borderSize = vec2(borderRight + borderLeft, borderTop + borderBottom);
79
85
  vec2 extraSize = borderSize * u_borderAlign;
80
86
  float gapLeft = step(0.001, borderLeft) * u_borderGap;
@@ -159,52 +165,49 @@ export const RoundedWithBorder: WebGlShaderType<RoundedWithBorderProps> = {
159
165
  varying vec4 v_outerBorderRadius;
160
166
  varying vec2 v_halfDimensions;
161
167
 
168
+ // Branchless quadrant radius select (r: x TL, y TR, z BR, w BL) --
169
+ // Mali 400-class fragment pipelines serialize any branch, ternaries included.
170
+ float quadRadius(vec2 p, vec4 r) {
171
+ vec2 stepVal = step(vec2(0.0), p);
172
+ return mix(mix(r.x, r.y, stepVal.x), mix(r.w, r.z, stepVal.x), stepVal.y);
173
+ }
174
+
162
175
  float roundedBox(vec2 p, vec2 s, vec4 r) {
163
- r.xy = (p.x > 0.0) ? r.yz : r.xw;
164
- r.x = (p.y > 0.0) ? r.y : r.x;
165
- vec2 q = abs(p) - s + r.x;
166
- return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - r.x;
176
+ float rad = quadRadius(p, r);
177
+ vec2 q = abs(p) - s + rad;
178
+ return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - rad;
167
179
  }
168
180
 
169
181
  void main() {
170
182
  vec4 color = texture2D(u_texture, v_textureCoords) * v_color;
171
- vec4 resultColor = vec4(0.0);
172
183
  vec2 boxUv = v_nodeCoords.xy * u_dimensions - v_halfDimensions;
173
184
  float borderZero = 1.0 - step(0.001, dot(abs(u_borderWidth), vec4(1.0)));
174
185
  float edgeWidth = 1.0 / u_pixelRatio;
175
186
 
176
- float nodeDist;
177
- float nodeAlpha;
178
-
179
- if(borderZero == 1.0) {
180
- nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius);
181
- nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist);
182
- gl_FragColor = (color * nodeAlpha) * u_alpha;
183
- return;
184
- }
187
+ float nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius);
188
+ float nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist);
185
189
 
186
190
  float outerDist = roundedBox(boxUv + v_outerBorderUv, v_outerSize - edgeWidth, v_outerBorderRadius);
187
191
  float innerDist = roundedBox(boxUv + v_innerBorderUv, v_innerSize - edgeWidth, v_innerBorderRadius);
188
-
189
- if(u_borderGap == 0.0) {
190
- float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist);
191
- float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist);
192
- resultColor = mix(resultColor, u_borderColor, outerAlpha * u_borderColor.a);
193
- resultColor = mix(resultColor, color, innerAlpha);
194
- gl_FragColor = resultColor * u_alpha;
195
- return;
196
- }
197
-
198
- nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius);
199
- nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist);
192
+ float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist);
200
193
  float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist);
201
- float gapAlpha = max(0.0, innerAlpha - nodeAlpha);
202
194
 
195
+ float gapAlpha = max(0.0, innerAlpha - nodeAlpha);
203
196
  float borderDist = max(-innerDist, outerDist);
204
197
  float borderAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, borderDist);
205
198
 
206
- resultColor = (color * nodeAlpha) + (u_fillColor * gapAlpha);
207
- resultColor = mix(resultColor, u_borderColor, borderAlpha * u_borderColor.a);
199
+ // Branchless path select -- Mali 400 serializes uniform branches, so all
200
+ // three composites (no gap / gap / zero border) are computed and
201
+ // mix()-selected instead of if/else'd.
202
+ vec4 resNoGap = mix(vec4(0.0), u_borderColor, outerAlpha * u_borderColor.a);
203
+ resNoGap = mix(resNoGap, color, innerAlpha);
204
+
205
+ vec4 resGap = (color * nodeAlpha) + (u_fillColor * gapAlpha);
206
+ resGap = mix(resGap, u_borderColor, borderAlpha * u_borderColor.a);
207
+
208
+ float hasGap = step(0.0001, abs(u_borderGap));
209
+ vec4 resultColor = mix(resNoGap, resGap, hasGap);
210
+ resultColor = mix(resultColor, color * nodeAlpha, borderZero);
208
211
  gl_FragColor = resultColor * u_alpha;
209
212
  }
210
213
  `,
@@ -71,6 +71,15 @@ export const RoundedWithBorderAndShadow: WebGlShaderType<RoundedWithBorderAndSha
71
71
  v_innerSize = vec2(0.0);
72
72
  v_outerSize = vec2(0.0);
73
73
 
74
+ // Defaults for the zero-border case: the fragment shader is branchless
75
+ // and always evaluates the border SDFs, so every varying must carry a
76
+ // finite value (an unwritten varying is undefined and can poison the
77
+ // final mix() with NaN).
78
+ v_outerBorderUv = vec2(0.0);
79
+ v_innerBorderUv = vec2(0.0);
80
+ v_outerBorderRadius = u_radius;
81
+ v_innerBorderRadius = u_radius;
82
+
74
83
  if(borderZero == 0.0) {
75
84
  vec4 adjustedBorderWidth = u_borderWidth - 1.0 + clamp(u_borderWidth, -1.0, 1.0);
76
85
 
@@ -79,9 +88,6 @@ export const RoundedWithBorderAndShadow: WebGlShaderType<RoundedWithBorderAndSha
79
88
  float borderBottom = adjustedBorderWidth.z;
80
89
  float borderLeft = adjustedBorderWidth.w;
81
90
 
82
- v_outerBorderUv = vec2(0.0);
83
- v_innerBorderUv = vec2(0.0);
84
-
85
91
  vec2 borderSize = vec2(borderRight + borderLeft, borderTop + borderBottom);
86
92
  vec2 extraSize = borderSize * u_borderAlign;
87
93
  float gapLeft = step(0.001, borderLeft) * u_borderGap;
@@ -166,67 +172,74 @@ export const RoundedWithBorderAndShadow: WebGlShaderType<RoundedWithBorderAndSha
166
172
  varying vec4 v_outerBorderRadius;
167
173
  varying vec2 v_halfDimensions;
168
174
 
175
+ // Branchless quadrant radius select (r: x TL, y TR, z BR, w BL) --
176
+ // Mali 400-class fragment pipelines serialize any branch, ternaries included.
177
+ float quadRadius(vec2 p, vec4 r) {
178
+ vec2 stepVal = step(vec2(0.0), p);
179
+ return mix(mix(r.x, r.y, stepVal.x), mix(r.w, r.z, stepVal.x), stepVal.y);
180
+ }
181
+
169
182
  float roundedBox(vec2 p, vec2 s, vec4 r) {
170
- r.xy = (p.x > 0.0) ? r.yz : r.xw;
171
- r.x = (p.y > 0.0) ? r.y : r.x;
172
- vec2 q = abs(p) - s + r.x;
173
- return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - r.x;
183
+ float rad = quadRadius(p, r);
184
+ vec2 q = abs(p) - s + rad;
185
+ return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - rad;
174
186
  }
175
187
 
176
188
  float shadowBox(vec2 p, vec2 s, vec4 r) {
177
- r.xy = (p.x > 0.0) ? r.yz : r.xw;
178
- r.x = (p.y > 0.0) ? r.y : r.x;
179
- vec2 q = abs(p) - s + r.x;
180
- float dist = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x;
189
+ float rad = quadRadius(p, r);
190
+ vec2 q = abs(p) - s + rad;
191
+ float dist = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rad;
181
192
  return 1.0 - smoothstep(-u_shadow.w, u_shadow.w + u_shadow.z, dist);
182
193
  }
183
194
 
184
195
  void main() {
185
196
  vec4 color = texture2D(u_texture, v_textureCoords) * v_color;
186
- vec4 resultColor = vec4(0.0);
187
197
  vec2 boxUv = v_nodeCoords.xy * u_dimensions - v_halfDimensions;
188
198
  float borderZero = 1.0 - step(0.001, dot(abs(u_borderWidth), vec4(1.0)));
189
199
  float edgeWidth = 1.0 / u_pixelRatio;
190
- float nodeDist;
191
- float nodeAlpha;
192
- float shadowAlpha;
193
-
194
- if(borderZero == 1.0) {
195
- nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius);
196
- nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist);
197
- shadowAlpha = shadowBox(boxUv - u_shadow.xy, v_halfDimensions + u_shadow.w - edgeWidth, u_radius + u_shadow.z);
198
- resultColor = mix(resultColor, u_shadowColor, shadowAlpha);
199
- gl_FragColor = mix(resultColor, color, nodeAlpha) * u_alpha;
200
- return;
201
- }
202
200
 
203
- if(v_outerSize.x > v_halfDimensions.x || v_outerSize.y > v_halfDimensions.y) {
204
- shadowAlpha = shadowBox(boxUv + v_outerBorderUv - u_shadow.xy, v_outerSize + u_shadow.w - edgeWidth, v_outerBorderRadius + u_shadow.z);
205
- }
206
- else {
207
- shadowAlpha = shadowBox(boxUv - u_shadow.xy, v_halfDimensions + u_shadow.w - edgeWidth, u_radius + u_shadow.z);
208
- }
201
+ float nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius);
202
+ float nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist);
203
+
204
+ // Shadow geometry select, branchless: when the border grows the quad
205
+ // beyond the node (outer size exceeds half dimensions on either axis)
206
+ // the shadow hugs the outer border box, otherwise the node box. This
207
+ // replaces a varying-driven if/else (worst offender: diverges per
208
+ // fragment) with a step()/mix() parameter select into ONE shadowBox call.
209
+ // When borderZero, v_outerSize is vec2(0.0) so outerSel is 0 and the
210
+ // node-box parameters win, matching the old zero-border path.
211
+ float outerSel = max(
212
+ step(v_halfDimensions.x, v_outerSize.x),
213
+ step(v_halfDimensions.y, v_outerSize.y)
214
+ );
215
+ float shadowAlpha = shadowBox(
216
+ boxUv + v_outerBorderUv * outerSel - u_shadow.xy,
217
+ mix(v_halfDimensions, v_outerSize, outerSel) + u_shadow.w - edgeWidth,
218
+ mix(u_radius, v_outerBorderRadius, outerSel) + u_shadow.z
219
+ );
209
220
 
210
221
  float outerDist = roundedBox(boxUv + v_outerBorderUv, v_outerSize - edgeWidth, v_outerBorderRadius);
211
222
  float innerDist = roundedBox(boxUv + v_innerBorderUv, v_innerSize - edgeWidth, v_innerBorderRadius);
223
+ float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist);
224
+ float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist);
212
225
 
213
- if(u_borderGap == 0.0) {
214
- float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist);
215
- float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist);
216
- resultColor = mix(resultColor, u_shadowColor, shadowAlpha);
217
- resultColor = mix(resultColor, u_borderColor, outerAlpha * u_borderColor.a);
218
- resultColor = mix(resultColor, color, innerAlpha);
219
- gl_FragColor = resultColor * u_alpha;
220
- return;
221
- }
222
-
223
- nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius);
224
- nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist);
225
226
  float borderDist = max(-innerDist, outerDist);
226
227
  float borderAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, borderDist);
227
- resultColor = mix(resultColor, u_shadowColor, shadowAlpha);
228
- resultColor = mix(resultColor, color, nodeAlpha);
229
- resultColor = mix(resultColor, u_borderColor, borderAlpha * u_borderColor.a);
228
+
229
+ // Branchless path select -- Mali 400 serializes uniform branches, so all
230
+ // three composites (no gap / gap / zero border) are computed and
231
+ // mix()-selected instead of if/else'd.
232
+ vec4 shadowBase = u_shadowColor * shadowAlpha;
233
+
234
+ vec4 resNoGap = mix(shadowBase, u_borderColor, outerAlpha * u_borderColor.a);
235
+ resNoGap = mix(resNoGap, color, innerAlpha);
236
+
237
+ vec4 resFill = mix(shadowBase, color, nodeAlpha);
238
+ vec4 resGap = mix(resFill, u_borderColor, borderAlpha * u_borderColor.a);
239
+
240
+ float hasGap = step(0.0001, abs(u_borderGap));
241
+ vec4 resultColor = mix(resNoGap, resGap, hasGap);
242
+ resultColor = mix(resultColor, resFill, borderZero);
230
243
  gl_FragColor = resultColor * u_alpha;
231
244
  }
232
245
  `,
@@ -98,18 +98,23 @@ export const RoundedWithShadow: WebGlShaderType<RoundedWithShadowProps> = {
98
98
  varying vec4 v_shadowRadius;
99
99
  varying vec2 v_shadowSmooth;
100
100
 
101
+ // Branchless quadrant radius select (r: x TL, y TR, z BR, w BL) --
102
+ // Mali 400-class fragment pipelines serialize any branch, ternaries included.
103
+ float quadRadius(vec2 p, vec4 r) {
104
+ vec2 stepVal = step(vec2(0.0), p);
105
+ return mix(mix(r.x, r.y, stepVal.x), mix(r.w, r.z, stepVal.x), stepVal.y);
106
+ }
107
+
101
108
  float roundedBox(vec2 p, vec2 s, vec4 r) {
102
- r.xy = (p.x > 0.0) ? r.yz : r.xw;
103
- r.x = (p.y > 0.0) ? r.y : r.x;
104
- vec2 q = abs(p) - s + r.x;
105
- return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - r.x;
109
+ float rad = quadRadius(p, r);
110
+ vec2 q = abs(p) - s + rad;
111
+ return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - rad;
106
112
  }
107
113
 
108
114
  float shadowBox(vec2 p, vec2 s, vec4 r) {
109
- r.xy = (p.x > 0.0) ? r.yz : r.xw;
110
- r.x = (p.y > 0.0) ? r.y : r.x;
111
- vec2 q = abs(p) - s + r.x;
112
- float dist = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x;
115
+ float rad = quadRadius(p, r);
116
+ vec2 q = abs(p) - s + rad;
117
+ float dist = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rad;
113
118
  return 1.0 - smoothstep(v_shadowSmooth.x, v_shadowSmooth.y, dist);
114
119
  }
115
120
 
@@ -119,11 +124,8 @@ export const RoundedWithShadow: WebGlShaderType<RoundedWithShadowProps> = {
119
124
  float boxDist = roundedBox(v_boxUv, v_boxSize, u_radius);
120
125
  float roundedAlpha = 1.0 - smoothstep(v_boxSmooth.x, v_boxSmooth.y, boxDist);
121
126
 
122
- vec4 resColor = vec4(0.0);
123
- if (u_shadow_color.a > 0.0) {
124
- float shadowAlpha = shadowBox(v_shadowBox, v_shadowSize, v_shadowRadius);
125
- resColor = u_shadow_color * shadowAlpha;
126
- }
127
+ float shadowAlpha = shadowBox(v_shadowBox, v_shadowSize, v_shadowRadius);
128
+ vec4 resColor = u_shadow_color * (shadowAlpha * step(0.0001, u_shadow_color.a));
127
129
 
128
130
  resColor = mix(resColor, color, min(color.a, roundedAlpha));
129
131
  gl_FragColor = resColor * u_alpha;
@@ -106,11 +106,10 @@ export const Shadow: WebGlShaderType<ShadowProps> = {
106
106
  float boxDist = box(v_boxUv, v_boxSize);
107
107
  float boxAlpha = 1.0 - smoothstep(v_boxSmooth.x, v_boxSmooth.y, boxDist);
108
108
 
109
- vec4 resColor = vec4(0.0);
110
- if (u_color.a > 0.0) {
111
- float shadowDist = shadowBox(v_shadowBox, v_shadowSize, v_shadowRadius);
112
- resColor = u_color * shadowDist;
113
- }
109
+ // Branchless alpha gate -- Mali 400 serializes uniform branches; the
110
+ // step() zeroes the shadow term exactly when u_color.a == 0.
111
+ float shadowDist = shadowBox(v_shadowBox, v_shadowSize, v_shadowRadius);
112
+ vec4 resColor = u_color * (shadowDist * step(0.0001, u_color.a));
114
113
 
115
114
  resColor = mix(resColor, color, min(color.a, boxAlpha));
116
115
  gl_FragColor = resColor * u_alpha;
@@ -133,6 +133,183 @@ describe('SdfTextRenderer layout cache', () => {
133
133
  });
134
134
  });
135
135
 
136
+ describe('SdfTextRenderer renderQuads cache dispatch', () => {
137
+ type FakeRenderer = {
138
+ stage: unknown;
139
+ sdfBufferIdx: number;
140
+ fSdfBuffer: Float32Array;
141
+ addSdfQuads: ReturnType<typeof vi.fn>;
142
+ addSdfCachedQuads: ReturnType<typeof vi.fn>;
143
+ addSdfTranslatedQuads: ReturnType<typeof vi.fn>;
144
+ };
145
+
146
+ const GLYPHS = 2;
147
+
148
+ const makeRenderer = (): FakeRenderer => {
149
+ const renderer: FakeRenderer = {
150
+ stage: {
151
+ options: { textLayoutCacheSize: 10 },
152
+ shManager: {
153
+ registerShaderType: vi.fn(),
154
+ createShader: vi.fn(() => ({})),
155
+ },
156
+ },
157
+ sdfBufferIdx: 0,
158
+ fSdfBuffer: new Float32Array(1024),
159
+ // The miss path snapshots [startIdx, endIdx) of fSdfBuffer, so the
160
+ // fake must advance sdfBufferIdx like the real writer does.
161
+ addSdfQuads: vi.fn(() => {
162
+ renderer.sdfBufferIdx += GLYPHS * 24;
163
+ }),
164
+ addSdfCachedQuads: vi.fn(),
165
+ addSdfTranslatedQuads: vi.fn(),
166
+ };
167
+ return renderer;
168
+ };
169
+
170
+ const makeLayout = () => ({
171
+ glyphs: new Float32Array(GLYPHS * 8),
172
+ glyphCount: GLYPHS,
173
+ fontScale: 1,
174
+ distanceRange: 4,
175
+ width: 100,
176
+ height: 50,
177
+ });
178
+
179
+ const makeCache = () => ({
180
+ vertices: null as Float32Array | null,
181
+ glyphCount: 0,
182
+ color: 0,
183
+ alpha: -1,
184
+ transform: new Float32Array(6),
185
+ layoutRef: null as unknown,
186
+ });
187
+
188
+ const makeRenderProps = (
189
+ cache: ReturnType<typeof makeCache>,
190
+ transform: number[],
191
+ ) => ({
192
+ fontFamily: 'Test',
193
+ fontSize: 42,
194
+ color: 0xffffffff,
195
+ offsetY: 0,
196
+ worldAlpha: 1,
197
+ globalTransform: new Float32Array(transform),
198
+ clippingRect: { x: 0, y: 0, w: 0, h: 0, valid: false },
199
+ width: 100,
200
+ height: 50,
201
+ parentHasRenderTexture: false,
202
+ framebufferDimensions: null,
203
+ sdfCache: cache,
204
+ });
205
+
206
+ const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1];
207
+ const TRANSLATED = [1, 0, 0, 0, 1, 0, 120, -40, 1];
208
+ const SCALED = [2, 0, 0, 0, 2, 0, 0, 0, 1];
209
+
210
+ const renderQuads = (
211
+ renderer: FakeRenderer,
212
+ layout: ReturnType<typeof makeLayout>,
213
+ renderProps: ReturnType<typeof makeRenderProps>,
214
+ ) =>
215
+ SdfTextRenderer.renderQuads(
216
+ renderer as never,
217
+ layout as never,
218
+ null as never,
219
+ renderProps as never,
220
+ );
221
+
222
+ const setupWithAtlas = () => {
223
+ initRenderer(10);
224
+ vi.mocked(SdfFontHandler.getAtlas).mockReturnValue({
225
+ ctxTexture: {},
226
+ } as never);
227
+ };
228
+
229
+ it('takes the miss path first and snapshots the cache', () => {
230
+ setupWithAtlas();
231
+ const renderer = makeRenderer();
232
+ const layout = makeLayout();
233
+ const cache = makeCache();
234
+
235
+ renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY));
236
+
237
+ expect(renderer.addSdfQuads.mock.calls.length).toBe(1);
238
+ expect(cache.vertices).not.toBeNull();
239
+ expect(cache.layoutRef).toBe(layout);
240
+ expect(cache.glyphCount).toBe(GLYPHS);
241
+ });
242
+
243
+ it('takes the mem-copy path when nothing changed', () => {
244
+ setupWithAtlas();
245
+ const renderer = makeRenderer();
246
+ const layout = makeLayout();
247
+ const cache = makeCache();
248
+
249
+ renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY));
250
+ renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY));
251
+
252
+ expect(renderer.addSdfQuads.mock.calls.length).toBe(1);
253
+ expect(renderer.addSdfCachedQuads.mock.calls.length).toBe(1);
254
+ expect(renderer.addSdfTranslatedQuads.mock.calls.length).toBe(0);
255
+ });
256
+
257
+ it('takes the translation path when only tx/ty changed, keeping the cache base', () => {
258
+ setupWithAtlas();
259
+ const renderer = makeRenderer();
260
+ const layout = makeLayout();
261
+ const cache = makeCache();
262
+
263
+ renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY));
264
+ const baseVertices = cache.vertices;
265
+
266
+ renderQuads(renderer, layout, makeRenderProps(cache, TRANSLATED));
267
+
268
+ expect(renderer.addSdfQuads.mock.calls.length).toBe(1);
269
+ expect(renderer.addSdfCachedQuads.mock.calls.length).toBe(0);
270
+ const calls = renderer.addSdfTranslatedQuads.mock.calls;
271
+ expect(calls.length).toBe(1);
272
+ // (cachedVertices, glyphCount, dx, dy, ...)
273
+ expect(calls[0]![0]).toBe(baseVertices);
274
+ expect(calls[0]![1]).toBe(GLYPHS);
275
+ expect(calls[0]![2]).toBe(120);
276
+ expect(calls[0]![3]).toBe(-40);
277
+ // The scroll path must not re-snapshot: base transform and vertices stay
278
+ expect(cache.vertices).toBe(baseVertices);
279
+ expect(cache.transform[4]).toBe(0);
280
+ expect(cache.transform[5]).toBe(0);
281
+ });
282
+
283
+ it('falls back to the miss path when scale or rotation changed', () => {
284
+ setupWithAtlas();
285
+ const renderer = makeRenderer();
286
+ const layout = makeLayout();
287
+ const cache = makeCache();
288
+
289
+ renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY));
290
+ renderQuads(renderer, layout, makeRenderProps(cache, SCALED));
291
+
292
+ expect(renderer.addSdfQuads.mock.calls.length).toBe(2);
293
+ expect(renderer.addSdfTranslatedQuads.mock.calls.length).toBe(0);
294
+ });
295
+
296
+ it('falls back to the miss path when color or alpha changed', () => {
297
+ setupWithAtlas();
298
+ const renderer = makeRenderer();
299
+ const layout = makeLayout();
300
+ const cache = makeCache();
301
+
302
+ renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY));
303
+
304
+ const faded = makeRenderProps(cache, TRANSLATED);
305
+ faded.worldAlpha = 0.5;
306
+ renderQuads(renderer, layout, faded);
307
+
308
+ expect(renderer.addSdfQuads.mock.calls.length).toBe(2);
309
+ expect(renderer.addSdfTranslatedQuads.mock.calls.length).toBe(0);
310
+ });
311
+ });
312
+
136
313
  describe('SdfTextRenderer lazy shader compile', () => {
137
314
  it('registers the SDF shader at init but defers compilation', () => {
138
315
  const shManager = {
@@ -150,11 +150,14 @@ const addQuads = (_layout?: TextLayout): Float32Array | null => {
150
150
  /**
151
151
  * Submit SDF glyphs to the renderer's shared batched buffer.
152
152
  *
153
- * Two paths:
154
- * 1. **Cache hit** — layout, transform, color, and alpha haven't changed.
155
- * The cached pre-transformed Float32Array is mem-copied directly into the
156
- * shared SDF buffer (no per-glyph matrix math).
157
- * 2. **Cache miss** — re-computes per-glyph world-space vertices via
153
+ * Three paths:
154
+ * 1. **Exact cache hit** — layout, transform, color, and alpha haven't
155
+ * changed. The cached pre-transformed Float32Array is mem-copied directly
156
+ * into the shared SDF buffer (no per-glyph matrix math).
157
+ * 2. **Translation hit** — only tx/ty changed (the scroll path). The cached
158
+ * vertices are copied with the position delta applied; the cache keeps its
159
+ * original base so nothing is re-snapshotted.
160
+ * 3. **Cache miss** — re-computes per-glyph world-space vertices via
158
161
  * `addSdfQuads`, then snapshots the result into the cache.
159
162
  */
160
163
  const renderQuads = (
@@ -176,7 +179,7 @@ const renderQuads = (
176
179
  // Compiles on the first real SDF draw; cheap memoized lookup thereafter.
177
180
  const shader = getSdfShader(webGlRenderer.stage);
178
181
 
179
- // --- Cache-hit fast path ------------------------------------------------
182
+ // --- Cache-hit fast paths -----------------------------------------------
180
183
  if (cache !== undefined && cache.vertices !== null) {
181
184
  const ct = cache.transform;
182
185
  const t = renderProps.globalTransform;
@@ -187,13 +190,38 @@ const renderQuads = (
187
190
  ct[0] === t[0] &&
188
191
  ct[1] === t[1] &&
189
192
  ct[2] === t[3] &&
190
- ct[3] === t[4] &&
191
- ct[4] === t[6] &&
192
- ct[5] === t[7]
193
+ ct[3] === t[4]
193
194
  ) {
194
- webGlRenderer.addSdfCachedQuads(
195
+ const dx = t[6]! - ct[4]!;
196
+ const dy = t[7]! - ct[5]!;
197
+
198
+ if (dx === 0 && dy === 0) {
199
+ // Fully static: mem-copy the cached vertices as-is.
200
+ webGlRenderer.addSdfCachedQuads(
201
+ cache.vertices,
202
+ cache.glyphCount,
203
+ ctxTexture,
204
+ renderProps.clippingRect,
205
+ renderProps.worldAlpha,
206
+ layout.width,
207
+ layout.height,
208
+ renderProps.parentHasRenderTexture,
209
+ renderProps.framebufferDimensions,
210
+ shader,
211
+ );
212
+ return null;
213
+ }
214
+
215
+ // Pure translation (the scroll path): same glyphs, same scale/rotation,
216
+ // only tx/ty moved. Copy the cached vertices shifted by the delta from
217
+ // the cached base transform. The cache keeps its original base, so
218
+ // every frame recomputes from the same reference — no drift and no
219
+ // per-frame re-snapshot.
220
+ webGlRenderer.addSdfTranslatedQuads(
195
221
  cache.vertices,
196
222
  cache.glyphCount,
223
+ dx,
224
+ dy,
197
225
  ctxTexture,
198
226
  renderProps.clippingRect,
199
227
  renderProps.worldAlpha,