@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.
Files changed (43) hide show
  1. package/dist/src/core/Stage.js +2 -1
  2. package/dist/src/core/Stage.js.map +1 -1
  3. package/dist/src/core/TextureMemoryManager.d.ts +23 -0
  4. package/dist/src/core/TextureMemoryManager.js +37 -3
  5. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  6. package/dist/src/core/lib/WebGlContextWrapper.d.ts +1 -1
  7. package/dist/src/core/lib/WebGlContextWrapper.js +10 -3
  8. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  9. package/dist/src/core/lib/textureCompression.js +68 -4
  10. package/dist/src/core/lib/textureCompression.js.map +1 -1
  11. package/dist/src/core/renderers/CoreRenderer.d.ts +1 -0
  12. package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
  13. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +8 -0
  14. package/dist/src/core/renderers/webgl/WebGlRenderer.js +49 -13
  15. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  16. package/dist/src/core/renderers/webgl/internal/RendererUtils.js +9 -2
  17. package/dist/src/core/renderers/webgl/internal/RendererUtils.js.map +1 -1
  18. package/dist/src/core/text-rendering/SdfTextRenderer.js +16 -3
  19. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  20. package/dist/src/main-api/Renderer.d.ts +16 -0
  21. package/dist/src/main-api/Renderer.js +2 -0
  22. package/dist/src/main-api/Renderer.js.map +1 -1
  23. package/dist/src/utils.js +23 -7
  24. package/dist/src/utils.js.map +1 -1
  25. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +1 -1
  28. package/src/core/Stage.ts +2 -0
  29. package/src/core/TextureMemoryManager.test.ts +51 -2
  30. package/src/core/TextureMemoryManager.ts +42 -3
  31. package/src/core/lib/WebGlContextWrapper.test.ts +58 -0
  32. package/src/core/lib/WebGlContextWrapper.ts +13 -3
  33. package/src/core/lib/textureCompression.test.ts +158 -0
  34. package/src/core/lib/textureCompression.ts +78 -4
  35. package/src/core/renderers/CoreRenderer.ts +1 -0
  36. package/src/core/renderers/webgl/WebGlRenderer.ts +58 -15
  37. package/src/core/renderers/webgl/internal/RendererUtils.test.ts +73 -0
  38. package/src/core/renderers/webgl/internal/RendererUtils.ts +9 -2
  39. package/src/core/text-rendering/SdfTextRenderer.test.ts +11 -10
  40. package/src/core/text-rendering/SdfTextRenderer.ts +17 -3
  41. package/src/main-api/Renderer.ts +19 -0
  42. package/src/utils.test.ts +57 -0
  43. 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
- if (glw.getExtension('WEBGL_compressed_texture_astc') === null) {
180
- throw new Error('ASTC compressed textures not supported by this device');
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
  }
@@ -11,6 +11,7 @@ export interface CoreRendererOptions {
11
11
  canvas: HTMLCanvasElement | OffscreenCanvas;
12
12
  contextSpy: ContextSpy | null;
13
13
  forceWebGL2: boolean;
14
+ disableVertexArrayObject: boolean;
14
15
  }
15
16
 
16
17
  export interface BufferInfo {
@@ -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(gl));
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
- if (
970
- this.needsFullUpload ||
971
- this.curBufferIdx > this.lastUploadedBufferSize
972
- ) {
973
- // Full GPU re-allocation: covers new nodes and structural reorders.
974
- // Also triggered when curBufferIdx has grown beyond the last uploaded
975
- // size (e.g. after RTT rendering consumed needsFullUpload and then
976
- // the main scene added more quads).
977
- // Uses DYNAMIC_DRAW to signal to the driver that the buffer will be
978
- // updated frequently in smaller pieces going forward.
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
- const renderList = this.stage.renderList;
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
- const renderList = this.stage.renderList;
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
- const maxQuads = ~~(size / 80);
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
- for (let i = 0, j = 0; i < maxQuads; i += 6, j += 4) {
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('cleanup trims to the cap and evicts least-recently-used first', () => {
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
- render('B'); // evicted -> miss
113
- render('A'); // survived -> hit
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, enforced on idle via `cleanup`.
28
- // Overridden from stage options in `init`. The cache is allowed to grow past
29
- // this during active rendering and is trimmed back to it when the stage idles.
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,
@@ -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
+ });