@solidtv/renderer 1.5.0-1 → 1.5.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/Stage.js +2 -1
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.d.ts +23 -0
- package/dist/src/core/TextureMemoryManager.js +37 -3
- 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/webgl/WebGlRenderer.d.ts +8 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +49 -13
- package/dist/src/core/renderers/webgl/WebGlRenderer.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/core/Stage.ts +2 -0
- package/src/core/TextureMemoryManager.test.ts +51 -2
- package/src/core/TextureMemoryManager.ts +42 -3
- 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/webgl/WebGlRenderer.ts +58 -15
- 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,158 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { uploadCompressedTexture } from './textureCompression.js';
|
|
3
|
+
import type { WebGlContextWrapper } from './WebGlContextWrapper.js';
|
|
4
|
+
import type { CompressedData } from '../textures/Texture.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A compressed format enum is only valid in compressedTexImage2D after its
|
|
8
|
+
* owning extension has been enabled via getExtension; otherwise the driver
|
|
9
|
+
* rejects it with GL_INVALID_ENUM (1280). These tests pin that every upload
|
|
10
|
+
* path enables its extension *before* uploading, and fails loudly when the
|
|
11
|
+
* device exposes none of the candidates.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface MockGlw {
|
|
15
|
+
glw: WebGlContextWrapper;
|
|
16
|
+
order: string[];
|
|
17
|
+
getExtension: ReturnType<typeof vi.fn>;
|
|
18
|
+
compressedTexImage2D: ReturnType<typeof vi.fn>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeGlw(supported: Set<string>): MockGlw {
|
|
22
|
+
const order: string[] = [];
|
|
23
|
+
const getExtension = vi.fn((name: string) => {
|
|
24
|
+
order.push(`getExtension:${name}`);
|
|
25
|
+
return supported.has(name) === true ? {} : null;
|
|
26
|
+
});
|
|
27
|
+
const compressedTexImage2D = vi.fn(() => {
|
|
28
|
+
order.push('compressedTexImage2D');
|
|
29
|
+
});
|
|
30
|
+
const glw = {
|
|
31
|
+
getExtension,
|
|
32
|
+
compressedTexImage2D,
|
|
33
|
+
bindTexture: vi.fn(),
|
|
34
|
+
texParameteri: vi.fn(),
|
|
35
|
+
TEXTURE_WRAP_S: 0,
|
|
36
|
+
TEXTURE_WRAP_T: 0,
|
|
37
|
+
TEXTURE_MAG_FILTER: 0,
|
|
38
|
+
TEXTURE_MIN_FILTER: 0,
|
|
39
|
+
CLAMP_TO_EDGE: 0,
|
|
40
|
+
LINEAR: 0,
|
|
41
|
+
LINEAR_MIPMAP_LINEAR: 0,
|
|
42
|
+
} as unknown as WebGlContextWrapper;
|
|
43
|
+
return { glw, order, getExtension, compressedTexImage2D };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeData(
|
|
47
|
+
type: 'ktx' | 'pvr' | 'astc',
|
|
48
|
+
glInternalFormat: number,
|
|
49
|
+
): CompressedData {
|
|
50
|
+
return {
|
|
51
|
+
type,
|
|
52
|
+
glInternalFormat,
|
|
53
|
+
w: 4,
|
|
54
|
+
h: 4,
|
|
55
|
+
mipmaps: [new ArrayBuffer(16)],
|
|
56
|
+
blockInfo: { width: 4, height: 4, bytes: 16 },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const texture = {} as WebGLTexture;
|
|
61
|
+
|
|
62
|
+
// COMPRESSED_RGBA_S3TC_DXT5_EXT
|
|
63
|
+
const S3TC_DXT5 = 0x83f3;
|
|
64
|
+
// COMPRESSED_RGB_PVRTC_4BPPV1_IMG
|
|
65
|
+
const PVRTC_4BPP = 0x8c00;
|
|
66
|
+
// COMPRESSED_RGB_ETC1_WEBGL
|
|
67
|
+
const ETC1 = 0x8d64;
|
|
68
|
+
// COMPRESSED_RGBA_ASTC_4x4_KHR
|
|
69
|
+
const ASTC_4x4 = 0x93b0;
|
|
70
|
+
|
|
71
|
+
describe('compressed texture extension guards', () => {
|
|
72
|
+
it('KTX enables the s3tc extension before uploading', () => {
|
|
73
|
+
const m = makeGlw(new Set(['WEBGL_compressed_texture_s3tc']));
|
|
74
|
+
uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', S3TC_DXT5));
|
|
75
|
+
|
|
76
|
+
expect(m.getExtension).toHaveBeenCalledWith(
|
|
77
|
+
'WEBGL_compressed_texture_s3tc',
|
|
78
|
+
);
|
|
79
|
+
expect(m.compressedTexImage2D).toHaveBeenCalled();
|
|
80
|
+
// getExtension must precede the first compressedTexImage2D call
|
|
81
|
+
expect(m.order.indexOf('getExtension:WEBGL_compressed_texture_s3tc')).toBe(
|
|
82
|
+
0,
|
|
83
|
+
);
|
|
84
|
+
expect(
|
|
85
|
+
m.order.indexOf('getExtension:WEBGL_compressed_texture_s3tc') <
|
|
86
|
+
m.order.indexOf('compressedTexImage2D'),
|
|
87
|
+
).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('KTX throws (no silent 1280) when the s3tc extension is unavailable', () => {
|
|
91
|
+
const m = makeGlw(new Set());
|
|
92
|
+
expect(() =>
|
|
93
|
+
uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', S3TC_DXT5)),
|
|
94
|
+
).toThrow(/not supported/);
|
|
95
|
+
expect(m.compressedTexImage2D).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('KTX enables the etc1 extension for ETC1 formats', () => {
|
|
99
|
+
const m = makeGlw(new Set(['WEBGL_compressed_texture_etc1']));
|
|
100
|
+
uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', ETC1));
|
|
101
|
+
expect(m.getExtension).toHaveBeenCalledWith(
|
|
102
|
+
'WEBGL_compressed_texture_etc1',
|
|
103
|
+
);
|
|
104
|
+
expect(m.compressedTexImage2D).toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('PVR enables the pvrtc extension before uploading', () => {
|
|
108
|
+
const m = makeGlw(new Set(['WEBGL_compressed_texture_pvrtc']));
|
|
109
|
+
uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP));
|
|
110
|
+
expect(m.getExtension).toHaveBeenCalledWith(
|
|
111
|
+
'WEBGL_compressed_texture_pvrtc',
|
|
112
|
+
);
|
|
113
|
+
expect(
|
|
114
|
+
m.order.indexOf('getExtension:WEBGL_compressed_texture_pvrtc') <
|
|
115
|
+
m.order.indexOf('compressedTexImage2D'),
|
|
116
|
+
).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('PVR falls back to the WebKit-prefixed pvrtc extension', () => {
|
|
120
|
+
const m = makeGlw(new Set(['WEBKIT_WEBGL_compressed_texture_pvrtc']));
|
|
121
|
+
uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP));
|
|
122
|
+
expect(m.getExtension).toHaveBeenCalledWith(
|
|
123
|
+
'WEBGL_compressed_texture_pvrtc',
|
|
124
|
+
);
|
|
125
|
+
expect(m.getExtension).toHaveBeenCalledWith(
|
|
126
|
+
'WEBKIT_WEBGL_compressed_texture_pvrtc',
|
|
127
|
+
);
|
|
128
|
+
expect(m.compressedTexImage2D).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('PVR throws when neither pvrtc extension is available', () => {
|
|
132
|
+
const m = makeGlw(new Set());
|
|
133
|
+
expect(() =>
|
|
134
|
+
uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP)),
|
|
135
|
+
).toThrow(/not supported/);
|
|
136
|
+
expect(m.compressedTexImage2D).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('ASTC enables the astc extension before uploading', () => {
|
|
140
|
+
const m = makeGlw(new Set(['WEBGL_compressed_texture_astc']));
|
|
141
|
+
uploadCompressedTexture.astc!(m.glw, texture, makeData('astc', ASTC_4x4));
|
|
142
|
+
expect(m.getExtension).toHaveBeenCalledWith(
|
|
143
|
+
'WEBGL_compressed_texture_astc',
|
|
144
|
+
);
|
|
145
|
+
expect(
|
|
146
|
+
m.order.indexOf('getExtension:WEBGL_compressed_texture_astc') <
|
|
147
|
+
m.order.indexOf('compressedTexImage2D'),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('ASTC throws when the astc extension is unavailable', () => {
|
|
152
|
+
const m = makeGlw(new Set());
|
|
153
|
+
expect(() =>
|
|
154
|
+
uploadCompressedTexture.astc!(m.glw, texture, makeData('astc', ASTC_4x4)),
|
|
155
|
+
).toThrow(/not supported/);
|
|
156
|
+
expect(m.compressedTexImage2D).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -171,18 +171,90 @@ const loadASTC = async function (view: DataView): Promise<TextureData> {
|
|
|
171
171
|
};
|
|
172
172
|
};
|
|
173
173
|
|
|
174
|
+
// Candidate extension name lists, hoisted to module constants so the per-upload
|
|
175
|
+
// resolver returns a shared reference instead of allocating a new array each
|
|
176
|
+
// call (zero GC pressure on the texture-upload path).
|
|
177
|
+
const EXT_ASTC = ['WEBGL_compressed_texture_astc'];
|
|
178
|
+
const EXT_S3TC = ['WEBGL_compressed_texture_s3tc'];
|
|
179
|
+
const EXT_ETC1 = ['WEBGL_compressed_texture_etc1'];
|
|
180
|
+
const EXT_ETC = ['WEBGL_compressed_texture_etc'];
|
|
181
|
+
// WebKit-prefixed name is the legacy fallback.
|
|
182
|
+
const EXT_PVRTC = [
|
|
183
|
+
'WEBGL_compressed_texture_pvrtc',
|
|
184
|
+
'WEBKIT_WEBGL_compressed_texture_pvrtc',
|
|
185
|
+
];
|
|
186
|
+
const EXT_NONE: string[] = [];
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve the WebGL extension(s) that must be enabled before a given compressed
|
|
190
|
+
* GL internal format may be used.
|
|
191
|
+
*
|
|
192
|
+
* @remarks
|
|
193
|
+
* `getExtension` is the call that actually enables a compressed format on a
|
|
194
|
+
* context — until it is called, the format enum is rejected by
|
|
195
|
+
* `compressedTexImage2D` with `GL_INVALID_ENUM` (1280). Listed in priority
|
|
196
|
+
* order; the first name the device exposes is used.
|
|
197
|
+
*/
|
|
198
|
+
const requiredExtensionsForFormat = (glInternalFormat: number): string[] => {
|
|
199
|
+
// ASTC (incl. sRGB variants): 0x93b0–0x93d5
|
|
200
|
+
if (glInternalFormat >= 0x93b0 && glInternalFormat <= 0x93d5) {
|
|
201
|
+
return EXT_ASTC;
|
|
202
|
+
}
|
|
203
|
+
// S3TC / DXTn: 0x83f0–0x83f3
|
|
204
|
+
if (glInternalFormat >= 0x83f0 && glInternalFormat <= 0x83f3) {
|
|
205
|
+
return EXT_S3TC;
|
|
206
|
+
}
|
|
207
|
+
// ETC1: 0x8d64
|
|
208
|
+
if (glInternalFormat === 0x8d64) {
|
|
209
|
+
return EXT_ETC1;
|
|
210
|
+
}
|
|
211
|
+
// ETC2 / EAC: 0x9274–0x9279
|
|
212
|
+
if (glInternalFormat >= 0x9274 && glInternalFormat <= 0x9279) {
|
|
213
|
+
return EXT_ETC;
|
|
214
|
+
}
|
|
215
|
+
// PVRTC: 0x8c00–0x8c03
|
|
216
|
+
if (glInternalFormat >= 0x8c00 && glInternalFormat <= 0x8c03) {
|
|
217
|
+
return EXT_PVRTC;
|
|
218
|
+
}
|
|
219
|
+
return EXT_NONE;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Enable the extension owning `glInternalFormat` so the format enum is valid in
|
|
224
|
+
* `compressedTexImage2D`, throwing a clear error if the device exposes none of
|
|
225
|
+
* the candidate extensions (instead of leaking a silent `GL_INVALID_ENUM`).
|
|
226
|
+
*/
|
|
227
|
+
const ensureCompressedFormatEnabled = (
|
|
228
|
+
glw: WebGlContextWrapper,
|
|
229
|
+
glInternalFormat: number,
|
|
230
|
+
): void => {
|
|
231
|
+
const names = requiredExtensionsForFormat(glInternalFormat);
|
|
232
|
+
const len = names.length;
|
|
233
|
+
if (len === 0) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
for (let i = 0; i < len; i++) {
|
|
237
|
+
if (glw.getExtension(names[i]!) !== null) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Compressed texture format 0x${glInternalFormat.toString(
|
|
243
|
+
16,
|
|
244
|
+
)} is not supported by this device (requires ${names.join(' or ')})`,
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
|
|
174
248
|
const uploadASTC = function (
|
|
175
249
|
glw: WebGlContextWrapper,
|
|
176
250
|
texture: WebGLTexture,
|
|
177
251
|
data: CompressedData,
|
|
178
252
|
) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
253
|
+
const { glInternalFormat, mipmaps, w, h } = data;
|
|
254
|
+
ensureCompressedFormatEnabled(glw, glInternalFormat);
|
|
182
255
|
|
|
183
256
|
glw.bindTexture(texture);
|
|
184
257
|
|
|
185
|
-
const { glInternalFormat, mipmaps, w, h } = data;
|
|
186
258
|
if (mipmaps === undefined) {
|
|
187
259
|
return;
|
|
188
260
|
}
|
|
@@ -277,6 +349,7 @@ const uploadKTX = function (
|
|
|
277
349
|
data: CompressedData,
|
|
278
350
|
) {
|
|
279
351
|
const { glInternalFormat, mipmaps, w: width, h: height, blockInfo } = data;
|
|
352
|
+
ensureCompressedFormatEnabled(glw, glInternalFormat);
|
|
280
353
|
if (mipmaps === undefined) {
|
|
281
354
|
return;
|
|
282
355
|
}
|
|
@@ -415,6 +488,7 @@ const uploadPVR = function (
|
|
|
415
488
|
data: CompressedData,
|
|
416
489
|
) {
|
|
417
490
|
const { glInternalFormat, mipmaps, w: width, h: height } = data;
|
|
491
|
+
ensureCompressedFormatEnabled(glw, glInternalFormat);
|
|
418
492
|
if (mipmaps === undefined) {
|
|
419
493
|
return;
|
|
420
494
|
}
|
|
@@ -48,6 +48,19 @@ const GL_OUT_OF_MEMORY = 0x0505;
|
|
|
48
48
|
*/
|
|
49
49
|
const MAX_DRAINED_GL_ERRORS = 8;
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Dirty-ratio cutoff that flips the per-frame quad upload from surgical
|
|
53
|
+
* `bufferSubData` (one call per changed node) to a single full `bufferData`.
|
|
54
|
+
*
|
|
55
|
+
* The surgical path wins when few nodes change per frame (typical UI), but
|
|
56
|
+
* degrades to one GL call per node when most of the scene moves at once
|
|
57
|
+
* (full-screen animation, scroll, zoom). At that point a single bulk upload is
|
|
58
|
+
* cheaper than N driver round-trips, so when the number of nodes we would
|
|
59
|
+
* `bufferSubData` exceeds this fraction of the render list we upload everything
|
|
60
|
+
* in one call instead. Range 0..1; ~0.4 balances the two regimes.
|
|
61
|
+
*/
|
|
62
|
+
const FULL_UPLOAD_DIRTY_RATIO = 0.4;
|
|
63
|
+
|
|
51
64
|
export type WebGlRenderOp = CoreNode | SdfRenderOp;
|
|
52
65
|
|
|
53
66
|
export class WebGlRenderer extends CoreRenderer {
|
|
@@ -161,6 +174,14 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
161
174
|
* capacity, requiring a full re-upload even when needsFullUpload is false.
|
|
162
175
|
*/
|
|
163
176
|
lastUploadedBufferSize = 0;
|
|
177
|
+
/**
|
|
178
|
+
* Count of main-scene nodes whose quad data changed this frame and which
|
|
179
|
+
* own a buffer slot. Accumulated for free during the addQuad pass (which
|
|
180
|
+
* already branches on isQuadDirty) and consumed in render() to choose
|
|
181
|
+
* between surgical bufferSubData uploads and a single full bufferData,
|
|
182
|
+
* avoiding a separate counting loop. Reset each frame in reset().
|
|
183
|
+
*/
|
|
184
|
+
dirtyQuadCount = 0;
|
|
164
185
|
/**
|
|
165
186
|
* Whether the renderer is currently rendering to a texture.
|
|
166
187
|
*/
|
|
@@ -187,7 +208,10 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
187
208
|
options.forceWebGL2,
|
|
188
209
|
options.contextSpy,
|
|
189
210
|
);
|
|
190
|
-
const glw = (this.glw = new WebGlContextWrapper(
|
|
211
|
+
const glw = (this.glw = new WebGlContextWrapper(
|
|
212
|
+
gl,
|
|
213
|
+
options.disableVertexArrayObject,
|
|
214
|
+
));
|
|
191
215
|
glw.viewport(0, 0, options.canvas.width, options.canvas.height);
|
|
192
216
|
|
|
193
217
|
this.attachContextLossListeners(options.canvas);
|
|
@@ -346,6 +370,7 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
346
370
|
}
|
|
347
371
|
this.curRenderOp = null;
|
|
348
372
|
this.curSdfRenderOp = null;
|
|
373
|
+
this.dirtyQuadCount = 0;
|
|
349
374
|
this.sdfBufferIdx = 0;
|
|
350
375
|
this.sdfQuadCount = 0;
|
|
351
376
|
this.renderOps.length = 0;
|
|
@@ -496,6 +521,14 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
496
521
|
// The GPU upload is deferred to render().
|
|
497
522
|
// During RTT, always write since the buffer is rebuilt from scratch.
|
|
498
523
|
if (!DIRTY_QUAD_BUFFER || isRTT || node.isQuadDirty) {
|
|
524
|
+
// Count main-scene dirty nodes here, while we already have the node in
|
|
525
|
+
// hand, so render() can pick full vs. surgical upload without a second
|
|
526
|
+
// pass over the render list. Slot is guaranteed assigned at this point
|
|
527
|
+
// (i = quadBufferIndex above), so no quadBufferIndex !== -1 guard needed.
|
|
528
|
+
if (DIRTY_QUAD_BUFFER && isRTT === false && node.isQuadDirty === true) {
|
|
529
|
+
this.dirtyQuadCount++;
|
|
530
|
+
}
|
|
531
|
+
|
|
499
532
|
const rc = node.renderCoords!;
|
|
500
533
|
const tc = node.textureCoords || this.defaultTextureCoords;
|
|
501
534
|
|
|
@@ -966,31 +999,41 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
966
999
|
const BYTES = Float32Array.BYTES_PER_ELEMENT;
|
|
967
1000
|
|
|
968
1001
|
if (DIRTY_QUAD_BUFFER) {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1002
|
+
const renderList = this.stage.renderList;
|
|
1003
|
+
const len = renderList.length;
|
|
1004
|
+
|
|
1005
|
+
// Growth/realloc always forces a full upload (new nodes, structural
|
|
1006
|
+
// reorders, or curBufferIdx grown past the last uploaded size).
|
|
1007
|
+
let fullUpload =
|
|
1008
|
+
this.needsFullUpload || this.curBufferIdx > this.lastUploadedBufferSize;
|
|
1009
|
+
|
|
1010
|
+
// Otherwise decide adaptively: if the number of nodes we would upload
|
|
1011
|
+
// surgically exceeds FULL_UPLOAD_DIRTY_RATIO of the render list, a single
|
|
1012
|
+
// bufferData is cheaper than that many bufferSubData calls. The count was
|
|
1013
|
+
// accumulated for free during the addQuad pass (dirtyQuadCount), so no
|
|
1014
|
+
// separate counting loop is needed here.
|
|
1015
|
+
if (fullUpload === false) {
|
|
1016
|
+
fullUpload = this.dirtyQuadCount > len * FULL_UPLOAD_DIRTY_RATIO;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (fullUpload === true) {
|
|
1020
|
+
// Full GPU re-allocation: covers the growth/realloc cases above and the
|
|
1021
|
+
// "most of the scene changed" case where one bulk upload beats N
|
|
1022
|
+
// per-node uploads. Uses DYNAMIC_DRAW to signal to the driver that the
|
|
1023
|
+
// buffer will be updated frequently going forward.
|
|
979
1024
|
const arr = new Float32Array(quadBuffer, 0, this.curBufferIdx);
|
|
980
1025
|
glw.arrayBufferData(buffer, arr, glw.DYNAMIC_DRAW);
|
|
981
1026
|
this.needsFullUpload = false;
|
|
982
1027
|
this.lastUploadedBufferSize = this.curBufferIdx;
|
|
983
1028
|
|
|
984
1029
|
// Clear dirty flags since we just uploaded everything.
|
|
985
|
-
|
|
986
|
-
for (let i = 0, len = renderList.length; i < len; i++) {
|
|
1030
|
+
for (let i = 0; i < len; i++) {
|
|
987
1031
|
renderList[i]!.isQuadDirty = false;
|
|
988
1032
|
}
|
|
989
1033
|
} else {
|
|
990
1034
|
// Surgical per-node uploads: only write the 20 float32s for nodes
|
|
991
1035
|
// whose quad data changed since the last frame.
|
|
992
|
-
|
|
993
|
-
for (let i = 0, len = renderList.length; i < len; i++) {
|
|
1036
|
+
for (let i = 0; i < len; i++) {
|
|
994
1037
|
const node = renderList[i]!;
|
|
995
1038
|
if (node.isQuadDirty && node.quadBufferIndex !== -1) {
|
|
996
1039
|
const byteOffset = node.quadBufferIndex * BYTES;
|
|
@@ -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
|
+
});
|