@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
@@ -392,6 +392,23 @@ export interface CoreNodeProps {
392
392
  * rendering.
393
393
  */
394
394
  colorBl: number;
395
+ /**
396
+ * Placeholder color shown while the Node's texture is not yet loaded.
397
+ *
398
+ * @remarks
399
+ * When set to a non-zero color and the Node has a texture (e.g. {@link src}),
400
+ * the Node renders a solid rectangle of this color until the texture
401
+ * finishes loading, instead of rendering nothing. The placeholder renders
402
+ * through the Node's shader, so rounded corners and borders apply to it.
403
+ * It also shows again if the texture is freed under memory pressure (until
404
+ * the reload completes) and remains if the texture permanently fails.
405
+ *
406
+ * The color value is a number in the format 0xRRGGBBAA. Set to `0` to
407
+ * disable (default). Has no effect on Nodes without a texture.
408
+ *
409
+ * @default `0`
410
+ */
411
+ placeholderColor: number;
395
412
  /**
396
413
  * The Node's parent Node.
397
414
  *
@@ -793,6 +810,15 @@ export class CoreNode extends EventEmitter {
793
810
  private hasColorProps = false;
794
811
  public textureLoaded = false;
795
812
 
813
+ /**
814
+ * True while this node should render its `placeholderColor` instead of its
815
+ * texture: `placeholderColor` is non-zero, a texture is set, and that
816
+ * texture is not loaded. Read by the renderers' quad path to substitute the
817
+ * stage's default (1x1 white) texture. Maintained by
818
+ * {@link updatePlaceholderActive} — never written elsewhere.
819
+ */
820
+ public placeholderActive = false;
821
+
796
822
  public updateType = UpdateType.All;
797
823
  public childUpdateType = UpdateType.None;
798
824
 
@@ -953,6 +979,31 @@ export class CoreNode extends EventEmitter {
953
979
  }
954
980
 
955
981
  //#region Textures
982
+ /**
983
+ * Recompute {@link placeholderActive} after any of its inputs changed
984
+ * (placeholderColor, texture, textureLoaded).
985
+ *
986
+ * @remarks
987
+ * On a toggle this raises `PremultipliedColors` (the quad's vertex colors
988
+ * switch between the placeholder color and the regular color props — this
989
+ * also marks the quad dirty) and `IsRenderable` (a loading texture with a
990
+ * placeholder is renderable). Both are processed in the same frame's update
991
+ * pass, before quads are submitted.
992
+ */
993
+ private updatePlaceholderActive(): void {
994
+ const active =
995
+ this.props.placeholderColor !== 0 &&
996
+ this.props.texture !== null &&
997
+ this.textureLoaded === false;
998
+
999
+ if (active !== this.placeholderActive) {
1000
+ this.placeholderActive = active;
1001
+ this.setUpdateType(
1002
+ UpdateType.PremultipliedColors | UpdateType.IsRenderable,
1003
+ );
1004
+ }
1005
+ }
1006
+
956
1007
  loadTexture(): void {
957
1008
  if (this.props.texture === null) {
958
1009
  return;
@@ -1023,6 +1074,7 @@ export class CoreNode extends EventEmitter {
1023
1074
  }
1024
1075
 
1025
1076
  this.textureLoaded = true;
1077
+ this.updatePlaceholderActive();
1026
1078
  this.setUpdateType(UpdateType.IsRenderable);
1027
1079
 
1028
1080
  // Texture was loaded. In case the RAF loop has already stopped, we request
@@ -1057,9 +1109,12 @@ export class CoreNode extends EventEmitter {
1057
1109
 
1058
1110
  private onTextureFailed: TextureFailedEventHandler = (_, error) => {
1059
1111
  // immediately set isRenderable to false, so that we handle the error
1060
- // without waiting for the next frame loop
1112
+ // without waiting for the next frame loop. With a placeholder set, the
1113
+ // same frame's update pass recomputes this to true and renders the
1114
+ // placeholder instead.
1061
1115
  this.textureLoaded = false;
1062
1116
  this.isRenderable = false;
1117
+ this.updatePlaceholderActive();
1063
1118
  this.updateTextureOwnership(false);
1064
1119
  this.setUpdateType(UpdateType.IsRenderable);
1065
1120
 
@@ -1081,9 +1136,12 @@ export class CoreNode extends EventEmitter {
1081
1136
 
1082
1137
  private onTextureFreed: TextureFreedEventHandler = () => {
1083
1138
  // immediately set isRenderable to false, so that we handle the error
1084
- // without waiting for the next frame loop
1139
+ // without waiting for the next frame loop. With a placeholder set, the
1140
+ // same frame's update pass recomputes this to true and renders the
1141
+ // placeholder while the texture reloads.
1085
1142
  this.textureLoaded = false;
1086
1143
  this.isRenderable = false;
1144
+ this.updatePlaceholderActive();
1087
1145
  this.updateTextureOwnership(false);
1088
1146
  this.setUpdateType(UpdateType.IsRenderable);
1089
1147
 
@@ -1355,7 +1413,13 @@ export class CoreNode extends EventEmitter {
1355
1413
  this.calculateRenderCoords();
1356
1414
  this.updateBoundingRect();
1357
1415
 
1358
- updateType |= UpdateType.RenderState | UpdateType.RecalcUniforms;
1416
+ // RecalcUniforms is intentionally NOT set here: shader uniforms are a
1417
+ // function of resolvedProps + w/h only (that is exactly the shader
1418
+ // value-key cache key), so pure transform changes (translate, scale,
1419
+ // rotate) cannot affect them. The flag is raised where w/h actually
1420
+ // change: the w/h setters, Autosizer.applyDimensions, text layout
1421
+ // application, and the shader setter itself.
1422
+ updateType |= UpdateType.RenderState;
1359
1423
 
1360
1424
  //only propagate children updates if not autosizing
1361
1425
  if ((updateType & UpdateType.Autosize) === 0) {
@@ -1420,27 +1484,39 @@ export class CoreNode extends EventEmitter {
1420
1484
  if (updateType & UpdateType.PremultipliedColors) {
1421
1485
  const alpha = this.worldAlpha;
1422
1486
 
1423
- const tl = props.colorTl;
1424
- const tr = props.colorTr;
1425
- const bl = props.colorBl;
1426
- const br = props.colorBr;
1427
-
1428
- // Fast equality check (covers all 4 corners)
1429
- const same = tl === tr && tl === bl && tl === br;
1430
-
1431
- const merged = premultiplyColorABGR(tl, alpha);
1432
-
1433
- this.premultipliedColorTl = merged;
1434
-
1435
- if (same === true) {
1436
- this.premultipliedColorTr =
1487
+ if (this.placeholderActive === true) {
1488
+ // Placeholder rendering: all four corners take the placeholder color.
1489
+ // The quad samples the stage's default 1x1 white texture, so this is
1490
+ // exactly the color-rect path.
1491
+ const merged = premultiplyColorABGR(props.placeholderColor, alpha);
1492
+ this.premultipliedColorTl =
1493
+ this.premultipliedColorTr =
1437
1494
  this.premultipliedColorBl =
1438
1495
  this.premultipliedColorBr =
1439
1496
  merged;
1440
1497
  } else {
1441
- this.premultipliedColorTr = premultiplyColorABGR(tr, alpha);
1442
- this.premultipliedColorBl = premultiplyColorABGR(bl, alpha);
1443
- this.premultipliedColorBr = premultiplyColorABGR(br, alpha);
1498
+ const tl = props.colorTl;
1499
+ const tr = props.colorTr;
1500
+ const bl = props.colorBl;
1501
+ const br = props.colorBr;
1502
+
1503
+ // Fast equality check (covers all 4 corners)
1504
+ const same = tl === tr && tl === bl && tl === br;
1505
+
1506
+ const merged = premultiplyColorABGR(tl, alpha);
1507
+
1508
+ this.premultipliedColorTl = merged;
1509
+
1510
+ if (same === true) {
1511
+ this.premultipliedColorTr =
1512
+ this.premultipliedColorBl =
1513
+ this.premultipliedColorBr =
1514
+ merged;
1515
+ } else {
1516
+ this.premultipliedColorTr = premultiplyColorABGR(tr, alpha);
1517
+ this.premultipliedColorBl = premultiplyColorABGR(bl, alpha);
1518
+ this.premultipliedColorBr = premultiplyColorABGR(br, alpha);
1519
+ }
1444
1520
  }
1445
1521
  }
1446
1522
 
@@ -1754,15 +1830,22 @@ export class CoreNode extends EventEmitter {
1754
1830
  // preemptive check for failed textures this will mark the current node as non-renderable
1755
1831
  // and will prevent further checks until the texture is reloaded or retry is reset on the texture
1756
1832
  if (this.texture.retryCount > this.texture.maxRetryCount) {
1757
- // texture has failed to load, we cannot render
1833
+ // texture has failed to load, we cannot render the texture itself —
1834
+ // but a placeholder color still renders in its place
1758
1835
  this.updateTextureOwnership(false);
1759
- this.setRenderable(false);
1836
+ this.setRenderable(
1837
+ this.placeholderActive === true &&
1838
+ (this.stage.renderOnlyInViewport === false ||
1839
+ this.renderState === CoreNodeRenderState.InViewport),
1840
+ );
1760
1841
  return;
1761
1842
  }
1762
1843
 
1763
1844
  needsTextureOwnership = true;
1764
- // Use cached boolean instead of string comparison
1765
- newIsRenderable = this.textureLoaded;
1845
+ // Use cached boolean instead of string comparison; a placeholder
1846
+ // renders while the texture is loading
1847
+ newIsRenderable =
1848
+ this.textureLoaded === true || this.placeholderActive === true;
1766
1849
  } else if (
1767
1850
  // check shader
1768
1851
  (this.props.shader !== this.stage.renderer.getDefaultShaderNode() ||
@@ -1774,6 +1857,17 @@ export class CoreNode extends EventEmitter {
1774
1857
  newIsRenderable = true;
1775
1858
  }
1776
1859
 
1860
+ // renderOnlyInViewport: nodes in the preload margin keep texture
1861
+ // ownership above (so loading proceeds) but stay out of the render list
1862
+ // until they actually intersect the viewport.
1863
+ if (
1864
+ newIsRenderable === true &&
1865
+ this.stage.renderOnlyInViewport === true &&
1866
+ this.renderState !== CoreNodeRenderState.InViewport
1867
+ ) {
1868
+ newIsRenderable = false;
1869
+ }
1870
+
1777
1871
  this.updateTextureOwnership(needsTextureOwnership);
1778
1872
  this.setRenderable(newIsRenderable);
1779
1873
  }
@@ -2049,6 +2143,9 @@ export class CoreNode extends EventEmitter {
2049
2143
  }
2050
2144
 
2051
2145
  get renderTexture(): Texture | null {
2146
+ if (this.placeholderActive === true) {
2147
+ return this.stage.defaultTexture;
2148
+ }
2052
2149
  return this.props.texture || this.stage.defaultTexture;
2053
2150
  }
2054
2151
 
@@ -2232,7 +2329,9 @@ export class CoreNode extends EventEmitter {
2232
2329
  const props = this.props;
2233
2330
  if (props.w !== value) {
2234
2331
  props.w = value;
2235
- let updateType = UpdateType.Local;
2332
+ // Dimensions feed shader uniforms (e.g. factored corner radius), so a
2333
+ // resize must recompute them; see the Global-update branch in update().
2334
+ let updateType = UpdateType.Local | UpdateType.RecalcUniforms;
2236
2335
 
2237
2336
  if (
2238
2337
  props.texture !== null &&
@@ -2262,7 +2361,9 @@ export class CoreNode extends EventEmitter {
2262
2361
  const props = this.props;
2263
2362
  if (props.h !== value) {
2264
2363
  props.h = value;
2265
- let updateType = UpdateType.Local;
2364
+ // Dimensions feed shader uniforms (e.g. factored corner radius), so a
2365
+ // resize must recompute them; see the Global-update branch in update().
2366
+ let updateType = UpdateType.Local | UpdateType.RecalcUniforms;
2266
2367
 
2267
2368
  if (
2268
2369
  props.texture !== null &&
@@ -2514,6 +2615,24 @@ export class CoreNode extends EventEmitter {
2514
2615
  this.setUpdateType(UpdateType.PremultipliedColors);
2515
2616
  }
2516
2617
 
2618
+ get placeholderColor(): number {
2619
+ return this.props.placeholderColor;
2620
+ }
2621
+
2622
+ set placeholderColor(value: number) {
2623
+ const p = this.props;
2624
+ if (p.placeholderColor === value) return;
2625
+
2626
+ p.placeholderColor = value;
2627
+ this.updatePlaceholderActive();
2628
+
2629
+ // If the placeholder is (still) showing, the new color must reach the
2630
+ // quad buffer even though the active state did not toggle.
2631
+ if (this.placeholderActive === true) {
2632
+ this.setUpdateType(UpdateType.PremultipliedColors);
2633
+ }
2634
+ }
2635
+
2517
2636
  get colorTop(): number {
2518
2637
  return this.props.colorTop;
2519
2638
  }
@@ -2934,6 +3053,7 @@ export class CoreNode extends EventEmitter {
2934
3053
  this.textureCoords = undefined;
2935
3054
  this.props.texture = value;
2936
3055
  this.textureLoaded = value !== null && value.state === 'loaded';
3056
+ this.updatePlaceholderActive();
2937
3057
 
2938
3058
  if (value !== null) {
2939
3059
  if (this.autosizer !== null) {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { mock } from 'vitest-mock-extended';
3
3
  import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js';
4
+ import { CoreNodeRenderState } from './CoreNode.js';
4
5
  import { Stage } from './Stage.js';
5
6
  import { CoreRenderer } from './renderers/CoreRenderer.js';
6
7
  import { createBound } from './lib/utils.js';
@@ -28,6 +29,7 @@ const defaultProps = (
28
29
  colorTl: 0xffffffff,
29
30
  colorTop: 0xffffffff,
30
31
  colorTr: 0xffffffff,
32
+ placeholderColor: 0,
31
33
  h: 0,
32
34
  mount: 0,
33
35
  mountX: 0,
@@ -143,3 +145,63 @@ describe('CoreTextNode (canvas) clearing text', () => {
143
145
  expect(node.isRenderable).toBe(false);
144
146
  });
145
147
  });
148
+
149
+ describe('CoreTextNode (sdf) renderOnlyInViewport', () => {
150
+ const makeSdfRenderer = (): TextRenderer => {
151
+ const font = mock<FontHandler>({ type: 'sdf' });
152
+ return {
153
+ type: 'sdf',
154
+ font,
155
+ renderText: vi.fn(),
156
+ addQuads: vi.fn(),
157
+ renderQuads: vi.fn(),
158
+ init: vi.fn(),
159
+ } as unknown as TextRenderer;
160
+ };
161
+
162
+ function sdfNodeWithLayout(renderOnlyInViewport: boolean): CoreTextNode {
163
+ const stage = mock<Stage>({
164
+ strictBound: createBound(0, 0, 200, 200),
165
+ preloadBound: createBound(0, 0, 400, 200),
166
+ defaultTexture: { state: 'loaded' } as never,
167
+ defShaderNode: null as never,
168
+ renderer: mock<CoreRenderer>() as CoreRenderer,
169
+ renderOnlyInViewport,
170
+ });
171
+ const node = new CoreTextNode(
172
+ stage,
173
+ defaultProps({ text: 'Hello' }),
174
+ makeSdfRenderer(),
175
+ );
176
+ (node as unknown as { _cachedLayout: object })._cachedLayout = {};
177
+ node.worldAlpha = 1;
178
+ return node;
179
+ }
180
+
181
+ it('off: margin-ring SDF text is renderable (legacy behavior)', () => {
182
+ const node = sdfNodeWithLayout(false);
183
+ node.renderState = CoreNodeRenderState.InBounds;
184
+
185
+ node.updateIsRenderable();
186
+
187
+ expect(node.isRenderable).toBe(true);
188
+ });
189
+
190
+ it('on: margin-ring SDF text stays out of the render list', () => {
191
+ const node = sdfNodeWithLayout(true);
192
+ node.renderState = CoreNodeRenderState.InBounds;
193
+
194
+ node.updateIsRenderable();
195
+
196
+ expect(node.isRenderable).toBe(false);
197
+ });
198
+
199
+ it('on: viewport SDF text is renderable', () => {
200
+ const node = sdfNodeWithLayout(true);
201
+ node.renderState = CoreNodeRenderState.InViewport;
202
+
203
+ node.updateIsRenderable();
204
+
205
+ expect(node.isRenderable).toBe(true);
206
+ });
207
+ });
@@ -227,9 +227,14 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
227
227
  return;
228
228
  }
229
229
 
230
- // For SDF, check if we have a cached layout
230
+ // For SDF, check if we have a cached layout. renderOnlyInViewport gates
231
+ // SDF text the same way CoreNode gates quads: margin-ring text stays out
232
+ // of the render list until it intersects the viewport.
231
233
  this.setRenderable(
232
- this.checkBasicRenderability() === true && this._cachedLayout !== null,
234
+ this.checkBasicRenderability() === true &&
235
+ this._cachedLayout !== null &&
236
+ (this.stage.renderOnlyInViewport === false ||
237
+ this.renderState === CoreNodeRenderState.InViewport),
233
238
  );
234
239
  }
235
240
 
@@ -271,8 +276,12 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
271
276
  this.props.w = width;
272
277
  this.props.h = height;
273
278
 
274
- // Text dimensions may have changed, recalculate transforms and bounds
275
- this.setUpdateType(UpdateType.Local | UpdateType.RenderBounds);
279
+ // Text dimensions may have changed, recalculate transforms and bounds.
280
+ // RecalcUniforms because the direct props.w/h write above bypasses the
281
+ // w/h setters and dimensions feed shader uniforms.
282
+ this.setUpdateType(
283
+ UpdateType.Local | UpdateType.RenderBounds | UpdateType.RecalcUniforms,
284
+ );
276
285
 
277
286
  // Handle SDF renderer (uses layout caching)
278
287
  if (textRendererType === 'sdf') {
package/src/core/Stage.ts CHANGED
@@ -97,6 +97,12 @@ export class Stage {
97
97
  public readonly renderer: CoreRenderer;
98
98
  public readonly root: CoreNode;
99
99
  public boundsMargin: [number, number, number, number];
100
+ /**
101
+ * When true, nodes inside the bounds margin but outside the viewport keep
102
+ * loading textures yet stay out of the render list. Read by
103
+ * `CoreNode.updateIsRenderable` on the scroll path.
104
+ */
105
+ public readonly renderOnlyInViewport: boolean;
100
106
  public readonly defShaderNode: CoreShaderNode | null = null;
101
107
  public strictBound: Bound;
102
108
  public preloadBound: Bound;
@@ -208,6 +214,7 @@ export class Stage {
208
214
  setBaselineMode(options.textBaselineMode);
209
215
 
210
216
  this.platform = platform;
217
+ this.renderOnlyInViewport = options.renderOnlyInViewport !== false;
211
218
 
212
219
  this.startTime = platform.getTimeStamp();
213
220
 
@@ -382,6 +389,7 @@ export class Stage {
382
389
  colorRight: 0x00000000,
383
390
  colorTl: 0x00000000,
384
391
  colorTr: 0x00000000,
392
+ placeholderColor: 0x00000000,
385
393
  colorBl: 0x00000000,
386
394
  colorBr: 0x00000000,
387
395
  zIndex: 0,
@@ -1043,6 +1051,7 @@ export class Stage {
1043
1051
  colorTr,
1044
1052
  colorBl,
1045
1053
  colorBr,
1054
+ placeholderColor: props.placeholderColor ?? 0,
1046
1055
  zIndex: props.zIndex ?? 0,
1047
1056
  parent: props.parent ?? null,
1048
1057
  texture: props.texture ?? null,