@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.
Files changed (42) 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 +115 -25
  8. package/dist/src/core/CoreNode.js.map +1 -1
  9. package/dist/src/core/CoreTextNode.js +11 -4
  10. package/dist/src/core/CoreTextNode.js.map +1 -1
  11. package/dist/src/core/Stage.d.ts +6 -0
  12. package/dist/src/core/Stage.js +9 -0
  13. package/dist/src/core/Stage.js.map +1 -1
  14. package/dist/src/core/TextureMemoryManager.d.ts +39 -2
  15. package/dist/src/core/TextureMemoryManager.js +53 -1
  16. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  17. package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
  18. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  19. package/dist/src/core/renderers/webgl/WebGlRenderer.js +5 -1
  20. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  21. package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
  22. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
  23. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  24. package/dist/src/main-api/Renderer.d.ts +23 -0
  25. package/dist/src/main-api/Renderer.js +2 -0
  26. package/dist/src/main-api/Renderer.js.map +1 -1
  27. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  28. package/package.json +1 -1
  29. package/src/common/EventEmitter.ts +21 -0
  30. package/src/core/Autosizer.ts +3 -1
  31. package/src/core/CoreNode.test.ts +425 -0
  32. package/src/core/CoreNode.ts +146 -26
  33. package/src/core/CoreTextNode.test.ts +62 -0
  34. package/src/core/CoreTextNode.ts +13 -4
  35. package/src/core/Stage.ts +9 -0
  36. package/src/core/TextureMemoryManager.test.ts +272 -5
  37. package/src/core/TextureMemoryManager.ts +59 -2
  38. package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
  39. package/src/core/renderers/webgl/WebGlRenderer.ts +6 -1
  40. package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
  41. package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
  42. 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 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
@@ -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 manager
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
- let texture = (node.props.texture || this.stage.defaultTexture) as Texture;
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
- let tx = props.texture || this.stage.defaultTexture!;
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
- // Bind render texture framebuffer dimensions as resolution
202
- // if the parent has a render texture
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
- this.glw.uniform1f('u_pixelRatio', 1.0);
208
-
240
+ pixelRatio = 1.0;
209
241
  // Set resolution to the framebuffer dimensions
210
- this.glw.uniform2f('u_resolution', w, h);
242
+ resolutionW = framebufferDimensions.w;
243
+ resolutionH = framebufferDimensions.h;
211
244
  } else {
212
- this.glw.uniform1f('u_pixelRatio', renderOp.stage.pixelRatio);
245
+ pixelRatio = renderOp.stage.pixelRatio;
246
+ resolutionW = this.glw.canvas.width;
247
+ resolutionH = this.glw.canvas.height;
248
+ }
213
249
 
214
- this.glw.uniform2f(
215
- 'u_resolution',
216
- this.glw.canvas.width,
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 (this.useTimeValue === true) {
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 (this.useSystemAlpha === true) {
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 (this.useSystemDimensions === true) {
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]!;