@solidtv/renderer 1.5.0 → 1.5.2
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 +115 -25
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/CoreTextNode.js +11 -4
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/Stage.d.ts +6 -0
- package/dist/src/core/Stage.js +9 -0
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.d.ts +39 -2
- package/dist/src/core/TextureMemoryManager.js +53 -1
- package/dist/src/core/TextureMemoryManager.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.js +5 -1
- 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/main-api/Renderer.d.ts +23 -0
- package/dist/src/main-api/Renderer.js +2 -0
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/tsconfig.dist.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 +425 -0
- package/src/core/CoreNode.ts +146 -26
- package/src/core/CoreTextNode.test.ts +62 -0
- package/src/core/CoreTextNode.ts +13 -4
- package/src/core/Stage.ts +9 -0
- package/src/core/TextureMemoryManager.test.ts +272 -5
- package/src/core/TextureMemoryManager.ts +59 -2
- package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
- package/src/core/renderers/webgl/WebGlRenderer.ts +6 -1
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
- package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
- package/src/main-api/Renderer.ts +26 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from './TextureMemoryManager.js';
|
|
6
6
|
import type { Stage } from './Stage.js';
|
|
7
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
|
|
@@ -145,3 +161,254 @@ describe('TextureMemoryManager — cleanup is reversible', () => {
|
|
|
145
161
|
expect(texture.memUsed).toBe(0);
|
|
146
162
|
});
|
|
147
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
|
+
});
|
|
@@ -205,11 +205,20 @@ export class TextureMemoryManager {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
|
-
* Destroy a texture and remove it from the memory
|
|
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.
|
|
209
218
|
*
|
|
210
219
|
* @param texture - The texture to destroy
|
|
211
220
|
*/
|
|
212
|
-
destroyTexture(texture: Texture) {
|
|
221
|
+
private destroyTexture(texture: Texture) {
|
|
213
222
|
if (this.debugLogging === true) {
|
|
214
223
|
console.log(
|
|
215
224
|
`[TextureMemoryManager] Destroying texture. State: ${texture.state}`,
|
|
@@ -278,6 +287,8 @@ export class TextureMemoryManager {
|
|
|
278
287
|
}
|
|
279
288
|
}
|
|
280
289
|
|
|
290
|
+
this.evictOrphanedTextures();
|
|
291
|
+
|
|
281
292
|
if (this.memUsed >= this.criticalThreshold) {
|
|
282
293
|
this.stage.queueFrameEvent('criticalCleanupFailed', {
|
|
283
294
|
memUsed: this.memUsed,
|
|
@@ -300,6 +311,52 @@ export class TextureMemoryManager {
|
|
|
300
311
|
}
|
|
301
312
|
}
|
|
302
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
|
+
|
|
303
360
|
/**
|
|
304
361
|
* Get the current texture memory usage information
|
|
305
362
|
*
|
|
@@ -52,7 +52,13 @@ export class CanvasRenderer extends CoreRenderer {
|
|
|
52
52
|
const ctx = this.context;
|
|
53
53
|
const { tx, ty, ta, tb, tc, td } = node.globalTransform!;
|
|
54
54
|
const clippingRect = node.clippingRect;
|
|
55
|
-
|
|
55
|
+
// While a placeholder is showing, render the color-rect path (the default
|
|
56
|
+
// ColorTexture) tinted by the node's premultiplied placeholder color.
|
|
57
|
+
let texture = (
|
|
58
|
+
node.placeholderActive === true
|
|
59
|
+
? this.stage.defaultTexture
|
|
60
|
+
: node.props.texture || this.stage.defaultTexture
|
|
61
|
+
) as Texture;
|
|
56
62
|
// The Canvas2D renderer only supports image textures, no textures are used for color blocks
|
|
57
63
|
if (texture !== null) {
|
|
58
64
|
const textureType = texture.type;
|
|
@@ -471,7 +471,12 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
471
471
|
}
|
|
472
472
|
|
|
473
473
|
const props = node.props;
|
|
474
|
-
|
|
474
|
+
// While a placeholder is showing, the quad samples the shared 1x1 white
|
|
475
|
+
// texture tinted by the node's premultiplied placeholder color.
|
|
476
|
+
let tx =
|
|
477
|
+
node.placeholderActive === true
|
|
478
|
+
? this.stage.defaultTexture!
|
|
479
|
+
: props.texture || this.stage.defaultTexture!;
|
|
475
480
|
|
|
476
481
|
if (tx.type === TextureType.subTexture) {
|
|
477
482
|
tx = (tx as SubTexture).parentTexture;
|
|
@@ -30,6 +30,35 @@ export class WebGlShaderProgram implements CoreShaderProgram {
|
|
|
30
30
|
public isDestroyed = false;
|
|
31
31
|
supportsIndexedTextures = false;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Shadow copies of this program's GL uniform state, used by
|
|
35
|
+
* {@link bindRenderOp} to skip redundant gl.uniform* calls.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* GL uniform values are state on the program object and persist across
|
|
39
|
+
* useProgram switches, and bindRenderOp is the only writer of these
|
|
40
|
+
* uniforms, so the shadows always mirror GL truth — even when ops using
|
|
41
|
+
* other programs are interleaved between two ops of this program.
|
|
42
|
+
*
|
|
43
|
+
* `lastBoundUniforms` tracks the shader node's uniform collection by
|
|
44
|
+
* identity: collections are created once, filled once, and shared by
|
|
45
|
+
* reference across shader nodes with the same value key (see
|
|
46
|
+
* WebGlShaderNode.update), so reference equality implies value equality.
|
|
47
|
+
* The failure direction is safe — a new collection holding identical
|
|
48
|
+
* values just causes a redundant upload, never a wrong skip.
|
|
49
|
+
*
|
|
50
|
+
* Sentinels: -1 never collides with real pixel ratios, resolutions,
|
|
51
|
+
* alphas, dimensions, or time values (all >= 0).
|
|
52
|
+
*/
|
|
53
|
+
protected lastBoundUniforms: unknown = null;
|
|
54
|
+
protected lastPixelRatio = -1;
|
|
55
|
+
protected lastResolutionW = -1;
|
|
56
|
+
protected lastResolutionH = -1;
|
|
57
|
+
protected lastAlpha = -1;
|
|
58
|
+
protected lastDimensionsW = -1;
|
|
59
|
+
protected lastDimensionsH = -1;
|
|
60
|
+
protected lastTime = -1;
|
|
61
|
+
|
|
33
62
|
/**
|
|
34
63
|
* Cached Vertex Array Objects, keyed by the buffer collection they capture.
|
|
35
64
|
*
|
|
@@ -198,44 +227,78 @@ export class WebGlShaderProgram implements CoreShaderProgram {
|
|
|
198
227
|
return;
|
|
199
228
|
}
|
|
200
229
|
|
|
201
|
-
//
|
|
202
|
-
//
|
|
230
|
+
// Resolve target pixel ratio / resolution, then compare-and-set against
|
|
231
|
+
// the program's shadow state. Each gl.uniform* call below crosses into
|
|
232
|
+
// the GPU-process command buffer, so skipping value-identical re-uploads
|
|
233
|
+
// is a real per-op CPU saving on embedded targets.
|
|
234
|
+
let pixelRatio: number;
|
|
235
|
+
let resolutionW: number;
|
|
236
|
+
let resolutionH: number;
|
|
203
237
|
if (USE_RTT && parentHasRenderTexture === true && framebufferDimensions) {
|
|
204
|
-
const { w, h } = framebufferDimensions;
|
|
205
238
|
// Force pixel ratio to 1.0 for render textures since they are always 1:1
|
|
206
239
|
// the final render texture will be rendered to the screen with the correct pixel ratio
|
|
207
|
-
|
|
208
|
-
|
|
240
|
+
pixelRatio = 1.0;
|
|
209
241
|
// Set resolution to the framebuffer dimensions
|
|
210
|
-
|
|
242
|
+
resolutionW = framebufferDimensions.w;
|
|
243
|
+
resolutionH = framebufferDimensions.h;
|
|
211
244
|
} else {
|
|
212
|
-
|
|
245
|
+
pixelRatio = renderOp.stage.pixelRatio;
|
|
246
|
+
resolutionW = this.glw.canvas.width;
|
|
247
|
+
resolutionH = this.glw.canvas.height;
|
|
248
|
+
}
|
|
213
249
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
this.glw.canvas.height,
|
|
218
|
-
);
|
|
250
|
+
if (pixelRatio !== this.lastPixelRatio) {
|
|
251
|
+
this.glw.uniform1f('u_pixelRatio', pixelRatio);
|
|
252
|
+
this.lastPixelRatio = pixelRatio;
|
|
219
253
|
}
|
|
220
254
|
|
|
221
|
-
if (
|
|
255
|
+
if (
|
|
256
|
+
resolutionW !== this.lastResolutionW ||
|
|
257
|
+
resolutionH !== this.lastResolutionH
|
|
258
|
+
) {
|
|
259
|
+
this.glw.uniform2f('u_resolution', resolutionW, resolutionH);
|
|
260
|
+
this.lastResolutionW = resolutionW;
|
|
261
|
+
this.lastResolutionH = resolutionH;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (this.useTimeValue === true && renderOp.time !== this.lastTime) {
|
|
222
265
|
this.glw.uniform1f('u_time', renderOp.time);
|
|
266
|
+
this.lastTime = renderOp.time;
|
|
223
267
|
}
|
|
224
268
|
|
|
225
|
-
if (
|
|
269
|
+
if (
|
|
270
|
+
this.useSystemAlpha === true &&
|
|
271
|
+
renderOp.worldAlpha !== this.lastAlpha
|
|
272
|
+
) {
|
|
226
273
|
this.glw.uniform1f('u_alpha', renderOp.worldAlpha);
|
|
274
|
+
this.lastAlpha = renderOp.worldAlpha;
|
|
227
275
|
}
|
|
228
276
|
|
|
229
|
-
if (
|
|
277
|
+
if (
|
|
278
|
+
this.useSystemDimensions === true &&
|
|
279
|
+
(renderOp.w !== this.lastDimensionsW ||
|
|
280
|
+
renderOp.h !== this.lastDimensionsH)
|
|
281
|
+
) {
|
|
230
282
|
this.glw.uniform2f('u_dimensions', renderOp.w, renderOp.h);
|
|
283
|
+
this.lastDimensionsW = renderOp.w;
|
|
284
|
+
this.lastDimensionsH = renderOp.h;
|
|
231
285
|
}
|
|
232
286
|
|
|
233
287
|
const shader = renderOp.shader as WebGlShaderNode;
|
|
234
288
|
if (shader.props !== undefined) {
|
|
235
289
|
/**
|
|
236
290
|
* loop over all precalculated uniform types
|
|
291
|
+
*
|
|
292
|
+
* Collections are immutable after being filled and shared by reference
|
|
293
|
+
* across shader nodes with equal value keys, so when the same object is
|
|
294
|
+
* already bound the GL program still holds exactly these values and the
|
|
295
|
+
* whole pass (loops + gl calls) can be skipped.
|
|
237
296
|
*/
|
|
238
297
|
const uniforms = shader.uniforms;
|
|
298
|
+
if ((uniforms as unknown) === this.lastBoundUniforms) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
this.lastBoundUniforms = uniforms;
|
|
239
302
|
|
|
240
303
|
for (const key in uniforms.single) {
|
|
241
304
|
const { method, value } = uniforms.single[key]!;
|