@solidtv/renderer 1.5.0-1 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/common/EventEmitter.d.ts +8 -0
- package/dist/src/common/EventEmitter.js +20 -0
- package/dist/src/common/EventEmitter.js.map +1 -1
- package/dist/src/core/Autosizer.js +3 -1
- package/dist/src/core/Autosizer.js.map +1 -1
- package/dist/src/core/CoreNode.d.ts +39 -0
- package/dist/src/core/CoreNode.js +105 -25
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/CoreTextNode.js +4 -2
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/Stage.js +4 -1
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.d.ts +62 -2
- package/dist/src/core/TextureMemoryManager.js +90 -4
- package/dist/src/core/TextureMemoryManager.js.map +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.d.ts +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.js +10 -3
- package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
- package/dist/src/core/lib/textureCompression.js +68 -4
- package/dist/src/core/lib/textureCompression.js.map +1 -1
- package/dist/src/core/renderers/CoreRenderer.d.ts +1 -0
- package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +8 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +54 -14
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
- package/dist/src/core/renderers/webgl/internal/RendererUtils.js +9 -2
- package/dist/src/core/renderers/webgl/internal/RendererUtils.js.map +1 -1
- package/dist/src/core/text-rendering/SdfTextRenderer.js +16 -3
- package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +16 -0
- package/dist/src/main-api/Renderer.js +2 -0
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/src/utils.js +23 -7
- package/dist/src/utils.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/common/EventEmitter.ts +21 -0
- package/src/core/Autosizer.ts +3 -1
- package/src/core/CoreNode.test.ts +283 -0
- package/src/core/CoreNode.ts +131 -26
- package/src/core/CoreTextNode.test.ts +1 -0
- package/src/core/CoreTextNode.ts +6 -2
- package/src/core/Stage.ts +4 -0
- package/src/core/TextureMemoryManager.test.ts +323 -7
- package/src/core/TextureMemoryManager.ts +101 -5
- package/src/core/lib/WebGlContextWrapper.test.ts +58 -0
- package/src/core/lib/WebGlContextWrapper.ts +13 -3
- package/src/core/lib/textureCompression.test.ts +158 -0
- package/src/core/lib/textureCompression.ts +78 -4
- package/src/core/renderers/CoreRenderer.ts +1 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
- package/src/core/renderers/webgl/WebGlRenderer.ts +64 -16
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
- package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
- package/src/core/renderers/webgl/internal/RendererUtils.test.ts +73 -0
- package/src/core/renderers/webgl/internal/RendererUtils.ts +9 -2
- package/src/core/text-rendering/SdfTextRenderer.test.ts +11 -10
- package/src/core/text-rendering/SdfTextRenderer.ts +17 -3
- package/src/main-api/Renderer.ts +19 -0
- package/src/utils.test.ts +57 -0
- package/src/utils.ts +24 -8
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
30
|
-
|
|
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
|
-
*
|
|
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
|
|
280
|
+
// Get memory before freeing
|
|
234
281
|
const textureMemory = texture.memUsed;
|
|
235
282
|
|
|
236
|
-
//
|
|
237
|
-
|
|
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(
|
|
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
|
|
159
|
-
|
|
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;
|