@solidtv/renderer 1.5.0-1 → 1.5.1
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/common/EventEmitter.d.ts +8 -0
- package/dist/src/common/EventEmitter.js +20 -0
- package/dist/src/common/EventEmitter.js.map +1 -1
- package/dist/src/core/Autosizer.js +3 -1
- package/dist/src/core/Autosizer.js.map +1 -1
- package/dist/src/core/CoreNode.d.ts +39 -0
- package/dist/src/core/CoreNode.js +105 -25
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/CoreTextNode.js +4 -2
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/Stage.js +4 -1
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.d.ts +62 -2
- package/dist/src/core/TextureMemoryManager.js +90 -4
- package/dist/src/core/TextureMemoryManager.js.map +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.d.ts +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.js +10 -3
- package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
- package/dist/src/core/lib/textureCompression.js +68 -4
- package/dist/src/core/lib/textureCompression.js.map +1 -1
- package/dist/src/core/renderers/CoreRenderer.d.ts +1 -0
- package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +8 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +54 -14
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
- package/dist/src/core/renderers/webgl/internal/RendererUtils.js +9 -2
- package/dist/src/core/renderers/webgl/internal/RendererUtils.js.map +1 -1
- package/dist/src/core/text-rendering/SdfTextRenderer.js +16 -3
- package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +16 -0
- package/dist/src/main-api/Renderer.js +2 -0
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/src/utils.js +23 -7
- package/dist/src/utils.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/common/EventEmitter.ts +21 -0
- package/src/core/Autosizer.ts +3 -1
- package/src/core/CoreNode.test.ts +283 -0
- package/src/core/CoreNode.ts +131 -26
- package/src/core/CoreTextNode.test.ts +1 -0
- package/src/core/CoreTextNode.ts +6 -2
- package/src/core/Stage.ts +4 -0
- package/src/core/TextureMemoryManager.test.ts +323 -7
- package/src/core/TextureMemoryManager.ts +101 -5
- package/src/core/lib/WebGlContextWrapper.test.ts +58 -0
- package/src/core/lib/WebGlContextWrapper.ts +13 -3
- package/src/core/lib/textureCompression.test.ts +158 -0
- package/src/core/lib/textureCompression.ts +78 -4
- package/src/core/renderers/CoreRenderer.ts +1 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
- package/src/core/renderers/webgl/WebGlRenderer.ts +64 -16
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
- package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
- package/src/core/renderers/webgl/internal/RendererUtils.test.ts +73 -0
- package/src/core/renderers/webgl/internal/RendererUtils.ts +9 -2
- package/src/core/text-rendering/SdfTextRenderer.test.ts +11 -10
- package/src/core/text-rendering/SdfTextRenderer.ts +17 -3
- package/src/main-api/Renderer.ts +19 -0
- package/src/utils.test.ts +57 -0
- package/src/utils.ts +24 -8
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { WebGlShaderProgram } from './WebGlShaderProgram.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the redundant-uniform-upload skip in bindRenderOp.
|
|
6
|
+
*
|
|
7
|
+
* The program instance is created without running the constructor (which
|
|
8
|
+
* compiles GLSL against a live GL context); the fields bindRenderOp touches
|
|
9
|
+
* are populated manually, and bindTextures/bindBufferCollection are stubbed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type FakeGlw = {
|
|
13
|
+
uniform1f: ReturnType<typeof vi.fn>;
|
|
14
|
+
uniform2f: ReturnType<typeof vi.fn>;
|
|
15
|
+
uniform4f: ReturnType<typeof vi.fn>;
|
|
16
|
+
canvas: { width: number; height: number };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => {
|
|
20
|
+
const glw: FakeGlw = {
|
|
21
|
+
uniform1f: vi.fn(),
|
|
22
|
+
uniform2f: vi.fn(),
|
|
23
|
+
uniform4f: vi.fn(),
|
|
24
|
+
canvas: { width: 1920, height: 1080 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const program = Object.create(
|
|
28
|
+
WebGlShaderProgram.prototype,
|
|
29
|
+
) as WebGlShaderProgram;
|
|
30
|
+
const p = program as unknown as Record<string, unknown>;
|
|
31
|
+
p['glw'] = glw;
|
|
32
|
+
p['useSystemAlpha'] = true;
|
|
33
|
+
p['useSystemDimensions'] = true;
|
|
34
|
+
p['useTimeValue'] = false;
|
|
35
|
+
p['lastBoundUniforms'] = null;
|
|
36
|
+
p['lastPixelRatio'] = -1;
|
|
37
|
+
p['lastResolutionW'] = -1;
|
|
38
|
+
p['lastResolutionH'] = -1;
|
|
39
|
+
p['lastAlpha'] = -1;
|
|
40
|
+
p['lastDimensionsW'] = -1;
|
|
41
|
+
p['lastDimensionsH'] = -1;
|
|
42
|
+
p['lastTime'] = -1;
|
|
43
|
+
// Stub the buffer/texture binding done before the uniform pass
|
|
44
|
+
p['bindTextures'] = vi.fn();
|
|
45
|
+
p['bindBufferCollection'] = vi.fn();
|
|
46
|
+
return { program, glw };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const makeUniforms = () => ({
|
|
50
|
+
single: {
|
|
51
|
+
u_borderGap: { method: 'uniform1f', value: 0 },
|
|
52
|
+
},
|
|
53
|
+
vec2: {},
|
|
54
|
+
vec3: {},
|
|
55
|
+
vec4: {
|
|
56
|
+
u_radius: { method: 'uniform4f', value: [16, 16, 16, 16] },
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const makeOp = (
|
|
61
|
+
uniforms: ReturnType<typeof makeUniforms>,
|
|
62
|
+
overrides?: Record<string, unknown>,
|
|
63
|
+
) => ({
|
|
64
|
+
isCoreNode: true,
|
|
65
|
+
rtt: false,
|
|
66
|
+
parentHasRenderTexture: false,
|
|
67
|
+
parentFramebufferDimensions: null,
|
|
68
|
+
framebufferDimensions: null,
|
|
69
|
+
stage: { pixelRatio: 2 },
|
|
70
|
+
time: 0,
|
|
71
|
+
worldAlpha: 1,
|
|
72
|
+
w: 200,
|
|
73
|
+
h: 100,
|
|
74
|
+
renderOpTextures: [],
|
|
75
|
+
quadBufferCollection: {},
|
|
76
|
+
shader: { props: {}, uniforms },
|
|
77
|
+
...overrides,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const totalCalls = (glw: FakeGlw): number =>
|
|
81
|
+
glw.uniform1f.mock.calls.length +
|
|
82
|
+
glw.uniform2f.mock.calls.length +
|
|
83
|
+
glw.uniform4f.mock.calls.length;
|
|
84
|
+
|
|
85
|
+
describe('bindRenderOp uniform dedup', () => {
|
|
86
|
+
it('should upload everything on the first bind', () => {
|
|
87
|
+
const { program, glw } = makeProgram();
|
|
88
|
+
const uniforms = makeUniforms();
|
|
89
|
+
|
|
90
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
91
|
+
|
|
92
|
+
// u_pixelRatio, u_alpha, u_borderGap (1f) + u_resolution, u_dimensions (2f) + u_radius (4f)
|
|
93
|
+
expect(glw.uniform1f.mock.calls.length).toBe(3);
|
|
94
|
+
expect(glw.uniform2f.mock.calls.length).toBe(2);
|
|
95
|
+
expect(glw.uniform4f.mock.calls.length).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should issue zero uniform calls on an identical re-bind', () => {
|
|
99
|
+
const { program, glw } = makeProgram();
|
|
100
|
+
const uniforms = makeUniforms();
|
|
101
|
+
|
|
102
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
103
|
+
glw.uniform1f.mockClear();
|
|
104
|
+
glw.uniform2f.mockClear();
|
|
105
|
+
glw.uniform4f.mockClear();
|
|
106
|
+
|
|
107
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
108
|
+
|
|
109
|
+
expect(totalCalls(glw)).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should skip across interleaved ops (shared collection, different op objects)', () => {
|
|
113
|
+
const { program, glw } = makeProgram();
|
|
114
|
+
const uniforms = makeUniforms();
|
|
115
|
+
|
|
116
|
+
// Two distinct ops (different cards) sharing the value-cached collection
|
|
117
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
118
|
+
glw.uniform1f.mockClear();
|
|
119
|
+
glw.uniform2f.mockClear();
|
|
120
|
+
glw.uniform4f.mockClear();
|
|
121
|
+
|
|
122
|
+
// GL uniform state persists on the program across useProgram switches,
|
|
123
|
+
// so an op of another program in between changes nothing here.
|
|
124
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
125
|
+
|
|
126
|
+
expect(totalCalls(glw)).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should re-upload only the changed system uniform', () => {
|
|
130
|
+
const { program, glw } = makeProgram();
|
|
131
|
+
const uniforms = makeUniforms();
|
|
132
|
+
|
|
133
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
134
|
+
glw.uniform1f.mockClear();
|
|
135
|
+
glw.uniform2f.mockClear();
|
|
136
|
+
glw.uniform4f.mockClear();
|
|
137
|
+
|
|
138
|
+
program.bindRenderOp(makeOp(uniforms, { worldAlpha: 0.5 }) as never);
|
|
139
|
+
|
|
140
|
+
expect(glw.uniform1f.mock.calls.length).toBe(1);
|
|
141
|
+
expect(glw.uniform1f.mock.calls[0]![0]).toBe('u_alpha');
|
|
142
|
+
expect(glw.uniform1f.mock.calls[0]![1]).toBe(0.5);
|
|
143
|
+
expect(glw.uniform2f.mock.calls.length).toBe(0);
|
|
144
|
+
expect(glw.uniform4f.mock.calls.length).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should re-upload dimensions when the op size differs', () => {
|
|
148
|
+
const { program, glw } = makeProgram();
|
|
149
|
+
const uniforms = makeUniforms();
|
|
150
|
+
|
|
151
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
152
|
+
glw.uniform2f.mockClear();
|
|
153
|
+
|
|
154
|
+
program.bindRenderOp(makeOp(uniforms, { w: 300, h: 150 }) as never);
|
|
155
|
+
|
|
156
|
+
expect(glw.uniform2f.mock.calls.length).toBe(1);
|
|
157
|
+
expect(glw.uniform2f.mock.calls[0]![0]).toBe('u_dimensions');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should re-run the collection pass for a different collection object', () => {
|
|
161
|
+
const { program, glw } = makeProgram();
|
|
162
|
+
const uniformsA = makeUniforms();
|
|
163
|
+
const uniformsB = makeUniforms(); // identical values, distinct identity
|
|
164
|
+
|
|
165
|
+
program.bindRenderOp(makeOp(uniformsA) as never);
|
|
166
|
+
glw.uniform1f.mockClear();
|
|
167
|
+
glw.uniform4f.mockClear();
|
|
168
|
+
|
|
169
|
+
program.bindRenderOp(makeOp(uniformsB) as never);
|
|
170
|
+
|
|
171
|
+
// Conservative direction: new object => redundant upload, never a skip
|
|
172
|
+
expect(glw.uniform1f.mock.calls.length).toBe(1); // u_borderGap
|
|
173
|
+
expect(glw.uniform4f.mock.calls.length).toBe(1); // u_radius
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should re-upload pixel ratio and resolution across an RTT flip', () => {
|
|
177
|
+
const { program, glw } = makeProgram();
|
|
178
|
+
const uniforms = makeUniforms();
|
|
179
|
+
|
|
180
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
181
|
+
glw.uniform1f.mockClear();
|
|
182
|
+
glw.uniform2f.mockClear();
|
|
183
|
+
|
|
184
|
+
// RTT op: pixelRatio forced to 1, resolution = framebuffer dimensions
|
|
185
|
+
program.bindRenderOp(
|
|
186
|
+
makeOp(uniforms, {
|
|
187
|
+
isCoreNode: false,
|
|
188
|
+
parentHasRenderTexture: true,
|
|
189
|
+
framebufferDimensions: { w: 512, h: 256 },
|
|
190
|
+
}) as never,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const calls1f = glw.uniform1f.mock.calls;
|
|
194
|
+
const calls2f = glw.uniform2f.mock.calls;
|
|
195
|
+
expect(calls1f.some((c) => c[0] === 'u_pixelRatio' && c[1] === 1)).toBe(
|
|
196
|
+
true,
|
|
197
|
+
);
|
|
198
|
+
expect(calls2f.some((c) => c[0] === 'u_resolution' && c[1] === 512)).toBe(
|
|
199
|
+
true,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
glw.uniform1f.mockClear();
|
|
203
|
+
glw.uniform2f.mockClear();
|
|
204
|
+
|
|
205
|
+
// Back to screen: values flip back and must re-upload
|
|
206
|
+
program.bindRenderOp(makeOp(uniforms) as never);
|
|
207
|
+
expect(
|
|
208
|
+
glw.uniform1f.mock.calls.some(
|
|
209
|
+
(c) => c[0] === 'u_pixelRatio' && c[1] === 2,
|
|
210
|
+
),
|
|
211
|
+
).toBe(true);
|
|
212
|
+
expect(
|
|
213
|
+
glw.uniform2f.mock.calls.some(
|
|
214
|
+
(c) => c[0] === 'u_resolution' && c[1] === 1920,
|
|
215
|
+
),
|
|
216
|
+
).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should skip the collection pass entirely for propless shaders', () => {
|
|
220
|
+
const { program, glw } = makeProgram();
|
|
221
|
+
|
|
222
|
+
program.bindRenderOp(
|
|
223
|
+
makeOp(makeUniforms(), {
|
|
224
|
+
shader: { props: undefined, uniforms: makeUniforms() },
|
|
225
|
+
}) as never,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Only system uniforms: pixelRatio + alpha (1f), resolution + dimensions (2f)
|
|
229
|
+
expect(glw.uniform1f.mock.calls.length).toBe(2);
|
|
230
|
+
expect(glw.uniform2f.mock.calls.length).toBe(2);
|
|
231
|
+
expect(glw.uniform4f.mock.calls.length).toBe(0);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createIndexBuffer } from './RendererUtils.js';
|
|
3
|
+
import type { WebGlContextWrapper } from '../../../lib/WebGlContextWrapper.js';
|
|
4
|
+
|
|
5
|
+
// Capture the Uint16Array handed to elementArrayBufferData so we can assert on
|
|
6
|
+
// the generated quad indices without a real GL context.
|
|
7
|
+
function mockGlw(): {
|
|
8
|
+
glw: WebGlContextWrapper;
|
|
9
|
+
getIndices: () => Uint16Array;
|
|
10
|
+
} {
|
|
11
|
+
let captured: Uint16Array | null = null;
|
|
12
|
+
const glw = {
|
|
13
|
+
STATIC_DRAW: 0,
|
|
14
|
+
createBuffer: () => ({}),
|
|
15
|
+
elementArrayBufferData: (_buffer: unknown, indices: Uint16Array) => {
|
|
16
|
+
captured = indices;
|
|
17
|
+
},
|
|
18
|
+
} as unknown as WebGlContextWrapper;
|
|
19
|
+
return {
|
|
20
|
+
glw,
|
|
21
|
+
getIndices: () => {
|
|
22
|
+
if (captured === null) {
|
|
23
|
+
throw new Error('elementArrayBufferData was not called');
|
|
24
|
+
}
|
|
25
|
+
return captured;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The expected 6 element indices for quad `q`: two triangles over the quad's
|
|
31
|
+
// four vertices [4q, 4q+1, 4q+2, 4q+3] wound as [0,1,2, 2,1,3].
|
|
32
|
+
const expectedQuad = (q: number): number[] => {
|
|
33
|
+
const j = q * 4;
|
|
34
|
+
return [j, j + 1, j + 2, j + 2, j + 1, j + 3];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('createIndexBuffer', () => {
|
|
38
|
+
it('fills indices for EVERY quad, not just the first 1/6', () => {
|
|
39
|
+
// size / 80 = 800 quads, comfortably under the Uint16 cap.
|
|
40
|
+
const { glw, getIndices } = mockGlw();
|
|
41
|
+
createIndexBuffer(glw, 800 * 80);
|
|
42
|
+
const indices = getIndices();
|
|
43
|
+
const maxQuads = 800;
|
|
44
|
+
|
|
45
|
+
expect(indices.length).toBe(maxQuads * 6);
|
|
46
|
+
|
|
47
|
+
// First, middle and — critically — the LAST quad must be populated. The
|
|
48
|
+
// original bug stopped the loop at `i < maxQuads`, zeroing every quad past
|
|
49
|
+
// ~maxQuads/6 and collapsing it into a degenerate triangle.
|
|
50
|
+
for (const q of [0, 1, maxQuads >> 1, maxQuads - 2, maxQuads - 1]) {
|
|
51
|
+
const slice = Array.from(indices.subarray(q * 6, q * 6 + 6));
|
|
52
|
+
expect(slice).toEqual(expectedQuad(q));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// No trailing zeroed slots (every quad past index 0 references vertices > 0).
|
|
56
|
+
const lastQuad = Array.from(indices.subarray((maxQuads - 1) * 6));
|
|
57
|
+
expect(lastQuad.some((v) => v !== 0)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('caps at 16384 quads so Uint16 vertex ids never overflow', () => {
|
|
61
|
+
// Request far more than Uint16 can address (25000 quads worth of bytes).
|
|
62
|
+
const { glw, getIndices } = mockGlw();
|
|
63
|
+
createIndexBuffer(glw, 25000 * 80);
|
|
64
|
+
const indices = getIndices();
|
|
65
|
+
|
|
66
|
+
expect(indices.length).toBe(16384 * 6);
|
|
67
|
+
// The very last vertex id is 16384*4 - 1 = 65535, the Uint16 maximum.
|
|
68
|
+
expect(indices[indices.length - 1]).toBe(65535);
|
|
69
|
+
expect(Array.from(indices.subarray(16383 * 6, 16383 * 6 + 6))).toEqual(
|
|
70
|
+
expectedQuad(16383),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -11,10 +11,17 @@ export function createIndexBuffer(
|
|
|
11
11
|
glw: WebGlContextWrapper,
|
|
12
12
|
size: number,
|
|
13
13
|
): WebGLBuffer | null {
|
|
14
|
-
|
|
14
|
+
// 4 vertices per quad. Element indices are Uint16, so the largest vertex id
|
|
15
|
+
// we can address is 65535 — i.e. 16384 quads (16384 * 4 = 65536). Never claim
|
|
16
|
+
// more than that, regardless of the requested byte budget.
|
|
17
|
+
const maxQuads = Math.min(~~(size / 80), 16384);
|
|
15
18
|
const indices = new Uint16Array(maxQuads * 6);
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
// i walks the index slot (6 per quad), j the vertex base (4 per quad). The
|
|
21
|
+
// bound must be maxQuads * 6 to fill every slot — stopping at maxQuads left
|
|
22
|
+
// ~5/6 of the buffer zeroed, collapsing every quad past ~maxQuads/6 into a
|
|
23
|
+
// degenerate triangle (the cause of tail geometry silently disappearing).
|
|
24
|
+
for (let i = 0, j = 0; i < maxQuads * 6; i += 6, j += 4) {
|
|
18
25
|
indices[i] = j;
|
|
19
26
|
indices[i + 1] = j + 1;
|
|
20
27
|
indices[i + 2] = j + 2;
|
|
@@ -97,21 +97,22 @@ describe('SdfTextRenderer layout cache', () => {
|
|
|
97
97
|
expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
it('
|
|
100
|
+
it('eagerly bounds the cache on insert (does not wait for idle cleanup)', () => {
|
|
101
|
+
// An animating scene never goes idle, so the cache must be bounded on insert
|
|
102
|
+
// rather than relying solely on idle `cleanup`. With cap 2, inserting a 3rd
|
|
103
|
+
// entry must immediately evict the least-recently-used (oldest) one — 'A'.
|
|
101
104
|
initRenderer(2);
|
|
102
105
|
|
|
103
|
-
render('A');
|
|
104
|
-
render('B');
|
|
105
|
-
render('C');
|
|
106
|
-
// Re-access 'A' so it becomes most-recently-used; 'B' is now the LRU.
|
|
107
|
-
render('A');
|
|
108
|
-
|
|
109
|
-
SdfTextRenderer.cleanup();
|
|
106
|
+
render('A'); // {A}
|
|
107
|
+
render('B'); // {A, B}
|
|
108
|
+
render('C'); // insert -> over cap -> eagerly evict LRU 'A' -> {B, C}
|
|
110
109
|
|
|
111
110
|
vi.clearAllMocks();
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
// Probe survivors first (hits only re-order, they don't change membership),
|
|
112
|
+
// then the evicted entry last so its re-insert can't perturb the assertion.
|
|
114
113
|
render('C'); // survived -> hit
|
|
114
|
+
render('B'); // survived -> hit
|
|
115
|
+
render('A'); // evicted -> miss (one layout regen)
|
|
115
116
|
|
|
116
117
|
expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
|
|
117
118
|
});
|
|
@@ -24,9 +24,14 @@ const type = 'sdf' as const;
|
|
|
24
24
|
|
|
25
25
|
let sdfShader: WebGlShaderNode | null = null;
|
|
26
26
|
|
|
27
|
-
// Upper bound on layoutCache entries
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// Upper bound on layoutCache entries. Overridden from stage options in `init`.
|
|
28
|
+
// Enforced both eagerly on insert (see `renderText`) and in bulk on idle (see
|
|
29
|
+
// `cleanup`). The eager bound matters because a continuously animating scene
|
|
30
|
+
// never goes idle, so idle-only eviction would let apps with ever-changing text
|
|
31
|
+
// (clocks, counters, score/fps readouts) grow the cache without limit until the
|
|
32
|
+
// page runs out of memory. The per-insert cost is a single Map delete on a
|
|
33
|
+
// cache miss (i.e. only when new text is laid out), so it does not compete with
|
|
34
|
+
// steady-state rendering.
|
|
30
35
|
let maxLayoutCacheSize = 250;
|
|
31
36
|
|
|
32
37
|
// Initialize the SDF text renderer
|
|
@@ -114,6 +119,15 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
|
|
|
114
119
|
layout = generateTextLayout(props, fontData);
|
|
115
120
|
layoutCache.set(cacheKey, layout);
|
|
116
121
|
|
|
122
|
+
// Eagerly bound the cache. Idle `cleanup` alone is not enough: an animating
|
|
123
|
+
// scene never idles, so without this, ever-changing text grows the cache
|
|
124
|
+
// without limit. The Map is insertion-ordered and cache hits re-insert
|
|
125
|
+
// (delete + set) to the end, so the first key is the least-recently-used.
|
|
126
|
+
if (layoutCache.size > maxLayoutCacheSize) {
|
|
127
|
+
const oldest = layoutCache.keys().next().value as string;
|
|
128
|
+
layoutCache.delete(oldest);
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
// For SDF renderer, ImageData is null since we render via WebGL
|
|
118
132
|
return {
|
|
119
133
|
remainingLines: 0,
|
package/src/main-api/Renderer.ts
CHANGED
|
@@ -542,6 +542,23 @@ export type RendererMainSettings = RendererRuntimeSettings & {
|
|
|
542
542
|
*/
|
|
543
543
|
forceWebGL2: boolean;
|
|
544
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Disable Vertex Array Objects
|
|
547
|
+
*
|
|
548
|
+
* @remarks
|
|
549
|
+
* By default the WebGL renderer caches each shader program's attribute layout
|
|
550
|
+
* in a Vertex Array Object (native on WebGL2, or via the
|
|
551
|
+
* `OES_vertex_array_object` extension on WebGL1) and binds it with a single
|
|
552
|
+
* call per draw instead of re-pointing every attribute. Set this to `true` to
|
|
553
|
+
* force the per-draw attribute-binding path instead.
|
|
554
|
+
*
|
|
555
|
+
* This is primarily a diagnostic/benchmarking switch — it lets you A/B the VAO
|
|
556
|
+
* optimization on a target device. It has no effect on the Canvas renderer.
|
|
557
|
+
*
|
|
558
|
+
* @defaultValue `false`
|
|
559
|
+
*/
|
|
560
|
+
disableVertexArrayObject: boolean;
|
|
561
|
+
|
|
545
562
|
/**
|
|
546
563
|
* Canvas object to use for rendering
|
|
547
564
|
*
|
|
@@ -717,6 +734,7 @@ export class RendererMain extends EventEmitter {
|
|
|
717
734
|
settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2,
|
|
718
735
|
enableContextSpy: settings.enableContextSpy ?? false,
|
|
719
736
|
forceWebGL2: settings.forceWebGL2 ?? false,
|
|
737
|
+
disableVertexArrayObject: settings.disableVertexArrayObject ?? false,
|
|
720
738
|
inspector: settings.inspector ?? false,
|
|
721
739
|
inspectorOptions: settings.inspectorOptions ?? {},
|
|
722
740
|
renderEngine: settings.renderEngine,
|
|
@@ -779,6 +797,7 @@ export class RendererMain extends EventEmitter {
|
|
|
779
797
|
devicePhysicalPixelRatio,
|
|
780
798
|
enableContextSpy: settings.enableContextSpy!,
|
|
781
799
|
forceWebGL2: settings.forceWebGL2!,
|
|
800
|
+
disableVertexArrayObject: settings.disableVertexArrayObject!,
|
|
782
801
|
fpsUpdateInterval: settings.fpsUpdateInterval!,
|
|
783
802
|
enableClear: settings.enableClear!,
|
|
784
803
|
numImageWorkers: settings.numImageWorkers!,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createWebGLContext } from './utils.js';
|
|
3
|
+
import { ContextSpy } from './core/lib/ContextSpy.js';
|
|
4
|
+
|
|
5
|
+
describe('createWebGLContext context spy', () => {
|
|
6
|
+
// Build a minimal fake GL context whose getExtension returns a fake
|
|
7
|
+
// OES_vertex_array_object so we can assert the spy counts calls on it.
|
|
8
|
+
function makeContext(spy: ContextSpy) {
|
|
9
|
+
let vaoCounter = 0;
|
|
10
|
+
const ext = {
|
|
11
|
+
createVertexArrayOES: () => ({ id: ++vaoCounter }),
|
|
12
|
+
bindVertexArrayOES: (_vao: unknown) => undefined,
|
|
13
|
+
deleteVertexArrayOES: (_vao: unknown) => undefined,
|
|
14
|
+
};
|
|
15
|
+
const gl = {
|
|
16
|
+
getExtension: (name: string) =>
|
|
17
|
+
name === 'OES_vertex_array_object' ? ext : null,
|
|
18
|
+
viewport: () => undefined,
|
|
19
|
+
};
|
|
20
|
+
const canvas = {
|
|
21
|
+
getContext: () => gl,
|
|
22
|
+
} as unknown as HTMLCanvasElement;
|
|
23
|
+
return createWebGLContext(canvas, false, spy);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
it('counts methods on extension objects returned by getExtension', () => {
|
|
27
|
+
const spy = new ContextSpy();
|
|
28
|
+
const gl = makeContext(spy);
|
|
29
|
+
|
|
30
|
+
// WebGL1 VAO calls route through the extension object.
|
|
31
|
+
const ext = gl.getExtension('OES_vertex_array_object') as unknown as {
|
|
32
|
+
createVertexArrayOES: () => unknown;
|
|
33
|
+
bindVertexArrayOES: (vao: unknown) => void;
|
|
34
|
+
};
|
|
35
|
+
const vao = ext.createVertexArrayOES();
|
|
36
|
+
ext.bindVertexArrayOES(vao);
|
|
37
|
+
ext.bindVertexArrayOES(null);
|
|
38
|
+
|
|
39
|
+
const data = spy.getData();
|
|
40
|
+
expect(data['getExtension']).toBeGreaterThan(0);
|
|
41
|
+
expect(data['createVertexArrayOES']).toBe(1);
|
|
42
|
+
expect(data['bindVertexArrayOES']).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('passes through a null extension without throwing', () => {
|
|
46
|
+
const spy = new ContextSpy();
|
|
47
|
+
const gl = makeContext(spy);
|
|
48
|
+
expect(gl.getExtension('NOPE' as 'OES_vertex_array_object')).toBe(null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('still counts direct context calls', () => {
|
|
52
|
+
const spy = new ContextSpy();
|
|
53
|
+
const gl = makeContext(spy);
|
|
54
|
+
gl.viewport(0, 0, 1, 1);
|
|
55
|
+
expect(spy.getData()['viewport']).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -29,17 +29,33 @@ export function createWebGLContext(
|
|
|
29
29
|
throw new Error('Unable to create WebGL context');
|
|
30
30
|
}
|
|
31
31
|
if (contextSpy) {
|
|
32
|
-
// Proxy the GL context
|
|
33
|
-
|
|
32
|
+
// Proxy the GL context — and any extension object it hands out — so every
|
|
33
|
+
// call is counted. The renderer routes WebGL1 Vertex Array Object calls
|
|
34
|
+
// through the OES_vertex_array_object extension object; without wrapping
|
|
35
|
+
// that object its methods (bindVertexArrayOES / createVertexArrayOES /
|
|
36
|
+
// deleteVertexArrayOES) would bypass the spy entirely. On WebGL2 the same
|
|
37
|
+
// calls live on the context itself and are already captured.
|
|
38
|
+
const handler: ProxyHandler<object> = {
|
|
34
39
|
get(target, prop) {
|
|
35
|
-
const value = target
|
|
36
|
-
if (typeof value
|
|
37
|
-
|
|
38
|
-
return value.bind(target);
|
|
40
|
+
const value = (target as Record<string | symbol, unknown>)[prop];
|
|
41
|
+
if (typeof value !== 'function') {
|
|
42
|
+
return value;
|
|
39
43
|
}
|
|
40
|
-
|
|
44
|
+
contextSpy.increment(String(prop));
|
|
45
|
+
const fn = value as (...args: unknown[]) => unknown;
|
|
46
|
+
if (prop === 'getExtension') {
|
|
47
|
+
return (...args: unknown[]): unknown => {
|
|
48
|
+
const ext = fn.apply(target, args);
|
|
49
|
+
if (ext !== null && typeof ext === 'object') {
|
|
50
|
+
return new Proxy(ext, handler);
|
|
51
|
+
}
|
|
52
|
+
return ext;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return fn.bind(target);
|
|
41
56
|
},
|
|
42
|
-
}
|
|
57
|
+
};
|
|
58
|
+
return new Proxy(gl as object, handler) as unknown as WebGLRenderingContext;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
return gl;
|