@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.
- package/dist/src/core/CoreNode.js +2 -2
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/Stage.js +9 -1
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.d.ts +17 -1
- package/dist/src/core/lib/WebGlContextWrapper.js +21 -0
- package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
- package/dist/src/core/lib/colorParser.d.ts +4 -1
- package/dist/src/core/lib/colorParser.js +16 -6
- package/dist/src/core/lib/colorParser.js.map +1 -1
- package/dist/src/core/platforms/web/WebPlatform.js +60 -50
- package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +4 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +28 -13
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasShaderNode.js +1 -0
- package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
- package/dist/src/core/renderers/webgl/SdfRenderOp.js +2 -2
- package/dist/src/core/renderers/webgl/SdfRenderOp.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +43 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +109 -10
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +21 -13
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.d.ts +8 -3
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.js +12 -11
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.js.map +1 -1
- package/dist/src/core/shaders/canvas/Rounded.js +3 -3
- package/dist/src/core/shaders/canvas/Rounded.js.map +1 -1
- package/dist/src/core/shaders/canvas/RoundedWithShadow.js +3 -3
- package/dist/src/core/shaders/canvas/RoundedWithShadow.js.map +1 -1
- package/dist/src/core/shaders/canvas/utils/render.js +3 -3
- package/dist/src/core/shaders/canvas/utils/render.js.map +1 -1
- package/dist/src/core/shaders/webgl/Border.js +12 -11
- package/dist/src/core/shaders/webgl/Border.js.map +1 -1
- package/dist/src/core/shaders/webgl/HolePunch.js +12 -5
- package/dist/src/core/shaders/webgl/HolePunch.js.map +1 -1
- package/dist/src/core/shaders/webgl/LinearGradient.js +3 -19
- package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialGradient.js +3 -20
- package/dist/src/core/shaders/webgl/RadialGradient.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialProgress.js +15 -23
- package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithBorder.js +35 -32
- package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +58 -45
- package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithShadow.js +15 -13
- package/dist/src/core/shaders/webgl/RoundedWithShadow.js.map +1 -1
- package/dist/src/core/shaders/webgl/Shadow.js +4 -5
- package/dist/src/core/shaders/webgl/Shadow.js.map +1 -1
- package/dist/src/core/text-rendering/SdfTextRenderer.js +23 -10
- package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
- package/dist/src/core/text-rendering/TextLayoutEngine.js +14 -2
- package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
- package/dist/src/utils.js +1 -1
- package/dist/src/utils.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/core/CoreNode.ts +2 -2
- package/src/core/Stage.renderListUpdate.test.ts +73 -0
- package/src/core/Stage.ts +11 -1
- package/src/core/lib/WebGlContextWrapper.test.ts +23 -1
- package/src/core/lib/WebGlContextWrapper.ts +23 -1
- package/src/core/lib/colorParser.test.ts +46 -0
- package/src/core/lib/colorParser.ts +17 -6
- package/src/core/platforms/web/WebPlatform.ts +63 -52
- package/src/core/renderers/canvas/CanvasRenderer.test.ts +230 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +29 -15
- package/src/core/renderers/canvas/CanvasShaderNode.test.ts +73 -0
- package/src/core/renderers/canvas/CanvasShaderNode.ts +1 -0
- package/src/core/renderers/webgl/SdfRenderOp.ts +2 -2
- package/src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts +122 -0
- package/src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts +302 -0
- package/src/core/renderers/webgl/WebGlRenderer.ts +151 -11
- package/src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts +150 -0
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +27 -15
- package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +4 -2
- package/src/core/renderers/webgl/internal/ShaderUtils.test.ts +124 -0
- package/src/core/renderers/webgl/internal/ShaderUtils.ts +14 -13
- package/src/core/shaders/canvas/Rounded.ts +3 -3
- package/src/core/shaders/canvas/RoundedWithShadow.ts +3 -3
- package/src/core/shaders/canvas/utils/render.ts +3 -3
- package/src/core/shaders/webgl/Border.ts +12 -11
- package/src/core/shaders/webgl/HolePunch.ts +12 -5
- package/src/core/shaders/webgl/LinearGradient.ts +3 -19
- package/src/core/shaders/webgl/RadialGradient.ts +3 -20
- package/src/core/shaders/webgl/RadialProgress.ts +15 -23
- package/src/core/shaders/webgl/RoundedWithBorder.ts +35 -32
- package/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +58 -45
- package/src/core/shaders/webgl/RoundedWithShadow.ts +15 -13
- package/src/core/shaders/webgl/Shadow.ts +4 -5
- package/src/core/text-rendering/SdfTextRenderer.test.ts +177 -0
- package/src/core/text-rendering/SdfTextRenderer.ts +38 -10
- package/src/core/text-rendering/TextLayoutEngine.ts +15 -2
- package/src/core/text-rendering/tests/TextLayoutEngine.test.ts +99 -0
- 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.
|
|
54
|
+
if (this.clearColorAlpha !== 0) {
|
|
40
55
|
ctx.fillStyle = this.clearColor;
|
|
41
|
-
ctx.fillRect(0, 0,
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
});
|
|
@@ -60,7 +60,7 @@ export class SdfRenderOp extends CoreRenderOp {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
draw() {
|
|
63
|
-
const { glw,
|
|
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
|
-
|
|
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
|
+
});
|