@solidtv/renderer 1.5.3 → 1.5.5

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 (98) hide show
  1. package/dist/exports/canvas-shaders.d.ts +1 -0
  2. package/dist/exports/canvas-shaders.js +1 -0
  3. package/dist/exports/canvas-shaders.js.map +1 -1
  4. package/dist/exports/index.d.ts +1 -0
  5. package/dist/exports/index.js +1 -0
  6. package/dist/exports/index.js.map +1 -1
  7. package/dist/exports/webgl-shaders.d.ts +1 -0
  8. package/dist/exports/webgl-shaders.js +1 -0
  9. package/dist/exports/webgl-shaders.js.map +1 -1
  10. package/dist/src/core/CoreNode.d.ts +7 -0
  11. package/dist/src/core/CoreNode.js +13 -0
  12. package/dist/src/core/CoreNode.js.map +1 -1
  13. package/dist/src/core/CoreShaderManager.js +16 -2
  14. package/dist/src/core/CoreShaderManager.js.map +1 -1
  15. package/dist/src/core/CoreTextNode.js +3 -2
  16. package/dist/src/core/CoreTextNode.js.map +1 -1
  17. package/dist/src/core/CoreTextureManager.d.ts +39 -3
  18. package/dist/src/core/CoreTextureManager.js +72 -61
  19. package/dist/src/core/CoreTextureManager.js.map +1 -1
  20. package/dist/src/core/Stage.js +6 -4
  21. package/dist/src/core/Stage.js.map +1 -1
  22. package/dist/src/core/TextureMemoryManager.js +1 -1
  23. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  24. package/dist/src/core/lib/WebGlContextWrapper.d.ts +13 -0
  25. package/dist/src/core/lib/WebGlContextWrapper.js +15 -0
  26. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  27. package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +10 -0
  28. package/dist/src/core/renderers/canvas/CanvasRenderer.js +19 -0
  29. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  30. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -3
  31. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
  32. package/dist/src/core/renderers/webgl/WebGlRenderer.js +14 -1
  33. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  34. package/dist/src/core/shaders/canvas/EdgeFade.d.ts +10 -0
  35. package/dist/src/core/shaders/canvas/EdgeFade.js +92 -0
  36. package/dist/src/core/shaders/canvas/EdgeFade.js.map +1 -0
  37. package/dist/src/core/shaders/canvas/LinearGradient.js +1 -18
  38. package/dist/src/core/shaders/canvas/LinearGradient.js.map +1 -1
  39. package/dist/src/core/shaders/templates/EdgeFadeTemplate.d.ts +32 -0
  40. package/dist/src/core/shaders/templates/EdgeFadeTemplate.js +9 -0
  41. package/dist/src/core/shaders/templates/EdgeFadeTemplate.js.map +1 -0
  42. package/dist/src/core/shaders/webgl/EdgeFade.d.ts +9 -0
  43. package/dist/src/core/shaders/webgl/EdgeFade.js +74 -0
  44. package/dist/src/core/shaders/webgl/EdgeFade.js.map +1 -0
  45. package/dist/src/core/shaders/webgl/LinearGradient.js +31 -51
  46. package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
  47. package/dist/src/core/shaders/webgl/RadialGradient.js +2 -2
  48. package/dist/src/core/shaders/webgl/RadialProgress.js +7 -0
  49. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
  50. package/dist/src/core/text-rendering/SdfFontHandler.d.ts +1 -0
  51. package/dist/src/core/text-rendering/SdfFontHandler.js +88 -51
  52. package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
  53. package/dist/src/core/textures/ImageTexture.js +2 -1
  54. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  55. package/dist/src/core/textures/SubTexture.js +1 -1
  56. package/dist/src/core/textures/SubTexture.js.map +1 -1
  57. package/dist/src/core/textures/Texture.d.ts +1 -1
  58. package/dist/src/core/textures/Texture.js +10 -13
  59. package/dist/src/core/textures/Texture.js.map +1 -1
  60. package/dist/src/main-api/Inspector.js +1 -1
  61. package/dist/src/main-api/Inspector.js.map +1 -1
  62. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  63. package/exports/canvas-shaders.ts +1 -0
  64. package/exports/index.ts +1 -0
  65. package/exports/webgl-shaders.ts +1 -0
  66. package/package.json +1 -1
  67. package/src/core/CoreNode.test.ts +165 -0
  68. package/src/core/CoreNode.ts +14 -0
  69. package/src/core/CoreShaderManager.contextLoss.test.ts +72 -0
  70. package/src/core/CoreShaderManager.ts +15 -2
  71. package/src/core/CoreTextNode.ts +3 -2
  72. package/src/core/CoreTextureManager.ts +75 -70
  73. package/src/core/Stage.ts +9 -4
  74. package/src/core/TextureMemoryManager.test.ts +2 -2
  75. package/src/core/TextureMemoryManager.ts +1 -1
  76. package/src/core/lib/WebGlContextWrapper.ts +16 -0
  77. package/src/core/renderers/canvas/CanvasRenderer.ts +20 -0
  78. package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -4
  79. package/src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts +49 -0
  80. package/src/core/renderers/webgl/WebGlRenderer.ts +13 -1
  81. package/src/core/shaders/canvas/EdgeFade.ts +105 -0
  82. package/src/core/shaders/canvas/LinearGradient.test.ts +45 -0
  83. package/src/core/shaders/canvas/LinearGradient.ts +1 -18
  84. package/src/core/shaders/templates/EdgeFadeTemplate.test.ts +35 -0
  85. package/src/core/shaders/templates/EdgeFadeTemplate.ts +41 -0
  86. package/src/core/shaders/webgl/EdgeFade.ts +84 -0
  87. package/src/core/shaders/webgl/LinearGradient.test.ts +70 -0
  88. package/src/core/shaders/webgl/LinearGradient.ts +36 -51
  89. package/src/core/shaders/webgl/RadialGradient.ts +2 -2
  90. package/src/core/shaders/webgl/RadialProgress.ts +7 -0
  91. package/src/core/text-rendering/SdfFontHandler.ts +77 -30
  92. package/src/core/text-rendering/tests/SdfFontHandler.test.ts +181 -35
  93. package/src/core/textures/ImageTexture.test.ts +91 -0
  94. package/src/core/textures/ImageTexture.ts +4 -1
  95. package/src/core/textures/SubTexture.ts +1 -1
  96. package/src/core/textures/Texture.test.ts +124 -0
  97. package/src/core/textures/Texture.ts +10 -13
  98. package/src/main-api/Inspector.ts +1 -1
@@ -31,24 +31,7 @@ export const LinearGradient: CanvasShaderType<
31
31
  y0: line * Math.sin(angle) + nHeight * 0.5,
32
32
  x1: line * Math.cos(angle + Math.PI) + nWidth * 0.5,
33
33
  y1: line * Math.sin(angle + Math.PI) + nHeight * 0.5,
34
- colors: this.props!.colors.map((value, i, arr) => {
35
- const alpha = (value >>> 24) & 0xff;
36
- if (alpha === 0) {
37
- let nearestRGB = value & 0x00ffffff;
38
- for (let step = 1; step < arr.length; step++) {
39
- if (i - step >= 0 && ((arr[i - step]! >>> 24) & 0xff) > 0) {
40
- nearestRGB = arr[i - step]! & 0x00ffffff;
41
- break;
42
- }
43
- if (i + step < arr.length && ((arr[i + step]! >>> 24) & 0xff) > 0) {
44
- nearestRGB = arr[i + step]! & 0x00ffffff;
45
- break;
46
- }
47
- }
48
- value = (value & 0xff000000) | nearestRGB;
49
- }
50
- return this.toColorString(value);
51
- }),
34
+ colors: this.props!.colors.map((value) => this.toColorString(value)),
52
35
  };
53
36
  },
54
37
  render(ctx, node, renderContext) {
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { EdgeFadeTemplate, type EdgeFadeProps } from './EdgeFadeTemplate.js';
3
+ import { resolveShaderProps } from '../../renderers/CoreShaderNode.js';
4
+
5
+ function resolve(input: Partial<EdgeFadeProps>): EdgeFadeProps {
6
+ const props = { ...input } as Record<string, unknown>;
7
+ resolveShaderProps(props, EdgeFadeTemplate.props as never);
8
+ return props as unknown as EdgeFadeProps;
9
+ }
10
+
11
+ describe('EdgeFadeTemplate', () => {
12
+ it('defaults all edges to 0 (no fade)', () => {
13
+ const props = resolve({});
14
+ expect(props.left).toBe(0);
15
+ expect(props.top).toBe(0);
16
+ expect(props.right).toBe(0);
17
+ expect(props.bottom).toBe(0);
18
+ });
19
+
20
+ it('passes through a single edge, leaving others at 0', () => {
21
+ const props = resolve({ right: 420 });
22
+ expect(props.right).toBe(420);
23
+ expect(props.left).toBe(0);
24
+ expect(props.top).toBe(0);
25
+ expect(props.bottom).toBe(0);
26
+ });
27
+
28
+ it('passes through all edges', () => {
29
+ const props = resolve({ left: 10, top: 20, right: 30, bottom: 40 });
30
+ expect(props.left).toBe(10);
31
+ expect(props.top).toBe(20);
32
+ expect(props.right).toBe(30);
33
+ expect(props.bottom).toBe(40);
34
+ });
35
+ });
@@ -0,0 +1,41 @@
1
+ import type { CoreShaderType } from '../../renderers/CoreShaderNode.js';
2
+
3
+ /**
4
+ * Properties of the {@link EdgeFade} shader
5
+ */
6
+ export interface EdgeFadeProps {
7
+ /**
8
+ * Fade distance in pixels from the left edge. Alpha ramps 0 → 1 over this
9
+ * distance. 0 disables the fade for this edge.
10
+ *
11
+ * @default 0
12
+ */
13
+ left: number;
14
+ /**
15
+ * Fade distance in pixels from the top edge.
16
+ *
17
+ * @default 0
18
+ */
19
+ top: number;
20
+ /**
21
+ * Fade distance in pixels from the right edge.
22
+ *
23
+ * @default 0
24
+ */
25
+ right: number;
26
+ /**
27
+ * Fade distance in pixels from the bottom edge.
28
+ *
29
+ * @default 0
30
+ */
31
+ bottom: number;
32
+ }
33
+
34
+ export const EdgeFadeTemplate: CoreShaderType<EdgeFadeProps> = {
35
+ props: {
36
+ left: 0,
37
+ top: 0,
38
+ right: 0,
39
+ bottom: 0,
40
+ },
41
+ };
@@ -0,0 +1,84 @@
1
+ import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js';
2
+ import {
3
+ EdgeFadeTemplate,
4
+ type EdgeFadeProps,
5
+ } from '../templates/EdgeFadeTemplate.js';
6
+
7
+ /**
8
+ * Multiplies the node's alpha by a linear ramp inward from each edge with a
9
+ * non-zero fade distance, revealing whatever is rendered behind the node.
10
+ * Unlike {@link LinearGradient} this masks the texture's own alpha instead of
11
+ * blending a gradient color over its RGB.
12
+ */
13
+ export const EdgeFade: WebGlShaderType<EdgeFadeProps> = {
14
+ props: EdgeFadeTemplate.props,
15
+ update() {
16
+ const props = this.props!;
17
+ // Reciprocals are uploaded so the fragment shader needs no division and
18
+ // no zero-guard branch: 1e6 saturates clamp(px * recip) to 1.0 within a
19
+ // fraction of a pixel, which is exactly "no fade on this edge".
20
+ this.uniform4f(
21
+ 'u_fadeRecip',
22
+ props.left > 0 ? 1 / props.left : 1e6,
23
+ props.top > 0 ? 1 / props.top : 1e6,
24
+ props.right > 0 ? 1 / props.right : 1e6,
25
+ props.bottom > 0 ? 1 / props.bottom : 1e6,
26
+ );
27
+ },
28
+ vertex: `
29
+ # ifdef GL_FRAGMENT_PRECISION_HIGH
30
+ precision highp float;
31
+ # else
32
+ precision mediump float;
33
+ # endif
34
+
35
+ attribute vec2 a_position;
36
+ attribute vec2 a_textureCoords;
37
+ attribute vec4 a_color;
38
+ attribute vec2 a_nodeCoords;
39
+
40
+ uniform vec2 u_resolution;
41
+ uniform float u_pixelRatio;
42
+
43
+ varying vec4 v_color;
44
+ varying vec2 v_textureCoords;
45
+ varying vec2 v_nodeCoords;
46
+
47
+ void main() {
48
+ vec2 normalized = a_position * u_pixelRatio / u_resolution;
49
+ vec2 zero_two = normalized * 2.0;
50
+ vec2 clip_space = zero_two - 1.0;
51
+
52
+ v_color = a_color;
53
+ v_textureCoords = a_textureCoords;
54
+ v_nodeCoords = a_nodeCoords;
55
+
56
+ gl_Position = vec4(clip_space * vec2(1.0, -1.0), 0, 1);
57
+ }
58
+ `,
59
+ fragment: `
60
+ # ifdef GL_FRAGMENT_PRECISION_HIGH
61
+ precision highp float;
62
+ # else
63
+ precision mediump float;
64
+ # endif
65
+
66
+ uniform vec2 u_dimensions;
67
+ uniform sampler2D u_texture;
68
+ uniform vec4 u_fadeRecip;
69
+
70
+ varying vec4 v_color;
71
+ varying vec2 v_textureCoords;
72
+ varying vec2 v_nodeCoords;
73
+
74
+ void main() {
75
+ vec4 color = v_color * texture2D(u_texture, v_textureCoords);
76
+ vec2 px = v_nodeCoords * u_dimensions;
77
+ float fade = clamp(px.x * u_fadeRecip.x, 0.0, 1.0)
78
+ * clamp(px.y * u_fadeRecip.y, 0.0, 1.0)
79
+ * clamp((u_dimensions.x - px.x) * u_fadeRecip.z, 0.0, 1.0)
80
+ * clamp((u_dimensions.y - px.y) * u_fadeRecip.w, 0.0, 1.0);
81
+ gl_FragColor = color * fade;
82
+ }
83
+ `,
84
+ };
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { LinearGradient } from './LinearGradient.js';
3
+
4
+ interface GradUniforms {
5
+ a: [number, number];
6
+ b: number;
7
+ }
8
+
9
+ /**
10
+ * Invoke the WebGL LinearGradient `update()` with a fake shader node that
11
+ * captures the gradient uniforms (`u_grad_a` / `u_grad_b`). The fragment
12
+ * shader computes `dist = dot(v_textureCoords, u_grad_a) + u_grad_b`, so these
13
+ * uniforms fully define the gradient ramp.
14
+ */
15
+ function computeGrad(angle: number, w: number, h: number): GradUniforms {
16
+ const out: GradUniforms = { a: [0, 0], b: 0 };
17
+ const ctx = {
18
+ props: { colors: [0x000000ff, 0xffffffff], stops: [0, 1], angle },
19
+ uniform2f: (name: string, v0: number, v1: number) => {
20
+ if (name === 'u_grad_a') out.a = [v0, v1];
21
+ },
22
+ uniform1f: (name: string, v: number) => {
23
+ if (name === 'u_grad_b') out.b = v;
24
+ },
25
+ uniform1fv: () => undefined,
26
+ uniform4fv: () => undefined,
27
+ };
28
+ LinearGradient.update!.call(ctx as never, { w, h } as never);
29
+ return out;
30
+ }
31
+
32
+ const distAt = (g: GradUniforms, tx: number, ty: number) =>
33
+ tx * g.a[0] + ty * g.a[1] + g.b;
34
+
35
+ describe('webgl LinearGradient gradient uniforms', () => {
36
+ it('angle 0 ramps top -> bottom across the node-local box', () => {
37
+ const g = computeGrad(0, 200, 100);
38
+ expect(g.a[0]).toBeCloseTo(0, 5);
39
+ expect(g.a[1]).toBeCloseTo(1, 5);
40
+ expect(g.b).toBeCloseTo(0, 5);
41
+ // dist runs 0 (top) -> 1 (bottom)
42
+ expect(distAt(g, 0.5, 0)).toBeCloseTo(0, 5);
43
+ expect(distAt(g, 0.5, 1)).toBeCloseTo(1, 5);
44
+ });
45
+
46
+ it('angle 90deg ramps horizontally', () => {
47
+ const g = computeGrad(Math.PI / 2, 200, 100);
48
+ expect(g.a[0]).toBeCloseTo(-1, 5);
49
+ expect(g.a[1]).toBeCloseTo(0, 5);
50
+ expect(distAt(g, 0, 0.5)).toBeCloseTo(1, 5);
51
+ expect(distAt(g, 1, 0.5)).toBeCloseTo(0, 5);
52
+ });
53
+
54
+ it('endpoints stay within [0,1] for an arbitrary angle', () => {
55
+ const g = computeGrad(Math.PI / 4, 640, 360);
56
+ // Box corners must map into the clampable [0,1] ramp range.
57
+ const corners = [
58
+ distAt(g, 0, 0),
59
+ distAt(g, 1, 0),
60
+ distAt(g, 0, 1),
61
+ distAt(g, 1, 1),
62
+ ];
63
+ for (let i = 0; i < corners.length; i++) {
64
+ expect(corners[i]).toBeGreaterThanOrEqual(-1e-6);
65
+ expect(corners[i]).toBeLessThanOrEqual(1 + 1e-6);
66
+ }
67
+ // The gradient axis is symmetric about the box center -> dist = 0.5 there.
68
+ expect(distAt(g, 0.5, 0.5)).toBeCloseTo(0.5, 5);
69
+ });
70
+ });
@@ -1,3 +1,4 @@
1
+ import type { CoreNode } from '../../CoreNode.js';
1
2
  import { getNormalizedRgbaComponents } from '../../lib/utils.js';
2
3
  import {
3
4
  LinearGradientTemplate,
@@ -8,9 +9,33 @@ import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js';
8
9
 
9
10
  export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
10
11
  props: LinearGradientTemplate.props,
11
- update() {
12
+ update(node: CoreNode) {
12
13
  const props = this.props!;
13
- this.uniform1f('u_angle', props.angle - (Math.PI / 180) * 90);
14
+
15
+ // The gradient distance is an affine function of the node-local texture
16
+ // coordinates, so it reduces to `dist = dot(v_textureCoords, a) + b`.
17
+ // `a`/`b` depend only on the angle and node dimensions (both are part of
18
+ // the value-key), so we compute them once on the CPU here instead of
19
+ // recomputing the trig per fragment on the GPU.
20
+ const angle = props.angle - (Math.PI / 180) * 90;
21
+ const c = Math.cos(angle);
22
+ const s = Math.sin(angle);
23
+ const w = node.w;
24
+ const h = node.h;
25
+
26
+ const lineDist = Math.abs(w * c) + Math.abs(h * s);
27
+ // Gradient axis (from -> to), gradVec = -lineDist * (c, s)
28
+ const gx = -lineDist * c;
29
+ const gy = -lineDist * s;
30
+ const gg = gx * gx + gy * gy;
31
+ const invGG = gg > 0 ? 1 / gg : 0;
32
+ // Gradient origin: f = lineDist * 0.5 * (c, s) + dimensions * 0.5
33
+ const fx = lineDist * 0.5 * c + w * 0.5;
34
+ const fy = lineDist * 0.5 * s + h * 0.5;
35
+
36
+ this.uniform2f('u_grad_a', w * gx * invGG, h * gy * invGG);
37
+ this.uniform1f('u_grad_b', -(fx * gx + fy * gy) * invGG);
38
+
14
39
  this.uniform1fv('u_stops', new Float32Array(props.stops));
15
40
  const colors: number[] = [];
16
41
  for (let i = 0; i < props.colors.length; i++) {
@@ -22,51 +47,6 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
22
47
  getCacheMarkers(props: LinearGradientProps) {
23
48
  return `colors:${props.colors.length}`;
24
49
  },
25
- vertex: `
26
- # ifdef GL_FRAGMENT_PRECISION_HIGH
27
- precision highp float;
28
- # else
29
- precision mediump float;
30
- # endif
31
-
32
- attribute vec2 a_position;
33
- attribute vec2 a_textureCoords;
34
- attribute vec4 a_color;
35
-
36
- uniform vec2 u_resolution;
37
- uniform float u_pixelRatio;
38
- uniform vec2 u_dimensions;
39
- uniform float u_angle;
40
-
41
- varying vec4 v_color;
42
- varying vec2 v_textureCoords;
43
- varying float v_dist;
44
-
45
- const float PI = 3.14159265359;
46
-
47
- vec2 calcPoint(float d, float angle) {
48
- return d * vec2(cos(angle), sin(angle)) + (u_dimensions * 0.5);
49
- }
50
-
51
- void main() {
52
- vec2 normalized = a_position * u_pixelRatio / u_resolution;
53
- vec2 zero_two = normalized * 2.0;
54
- vec2 clip_space = zero_two - 1.0;
55
-
56
- gl_Position = vec4(clip_space * vec2(1.0, -1.0), 0, 1);
57
-
58
- v_color = a_color;
59
- v_textureCoords = a_textureCoords;
60
-
61
- float a = u_angle;
62
- float lineDist = abs(u_dimensions.x * cos(a)) + abs(u_dimensions.y * sin(a));
63
- vec2 f = calcPoint(lineDist * 0.5, a);
64
- vec2 t = calcPoint(lineDist * 0.5, a + PI);
65
- vec2 gradVec = t - f;
66
- float dist = dot(a_textureCoords * u_dimensions - f, gradVec) / dot(gradVec, gradVec);
67
- v_dist = dist;
68
- }
69
- `,
70
50
  fragment(renderer: WebGlRenderer, props: LinearGradientProps) {
71
51
  return `
72
52
  # ifdef GL_FRAGMENT_PRECISION_HIGH
@@ -78,13 +58,17 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
78
58
  #define MAX_STOPS ${props.colors.length}
79
59
  #define LAST_STOP ${props.colors.length - 1}
80
60
 
61
+ uniform float u_alpha;
62
+
81
63
  uniform sampler2D u_texture;
64
+
65
+ uniform vec2 u_grad_a;
66
+ uniform float u_grad_b;
82
67
  uniform float u_stops[MAX_STOPS];
83
68
  uniform vec4 u_colors[MAX_STOPS];
84
69
 
85
70
  varying vec4 v_color;
86
71
  varying vec2 v_textureCoords;
87
- varying float v_dist;
88
72
 
89
73
  vec4 getGradientColor(float dist) {
90
74
  dist = clamp(dist, 0.0, 1.0);
@@ -110,9 +94,10 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
110
94
 
111
95
  void main() {
112
96
  vec4 color = texture2D(u_texture, v_textureCoords) * v_color;
113
- vec4 colorOut = getGradientColor(v_dist);
114
- vec3 blendedRGB = mix(color.rgb, colorOut.rgb, clamp(colorOut.a, 0.0, 1.0));
115
- gl_FragColor = vec4(blendedRGB, color.a);
97
+ float dist = dot(v_textureCoords, u_grad_a) + u_grad_b;
98
+ vec4 colorOut = getGradientColor(dist);
99
+ color = mix(color, colorOut, clamp(colorOut.a, 0.0, 1.0));
100
+ gl_FragColor = color * u_alpha;
116
101
  }
117
102
  `;
118
103
  },
@@ -83,8 +83,8 @@ export const RadialGradient: WebGlShaderType<RadialGradientProps> = {
83
83
  float dist = length((point - u_projection) / u_size);
84
84
 
85
85
  vec4 colorOut = getGradientColor(dist);
86
- vec3 blendedRGB = mix(color.rgb, colorOut.rgb, clamp(colorOut.a, 0.0, 1.0));
87
- gl_FragColor = vec4(blendedRGB, color.a);
86
+ color = mix(color, colorOut, clamp(colorOut.a, 0.0, 1.0));
87
+ gl_FragColor = color * u_alpha;
88
88
  }
89
89
  `;
90
90
  },
@@ -171,6 +171,13 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
171
171
  layer = fillPM * fillCoverage;
172
172
  #endif
173
173
 
174
+ // Apply node opacity to the introduced ring/track colors. They come
175
+ // from u_colors / u_trackColor and do not carry worldAlpha, so without
176
+ // this a fading RadialProgress node would keep its ring fully opaque.
177
+ // \`base\` already includes worldAlpha (via v_color), so it is left as-is
178
+ // to avoid double-applying. Scaling a premultiplied layer is valid.
179
+ layer *= u_alpha;
180
+
174
181
  // Premultiplied "over": out = src + dst*(1 - src.a). The output stays
175
182
  // visible on a fully-transparent \`base\` because layer brings its own alpha.
176
183
  float la = clamp(layer.a, 0.0, 1.0);
@@ -109,6 +109,12 @@ export interface SdfFont {
109
109
  maxCharHeight: number;
110
110
  }
111
111
 
112
+ // Number of times a failed font load is automatically retried before
113
+ // loadFont() finally rejects. Counts reloads *after* the initial attempt
114
+ // (matching the `maxRetryCount` = retries convention used for textures), so
115
+ // the load is attempted up to MAX_FONT_LOAD_RETRIES + 1 times in total.
116
+ export const MAX_FONT_LOAD_RETRIES = 3;
117
+
112
118
  //global state variables for SdfFontHandler
113
119
  const fontCache = new Map<string, SdfFont>();
114
120
  const fontLoadPromises = new Map<string, Promise<void>>();
@@ -320,10 +326,18 @@ export const loadFont = (
320
326
  );
321
327
  }
322
328
 
323
- const nwff: CoreTextNode[] = (nodesWaitingForFont[fontFamily] = []);
324
- // Create loading promise
325
- const loadPromise = (async (): Promise<void> => {
326
- const fontData = await new Promise<SdfFontData>((resolve, reject) => {
329
+ // Reuse an existing waiter list. A previous load attempt for this font may
330
+ // have failed and left nodes parked here; overwriting the list would strand
331
+ // them, so a successful retry could never wake them. The list is consumed
332
+ // (and deleted) on the next successful load.
333
+ let nwff = nodesWaitingForFont[fontFamily];
334
+ if (nwff === undefined) {
335
+ nwff = nodesWaitingForFont[fontFamily] = [];
336
+ }
337
+ // One attempt at fetching + decoding the JSON atlas description. A fresh
338
+ // XHR runs per attempt so a transient network/parse failure can recover.
339
+ const fetchFontData = (): Promise<SdfFontData> =>
340
+ new Promise<SdfFontData>((resolve, reject) => {
327
341
  const xhr = new XMLHttpRequest();
328
342
  xhr.open('GET', atlasDataUrl, true);
329
343
  xhr.responseType = 'json';
@@ -352,16 +366,23 @@ export const loadFont = (
352
366
  };
353
367
  xhr.send(null);
354
368
  });
369
+
370
+ // One attempt at loading the atlas texture for the given font data. On
371
+ // success it processes + caches the font and wakes parked nodes. On failure
372
+ // it drops the dead atlas texture (createTexture caches by `src`, so the
373
+ // next attempt must evict it to build a fresh one) and rejects.
374
+ const loadAtlas = (fontData: SdfFontData): Promise<void> => {
355
375
  if (!fontData || !fontData.chars) {
356
- throw new Error('Invalid SDF font data format');
376
+ return Promise.reject(new Error('Invalid SDF font data format'));
357
377
  }
358
378
 
359
379
  // Atlas texture should be provided externally
360
380
  if (!atlasUrl) {
361
- throw new Error('Atlas texture must be provided for SDF fonts');
381
+ return Promise.reject(
382
+ new Error('Atlas texture must be provided for SDF fonts'),
383
+ );
362
384
  }
363
385
 
364
- // Wait for atlas texture to load
365
386
  return new Promise<void>((resolve, reject) => {
366
387
  // create new atlas texture using ImageTexture
367
388
  const atlasTexture = stage.txManager.createTexture('ImageTexture', {
@@ -372,46 +393,72 @@ export const loadFont = (
372
393
  atlasTexture.setRenderableOwner(fontFamily, true);
373
394
  atlasTexture.preventCleanup = true; // Prevent automatic cleanup
374
395
 
375
- if (atlasTexture.state === 'loaded') {
376
- // If already loaded, process immediately
377
- processFontData(fontFamily, fontData, atlasTexture, metrics);
378
- fontLoadPromises.delete(fontFamily);
379
-
380
- for (let key in nwff) {
381
- nwff[key]!.setUpdateType(UpdateType.Local);
382
- }
383
- delete nodesWaitingForFont[fontFamily];
384
- return resolve();
385
- }
386
-
387
- atlasTexture.on('loaded', () => {
396
+ const onLoaded = () => {
388
397
  // Process and cache font data
389
398
  processFontData(fontFamily, fontData, atlasTexture, metrics);
390
399
 
391
- // remove from promises
392
- fontLoadPromises.delete(fontFamily);
393
-
394
400
  for (let key in nwff) {
395
401
  nwff[key]!.setUpdateType(UpdateType.Local);
396
402
  }
397
403
  delete nodesWaitingForFont[fontFamily];
398
404
  resolve();
399
- });
405
+ };
406
+
407
+ if (atlasTexture.state === 'loaded') {
408
+ // If already loaded, process immediately
409
+ onLoaded();
410
+ return;
411
+ }
412
+
413
+ atlasTexture.on('loaded', onLoaded);
400
414
 
401
415
  // EventEmitter invokes listeners as (target, data), so the error payload
402
416
  // is the SECOND argument. The first arg is the Texture that emitted the
403
417
  // event. Reading it as the only param (the previous behavior) rejected
404
418
  // and logged the Texture instead of the actual TextureError.
405
419
  atlasTexture.on('failed', (_target, error: TextureError) => {
406
- // Cleanup on error
407
- fontLoadPromises.delete(fontFamily);
408
- if (fontCache[fontFamily]) {
409
- delete fontCache[fontFamily];
410
- }
411
- console.error(`Failed to load SDF font: ${fontFamily}`, error);
420
+ // Drop the failed atlas so a retry builds a fresh texture rather than
421
+ // getting this dead instance back from the createTexture key-cache.
422
+ atlasTexture.setRenderableOwner(fontFamily, false);
423
+ stage.txManager.removeTextureFromCache(atlasTexture);
412
424
  reject(error);
413
425
  });
414
426
  });
427
+ };
428
+
429
+ // Initial attempt plus up to MAX_FONT_LOAD_RETRIES automatic reloads.
430
+ const loadPromise = (async (): Promise<void> => {
431
+ let lastError: unknown;
432
+ for (let attempt = 0; attempt <= MAX_FONT_LOAD_RETRIES; attempt++) {
433
+ try {
434
+ await loadAtlas(await fetchFontData());
435
+ // Success: clear the in-flight marker (the font now lives in fontCache)
436
+ // — parked nodes were already woken inside loadAtlas.
437
+ fontLoadPromises.delete(fontFamily);
438
+ return;
439
+ } catch (error) {
440
+ lastError = error;
441
+ if (attempt < MAX_FONT_LOAD_RETRIES) {
442
+ console.warn(
443
+ `SDF font "${fontFamily}" failed to load (attempt ${
444
+ attempt + 1
445
+ } of ${MAX_FONT_LOAD_RETRIES + 1}), retrying.`,
446
+ error,
447
+ );
448
+ }
449
+ }
450
+ }
451
+
452
+ // Every attempt failed. Clear the in-flight marker so the font can be
453
+ // requested again and drop any partial cache entry. nodesWaitingForFont
454
+ // is deliberately kept: nodes parked here must survive so a later
455
+ // loadFont() (which reuses the list) can still wake them if the font
456
+ // eventually loads. The list shrinks as nodes self-remove via
457
+ // stopWaitingForFont on destroy.
458
+ fontLoadPromises.delete(fontFamily);
459
+ fontCache.delete(fontFamily);
460
+ console.error(`Failed to load SDF font: ${fontFamily}`, lastError);
461
+ throw lastError;
415
462
  })();
416
463
 
417
464
  fontLoadPromises.set(fontFamily, loadPromise);