@solidtv/renderer 1.5.0-0 → 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 (50) hide show
  1. package/dist/src/common/CommonTypes.d.ts +20 -0
  2. package/dist/src/core/Stage.d.ts +1 -0
  3. package/dist/src/core/Stage.js +13 -2
  4. package/dist/src/core/Stage.js.map +1 -1
  5. package/dist/src/core/TextureMemoryManager.d.ts +23 -0
  6. package/dist/src/core/TextureMemoryManager.js +37 -3
  7. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  8. package/dist/src/core/lib/WebGlContextWrapper.d.ts +1 -1
  9. package/dist/src/core/lib/WebGlContextWrapper.js +10 -3
  10. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  11. package/dist/src/core/lib/textureCompression.js +68 -4
  12. package/dist/src/core/lib/textureCompression.js.map +1 -1
  13. package/dist/src/core/renderers/CoreRenderer.d.ts +1 -0
  14. package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
  15. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +8 -0
  16. package/dist/src/core/renderers/webgl/WebGlRenderer.js +49 -13
  17. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  18. package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +2 -0
  19. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +18 -5
  20. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  21. package/dist/src/core/renderers/webgl/internal/RendererUtils.js +9 -2
  22. package/dist/src/core/renderers/webgl/internal/RendererUtils.js.map +1 -1
  23. package/dist/src/core/text-rendering/SdfTextRenderer.js +16 -3
  24. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  25. package/dist/src/main-api/Renderer.d.ts +33 -1
  26. package/dist/src/main-api/Renderer.js +2 -0
  27. package/dist/src/main-api/Renderer.js.map +1 -1
  28. package/dist/src/utils.js +23 -7
  29. package/dist/src/utils.js.map +1 -1
  30. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +1 -1
  33. package/src/common/CommonTypes.ts +20 -0
  34. package/src/core/Stage.ts +16 -1
  35. package/src/core/TextureMemoryManager.test.ts +51 -2
  36. package/src/core/TextureMemoryManager.ts +42 -3
  37. package/src/core/lib/WebGlContextWrapper.test.ts +58 -0
  38. package/src/core/lib/WebGlContextWrapper.ts +13 -3
  39. package/src/core/lib/textureCompression.test.ts +158 -0
  40. package/src/core/lib/textureCompression.ts +78 -4
  41. package/src/core/renderers/CoreRenderer.ts +1 -0
  42. package/src/core/renderers/webgl/WebGlRenderer.ts +58 -15
  43. package/src/core/renderers/webgl/WebGlShaderProgram.ts +18 -5
  44. package/src/core/renderers/webgl/internal/RendererUtils.test.ts +73 -0
  45. package/src/core/renderers/webgl/internal/RendererUtils.ts +9 -2
  46. package/src/core/text-rendering/SdfTextRenderer.test.ts +11 -10
  47. package/src/core/text-rendering/SdfTextRenderer.ts +17 -3
  48. package/src/main-api/Renderer.ts +36 -1
  49. package/src/utils.test.ts +57 -0
  50. package/src/utils.ts +24 -8
@@ -166,6 +166,44 @@ export class TextureMemoryManager {
166
166
  return this.criticalThreshold > 0 && this.memUsed > this.criticalThreshold;
167
167
  }
168
168
 
169
+ /**
170
+ * Reversibly free a texture's GPU resources under memory pressure.
171
+ *
172
+ * @remarks
173
+ * Unlike {@link destroyTexture}, this keeps the `Texture` object, its event
174
+ * listeners, and its cache entry intact. It only releases the GPU-side
175
+ * resource and transitions the source to the `freed` state. A `CoreNode` that
176
+ * still references this texture will reload it — and be re-notified via its
177
+ * `loaded` listener — when it re-enters the viewport (see
178
+ * `Texture.setRenderableOwner` → `Texture.load`).
179
+ *
180
+ * This is the correct path for LRU/idle cleanup: destroying instead would
181
+ * sever the node's subscription (`removeAllListeners`) and evict the cache
182
+ * entry, leaving the node stuck on a texture that reloads to `loaded` but is
183
+ * never displayed.
184
+ *
185
+ * `texture.free()` reclaims tracked memory via `setTextureMemUse(0)` when a
186
+ * ctxTexture exists; the guard below keeps the accounting correct for any
187
+ * texture that entered `loadedTextures` without one.
188
+ *
189
+ * @param texture - The texture to free
190
+ */
191
+ freeTexture(texture: Texture) {
192
+ if (this.debugLogging === true) {
193
+ console.log(
194
+ `[TextureMemoryManager] Freeing texture. State: ${texture.state}`,
195
+ );
196
+ }
197
+
198
+ texture.free();
199
+
200
+ if (this.loadedTextures.has(texture) === true) {
201
+ this.loadedTextures.delete(texture);
202
+ this.memUsed -= texture.memUsed;
203
+ texture.memUsed = 0;
204
+ }
205
+ }
206
+
169
207
  /**
170
208
  * Destroy a texture and remove it from the memory manager
171
209
  *
@@ -230,11 +268,12 @@ export class TextureMemoryManager {
230
268
 
231
269
  // Immediate cleanup if eligible
232
270
  if (isCleanableType && texture.canBeCleanedUp() === true) {
233
- // Get memory before destroying
271
+ // Get memory before freeing
234
272
  const textureMemory = texture.memUsed;
235
273
 
236
- // Destroy texture (which will null out the array position)
237
- this.destroyTexture(texture);
274
+ // Reversibly free (keeps listeners + cache) so the texture reloads when
275
+ // its node re-enters the viewport.
276
+ this.freeTexture(texture);
238
277
  currentMemUsed -= textureMemory;
239
278
  }
240
279
  }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { WebGlContextWrapper } from './WebGlContextWrapper.js';
3
+
4
+ // The wrapper reads `self.WebGL2RenderingContext` to detect WebGL2. The unit
5
+ // test environment is plain Node where `self` is undefined, so provide one.
6
+ // With no WebGL2RenderingContext on it, the mock context is treated as WebGL1.
7
+ const g = globalThis as unknown as { self?: unknown };
8
+ if (g.self === undefined) {
9
+ g.self = globalThis;
10
+ }
11
+
12
+ // Minimal WebGL1-like context. A Proxy returns 0 for every GLenum constant the
13
+ // constructor copies; only the handful of methods/props it actually calls are
14
+ // implemented.
15
+ function mockWebGl1(hasVaoExtension: boolean): WebGLRenderingContext {
16
+ const vaoExt = {
17
+ createVertexArrayOES: () => ({}),
18
+ bindVertexArrayOES: (_vao: unknown) => undefined,
19
+ deleteVertexArrayOES: (_vao: unknown) => undefined,
20
+ };
21
+ const target: Record<string, unknown> = {
22
+ canvas: {},
23
+ drawingBufferWidth: 100,
24
+ drawingBufferHeight: 100,
25
+ getParameter: () => 8,
26
+ getExtension: (name: string) =>
27
+ name === 'OES_vertex_array_object' && hasVaoExtension ? vaoExt : null,
28
+ };
29
+ return new Proxy(target, {
30
+ get(t, prop) {
31
+ if (prop in t) {
32
+ return t[prop as string];
33
+ }
34
+ // Every other access is a GLenum constant.
35
+ return 0;
36
+ },
37
+ }) as unknown as WebGLRenderingContext;
38
+ }
39
+
40
+ describe('WebGlContextWrapper VAO support', () => {
41
+ it('enables VAOs when the OES extension is present', () => {
42
+ const glw = new WebGlContextWrapper(mockWebGl1(true));
43
+ expect(glw.canUseVertexArrayObject).toBe(true);
44
+ expect(glw.isWebGl2).toBe(false);
45
+ });
46
+
47
+ it('disables VAOs when the OES extension is absent', () => {
48
+ const glw = new WebGlContextWrapper(mockWebGl1(false));
49
+ expect(glw.canUseVertexArrayObject).toBe(false);
50
+ });
51
+
52
+ it('forces VAOs off when disableVertexArrayObject is true, even if supported', () => {
53
+ const glw = new WebGlContextWrapper(mockWebGl1(true), true);
54
+ expect(glw.canUseVertexArrayObject).toBe(false);
55
+ // The flag gates VAO usage only; it does not change the detected context.
56
+ expect(glw.isWebGl2).toBe(false);
57
+ });
58
+ });
@@ -111,7 +111,10 @@ export class WebGlContextWrapper {
111
111
  public readonly INVALID_OPERATION: number;
112
112
  //#endregion WebGL Enums
113
113
 
114
- constructor(private gl: WebGLRenderingContext | WebGL2RenderingContext) {
114
+ constructor(
115
+ private gl: WebGLRenderingContext | WebGL2RenderingContext,
116
+ disableVertexArrayObject = false,
117
+ ) {
115
118
  // A freshly created WebGL context is in a fully specified default state.
116
119
  // Rather than reading that state back with getParameter/isEnabled — each a
117
120
  // synchronous CPU<->GPU round-trip, and previously ~one per texture unit
@@ -154,9 +157,16 @@ export class WebGlContextWrapper {
154
157
  self.WebGL2RenderingContext && gl instanceof self.WebGL2RenderingContext
155
158
  ? (gl as WebGL2RenderingContext)
156
159
  : null;
160
+ // `disableVertexArrayObject` forces the per-draw attribute-binding path even
161
+ // when VAOs are available (diagnostics / benchmarking). isWebGl2 still
162
+ // reflects the real context — the flag only gates VAO usage.
157
163
  this.vaoExt =
158
- this.gl2 === null ? gl.getExtension('OES_vertex_array_object') : null;
159
- this.canUseVertexArrayObject = this.gl2 !== null || this.vaoExt !== null;
164
+ this.gl2 === null && disableVertexArrayObject !== true
165
+ ? gl.getExtension('OES_vertex_array_object')
166
+ : null;
167
+ this.canUseVertexArrayObject =
168
+ disableVertexArrayObject !== true &&
169
+ (this.gl2 !== null || this.vaoExt !== null);
160
170
  this.isWebGl2 = this.gl2 !== null;
161
171
 
162
172
  this.canvas = gl.canvas;
@@ -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;
@@ -276,17 +276,25 @@ export class WebGlShaderProgram implements CoreShaderProgram {
276
276
  if (glw.canUseVertexArrayObject === true) {
277
277
  let vao = this.vaos.get(buffer);
278
278
  if (vao === undefined) {
279
- // First draw with this collection: record the attribute layout into a
280
- // VAO. createVao leaves the new VAO bound, so there's nothing more to do.
279
+ // First draw with this collection: try to capture the attribute layout
280
+ // in a VAO. Cache the result including a null on allocation failure,
281
+ // so we don't retry createVertexArray every frame.
281
282
  vao = this.createVao(buffer);
282
283
  this.vaos.set(buffer, vao);
284
+ }
285
+ if (vao !== null) {
286
+ // createVao leaves a freshly built VAO bound; re-binding is cheap and
287
+ // keeps the build and reuse paths identical.
288
+ glw.bindVertexArray(vao);
283
289
  return;
284
290
  }
285
- glw.bindVertexArray(vao);
286
- return;
291
+ // VAO allocation failed (e.g. under GL OOM). Bind the default VAO so the
292
+ // per-draw attribute setup below records into it rather than corrupting
293
+ // another program's cached VAO, then fall through.
294
+ glw.bindVertexArray(null);
287
295
  }
288
296
 
289
- // No VAO support: re-point every attribute on each draw.
297
+ // No (usable) VAO: re-point every attribute on each draw.
290
298
  this.bindAttributes(buffer);
291
299
  }
292
300
 
@@ -322,10 +330,15 @@ export class WebGlShaderProgram implements CoreShaderProgram {
322
330
  /**
323
331
  * Create and populate a Vertex Array Object capturing this program's
324
332
  * attribute layout for the given buffer collection. The new VAO is left bound.
333
+ * Returns null if the context reports VAO support but allocation fails (e.g.
334
+ * under GL OOM), in which case the caller falls back to per-draw binding.
325
335
  */
326
336
  private createVao(buffer: BufferCollection): WebGLVertexArrayObject | null {
327
337
  const { glw } = this;
328
338
  const vao = glw.createVertexArray();
339
+ if (vao === null) {
340
+ return null;
341
+ }
329
342
  glw.bindVertexArray(vao);
330
343
 
331
344
  this.bindAttributes(buffer);
@@ -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
+ });