@solidtv/renderer 1.5.0 → 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 (37) 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 +2 -0
  12. package/dist/src/core/Stage.js.map +1 -1
  13. package/dist/src/core/TextureMemoryManager.d.ts +39 -2
  14. package/dist/src/core/TextureMemoryManager.js +53 -1
  15. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  16. package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
  17. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  18. package/dist/src/core/renderers/webgl/WebGlRenderer.js +5 -1
  19. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  20. package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
  21. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
  22. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  23. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  24. package/package.json +1 -1
  25. package/src/common/EventEmitter.ts +21 -0
  26. package/src/core/Autosizer.ts +3 -1
  27. package/src/core/CoreNode.test.ts +283 -0
  28. package/src/core/CoreNode.ts +131 -26
  29. package/src/core/CoreTextNode.test.ts +1 -0
  30. package/src/core/CoreTextNode.ts +6 -2
  31. package/src/core/Stage.ts +2 -0
  32. package/src/core/TextureMemoryManager.test.ts +272 -5
  33. package/src/core/TextureMemoryManager.ts +59 -2
  34. package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
  35. package/src/core/renderers/webgl/WebGlRenderer.ts +6 -1
  36. package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
  37. package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
@@ -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]!;
@@ -0,0 +1,233 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { WebGlShaderProgram } from './WebGlShaderProgram.js';
3
+
4
+ /**
5
+ * Tests for the redundant-uniform-upload skip in bindRenderOp.
6
+ *
7
+ * The program instance is created without running the constructor (which
8
+ * compiles GLSL against a live GL context); the fields bindRenderOp touches
9
+ * are populated manually, and bindTextures/bindBufferCollection are stubbed.
10
+ */
11
+
12
+ type FakeGlw = {
13
+ uniform1f: ReturnType<typeof vi.fn>;
14
+ uniform2f: ReturnType<typeof vi.fn>;
15
+ uniform4f: ReturnType<typeof vi.fn>;
16
+ canvas: { width: number; height: number };
17
+ };
18
+
19
+ const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => {
20
+ const glw: FakeGlw = {
21
+ uniform1f: vi.fn(),
22
+ uniform2f: vi.fn(),
23
+ uniform4f: vi.fn(),
24
+ canvas: { width: 1920, height: 1080 },
25
+ };
26
+
27
+ const program = Object.create(
28
+ WebGlShaderProgram.prototype,
29
+ ) as WebGlShaderProgram;
30
+ const p = program as unknown as Record<string, unknown>;
31
+ p['glw'] = glw;
32
+ p['useSystemAlpha'] = true;
33
+ p['useSystemDimensions'] = true;
34
+ p['useTimeValue'] = false;
35
+ p['lastBoundUniforms'] = null;
36
+ p['lastPixelRatio'] = -1;
37
+ p['lastResolutionW'] = -1;
38
+ p['lastResolutionH'] = -1;
39
+ p['lastAlpha'] = -1;
40
+ p['lastDimensionsW'] = -1;
41
+ p['lastDimensionsH'] = -1;
42
+ p['lastTime'] = -1;
43
+ // Stub the buffer/texture binding done before the uniform pass
44
+ p['bindTextures'] = vi.fn();
45
+ p['bindBufferCollection'] = vi.fn();
46
+ return { program, glw };
47
+ };
48
+
49
+ const makeUniforms = () => ({
50
+ single: {
51
+ u_borderGap: { method: 'uniform1f', value: 0 },
52
+ },
53
+ vec2: {},
54
+ vec3: {},
55
+ vec4: {
56
+ u_radius: { method: 'uniform4f', value: [16, 16, 16, 16] },
57
+ },
58
+ });
59
+
60
+ const makeOp = (
61
+ uniforms: ReturnType<typeof makeUniforms>,
62
+ overrides?: Record<string, unknown>,
63
+ ) => ({
64
+ isCoreNode: true,
65
+ rtt: false,
66
+ parentHasRenderTexture: false,
67
+ parentFramebufferDimensions: null,
68
+ framebufferDimensions: null,
69
+ stage: { pixelRatio: 2 },
70
+ time: 0,
71
+ worldAlpha: 1,
72
+ w: 200,
73
+ h: 100,
74
+ renderOpTextures: [],
75
+ quadBufferCollection: {},
76
+ shader: { props: {}, uniforms },
77
+ ...overrides,
78
+ });
79
+
80
+ const totalCalls = (glw: FakeGlw): number =>
81
+ glw.uniform1f.mock.calls.length +
82
+ glw.uniform2f.mock.calls.length +
83
+ glw.uniform4f.mock.calls.length;
84
+
85
+ describe('bindRenderOp uniform dedup', () => {
86
+ it('should upload everything on the first bind', () => {
87
+ const { program, glw } = makeProgram();
88
+ const uniforms = makeUniforms();
89
+
90
+ program.bindRenderOp(makeOp(uniforms) as never);
91
+
92
+ // u_pixelRatio, u_alpha, u_borderGap (1f) + u_resolution, u_dimensions (2f) + u_radius (4f)
93
+ expect(glw.uniform1f.mock.calls.length).toBe(3);
94
+ expect(glw.uniform2f.mock.calls.length).toBe(2);
95
+ expect(glw.uniform4f.mock.calls.length).toBe(1);
96
+ });
97
+
98
+ it('should issue zero uniform calls on an identical re-bind', () => {
99
+ const { program, glw } = makeProgram();
100
+ const uniforms = makeUniforms();
101
+
102
+ program.bindRenderOp(makeOp(uniforms) as never);
103
+ glw.uniform1f.mockClear();
104
+ glw.uniform2f.mockClear();
105
+ glw.uniform4f.mockClear();
106
+
107
+ program.bindRenderOp(makeOp(uniforms) as never);
108
+
109
+ expect(totalCalls(glw)).toBe(0);
110
+ });
111
+
112
+ it('should skip across interleaved ops (shared collection, different op objects)', () => {
113
+ const { program, glw } = makeProgram();
114
+ const uniforms = makeUniforms();
115
+
116
+ // Two distinct ops (different cards) sharing the value-cached collection
117
+ program.bindRenderOp(makeOp(uniforms) as never);
118
+ glw.uniform1f.mockClear();
119
+ glw.uniform2f.mockClear();
120
+ glw.uniform4f.mockClear();
121
+
122
+ // GL uniform state persists on the program across useProgram switches,
123
+ // so an op of another program in between changes nothing here.
124
+ program.bindRenderOp(makeOp(uniforms) as never);
125
+
126
+ expect(totalCalls(glw)).toBe(0);
127
+ });
128
+
129
+ it('should re-upload only the changed system uniform', () => {
130
+ const { program, glw } = makeProgram();
131
+ const uniforms = makeUniforms();
132
+
133
+ program.bindRenderOp(makeOp(uniforms) as never);
134
+ glw.uniform1f.mockClear();
135
+ glw.uniform2f.mockClear();
136
+ glw.uniform4f.mockClear();
137
+
138
+ program.bindRenderOp(makeOp(uniforms, { worldAlpha: 0.5 }) as never);
139
+
140
+ expect(glw.uniform1f.mock.calls.length).toBe(1);
141
+ expect(glw.uniform1f.mock.calls[0]![0]).toBe('u_alpha');
142
+ expect(glw.uniform1f.mock.calls[0]![1]).toBe(0.5);
143
+ expect(glw.uniform2f.mock.calls.length).toBe(0);
144
+ expect(glw.uniform4f.mock.calls.length).toBe(0);
145
+ });
146
+
147
+ it('should re-upload dimensions when the op size differs', () => {
148
+ const { program, glw } = makeProgram();
149
+ const uniforms = makeUniforms();
150
+
151
+ program.bindRenderOp(makeOp(uniforms) as never);
152
+ glw.uniform2f.mockClear();
153
+
154
+ program.bindRenderOp(makeOp(uniforms, { w: 300, h: 150 }) as never);
155
+
156
+ expect(glw.uniform2f.mock.calls.length).toBe(1);
157
+ expect(glw.uniform2f.mock.calls[0]![0]).toBe('u_dimensions');
158
+ });
159
+
160
+ it('should re-run the collection pass for a different collection object', () => {
161
+ const { program, glw } = makeProgram();
162
+ const uniformsA = makeUniforms();
163
+ const uniformsB = makeUniforms(); // identical values, distinct identity
164
+
165
+ program.bindRenderOp(makeOp(uniformsA) as never);
166
+ glw.uniform1f.mockClear();
167
+ glw.uniform4f.mockClear();
168
+
169
+ program.bindRenderOp(makeOp(uniformsB) as never);
170
+
171
+ // Conservative direction: new object => redundant upload, never a skip
172
+ expect(glw.uniform1f.mock.calls.length).toBe(1); // u_borderGap
173
+ expect(glw.uniform4f.mock.calls.length).toBe(1); // u_radius
174
+ });
175
+
176
+ it('should re-upload pixel ratio and resolution across an RTT flip', () => {
177
+ const { program, glw } = makeProgram();
178
+ const uniforms = makeUniforms();
179
+
180
+ program.bindRenderOp(makeOp(uniforms) as never);
181
+ glw.uniform1f.mockClear();
182
+ glw.uniform2f.mockClear();
183
+
184
+ // RTT op: pixelRatio forced to 1, resolution = framebuffer dimensions
185
+ program.bindRenderOp(
186
+ makeOp(uniforms, {
187
+ isCoreNode: false,
188
+ parentHasRenderTexture: true,
189
+ framebufferDimensions: { w: 512, h: 256 },
190
+ }) as never,
191
+ );
192
+
193
+ const calls1f = glw.uniform1f.mock.calls;
194
+ const calls2f = glw.uniform2f.mock.calls;
195
+ expect(calls1f.some((c) => c[0] === 'u_pixelRatio' && c[1] === 1)).toBe(
196
+ true,
197
+ );
198
+ expect(calls2f.some((c) => c[0] === 'u_resolution' && c[1] === 512)).toBe(
199
+ true,
200
+ );
201
+
202
+ glw.uniform1f.mockClear();
203
+ glw.uniform2f.mockClear();
204
+
205
+ // Back to screen: values flip back and must re-upload
206
+ program.bindRenderOp(makeOp(uniforms) as never);
207
+ expect(
208
+ glw.uniform1f.mock.calls.some(
209
+ (c) => c[0] === 'u_pixelRatio' && c[1] === 2,
210
+ ),
211
+ ).toBe(true);
212
+ expect(
213
+ glw.uniform2f.mock.calls.some(
214
+ (c) => c[0] === 'u_resolution' && c[1] === 1920,
215
+ ),
216
+ ).toBe(true);
217
+ });
218
+
219
+ it('should skip the collection pass entirely for propless shaders', () => {
220
+ const { program, glw } = makeProgram();
221
+
222
+ program.bindRenderOp(
223
+ makeOp(makeUniforms(), {
224
+ shader: { props: undefined, uniforms: makeUniforms() },
225
+ }) as never,
226
+ );
227
+
228
+ // Only system uniforms: pixelRatio + alpha (1f), resolution + dimensions (2f)
229
+ expect(glw.uniform1f.mock.calls.length).toBe(2);
230
+ expect(glw.uniform2f.mock.calls.length).toBe(2);
231
+ expect(glw.uniform4f.mock.calls.length).toBe(0);
232
+ });
233
+ });