@solidtv/renderer 1.5.0-1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/src/common/EventEmitter.d.ts +8 -0
  2. package/dist/src/common/EventEmitter.js +20 -0
  3. package/dist/src/common/EventEmitter.js.map +1 -1
  4. package/dist/src/core/Autosizer.js +3 -1
  5. package/dist/src/core/Autosizer.js.map +1 -1
  6. package/dist/src/core/CoreNode.d.ts +39 -0
  7. package/dist/src/core/CoreNode.js +105 -25
  8. package/dist/src/core/CoreNode.js.map +1 -1
  9. package/dist/src/core/CoreTextNode.js +4 -2
  10. package/dist/src/core/CoreTextNode.js.map +1 -1
  11. package/dist/src/core/Stage.js +4 -1
  12. package/dist/src/core/Stage.js.map +1 -1
  13. package/dist/src/core/TextureMemoryManager.d.ts +62 -2
  14. package/dist/src/core/TextureMemoryManager.js +90 -4
  15. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  16. package/dist/src/core/lib/WebGlContextWrapper.d.ts +1 -1
  17. package/dist/src/core/lib/WebGlContextWrapper.js +10 -3
  18. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  19. package/dist/src/core/lib/textureCompression.js +68 -4
  20. package/dist/src/core/lib/textureCompression.js.map +1 -1
  21. package/dist/src/core/renderers/CoreRenderer.d.ts +1 -0
  22. package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
  23. package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
  24. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  25. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +8 -0
  26. package/dist/src/core/renderers/webgl/WebGlRenderer.js +54 -14
  27. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  28. package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
  29. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
  30. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  31. package/dist/src/core/renderers/webgl/internal/RendererUtils.js +9 -2
  32. package/dist/src/core/renderers/webgl/internal/RendererUtils.js.map +1 -1
  33. package/dist/src/core/text-rendering/SdfTextRenderer.js +16 -3
  34. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  35. package/dist/src/main-api/Renderer.d.ts +16 -0
  36. package/dist/src/main-api/Renderer.js +2 -0
  37. package/dist/src/main-api/Renderer.js.map +1 -1
  38. package/dist/src/utils.js +23 -7
  39. package/dist/src/utils.js.map +1 -1
  40. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +1 -1
  43. package/src/common/EventEmitter.ts +21 -0
  44. package/src/core/Autosizer.ts +3 -1
  45. package/src/core/CoreNode.test.ts +283 -0
  46. package/src/core/CoreNode.ts +131 -26
  47. package/src/core/CoreTextNode.test.ts +1 -0
  48. package/src/core/CoreTextNode.ts +6 -2
  49. package/src/core/Stage.ts +4 -0
  50. package/src/core/TextureMemoryManager.test.ts +323 -7
  51. package/src/core/TextureMemoryManager.ts +101 -5
  52. package/src/core/lib/WebGlContextWrapper.test.ts +58 -0
  53. package/src/core/lib/WebGlContextWrapper.ts +13 -3
  54. package/src/core/lib/textureCompression.test.ts +158 -0
  55. package/src/core/lib/textureCompression.ts +78 -4
  56. package/src/core/renderers/CoreRenderer.ts +1 -0
  57. package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
  58. package/src/core/renderers/webgl/WebGlRenderer.ts +64 -16
  59. package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
  60. package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
  61. package/src/core/renderers/webgl/internal/RendererUtils.test.ts +73 -0
  62. package/src/core/renderers/webgl/internal/RendererUtils.ts +9 -2
  63. package/src/core/text-rendering/SdfTextRenderer.test.ts +11 -10
  64. package/src/core/text-rendering/SdfTextRenderer.ts +17 -3
  65. package/src/main-api/Renderer.ts +19 -0
  66. package/src/utils.test.ts +57 -0
  67. package/src/utils.ts +24 -8
@@ -1,10 +1,11 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import {
3
3
  TextureMemoryManager,
4
4
  type TextureMemoryManagerSettings,
5
5
  } from './TextureMemoryManager.js';
6
6
  import type { Stage } from './Stage.js';
7
- import type { Texture } from './textures/Texture.js';
7
+ import { TextureType, type Texture } from './textures/Texture.js';
8
+ import { EventEmitter } from '../common/EventEmitter.js';
8
9
 
9
10
  function makeSettings(
10
11
  overrides: Partial<TextureMemoryManagerSettings> = {},
@@ -20,23 +21,38 @@ function makeSettings(
20
21
  };
21
22
  }
22
23
 
23
- // The only Stage method the OOM path touches is queueFrameEvent.
24
+ // The OOM path touches queueFrameEvent; cleanup() additionally sweeps the
25
+ // texture manager's keyCache and evicts orphans via removeTextureFromCache.
24
26
  function makeStage(): {
25
27
  stage: Stage;
26
28
  queueFrameEvent: ReturnType<typeof vi.fn>;
29
+ keyCache: Map<string, Texture>;
27
30
  } {
28
31
  const queueFrameEvent = vi.fn();
29
- const stage = { queueFrameEvent } as unknown as Stage;
30
- return { stage, queueFrameEvent };
32
+ const keyCache = new Map<string, Texture>();
33
+ const txManager = {
34
+ keyCache,
35
+ removeTextureFromCache: (texture: Texture) => {
36
+ // Mirrors CoreTextureManager.removeTextureFromCache
37
+ const cacheKey = texture.cacheKey;
38
+ if (cacheKey !== null) {
39
+ keyCache.delete(cacheKey);
40
+ texture.cacheKey = null;
41
+ }
42
+ },
43
+ };
44
+ const stage = { queueFrameEvent, txManager } as unknown as Stage;
45
+ return { stage, queueFrameEvent, keyCache };
31
46
  }
32
47
 
33
48
  function makeManager(overrides: Partial<TextureMemoryManagerSettings> = {}): {
34
49
  mgr: TextureMemoryManager;
35
50
  queueFrameEvent: ReturnType<typeof vi.fn>;
51
+ keyCache: Map<string, Texture>;
36
52
  } {
37
- const { stage, queueFrameEvent } = makeStage();
53
+ const { stage, queueFrameEvent, keyCache } = makeStage();
38
54
  const mgr = new TextureMemoryManager(stage, makeSettings(overrides));
39
- return { mgr, queueFrameEvent };
55
+ return { mgr, queueFrameEvent, keyCache };
40
56
  }
41
57
 
42
58
  // setTextureMemUse expects a Texture with a mutable memUsed field; nothing else
@@ -96,3 +112,303 @@ describe('TextureMemoryManager — out-of-memory event', () => {
96
112
  });
97
113
  });
98
114
  });
115
+
116
+ // A cleanable image texture: spies on free()/destroy() so we can assert which
117
+ // reclamation path cleanup() takes. memUsed starts at 0 — setTextureMemUse is
118
+ // what registers its size with the manager.
119
+ function cleanableTexture(): Texture & {
120
+ free: ReturnType<typeof vi.fn>;
121
+ destroy: ReturnType<typeof vi.fn>;
122
+ } {
123
+ return {
124
+ memUsed: 0,
125
+ state: 'loaded',
126
+ type: TextureType.image,
127
+ free: vi.fn(),
128
+ destroy: vi.fn(),
129
+ canBeCleanedUp: () => true,
130
+ } as unknown as Texture & {
131
+ free: ReturnType<typeof vi.fn>;
132
+ destroy: ReturnType<typeof vi.fn>;
133
+ };
134
+ }
135
+
136
+ describe('TextureMemoryManager — cleanup is reversible', () => {
137
+ it('frees textures rather than destroying them so they can reload', () => {
138
+ const { mgr } = makeManager({ criticalThreshold: 200e6 });
139
+ const texture = cleanableTexture();
140
+ mgr.setTextureMemUse(texture, 100e6);
141
+
142
+ mgr.cleanup(true);
143
+
144
+ // Reversible free path — keeps listeners + cache so a node still
145
+ // referencing the texture reloads (and is re-notified) on viewport
146
+ // re-entry. The terminal destroy path (removeAllListeners + cache evict)
147
+ // must NOT be taken.
148
+ expect(texture.free).toHaveBeenCalledTimes(1);
149
+ expect(texture.destroy).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it('reclaims the freed texture memory', () => {
153
+ const { mgr } = makeManager({ criticalThreshold: 200e6 });
154
+ const texture = cleanableTexture();
155
+ mgr.setTextureMemUse(texture, 100e6);
156
+ expect(mgr.getMemoryInfo().memUsed).toBe(126e6); // 26e6 baseline + 100e6
157
+
158
+ mgr.cleanup(true);
159
+
160
+ expect(mgr.getMemoryInfo().memUsed).toBe(26e6); // back to baseline
161
+ expect(texture.memUsed).toBe(0);
162
+ });
163
+ });
164
+
165
+ // A cached, already-freed texture as left behind by a prior cleanup(): GPU and
166
+ // CPU data released (memUsed 0, not in loadedTextures), but the Texture object
167
+ // still sits in the keyCache. Built on a real EventEmitter so hasListeners()
168
+ // reflects actual on()/off() subscriptions, exactly like CoreNode's
169
+ // loadTextureTask/unloadTexture.
170
+ function freedCachedTexture(cacheKey: string): Texture & {
171
+ destroy: ReturnType<typeof vi.fn>;
172
+ } {
173
+ const texture = Object.assign(new EventEmitter(), {
174
+ memUsed: 0,
175
+ state: 'freed',
176
+ type: TextureType.image,
177
+ preventCleanup: false,
178
+ renderableOwners: [],
179
+ cacheKey,
180
+ free: vi.fn(),
181
+ destroy: vi.fn(),
182
+ canBeCleanedUp: () => true,
183
+ // Fresh texture by default — within the 2s startup grace period.
184
+ isWithinStartupGracePeriod: () => true,
185
+ });
186
+ return texture as unknown as Texture & {
187
+ destroy: ReturnType<typeof vi.fn>;
188
+ };
189
+ }
190
+
191
+ describe('TextureMemoryManager — orphaned freed texture eviction', () => {
192
+ it('keeps a freed texture that a node still references via listeners', () => {
193
+ const { mgr, keyCache } = makeManager();
194
+ const texture = freedCachedTexture('img:poster.png');
195
+ keyCache.set('img:poster.png', texture);
196
+ // CoreNode.loadTextureTask subscribes; the node is alive but offscreen.
197
+ texture.on('freed', () => {});
198
+
199
+ mgr.cleanup();
200
+
201
+ expect(texture.destroy).not.toHaveBeenCalled();
202
+ expect(keyCache.get('img:poster.png')).toBe(texture);
203
+ expect(texture.cacheKey).toBe('img:poster.png');
204
+ });
205
+
206
+ it('keeps a freed texture that still has renderable owners', () => {
207
+ const { mgr, keyCache } = makeManager();
208
+ const texture = freedCachedTexture('img:poster.png');
209
+ (texture.renderableOwners as unknown[]).push(1);
210
+ keyCache.set('img:poster.png', texture);
211
+
212
+ mgr.cleanup();
213
+
214
+ expect(texture.destroy).not.toHaveBeenCalled();
215
+ expect(keyCache.get('img:poster.png')).toBe(texture);
216
+ });
217
+
218
+ it('keeps a freed texture marked preventCleanup', () => {
219
+ const { mgr, keyCache } = makeManager();
220
+ const texture = freedCachedTexture('img:poster.png');
221
+ (texture as { preventCleanup: boolean }).preventCleanup = true;
222
+ keyCache.set('img:poster.png', texture);
223
+
224
+ mgr.cleanup();
225
+
226
+ expect(texture.destroy).not.toHaveBeenCalled();
227
+ expect(keyCache.get('img:poster.png')).toBe(texture);
228
+ });
229
+
230
+ it('does not evict loaded or in-flight cached textures, even aged orphans', () => {
231
+ const { mgr, keyCache } = makeManager();
232
+ const states = ['loaded', 'fetching', 'fetched', 'loading'];
233
+ const textures: ReturnType<typeof freedCachedTexture>[] = [];
234
+ for (const state of states) {
235
+ const texture = freedCachedTexture(`img:${state}.png`);
236
+ (texture as { state: string }).state = state;
237
+ // Aged out — eviction must be blocked by state alone.
238
+ (
239
+ texture as { isWithinStartupGracePeriod: () => boolean }
240
+ ).isWithinStartupGracePeriod = () => false;
241
+ keyCache.set(`img:${state}.png`, texture);
242
+ textures.push(texture);
243
+ }
244
+
245
+ mgr.cleanup();
246
+
247
+ for (const texture of textures) {
248
+ expect(texture.destroy).not.toHaveBeenCalled();
249
+ }
250
+ expect(keyCache.size).toBe(states.length);
251
+ });
252
+
253
+ it('keeps a fresh initial texture during the startup grace period', () => {
254
+ // A node created this same frame subscribes in a queued microtask, so a
255
+ // brand-new texture can look orphaned during a same-frame cleanup. The
256
+ // grace period is the race guard.
257
+ const { mgr, keyCache } = makeManager();
258
+ const texture = freedCachedTexture('img:fresh.png');
259
+ (texture as { state: string }).state = 'initial';
260
+
261
+ keyCache.set('img:fresh.png', texture);
262
+ mgr.cleanup();
263
+
264
+ expect(texture.destroy).not.toHaveBeenCalled();
265
+ expect(keyCache.get('img:fresh.png')).toBe(texture);
266
+ });
267
+
268
+ it('evicts an orphaned initial texture once the grace period expires', () => {
269
+ const { mgr, keyCache } = makeManager();
270
+ const texture = freedCachedTexture('img:never-loaded.png');
271
+ (texture as { state: string }).state = 'initial';
272
+ (
273
+ texture as { isWithinStartupGracePeriod: () => boolean }
274
+ ).isWithinStartupGracePeriod = () => false;
275
+
276
+ keyCache.set('img:never-loaded.png', texture);
277
+ mgr.cleanup();
278
+
279
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
280
+ expect(keyCache.has('img:never-loaded.png')).toBe(false);
281
+ expect(texture.cacheKey).toBe(null);
282
+ });
283
+
284
+ it('evicts an orphaned failed texture once the grace period expires', () => {
285
+ // Retry only happens through a node's 'failed' listener — with no
286
+ // listeners nothing will ever retry it.
287
+ const { mgr, keyCache } = makeManager();
288
+ const texture = freedCachedTexture('img:404.png');
289
+ (texture as { state: string }).state = 'failed';
290
+ (
291
+ texture as { isWithinStartupGracePeriod: () => boolean }
292
+ ).isWithinStartupGracePeriod = () => false;
293
+
294
+ keyCache.set('img:404.png', texture);
295
+ mgr.cleanup();
296
+
297
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
298
+ expect(keyCache.has('img:404.png')).toBe(false);
299
+ });
300
+
301
+ it('keeps an aged initial texture that a node references via listeners', () => {
302
+ const { mgr, keyCache } = makeManager();
303
+ const texture = freedCachedTexture('img:queued.png');
304
+ (texture as { state: string }).state = 'initial';
305
+ (
306
+ texture as { isWithinStartupGracePeriod: () => boolean }
307
+ ).isWithinStartupGracePeriod = () => false;
308
+ texture.on('loaded', () => {});
309
+
310
+ keyCache.set('img:queued.png', texture);
311
+ mgr.cleanup();
312
+
313
+ expect(texture.destroy).not.toHaveBeenCalled();
314
+ expect(keyCache.get('img:queued.png')).toBe(texture);
315
+ });
316
+
317
+ it('destroys and evicts an orphaned freed texture', () => {
318
+ const { mgr, keyCache } = makeManager();
319
+ const texture = freedCachedTexture('img:gone.png');
320
+ keyCache.set('img:gone.png', texture);
321
+
322
+ mgr.cleanup();
323
+
324
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
325
+ expect(keyCache.has('img:gone.png')).toBe(false);
326
+ expect(texture.cacheKey).toBe(null);
327
+ });
328
+
329
+ it('evicts only once the last listener is removed (node destroyed)', () => {
330
+ const { mgr, keyCache } = makeManager();
331
+ const texture = freedCachedTexture('img:row-item.png');
332
+ keyCache.set('img:row-item.png', texture);
333
+ const onFreed = () => {};
334
+ texture.on('freed', onFreed);
335
+
336
+ mgr.cleanup();
337
+ expect(texture.destroy).not.toHaveBeenCalled();
338
+ expect(keyCache.has('img:row-item.png')).toBe(true);
339
+
340
+ // Node destroyed: CoreNode.unloadTexture removes its subscriptions.
341
+ texture.off('freed', onFreed);
342
+
343
+ mgr.cleanup();
344
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
345
+ expect(keyCache.has('img:row-item.png')).toBe(false);
346
+ });
347
+
348
+ it('evicts orphans freed by the same cleanup pass', () => {
349
+ const { mgr, keyCache } = makeManager({ criticalThreshold: 200e6 });
350
+ // A loaded, cleanable texture whose free() transitions it to 'freed',
351
+ // mirroring ctxTexture.free() -> setState('freed'). No listeners and no
352
+ // owners: its node was already destroyed.
353
+ const texture = freedCachedTexture('img:orphan.png');
354
+ (texture as { state: string }).state = 'loaded';
355
+ (texture as { free: () => void }).free = () => {
356
+ (texture as { state: string }).state = 'freed';
357
+ };
358
+ keyCache.set('img:orphan.png', texture);
359
+ mgr.setTextureMemUse(texture, 100e6);
360
+
361
+ mgr.cleanup(true);
362
+
363
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
364
+ expect(keyCache.has('img:orphan.png')).toBe(false);
365
+ expect(mgr.getMemoryInfo().memUsed).toBe(26e6); // baseline only
366
+ });
367
+ });
368
+
369
+ describe('EventEmitter — hasListeners', () => {
370
+ it('is false before any listener is registered', () => {
371
+ const emitter = new EventEmitter();
372
+ expect(emitter.hasListeners()).toBe(false);
373
+ });
374
+
375
+ it('is true while a listener is registered and false after off()', () => {
376
+ const emitter = new EventEmitter();
377
+ const listener = () => {};
378
+ emitter.on('loaded', listener);
379
+ expect(emitter.hasListeners()).toBe(true);
380
+
381
+ emitter.off('loaded', listener);
382
+ expect(emitter.hasListeners()).toBe(false);
383
+ });
384
+
385
+ it('is false after off() removes all listeners for an event by name', () => {
386
+ const emitter = new EventEmitter();
387
+ emitter.on('loaded', () => {});
388
+ emitter.off('loaded');
389
+ expect(emitter.hasListeners()).toBe(false);
390
+ });
391
+
392
+ it('is true if any one of several events still has a listener', () => {
393
+ const emitter = new EventEmitter();
394
+ const a = () => {};
395
+ emitter.on('loaded', a);
396
+ emitter.on('freed', () => {});
397
+ emitter.off('loaded', a);
398
+ expect(emitter.hasListeners()).toBe(true);
399
+ });
400
+
401
+ it('is false after a once() listener has fired', () => {
402
+ const emitter = new EventEmitter();
403
+ emitter.once('loaded', () => {});
404
+ emitter.emit('loaded');
405
+ expect(emitter.hasListeners()).toBe(false);
406
+ });
407
+
408
+ it('is false after removeAllListeners()', () => {
409
+ const emitter = new EventEmitter();
410
+ emitter.on('loaded', () => {});
411
+ emitter.removeAllListeners();
412
+ expect(emitter.hasListeners()).toBe(false);
413
+ });
414
+ });
@@ -167,11 +167,58 @@ export class TextureMemoryManager {
167
167
  }
168
168
 
169
169
  /**
170
- * Destroy a texture and remove it from the memory manager
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
+
207
+ /**
208
+ * Destroy a texture, evict its cache entry, and remove it from the memory
209
+ * manager.
210
+ *
211
+ * @remarks
212
+ * Private on purpose: `destroy()` calls `removeAllListeners()`, so running
213
+ * this on a texture that still has subscribers severs a live `CoreNode`'s
214
+ * (or `SubTexture`'s) connection — the blank-poster bug. The only safe
215
+ * entry point is {@link evictOrphanedTextures}, which proves the texture
216
+ * is unreferenced first. For memory pressure use {@link freeTexture},
217
+ * which is reversible.
171
218
  *
172
219
  * @param texture - The texture to destroy
173
220
  */
174
- destroyTexture(texture: Texture) {
221
+ private destroyTexture(texture: Texture) {
175
222
  if (this.debugLogging === true) {
176
223
  console.log(
177
224
  `[TextureMemoryManager] Destroying texture. State: ${texture.state}`,
@@ -230,15 +277,18 @@ export class TextureMemoryManager {
230
277
 
231
278
  // Immediate cleanup if eligible
232
279
  if (isCleanableType && texture.canBeCleanedUp() === true) {
233
- // Get memory before destroying
280
+ // Get memory before freeing
234
281
  const textureMemory = texture.memUsed;
235
282
 
236
- // Destroy texture (which will null out the array position)
237
- this.destroyTexture(texture);
283
+ // Reversibly free (keeps listeners + cache) so the texture reloads when
284
+ // its node re-enters the viewport.
285
+ this.freeTexture(texture);
238
286
  currentMemUsed -= textureMemory;
239
287
  }
240
288
  }
241
289
 
290
+ this.evictOrphanedTextures();
291
+
242
292
  if (this.memUsed >= this.criticalThreshold) {
243
293
  this.stage.queueFrameEvent('criticalCleanupFailed', {
244
294
  memUsed: this.memUsed,
@@ -261,6 +311,52 @@ export class TextureMemoryManager {
261
311
  }
262
312
  }
263
313
 
314
+ /**
315
+ * Destroy-and-evict freed textures that nothing references anymore.
316
+ *
317
+ * @remarks
318
+ * {@link freeTexture} intentionally keeps the texture's cache entry and
319
+ * listeners so a live `CoreNode` can reload it in place. But once the last
320
+ * referencing node is destroyed (`unloadTexture` removes its listeners and
321
+ * owner), the freed texture's `keyCache` entry can never be displayed again
322
+ * — without eviction the cache grows unboundedly in apps cycling many
323
+ * unique textures.
324
+ *
325
+ * A texture is an orphan only when it has zero `renderableOwners` AND zero
326
+ * event listeners: every live referencer (`CoreNode.loadTextureTask`,
327
+ * `SubTexture`) subscribes via `on()`. A texture with any listener must
328
+ * NEVER be destroyed here — `destroy()` calls `removeAllListeners()`, which
329
+ * would sever the node's subscription and reintroduce the blank-poster bug
330
+ * that {@link freeTexture} exists to prevent.
331
+ *
332
+ * `'initial'` and `'failed'` orphans leak the same way (created or failed,
333
+ * then the node was destroyed before the texture ever loaded; nothing will
334
+ * ever retry a `'failed'` texture without a listener). They are evicted only
335
+ * after the startup grace period: a node created this same frame subscribes
336
+ * in a queued microtask, so a fresh texture can look orphaned during a
337
+ * same-frame cleanup. In-flight states (`'fetching'`/`'loading'`/`'fetched'`)
338
+ * are never evicted; `'loaded'` orphans go through the pressure-driven free
339
+ * loop first and are swept here as `'freed'` on a later pass.
340
+ */
341
+ private evictOrphanedTextures(): void {
342
+ const keyCache = this.stage.txManager.keyCache;
343
+ for (const texture of keyCache.values()) {
344
+ const state = texture.state;
345
+ const evictable =
346
+ state === 'freed' ||
347
+ ((state === 'initial' || state === 'failed') &&
348
+ texture.isWithinStartupGracePeriod() === false);
349
+ if (
350
+ evictable === true &&
351
+ texture.preventCleanup === false &&
352
+ texture.renderableOwners.length === 0 &&
353
+ texture.hasListeners() === false
354
+ ) {
355
+ this.destroyTexture(texture);
356
+ }
357
+ }
358
+ }
359
+
264
360
  /**
265
361
  * Get the current texture memory usage information
266
362
  *
@@ -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;