@solidtv/renderer 1.5.0-1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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 +4 -1
  12. package/dist/src/core/Stage.js.map +1 -1
  13. package/dist/src/core/TextureMemoryManager.d.ts +62 -2
  14. package/dist/src/core/TextureMemoryManager.js +90 -4
  15. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  16. package/dist/src/core/lib/WebGlContextWrapper.d.ts +1 -1
  17. package/dist/src/core/lib/WebGlContextWrapper.js +10 -3
  18. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  19. package/dist/src/core/lib/textureCompression.js +68 -4
  20. package/dist/src/core/lib/textureCompression.js.map +1 -1
  21. package/dist/src/core/renderers/CoreRenderer.d.ts +1 -0
  22. package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
  23. package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
  24. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  25. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +8 -0
  26. package/dist/src/core/renderers/webgl/WebGlRenderer.js +54 -14
  27. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  28. package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
  29. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
  30. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  31. package/dist/src/core/renderers/webgl/internal/RendererUtils.js +9 -2
  32. package/dist/src/core/renderers/webgl/internal/RendererUtils.js.map +1 -1
  33. package/dist/src/core/text-rendering/SdfTextRenderer.js +16 -3
  34. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  35. package/dist/src/main-api/Renderer.d.ts +16 -0
  36. package/dist/src/main-api/Renderer.js +2 -0
  37. package/dist/src/main-api/Renderer.js.map +1 -1
  38. package/dist/src/utils.js +23 -7
  39. package/dist/src/utils.js.map +1 -1
  40. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +1 -1
  43. package/src/common/EventEmitter.ts +21 -0
  44. package/src/core/Autosizer.ts +3 -1
  45. package/src/core/CoreNode.test.ts +283 -0
  46. package/src/core/CoreNode.ts +131 -26
  47. package/src/core/CoreTextNode.test.ts +1 -0
  48. package/src/core/CoreTextNode.ts +6 -2
  49. package/src/core/Stage.ts +4 -0
  50. package/src/core/TextureMemoryManager.test.ts +323 -7
  51. package/src/core/TextureMemoryManager.ts +101 -5
  52. package/src/core/lib/WebGlContextWrapper.test.ts +58 -0
  53. package/src/core/lib/WebGlContextWrapper.ts +13 -3
  54. package/src/core/lib/textureCompression.test.ts +158 -0
  55. package/src/core/lib/textureCompression.ts +78 -4
  56. package/src/core/renderers/CoreRenderer.ts +1 -0
  57. package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
  58. package/src/core/renderers/webgl/WebGlRenderer.ts +64 -16
  59. package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
  60. package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
  61. package/src/core/renderers/webgl/internal/RendererUtils.test.ts +73 -0
  62. package/src/core/renderers/webgl/internal/RendererUtils.ts +9 -2
  63. package/src/core/text-rendering/SdfTextRenderer.test.ts +11 -10
  64. package/src/core/text-rendering/SdfTextRenderer.ts +17 -3
  65. package/src/main-api/Renderer.ts +19 -0
  66. package/src/utils.test.ts +57 -0
  67. package/src/utils.ts +24 -8
@@ -7,6 +7,8 @@ import { type TextureOptions } from './CoreTextureManager.js';
7
7
  import { createBound } from './lib/utils.js';
8
8
  import { ImageTexture } from './textures/ImageTexture.js';
9
9
  import { Matrix3d } from './lib/Matrix3d.js';
10
+ import { EventEmitter } from '../common/EventEmitter.js';
11
+ import { premultiplyColorABGR } from '../utils.js';
10
12
 
11
13
  describe('set color()', () => {
12
14
  const defaultProps = (overrides?: Partial<CoreNodeProps>): CoreNodeProps => ({
@@ -23,6 +25,7 @@ describe('set color()', () => {
23
25
  colorTl: 0,
24
26
  colorTop: 0,
25
27
  colorTr: 0,
28
+ placeholderColor: 0,
26
29
  h: 0,
27
30
  mount: 0,
28
31
  mountX: 0,
@@ -1180,4 +1183,284 @@ describe('set color()', () => {
1180
1183
  expect(node.clippingRect.h).toBe(30);
1181
1184
  });
1182
1185
  });
1186
+
1187
+ describe('RecalcUniforms scoping', () => {
1188
+ const makeAttachedNode = () => {
1189
+ // Fresh stage per node: earlier tests can mutate the shared stage
1190
+ // mock's bound objects through by-reference strictBound assignment
1191
+ // in createRenderBounds.
1192
+ const localStage = mock<Stage>({
1193
+ strictBound: createBound(0, 0, 200, 200),
1194
+ preloadBound: createBound(0, 0, 200, 200),
1195
+ defaultTexture: {
1196
+ state: 'loaded',
1197
+ },
1198
+ renderer: mock<CoreRenderer>() as CoreRenderer,
1199
+ });
1200
+ const parent = new CoreNode(localStage, defaultProps());
1201
+ parent.globalTransform = Matrix3d.identity();
1202
+ parent.worldAlpha = 1;
1203
+
1204
+ const node = new CoreNode(
1205
+ localStage,
1206
+ defaultProps({ parent, w: 100, h: 100 }),
1207
+ );
1208
+ node.alpha = 1;
1209
+ return node;
1210
+ };
1211
+
1212
+ it('should not set RecalcUniforms on pure translation', () => {
1213
+ const node = makeAttachedNode();
1214
+ node.update(0, clippingRect);
1215
+
1216
+ node.x = 50;
1217
+ node.y = 25;
1218
+
1219
+ expect(node.updateType & UpdateType.Local).toBe(UpdateType.Local);
1220
+ expect(node.updateType & UpdateType.RecalcUniforms).toBe(0);
1221
+ });
1222
+
1223
+ it('should set RecalcUniforms when w changes', () => {
1224
+ const node = makeAttachedNode();
1225
+ node.update(0, clippingRect);
1226
+
1227
+ node.w = 150;
1228
+
1229
+ expect(node.updateType & UpdateType.RecalcUniforms).toBe(
1230
+ UpdateType.RecalcUniforms,
1231
+ );
1232
+ });
1233
+
1234
+ it('should set RecalcUniforms when h changes', () => {
1235
+ const node = makeAttachedNode();
1236
+ node.update(0, clippingRect);
1237
+
1238
+ node.h = 75;
1239
+
1240
+ expect(node.updateType & UpdateType.RecalcUniforms).toBe(
1241
+ UpdateType.RecalcUniforms,
1242
+ );
1243
+ });
1244
+
1245
+ it('should run the shader updater on resize but not on translation', () => {
1246
+ const node = makeAttachedNode();
1247
+ const shader = {
1248
+ shaderKey: 'test',
1249
+ update: vi.fn(),
1250
+ attachNode: vi.fn(),
1251
+ time: undefined,
1252
+ };
1253
+ // Assignment raises RecalcUniforms | IsRenderable via the setter
1254
+ node.shader = shader as never;
1255
+ node.update(0, clippingRect);
1256
+ expect(shader.update).toHaveBeenCalledTimes(1);
1257
+
1258
+ // Pure translation: no uniform recompute
1259
+ node.x = 50;
1260
+ node.update(0, clippingRect);
1261
+ expect(shader.update).toHaveBeenCalledTimes(1);
1262
+
1263
+ // Resize: uniforms depend on dimensions, must recompute
1264
+ node.w = 150;
1265
+ node.update(0, clippingRect);
1266
+ expect(shader.update).toHaveBeenCalledTimes(2);
1267
+ });
1268
+ });
1269
+
1270
+ describe('placeholderColor', () => {
1271
+ // A texture fake on a real EventEmitter so CoreNode's loadTextureTask
1272
+ // subscribes for real and we can drive the loaded/freed/failed handler
1273
+ // chain by emitting, like the engine does.
1274
+ function emittingTexture(state: string): ImageTexture & {
1275
+ emit: (event: string, data?: unknown) => void;
1276
+ } {
1277
+ return Object.assign(new EventEmitter(), {
1278
+ state,
1279
+ preventCleanup: false,
1280
+ retryCount: 0,
1281
+ maxRetryCount: 1,
1282
+ dimensions: { w: 100, h: 100 },
1283
+ setRenderableOwner: vi.fn(),
1284
+ }) as unknown as ImageTexture & {
1285
+ emit: (event: string, data?: unknown) => void;
1286
+ };
1287
+ }
1288
+
1289
+ function visibleNode(): CoreNode {
1290
+ const parent = new CoreNode(stage, defaultProps());
1291
+ parent.globalTransform = Matrix3d.identity();
1292
+ parent.worldAlpha = 1;
1293
+
1294
+ const node = new CoreNode(stage, defaultProps({ parent }));
1295
+ node.alpha = 1;
1296
+ node.x = 0;
1297
+ node.y = 0;
1298
+ node.w = 100;
1299
+ node.h = 100;
1300
+ return node;
1301
+ }
1302
+
1303
+ // Flush the queueMicrotask(loadTextureTask) so listeners attach.
1304
+ const flushMicrotasks = () => Promise.resolve();
1305
+
1306
+ it('renders the placeholder while the texture is loading', () => {
1307
+ const node = visibleNode();
1308
+ node.placeholderColor = 0x336699ff;
1309
+ node.texture = emittingTexture('initial');
1310
+
1311
+ node.update(0, clippingRect);
1312
+
1313
+ expect(node.placeholderActive).toBe(true);
1314
+ expect(node.isRenderable).toBe(true);
1315
+ expect(node.renderTexture).toBe(stage.defaultTexture);
1316
+
1317
+ const expected = premultiplyColorABGR(0x336699ff, 1);
1318
+ expect(node.premultipliedColorTl).toBe(expected);
1319
+ expect(node.premultipliedColorTr).toBe(expected);
1320
+ expect(node.premultipliedColorBl).toBe(expected);
1321
+ expect(node.premultipliedColorBr).toBe(expected);
1322
+ });
1323
+
1324
+ it('is inactive without a placeholderColor (loading renders nothing)', () => {
1325
+ const node = visibleNode();
1326
+ node.texture = emittingTexture('initial');
1327
+
1328
+ node.update(0, clippingRect);
1329
+
1330
+ expect(node.placeholderActive).toBe(false);
1331
+ expect(node.isRenderable).toBe(false);
1332
+ });
1333
+
1334
+ it('is inactive without a texture (color-only nodes are unaffected)', () => {
1335
+ const node = visibleNode();
1336
+ node.placeholderColor = 0x336699ff;
1337
+
1338
+ node.update(0, clippingRect);
1339
+
1340
+ expect(node.placeholderActive).toBe(false);
1341
+ });
1342
+
1343
+ it('switches to the texture and regular colors once loaded', async () => {
1344
+ const node = visibleNode();
1345
+ node.color = 0xffffffff;
1346
+ node.placeholderColor = 0x336699ff;
1347
+ const texture = emittingTexture('initial');
1348
+ node.texture = texture;
1349
+ node.update(0, clippingRect);
1350
+ expect(node.renderTexture).toBe(stage.defaultTexture);
1351
+
1352
+ await flushMicrotasks();
1353
+ (texture as { state: string }).state = 'loaded';
1354
+ texture.emit('loaded', { w: 100, h: 100 });
1355
+ node.isQuadDirty = false;
1356
+ node.update(1, clippingRect);
1357
+
1358
+ expect(node.placeholderActive).toBe(false);
1359
+ expect(node.isRenderable).toBe(true);
1360
+ expect(node.renderTexture).toBe(texture);
1361
+ expect(node.premultipliedColorTl).toBe(
1362
+ premultiplyColorABGR(0xffffffff, 1),
1363
+ );
1364
+ // The color switch must reach the GPU quad buffer
1365
+ expect(node.isQuadDirty).toBe(true);
1366
+ });
1367
+
1368
+ it('shows the placeholder again while a freed texture reloads', async () => {
1369
+ const node = visibleNode();
1370
+ node.placeholderColor = 0x336699ff;
1371
+ const texture = emittingTexture('initial');
1372
+ node.texture = texture;
1373
+ node.update(0, clippingRect);
1374
+
1375
+ await flushMicrotasks();
1376
+ (texture as { state: string }).state = 'loaded';
1377
+ texture.emit('loaded', { w: 100, h: 100 });
1378
+ node.update(1, clippingRect);
1379
+ expect(node.placeholderActive).toBe(false);
1380
+
1381
+ (texture as { state: string }).state = 'freed';
1382
+ texture.emit('freed');
1383
+ node.update(2, clippingRect);
1384
+
1385
+ expect(node.placeholderActive).toBe(true);
1386
+ expect(node.isRenderable).toBe(true);
1387
+ expect(node.renderTexture).toBe(stage.defaultTexture);
1388
+ expect(node.premultipliedColorTl).toBe(
1389
+ premultiplyColorABGR(0x336699ff, 1),
1390
+ );
1391
+ });
1392
+
1393
+ it('keeps the placeholder when the texture permanently fails', async () => {
1394
+ const node = visibleNode();
1395
+ node.placeholderColor = 0x336699ff;
1396
+ const texture = emittingTexture('initial');
1397
+ node.texture = texture;
1398
+ node.update(0, clippingRect);
1399
+
1400
+ await flushMicrotasks();
1401
+ (texture as { state: string }).state = 'failed';
1402
+ (texture as { retryCount: number }).retryCount = 2; // > maxRetryCount (1)
1403
+ texture.emit('failed', new Error('404'));
1404
+ node.update(1, clippingRect);
1405
+
1406
+ expect(node.placeholderActive).toBe(true);
1407
+ expect(node.isRenderable).toBe(true);
1408
+ expect(node.renderTexture).toBe(stage.defaultTexture);
1409
+ });
1410
+
1411
+ it('a permanently failed texture without a placeholder stays non-renderable', async () => {
1412
+ const node = visibleNode();
1413
+ const texture = emittingTexture('initial');
1414
+ node.texture = texture;
1415
+ node.update(0, clippingRect);
1416
+
1417
+ await flushMicrotasks();
1418
+ (texture as { state: string }).state = 'failed';
1419
+ (texture as { retryCount: number }).retryCount = 2;
1420
+ texture.emit('failed', new Error('404'));
1421
+ node.update(1, clippingRect);
1422
+
1423
+ expect(node.isRenderable).toBe(false);
1424
+ });
1425
+
1426
+ it('deactivates when placeholderColor is cleared while loading', () => {
1427
+ const node = visibleNode();
1428
+ node.placeholderColor = 0x336699ff;
1429
+ node.texture = emittingTexture('initial');
1430
+ node.update(0, clippingRect);
1431
+ expect(node.isRenderable).toBe(true);
1432
+
1433
+ node.placeholderColor = 0;
1434
+ node.update(1, clippingRect);
1435
+
1436
+ expect(node.placeholderActive).toBe(false);
1437
+ expect(node.isRenderable).toBe(false);
1438
+ });
1439
+
1440
+ it('updates the shown color when placeholderColor changes while active', () => {
1441
+ const node = visibleNode();
1442
+ node.placeholderColor = 0x336699ff;
1443
+ node.texture = emittingTexture('initial');
1444
+ node.update(0, clippingRect);
1445
+
1446
+ node.placeholderColor = 0x993311ff;
1447
+ node.update(1, clippingRect);
1448
+
1449
+ expect(node.premultipliedColorTl).toBe(
1450
+ premultiplyColorABGR(0x993311ff, 1),
1451
+ );
1452
+ });
1453
+
1454
+ it('does not activate when the assigned texture is already loaded', () => {
1455
+ const node = visibleNode();
1456
+ node.placeholderColor = 0x336699ff;
1457
+ const texture = emittingTexture('loaded');
1458
+ node.texture = texture;
1459
+
1460
+ node.update(0, clippingRect);
1461
+
1462
+ expect(node.placeholderActive).toBe(false);
1463
+ expect(node.renderTexture).toBe(texture);
1464
+ });
1465
+ });
1183
1466
  });
@@ -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,18 @@ 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(this.placeholderActive);
1760
1837
  return;
1761
1838
  }
1762
1839
 
1763
1840
  needsTextureOwnership = true;
1764
- // Use cached boolean instead of string comparison
1765
- newIsRenderable = this.textureLoaded;
1841
+ // Use cached boolean instead of string comparison; a placeholder
1842
+ // renders while the texture is loading
1843
+ newIsRenderable =
1844
+ this.textureLoaded === true || this.placeholderActive === true;
1766
1845
  } else if (
1767
1846
  // check shader
1768
1847
  (this.props.shader !== this.stage.renderer.getDefaultShaderNode() ||
@@ -2049,6 +2128,9 @@ export class CoreNode extends EventEmitter {
2049
2128
  }
2050
2129
 
2051
2130
  get renderTexture(): Texture | null {
2131
+ if (this.placeholderActive === true) {
2132
+ return this.stage.defaultTexture;
2133
+ }
2052
2134
  return this.props.texture || this.stage.defaultTexture;
2053
2135
  }
2054
2136
 
@@ -2232,7 +2314,9 @@ export class CoreNode extends EventEmitter {
2232
2314
  const props = this.props;
2233
2315
  if (props.w !== value) {
2234
2316
  props.w = value;
2235
- let updateType = UpdateType.Local;
2317
+ // Dimensions feed shader uniforms (e.g. factored corner radius), so a
2318
+ // resize must recompute them; see the Global-update branch in update().
2319
+ let updateType = UpdateType.Local | UpdateType.RecalcUniforms;
2236
2320
 
2237
2321
  if (
2238
2322
  props.texture !== null &&
@@ -2262,7 +2346,9 @@ export class CoreNode extends EventEmitter {
2262
2346
  const props = this.props;
2263
2347
  if (props.h !== value) {
2264
2348
  props.h = value;
2265
- let updateType = UpdateType.Local;
2349
+ // Dimensions feed shader uniforms (e.g. factored corner radius), so a
2350
+ // resize must recompute them; see the Global-update branch in update().
2351
+ let updateType = UpdateType.Local | UpdateType.RecalcUniforms;
2266
2352
 
2267
2353
  if (
2268
2354
  props.texture !== null &&
@@ -2514,6 +2600,24 @@ export class CoreNode extends EventEmitter {
2514
2600
  this.setUpdateType(UpdateType.PremultipliedColors);
2515
2601
  }
2516
2602
 
2603
+ get placeholderColor(): number {
2604
+ return this.props.placeholderColor;
2605
+ }
2606
+
2607
+ set placeholderColor(value: number) {
2608
+ const p = this.props;
2609
+ if (p.placeholderColor === value) return;
2610
+
2611
+ p.placeholderColor = value;
2612
+ this.updatePlaceholderActive();
2613
+
2614
+ // If the placeholder is (still) showing, the new color must reach the
2615
+ // quad buffer even though the active state did not toggle.
2616
+ if (this.placeholderActive === true) {
2617
+ this.setUpdateType(UpdateType.PremultipliedColors);
2618
+ }
2619
+ }
2620
+
2517
2621
  get colorTop(): number {
2518
2622
  return this.props.colorTop;
2519
2623
  }
@@ -2934,6 +3038,7 @@ export class CoreNode extends EventEmitter {
2934
3038
  this.textureCoords = undefined;
2935
3039
  this.props.texture = value;
2936
3040
  this.textureLoaded = value !== null && value.state === 'loaded';
3041
+ this.updatePlaceholderActive();
2937
3042
 
2938
3043
  if (value !== null) {
2939
3044
  if (this.autosizer !== null) {
@@ -28,6 +28,7 @@ const defaultProps = (
28
28
  colorTl: 0xffffffff,
29
29
  colorTop: 0xffffffff,
30
30
  colorTr: 0xffffffff,
31
+ placeholderColor: 0,
31
32
  h: 0,
32
33
  mount: 0,
33
34
  mountX: 0,
@@ -271,8 +271,12 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
271
271
  this.props.w = width;
272
272
  this.props.h = height;
273
273
 
274
- // Text dimensions may have changed, recalculate transforms and bounds
275
- this.setUpdateType(UpdateType.Local | UpdateType.RenderBounds);
274
+ // Text dimensions may have changed, recalculate transforms and bounds.
275
+ // RecalcUniforms because the direct props.w/h write above bypasses the
276
+ // w/h setters and dimensions feed shader uniforms.
277
+ this.setUpdateType(
278
+ UpdateType.Local | UpdateType.RenderBounds | UpdateType.RecalcUniforms,
279
+ );
276
280
 
277
281
  // Handle SDF renderer (uses layout caching)
278
282
  if (textRendererType === 'sdf') {
package/src/core/Stage.ts CHANGED
@@ -185,6 +185,7 @@ export class Stage {
185
185
  boundsMargin,
186
186
  enableContextSpy,
187
187
  forceWebGL2,
188
+ disableVertexArrayObject,
188
189
  numImageWorkers,
189
190
  textureMemory,
190
191
  renderEngine,
@@ -256,6 +257,7 @@ export class Stage {
256
257
  canvas,
257
258
  contextSpy: this.contextSpy,
258
259
  forceWebGL2,
260
+ disableVertexArrayObject,
259
261
  });
260
262
 
261
263
  this.shManager = new CoreShaderManager(this);
@@ -380,6 +382,7 @@ export class Stage {
380
382
  colorRight: 0x00000000,
381
383
  colorTl: 0x00000000,
382
384
  colorTr: 0x00000000,
385
+ placeholderColor: 0x00000000,
383
386
  colorBl: 0x00000000,
384
387
  colorBr: 0x00000000,
385
388
  zIndex: 0,
@@ -1041,6 +1044,7 @@ export class Stage {
1041
1044
  colorTr,
1042
1045
  colorBl,
1043
1046
  colorBr,
1047
+ placeholderColor: props.placeholderColor ?? 0,
1044
1048
  zIndex: props.zIndex ?? 0,
1045
1049
  parent: props.parent ?? null,
1046
1050
  texture: props.texture ?? null,