@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
@@ -17,8 +17,16 @@ export class CanvasRenderer extends CoreRenderer {
17
17
  private canvas: HTMLCanvasElement;
18
18
  private pixelRatio: number;
19
19
  private clearColor: string;
20
+ private clearColorAlpha: number;
20
21
  public renderToTextureActive = false;
21
22
  activeRttNode: CoreNode | null = null;
23
+ // Scratch state for the preallocated shader renderContext callback below —
24
+ // valid only for the duration of the shader render() call in addQuad.
25
+ private shaderContextNode: CoreNode | null = null;
26
+ private shaderContextTexture: Texture | null = null;
27
+ private shaderRenderContext = () => {
28
+ this.renderContext(this.shaderContextNode!, this.shaderContextTexture!);
29
+ };
22
30
 
23
31
  constructor(options: CoreRendererOptions) {
24
32
  super(options);
@@ -29,16 +37,23 @@ export class CanvasRenderer extends CoreRenderer {
29
37
  this.context = canvas.getContext('2d') as CanvasRenderingContext2D;
30
38
  this.pixelRatio = this.stage.pixelRatio;
31
39
  this.clearColor = normalizeCanvasColor(this.stage.clearColor);
40
+ this.clearColorAlpha = (this.stage.clearColor >>> 24) & 0xff;
32
41
  }
33
42
 
34
43
  reset(): void {
35
- this.canvas.width = this.canvas.width; // quick reset canvas
36
-
37
44
  const ctx = this.context;
45
+ const w = this.canvas.width;
46
+ const h = this.canvas.height;
47
+
48
+ // The frame's save/restore pairs are balanced, so resetting the transform
49
+ // and clearing is equivalent to the `canvas.width = canvas.width` trick
50
+ // without tearing down the backing store every frame.
51
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
52
+ ctx.clearRect(0, 0, w, h);
38
53
 
39
- if (this.clearColor) {
54
+ if (this.clearColorAlpha !== 0) {
40
55
  ctx.fillStyle = this.clearColor;
41
- ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
56
+ ctx.fillRect(0, 0, w, h);
42
57
  }
43
58
 
44
59
  ctx.scale(this.pixelRatio, this.pixelRatio);
@@ -72,7 +87,7 @@ export class CanvasRenderer extends CoreRenderer {
72
87
  }
73
88
  }
74
89
 
75
- const hasTransform = ta !== 1;
90
+ const hasTransform = ta !== 1 || td !== 1 || tb !== 0 || tc !== 0;
76
91
  const clippingValid = clippingRect.valid === true;
77
92
 
78
93
  // If the clipping rect is valid but zero-area, the node is fully clipped — skip rendering
@@ -99,10 +114,9 @@ export class CanvasRenderer extends CoreRenderer {
99
114
  }
100
115
 
101
116
  if (hasClipping === true) {
102
- const path = new Path2D();
103
- const { x, y, w, h } = clippingRect;
104
- path.rect(x, y, w, h);
105
- ctx.clip(path);
117
+ ctx.beginPath();
118
+ ctx.rect(clippingRect.x, clippingRect.y, clippingRect.w, clippingRect.h);
119
+ ctx.clip();
106
120
  }
107
121
 
108
122
  if (hasTransform === true) {
@@ -121,12 +135,11 @@ export class CanvasRenderer extends CoreRenderer {
121
135
  }
122
136
 
123
137
  if (hasShader === true) {
124
- let renderContext: (() => void) | null = () => {
125
- this.renderContext(node, texture);
126
- };
127
-
128
- (shader as CanvasShaderNode).render(ctx, node, renderContext);
129
- renderContext = null;
138
+ this.shaderContextNode = node;
139
+ this.shaderContextTexture = texture;
140
+ (shader as CanvasShaderNode).render(ctx, node, this.shaderRenderContext);
141
+ this.shaderContextNode = null;
142
+ this.shaderContextTexture = null;
130
143
  } else {
131
144
  this.renderContext(node, texture);
132
145
  }
@@ -345,6 +358,7 @@ export class CanvasRenderer extends CoreRenderer {
345
358
  */
346
359
  updateClearColor(color: number) {
347
360
  this.clearColor = normalizeCanvasColor(color);
361
+ this.clearColorAlpha = (color >>> 24) & 0xff;
348
362
  }
349
363
 
350
364
  override getTextureCoords(
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { CanvasShaderNode, type CanvasShaderType } from './CanvasShaderNode.js';
3
+ import type { CoreNode } from '../../CoreNode.js';
4
+ import type { Stage } from '../../Stage.js';
5
+
6
+ type TestProps = { radius: number };
7
+
8
+ const makeShaderNode = (props: TestProps) => {
9
+ const shManager = {
10
+ getShaderValues: vi.fn(),
11
+ setShaderValues: vi.fn(),
12
+ mutateShaderValueUsage: vi.fn(),
13
+ };
14
+ const stage = { shManager } as unknown as Stage;
15
+ const updater = vi.fn(function (this: CanvasShaderNode<TestProps>) {
16
+ (this.computed as Record<string, unknown>).value = 'computed';
17
+ });
18
+ const config = {
19
+ props: { radius: 0 },
20
+ render: vi.fn(),
21
+ update: updater,
22
+ } as unknown as CanvasShaderType<TestProps>;
23
+ const shaderNode = new CanvasShaderNode<TestProps>(
24
+ 'test',
25
+ config,
26
+ stage,
27
+ props,
28
+ );
29
+ shaderNode.attachNode({ w: 100, h: 50 } as CoreNode);
30
+ return { shaderNode, shManager, updater };
31
+ };
32
+
33
+ describe('CanvasShaderNode.update value cache', () => {
34
+ it('computes and registers values on a cache miss', () => {
35
+ const { shaderNode, shManager, updater } = makeShaderNode({ radius: 4 });
36
+ shManager.getShaderValues.mockReturnValue(undefined);
37
+
38
+ shaderNode.update!();
39
+
40
+ expect(updater).toHaveBeenCalledTimes(1);
41
+ expect(shManager.setShaderValues).toHaveBeenCalledTimes(1);
42
+ expect(shManager.setShaderValues).toHaveBeenCalledWith(
43
+ shaderNode.valueKey,
44
+ shaderNode.computed,
45
+ );
46
+ expect((shaderNode.computed as Record<string, unknown>).value).toBe(
47
+ 'computed',
48
+ );
49
+ });
50
+
51
+ it('reuses cached values on a cache hit without recomputing', () => {
52
+ const { shaderNode, shManager, updater } = makeShaderNode({ radius: 4 });
53
+ const cached = { value: 'cached' };
54
+ shManager.getShaderValues.mockReturnValue(cached);
55
+
56
+ shaderNode.update!();
57
+
58
+ expect(shaderNode.computed).toBe(cached);
59
+ expect(updater).not.toHaveBeenCalled();
60
+ expect(shManager.setShaderValues).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('skips all work when the value key is unchanged', () => {
64
+ const { shaderNode, shManager, updater } = makeShaderNode({ radius: 4 });
65
+ shManager.getShaderValues.mockReturnValue(undefined);
66
+
67
+ shaderNode.update!();
68
+ shaderNode.update!();
69
+
70
+ expect(updater).toHaveBeenCalledTimes(1);
71
+ expect(shManager.getShaderValues).toHaveBeenCalledTimes(1);
72
+ });
73
+ });
@@ -63,6 +63,7 @@ export class CanvasShaderNode<
63
63
  ) as Record<string, unknown>;
64
64
  if (computed !== undefined) {
65
65
  this.computed = computed as Computed;
66
+ return;
66
67
  }
67
68
  this.computed = {};
68
69
  this.updater!(this.node as CoreNode);
@@ -60,7 +60,7 @@ export class SdfRenderOp extends CoreRenderOp {
60
60
  }
61
61
 
62
62
  draw() {
63
- const { glw, options, stage } = this.renderer;
63
+ const { glw, stage } = this.renderer;
64
64
 
65
65
  stage.shManager.useShader(this.shader.program);
66
66
  this.shader.program.bindRenderOp(this);
@@ -73,7 +73,7 @@ export class SdfRenderOp extends CoreRenderOp {
73
73
  const clipWidth = Math.round(this.clippingRect.w * pixelRatio);
74
74
  const clipHeight = Math.round(this.clippingRect.h * pixelRatio);
75
75
  let clipY = Math.round(
76
- options.canvas.height - clipHeight - this.clippingRect.y * pixelRatio,
76
+ glw.canvasH - clipHeight - this.clippingRect.y * pixelRatio,
77
77
  );
78
78
  // if parent has render texture, we need to adjust the scissor rect
79
79
  // to be relative to the parent's framebuffer
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { WebGlRenderer } from './WebGlRenderer.js';
3
+
4
+ /**
5
+ * Tests for the shader-node batching gate in WebGlRenderer.reuseRenderOp.
6
+ *
7
+ * A full renderer needs a live GL context, so the method is exercised on a
8
+ * minimal fake `this` holding only curRenderOp. Nodes and shader nodes are
9
+ * plain objects with the fields reuseRenderOp reads.
10
+ */
11
+
12
+ const clippingRect = { x: 0, y: 0, w: 0, h: 0, valid: false };
13
+
14
+ type FakeShaderNode = {
15
+ shaderKey: string;
16
+ program: { reuseRenderOp: ReturnType<typeof vi.fn> };
17
+ uniforms: object;
18
+ resolvedProps: Record<string, unknown>;
19
+ };
20
+
21
+ const makeShaderNode = (
22
+ program: FakeShaderNode['program'],
23
+ uniforms: object,
24
+ shaderKey = 'Rounded',
25
+ ): FakeShaderNode => ({
26
+ shaderKey,
27
+ program,
28
+ uniforms,
29
+ resolvedProps: { radius: [16, 16, 16, 16] },
30
+ });
31
+
32
+ const makeProgram = (batches = true) => ({
33
+ reuseRenderOp: vi.fn(() => batches),
34
+ });
35
+
36
+ // Node shape shared by the op anchor and the incoming quad
37
+ const makeNode = (shader: FakeShaderNode) => ({
38
+ isCoreNode: true,
39
+ parentHasRenderTexture: false,
40
+ parentFramebufferDimensions: null,
41
+ rtt: false,
42
+ clippingRect,
43
+ props: { rtt: false, shader },
44
+ shader,
45
+ });
46
+
47
+ const reuse = (curRenderOp: unknown, node: unknown): boolean =>
48
+ WebGlRenderer.prototype.reuseRenderOp.call(
49
+ { curRenderOp } as WebGlRenderer,
50
+ node as never,
51
+ );
52
+
53
+ describe('WebGlRenderer.reuseRenderOp shader batching', () => {
54
+ it('rejects when there is no current render op', () => {
55
+ const shader = makeShaderNode(makeProgram(), {});
56
+ expect(reuse(null, makeNode(shader))).toBe(false);
57
+ });
58
+
59
+ it('batches two quads using the same shader node', () => {
60
+ const shader = makeShaderNode(makeProgram(), {});
61
+
62
+ expect(reuse(makeNode(shader), makeNode(shader))).toBe(true);
63
+ expect(shader.program.reuseRenderOp.mock.calls.length).toBe(1);
64
+ });
65
+
66
+ it('batches distinct shader nodes sharing program and uniform collection', () => {
67
+ const program = makeProgram();
68
+ const uniforms = {};
69
+ const shaderA = makeShaderNode(program, uniforms);
70
+ const shaderB = makeShaderNode(program, uniforms);
71
+
72
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(true);
73
+ });
74
+
75
+ it('rejects distinct shader nodes with different uniform collections', () => {
76
+ const program = makeProgram();
77
+ const shaderA = makeShaderNode(program, {});
78
+ const shaderB = makeShaderNode(program, {});
79
+
80
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
81
+ // Gate rejects before the program-level prop compare runs
82
+ expect(program.reuseRenderOp.mock.calls.length).toBe(0);
83
+ });
84
+
85
+ it('rejects equal uniform collections from different programs', () => {
86
+ // The shader value key does not include the shader type, so a collection
87
+ // shared across programs (key collision) must never merge draw calls.
88
+ const uniforms = {};
89
+ const shaderA = makeShaderNode(makeProgram(), uniforms, 'TypeA');
90
+ const shaderB = makeShaderNode(makeProgram(), uniforms, 'TypeB');
91
+
92
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
93
+ });
94
+
95
+ it('batches default-shader quads without consulting the program', () => {
96
+ const shaderA = makeShaderNode(makeProgram(), {}, 'default');
97
+ const shaderB = makeShaderNode(makeProgram(), {}, 'default');
98
+
99
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(true);
100
+ expect(shaderA.program.reuseRenderOp.mock.calls.length).toBe(0);
101
+ });
102
+
103
+ it('rejects when clipping rects differ', () => {
104
+ const shader = makeShaderNode(makeProgram(), {});
105
+ const node = makeNode(shader);
106
+ node.clippingRect = { x: 0, y: 0, w: 100, h: 100, valid: true };
107
+
108
+ expect(reuse(makeNode(shader), node)).toBe(false);
109
+ });
110
+
111
+ it('defers to the program-level check for batched shader nodes', () => {
112
+ const program = makeProgram(false);
113
+ const uniforms = {};
114
+ const shaderA = makeShaderNode(program, uniforms);
115
+ const shaderB = makeShaderNode(program, uniforms);
116
+
117
+ // Same program + shared collection passes the gate, but the program can
118
+ // still veto (e.g. worldAlpha mismatch on a u_alpha shader).
119
+ expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
120
+ expect(program.reuseRenderOp.mock.calls.length).toBe(1);
121
+ });
122
+ });
@@ -0,0 +1,302 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { WebGlRenderer } from './WebGlRenderer.js';
3
+
4
+ /**
5
+ * Tests for the SDF buffer write paths and the upload skip.
6
+ *
7
+ * A full renderer needs a live GL context, so methods are exercised on a
8
+ * minimal fake `this` via the prototype, holding only the fields each method
9
+ * touches.
10
+ */
11
+
12
+ const FLOATS_PER_GLYPH = 24; // 4 vertices x 6 floats
13
+
14
+ /**
15
+ * Build one glyph (4 vertices) of cached vertex data with a bit-pattern color.
16
+ * Layout per vertex: [x, y, u, v, color(uint32), distRange].
17
+ */
18
+ const makeCachedGlyph = (color: number): Float32Array => {
19
+ const buf = new ArrayBuffer(FLOATS_PER_GLYPH * 4);
20
+ const f = new Float32Array(buf);
21
+ const u = new Uint32Array(buf);
22
+ const corners = [
23
+ [10, 20, 0.1, 0.2],
24
+ [30, 20, 0.3, 0.2],
25
+ [10, 40, 0.1, 0.4],
26
+ [30, 40, 0.3, 0.4],
27
+ ];
28
+ for (let v = 0; v < 4; v++) {
29
+ const i = v * 6;
30
+ f[i] = corners[v]![0]!;
31
+ f[i + 1] = corners[v]![1]!;
32
+ f[i + 2] = corners[v]![2]!;
33
+ f[i + 3] = corners[v]![3]!;
34
+ u[i + 4] = color;
35
+ f[i + 5] = 4; // distanceRange
36
+ }
37
+ return f;
38
+ };
39
+
40
+ type SdfWriterFake = {
41
+ sdfBufferIdx: number;
42
+ sdfQuadCount: number;
43
+ sdfBuffer: ArrayBuffer;
44
+ fSdfBuffer: Float32Array;
45
+ uiSdfBuffer: Uint32Array;
46
+ sdfBufferChanged: boolean;
47
+ ensureSdfBufferCapacity: ReturnType<typeof vi.fn>;
48
+ finalizeSdfBatch: ReturnType<typeof vi.fn>;
49
+ };
50
+
51
+ const makeWriterFake = (): SdfWriterFake => {
52
+ const sdfBuffer = new ArrayBuffer(1024 * 4);
53
+ return {
54
+ sdfBufferIdx: 0,
55
+ sdfQuadCount: 0,
56
+ sdfBuffer,
57
+ fSdfBuffer: new Float32Array(sdfBuffer),
58
+ uiSdfBuffer: new Uint32Array(sdfBuffer),
59
+ sdfBufferChanged: false,
60
+ ensureSdfBufferCapacity: vi.fn(),
61
+ finalizeSdfBatch: vi.fn(),
62
+ };
63
+ };
64
+
65
+ const finalizeArgs = [
66
+ {} as never, // atlasTexture
67
+ { x: 0, y: 0, w: 0, h: 0, valid: false }, // clippingRect
68
+ 1, // worldAlpha
69
+ 100, // width
70
+ 50, // height
71
+ false, // parentHasRenderTexture
72
+ null, // framebufferDimensions
73
+ {} as never, // sdfShader
74
+ ] as const;
75
+
76
+ describe('WebGlRenderer.addSdfTranslatedQuads', () => {
77
+ it('copies cached vertices with the position delta applied', () => {
78
+ const fake = makeWriterFake();
79
+ const cached = makeCachedGlyph(0x00ff00ff);
80
+
81
+ WebGlRenderer.prototype.addSdfTranslatedQuads.call(
82
+ fake as never,
83
+ cached,
84
+ 1,
85
+ 5,
86
+ -3,
87
+ ...finalizeArgs,
88
+ );
89
+
90
+ const f = fake.fSdfBuffer;
91
+ // Positions shifted by (5, -3)
92
+ expect([f[0], f[1]]).toEqual([15, 17]);
93
+ expect([f[6], f[7]]).toEqual([35, 17]);
94
+ expect([f[12], f[13]]).toEqual([15, 37]);
95
+ expect([f[18], f[19]]).toEqual([35, 37]);
96
+ // UVs and distanceRange untouched
97
+ expect([f[2], f[3], f[5]]).toEqual([Math.fround(0.1), Math.fround(0.2), 4]);
98
+ expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH);
99
+ expect(fake.sdfQuadCount).toBe(1);
100
+ expect(fake.sdfBufferChanged).toBe(true);
101
+ expect(fake.finalizeSdfBatch.mock.calls.length).toBe(1);
102
+ // startQuad 0, numGlyphs 1
103
+ expect(fake.finalizeSdfBatch.mock.calls[0]![0]).toBe(0);
104
+ expect(fake.finalizeSdfBatch.mock.calls[0]![1]).toBe(1);
105
+ // Source cache untouched
106
+ expect(cached[0]).toBe(10);
107
+ expect(cached[1]).toBe(20);
108
+ });
109
+
110
+ it('preserves packed color bits exactly, including float32 NaN patterns', () => {
111
+ const fake = makeWriterFake();
112
+ // 0xFFFFFFFF (white, full alpha) is a float32 NaN bit pattern — an
113
+ // element-wise float copy could canonicalize it. The memcpy must not.
114
+ const cached = makeCachedGlyph(0xffffffff);
115
+
116
+ WebGlRenderer.prototype.addSdfTranslatedQuads.call(
117
+ fake as never,
118
+ cached,
119
+ 1,
120
+ 100,
121
+ 200,
122
+ ...finalizeArgs,
123
+ );
124
+
125
+ for (let v = 0; v < 4; v++) {
126
+ expect(fake.uiSdfBuffer[v * 6 + 4]).toBe(0xffffffff);
127
+ }
128
+ });
129
+
130
+ it('appends after existing content and is a no-op for zero glyphs', () => {
131
+ const fake = makeWriterFake();
132
+ fake.sdfBufferIdx = FLOATS_PER_GLYPH;
133
+ fake.sdfQuadCount = 1;
134
+ const cached = makeCachedGlyph(0x000000ff);
135
+
136
+ WebGlRenderer.prototype.addSdfTranslatedQuads.call(
137
+ fake as never,
138
+ cached,
139
+ 0,
140
+ 5,
141
+ 5,
142
+ ...finalizeArgs,
143
+ );
144
+ expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH);
145
+ expect(fake.sdfBufferChanged).toBe(false);
146
+ expect(fake.finalizeSdfBatch.mock.calls.length).toBe(0);
147
+
148
+ WebGlRenderer.prototype.addSdfTranslatedQuads.call(
149
+ fake as never,
150
+ cached,
151
+ 1,
152
+ 5,
153
+ 5,
154
+ ...finalizeArgs,
155
+ );
156
+ expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH * 2);
157
+ expect(fake.fSdfBuffer[FLOATS_PER_GLYPH]).toBe(15);
158
+ // startQuad continues from the existing quad count
159
+ expect(fake.finalizeSdfBatch.mock.calls[0]![0]).toBe(1);
160
+ });
161
+ });
162
+
163
+ type UploadFake = {
164
+ sdfBufferIdx: number;
165
+ sdfBufferChanged: boolean;
166
+ lastUploadedSdfSize: number;
167
+ sdfBuffer: ArrayBuffer;
168
+ sdfQuadBufferCollection: { getBuffer: ReturnType<typeof vi.fn> };
169
+ glw: { arrayBufferData: ReturnType<typeof vi.fn>; DYNAMIC_DRAW: number };
170
+ };
171
+
172
+ const makeUploadFake = (): UploadFake => ({
173
+ sdfBufferIdx: 0,
174
+ sdfBufferChanged: true,
175
+ lastUploadedSdfSize: 0,
176
+ sdfBuffer: new ArrayBuffer(1024),
177
+ sdfQuadBufferCollection: { getBuffer: vi.fn(() => ({})) },
178
+ glw: { arrayBufferData: vi.fn(), DYNAMIC_DRAW: 35048 },
179
+ });
180
+
181
+ const upload = (fake: UploadFake) =>
182
+ (
183
+ WebGlRenderer.prototype as unknown as {
184
+ uploadSdfBuffer: (this: unknown) => void;
185
+ }
186
+ ).uploadSdfBuffer.call(fake);
187
+
188
+ describe('WebGlRenderer.uploadSdfBuffer skip', () => {
189
+ it('does nothing when no SDF glyphs were written', () => {
190
+ const fake = makeUploadFake();
191
+
192
+ upload(fake);
193
+
194
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(0);
195
+ // Flag is kept so a later frame with content still uploads
196
+ expect(fake.sdfBufferChanged).toBe(true);
197
+ });
198
+
199
+ it('uploads when changed, then skips identical follow-up frames', () => {
200
+ const fake = makeUploadFake();
201
+ fake.sdfBufferIdx = 48;
202
+
203
+ upload(fake);
204
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(1);
205
+ expect(fake.sdfBufferChanged).toBe(false);
206
+ expect(fake.lastUploadedSdfSize).toBe(48);
207
+
208
+ // Next frame: exact cache hits rewrote identical bytes, same size
209
+ upload(fake);
210
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(1);
211
+ });
212
+
213
+ it('uploads again when the changed flag is raised', () => {
214
+ const fake = makeUploadFake();
215
+ fake.sdfBufferIdx = 48;
216
+ upload(fake);
217
+
218
+ fake.sdfBufferChanged = true;
219
+ upload(fake);
220
+
221
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(2);
222
+ });
223
+
224
+ it('uploads when the size differs even if the flag is clear', () => {
225
+ const fake = makeUploadFake();
226
+ fake.sdfBufferIdx = 48;
227
+ upload(fake);
228
+
229
+ fake.sdfBufferIdx = 24;
230
+ upload(fake);
231
+
232
+ expect(fake.glw.arrayBufferData.mock.calls.length).toBe(2);
233
+ expect(fake.lastUploadedSdfSize).toBe(24);
234
+ });
235
+ });
236
+
237
+ describe('sdfBufferChanged raisers', () => {
238
+ it('addSdfQuads marks the buffer changed', () => {
239
+ const fake = makeWriterFake();
240
+ // One glyph record: [x, y, w, h, u, v, uw, vh]
241
+ const glyphs = new Float32Array([0, 0, 10, 10, 0, 0, 0.1, 0.1]);
242
+ const transform = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
243
+
244
+ WebGlRenderer.prototype.addSdfQuads.call(
245
+ fake as never,
246
+ glyphs,
247
+ 1, // glyphCount
248
+ 1, // fontScale
249
+ transform,
250
+ 0xffffffff, // color
251
+ 1, // worldAlpha
252
+ 4, // distanceRange
253
+ {} as never, // atlasTexture
254
+ { x: 0, y: 0, w: 0, h: 0, valid: false }, // clippingRect
255
+ 100, // width
256
+ 50, // height
257
+ false, // parentHasRenderTexture
258
+ null, // framebufferDimensions
259
+ {} as never, // sdfShader
260
+ );
261
+
262
+ expect(fake.sdfBufferChanged).toBe(true);
263
+ expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH);
264
+ });
265
+
266
+ it('invalidateQuadBuffer marks the buffer changed (render-list rebuild)', () => {
267
+ const fake = {
268
+ sdfBufferChanged: false,
269
+ curBufferIdx: 99,
270
+ lastUploadedBufferSize: 99,
271
+ needsFullUpload: false,
272
+ stage: { renderList: [] },
273
+ };
274
+
275
+ WebGlRenderer.prototype.invalidateQuadBuffer.call(fake as never);
276
+
277
+ expect(fake.sdfBufferChanged).toBe(true);
278
+ });
279
+
280
+ it('ensureSdfBufferCapacity marks the buffer changed on growth only', () => {
281
+ const sdfBuffer = new ArrayBuffer(FLOATS_PER_GLYPH * 4);
282
+ const fake = {
283
+ sdfBuffer,
284
+ fSdfBuffer: new Float32Array(sdfBuffer),
285
+ uiSdfBuffer: new Uint32Array(sdfBuffer),
286
+ sdfBufferChanged: false,
287
+ };
288
+ const ensure = (size: number) =>
289
+ (
290
+ WebGlRenderer.prototype as unknown as {
291
+ ensureSdfBufferCapacity: (this: unknown, n: number) => void;
292
+ }
293
+ ).ensureSdfBufferCapacity.call(fake, size);
294
+
295
+ ensure(FLOATS_PER_GLYPH); // fits — no realloc
296
+ expect(fake.sdfBufferChanged).toBe(false);
297
+
298
+ ensure(FLOATS_PER_GLYPH * 8); // grows
299
+ expect(fake.sdfBufferChanged).toBe(true);
300
+ expect(fake.fSdfBuffer.length).toBeGreaterThanOrEqual(FLOATS_PER_GLYPH * 8);
301
+ });
302
+ });