@solidtv/renderer 1.5.2 → 1.5.4

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 (41) hide show
  1. package/dist/src/core/CoreNode.d.ts +33 -0
  2. package/dist/src/core/CoreNode.js +34 -6
  3. package/dist/src/core/CoreNode.js.map +1 -1
  4. package/dist/src/core/CoreTextNode.js +3 -2
  5. package/dist/src/core/CoreTextNode.js.map +1 -1
  6. package/dist/src/core/CoreTextureManager.d.ts +39 -3
  7. package/dist/src/core/CoreTextureManager.js +72 -61
  8. package/dist/src/core/CoreTextureManager.js.map +1 -1
  9. package/dist/src/core/Stage.js +8 -4
  10. package/dist/src/core/Stage.js.map +1 -1
  11. package/dist/src/core/TextureMemoryManager.js +1 -1
  12. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  13. package/dist/src/core/lib/collectionUtils.d.ts +1 -1
  14. package/dist/src/core/lib/collectionUtils.js +13 -31
  15. package/dist/src/core/lib/collectionUtils.js.map +1 -1
  16. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -3
  17. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
  18. package/dist/src/core/textures/SubTexture.js +1 -1
  19. package/dist/src/core/textures/SubTexture.js.map +1 -1
  20. package/dist/src/core/textures/Texture.d.ts +1 -1
  21. package/dist/src/core/textures/Texture.js +10 -13
  22. package/dist/src/core/textures/Texture.js.map +1 -1
  23. package/dist/src/main-api/Inspector.js +1 -1
  24. package/dist/src/main-api/Inspector.js.map +1 -1
  25. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  26. package/package.json +1 -1
  27. package/src/core/CoreNode.test.ts +272 -0
  28. package/src/core/CoreNode.ts +62 -8
  29. package/src/core/CoreTextNode.test.ts +1 -0
  30. package/src/core/CoreTextNode.ts +3 -2
  31. package/src/core/CoreTextureManager.ts +75 -70
  32. package/src/core/Stage.ts +11 -4
  33. package/src/core/TextureMemoryManager.test.ts +2 -2
  34. package/src/core/TextureMemoryManager.ts +1 -1
  35. package/src/core/lib/collectionUtils.test.ts +91 -0
  36. package/src/core/lib/collectionUtils.ts +13 -34
  37. package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -4
  38. package/src/core/textures/SubTexture.ts +1 -1
  39. package/src/core/textures/Texture.test.ts +124 -0
  40. package/src/core/textures/Texture.ts +10 -13
  41. package/src/main-api/Inspector.ts +1 -1
@@ -13,6 +13,7 @@ import { premultiplyColorABGR } from '../utils.js';
13
13
  describe('set color()', () => {
14
14
  const defaultProps = (overrides?: Partial<CoreNodeProps>): CoreNodeProps => ({
15
15
  alpha: 0,
16
+ ignoreParentAlpha: false,
16
17
  autosize: false,
17
18
  boundsMargin: null,
18
19
  clipping: false,
@@ -264,6 +265,112 @@ describe('set color()', () => {
264
265
  });
265
266
  });
266
267
 
268
+ describe('ignoreParentAlpha', () => {
269
+ const makeParent = (worldAlpha: number) => {
270
+ const parent = new CoreNode(stage, defaultProps());
271
+ parent.globalTransform = Matrix3d.identity();
272
+ parent.worldAlpha = worldAlpha;
273
+ return parent;
274
+ };
275
+
276
+ it('multiplies parent world alpha by default', () => {
277
+ const parent = makeParent(0.5);
278
+ const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 }));
279
+
280
+ node.update(0, clippingRect);
281
+
282
+ expect(node.worldAlpha).toBeCloseTo(0.4);
283
+ });
284
+
285
+ it('uses own alpha only when enabled', () => {
286
+ const parent = makeParent(0.5);
287
+ const node = new CoreNode(
288
+ stage,
289
+ defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
290
+ );
291
+
292
+ node.update(0, clippingRect);
293
+
294
+ expect(node.worldAlpha).toBe(0.8);
295
+ });
296
+
297
+ it('keeps its own world alpha while the parent fades toward 0', () => {
298
+ const parent = makeParent(0.01);
299
+ const node = new CoreNode(
300
+ stage,
301
+ defaultProps({ parent, alpha: 1, ignoreParentAlpha: true }),
302
+ );
303
+ node.w = 100;
304
+ node.h = 100;
305
+ node.color = 0xffffffff;
306
+
307
+ node.update(0, clippingRect);
308
+
309
+ expect(node.worldAlpha).toBe(1);
310
+ expect(node.isRenderable).toBe(true);
311
+ });
312
+
313
+ it('premultiplies colors with the node own alpha when enabled', () => {
314
+ const parent = makeParent(0.25);
315
+ const node = new CoreNode(
316
+ stage,
317
+ defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
318
+ );
319
+ node.w = 100;
320
+ node.h = 100;
321
+ node.color = 0xff0000ff;
322
+
323
+ node.update(0, clippingRect);
324
+
325
+ expect(node.premultipliedColorTl).toBe(
326
+ premultiplyColorABGR(0xff0000ff, 0.8),
327
+ );
328
+ });
329
+
330
+ it('toggling the setter recomputes world alpha', () => {
331
+ const parent = makeParent(0.5);
332
+ const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 }));
333
+
334
+ node.update(0, clippingRect);
335
+ expect(node.worldAlpha).toBeCloseTo(0.4);
336
+
337
+ node.ignoreParentAlpha = true;
338
+ node.update(1, clippingRect);
339
+ expect(node.worldAlpha).toBe(0.8);
340
+
341
+ node.ignoreParentAlpha = false;
342
+ node.update(2, clippingRect);
343
+ expect(node.worldAlpha).toBeCloseTo(0.4);
344
+ });
345
+
346
+ it('setting the same value does not flag an update', () => {
347
+ const node = new CoreNode(stage, defaultProps());
348
+ const updateTypeBefore = node.updateType;
349
+
350
+ node.ignoreParentAlpha = false;
351
+
352
+ expect(node.updateType).toBe(updateTypeBefore);
353
+ });
354
+
355
+ it('descendants inherit the node world alpha as usual', () => {
356
+ const parent = makeParent(0.5);
357
+ const node = new CoreNode(
358
+ stage,
359
+ defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
360
+ );
361
+ const child = new CoreNode(
362
+ stage,
363
+ defaultProps({ parent: node, alpha: 0.5 }),
364
+ );
365
+
366
+ node.update(0, clippingRect);
367
+ child.update(0, clippingRect);
368
+
369
+ expect(node.worldAlpha).toBe(0.8);
370
+ expect(child.worldAlpha).toBeCloseTo(0.4);
371
+ });
372
+ });
373
+
267
374
  describe('autosize system', () => {
268
375
  it('should initialize with autosize disabled', () => {
269
376
  const node = new CoreNode(stage, defaultProps());
@@ -1605,4 +1712,169 @@ describe('set color()', () => {
1605
1712
  expect(node.isRenderable).toBe(true);
1606
1713
  });
1607
1714
  });
1715
+
1716
+ describe('texture ownership cache', () => {
1717
+ // Same shape as the placeholderColor texture fake: a real EventEmitter so
1718
+ // loadTextureTask subscribes and we can drive freed/loaded by emitting.
1719
+ function emittingTexture(state: string): ImageTexture & {
1720
+ emit: (event: string, data?: unknown) => void;
1721
+ } {
1722
+ return Object.assign(new EventEmitter(), {
1723
+ state,
1724
+ preventCleanup: false,
1725
+ retryCount: 0,
1726
+ maxRetryCount: 1,
1727
+ dimensions: { w: 100, h: 100 },
1728
+ setRenderableOwner: vi.fn(),
1729
+ }) as unknown as ImageTexture & {
1730
+ emit: (event: string, data?: unknown) => void;
1731
+ };
1732
+ }
1733
+
1734
+ function visibleNode(): CoreNode {
1735
+ const parent = new CoreNode(stage, defaultProps());
1736
+ parent.globalTransform = Matrix3d.identity();
1737
+ parent.worldAlpha = 1;
1738
+
1739
+ const node = new CoreNode(stage, defaultProps({ parent }));
1740
+ node.alpha = 1;
1741
+ node.x = 0;
1742
+ node.y = 0;
1743
+ node.w = 100;
1744
+ node.h = 100;
1745
+ return node;
1746
+ }
1747
+
1748
+ const flushMicrotasks = () => Promise.resolve();
1749
+
1750
+ it('repeated updates with unchanged state call setRenderableOwner once', () => {
1751
+ const node = visibleNode();
1752
+ const texture = emittingTexture('loaded');
1753
+ node.texture = texture;
1754
+ node.textureLoaded = true;
1755
+
1756
+ // The texture setter registers ownership with the node's current
1757
+ // isRenderable (false here), so the cache starts false.
1758
+ expect(texture.setRenderableOwner).toHaveBeenCalledTimes(1);
1759
+ expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
1760
+ expect.anything(),
1761
+ false,
1762
+ );
1763
+
1764
+ node.update(0, clippingRect);
1765
+ expect(texture.setRenderableOwner).toHaveBeenCalledTimes(2);
1766
+ expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
1767
+ expect.anything(),
1768
+ true,
1769
+ );
1770
+
1771
+ // Steady-state scroll: ownership unchanged, no further calls.
1772
+ node.update(1, clippingRect);
1773
+ node.update(2, clippingRect);
1774
+ expect(texture.setRenderableOwner).toHaveBeenCalledTimes(2);
1775
+ });
1776
+
1777
+ it('moving out of bounds releases ownership once, returning re-adds', () => {
1778
+ const node = visibleNode();
1779
+ const texture = emittingTexture('loaded');
1780
+ node.texture = texture;
1781
+ node.textureLoaded = true;
1782
+
1783
+ node.update(0, clippingRect);
1784
+ expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
1785
+ expect.anything(),
1786
+ true,
1787
+ );
1788
+ const callsAfterFirstUpdate = (
1789
+ texture.setRenderableOwner as ReturnType<typeof vi.fn>
1790
+ ).mock.calls.length;
1791
+
1792
+ // Out of the 200x200 stage bounds entirely.
1793
+ node.x = 1000;
1794
+ node.update(1, clippingRect);
1795
+ expect(texture.setRenderableOwner).toHaveBeenCalledTimes(
1796
+ callsAfterFirstUpdate + 1,
1797
+ );
1798
+ expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
1799
+ expect.anything(),
1800
+ false,
1801
+ );
1802
+
1803
+ // Still out of bounds: no repeated release.
1804
+ node.update(2, clippingRect);
1805
+ expect(texture.setRenderableOwner).toHaveBeenCalledTimes(
1806
+ callsAfterFirstUpdate + 1,
1807
+ );
1808
+
1809
+ // Back in view: re-registered exactly once.
1810
+ node.x = 0;
1811
+ node.update(3, clippingRect);
1812
+ expect(texture.setRenderableOwner).toHaveBeenCalledTimes(
1813
+ callsAfterFirstUpdate + 2,
1814
+ );
1815
+ expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
1816
+ expect.anything(),
1817
+ true,
1818
+ );
1819
+ });
1820
+
1821
+ it('swapping textures releases the old owner and registers the new one', () => {
1822
+ const node = visibleNode();
1823
+ const textureA = emittingTexture('loaded');
1824
+ node.texture = textureA;
1825
+ node.textureLoaded = true;
1826
+ node.update(0, clippingRect);
1827
+ expect(textureA.setRenderableOwner).toHaveBeenLastCalledWith(
1828
+ expect.anything(),
1829
+ true,
1830
+ );
1831
+
1832
+ const textureB = emittingTexture('loaded');
1833
+ node.texture = textureB;
1834
+
1835
+ // A is released via unloadTexture.
1836
+ expect(textureA.setRenderableOwner).toHaveBeenLastCalledWith(
1837
+ expect.anything(),
1838
+ false,
1839
+ );
1840
+ // B is registered with the node's current renderable state (true),
1841
+ // proving the cache reset on swap — a stale cache would skip this.
1842
+ expect(textureB.setRenderableOwner).toHaveBeenCalledWith(
1843
+ expect.anything(),
1844
+ true,
1845
+ );
1846
+ });
1847
+
1848
+ it('freed texture is re-registered on the next update (reload trigger)', async () => {
1849
+ const node = visibleNode();
1850
+ const texture = emittingTexture('initial');
1851
+ node.texture = texture;
1852
+ node.update(0, clippingRect);
1853
+
1854
+ await flushMicrotasks();
1855
+ (texture as { state: string }).state = 'loaded';
1856
+ texture.emit('loaded', { w: 100, h: 100 });
1857
+ node.update(1, clippingRect);
1858
+ expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
1859
+ expect.anything(),
1860
+ true,
1861
+ );
1862
+
1863
+ // Memory manager frees the texture: the node must drop ownership...
1864
+ (texture as { state: string }).state = 'freed';
1865
+ texture.emit('freed');
1866
+ expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
1867
+ expect.anything(),
1868
+ false,
1869
+ );
1870
+
1871
+ // ...and the next update pass re-adds it, which is what triggers
1872
+ // Texture.load() for the reload. A stale cache would skip this call.
1873
+ node.update(2, clippingRect);
1874
+ expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
1875
+ expect.anything(),
1876
+ true,
1877
+ );
1878
+ });
1879
+ });
1608
1880
  });
@@ -46,7 +46,7 @@ import type { IAnimationController } from '../common/IAnimationController.js';
46
46
  import { createAnimation } from './animations/CoreAnimation.js';
47
47
  import type { CoreShaderNode } from './renderers/CoreShaderNode.js';
48
48
  import { AutosizeMode, Autosizer } from './Autosizer.js';
49
- import { removeChild } from './lib/collectionUtils.js';
49
+ import { removeChild, sortByZIndexStable } from './lib/collectionUtils.js';
50
50
 
51
51
  export enum CoreNodeRenderState {
52
52
  Init = 0,
@@ -63,11 +63,6 @@ const NO_CLIPPING_RECT: RectWithValid = {
63
63
  valid: false,
64
64
  };
65
65
 
66
- // Hoisted so `sortChildren` doesn't allocate a fresh comparator closure on
67
- // every z-index reorder.
68
- const compareZIndex = (a: CoreNode, b: CoreNode): number =>
69
- a.props.zIndex - b.props.zIndex;
70
-
71
66
  const CoreNodeRenderStateMap: Map<CoreNodeRenderState, string> = new Map();
72
67
  CoreNodeRenderStateMap.set(CoreNodeRenderState.Init, 'init');
73
68
  CoreNodeRenderStateMap.set(CoreNodeRenderState.OutOfBounds, 'outOfBounds');
@@ -136,6 +131,7 @@ export enum UpdateType {
136
131
  * @remarks
137
132
  * CoreNode Properties Updated:
138
133
  * - `worldAlpha` = `parent.worldAlpha` * `alpha`
134
+ * (or just `alpha` when `ignoreParentAlpha` is enabled)
139
135
  */
140
136
  WorldAlpha = 64,
141
137
 
@@ -250,6 +246,29 @@ export interface CoreNodeProps {
250
246
  * @default `1`
251
247
  */
252
248
  alpha: number;
249
+ /**
250
+ * When enabled, the Node's world alpha is computed from its own
251
+ * {@link alpha} only, ignoring the alpha inherited from its ancestors.
252
+ *
253
+ * @remarks
254
+ * Normally `worldAlpha = parent.worldAlpha * alpha`, so fading a parent
255
+ * fades every descendant with it. With `ignoreParentAlpha` enabled this
256
+ * Node keeps rendering at its own alpha while its parent (and the rest of
257
+ * the subtree) fades.
258
+ *
259
+ * Subtrees whose world alpha reaches exactly 0 are culled from rendering
260
+ * entirely, so this Node still disappears once an ancestor hits alpha 0 —
261
+ * the prop only has an effect while every ancestor's alpha is above 0.
262
+ * This keeps the fully-transparent subtree cull free of bookkeeping.
263
+ *
264
+ * Descendants of this Node inherit from its world alpha as usual.
265
+ *
266
+ * Has no effect inside a render-to-texture subtree: the RTT root's
267
+ * composited quad is still faded as a single unit by its own world alpha.
268
+ *
269
+ * @default `false`
270
+ */
271
+ ignoreParentAlpha: boolean;
253
272
  /**
254
273
  * Autosize
255
274
  *
@@ -810,6 +829,14 @@ export class CoreNode extends EventEmitter {
810
829
  private hasColorProps = false;
811
830
  public textureLoaded = false;
812
831
 
832
+ /**
833
+ * Last ownership value sent to the current texture via
834
+ * {@link updateTextureOwnership}. Per (node, texture) pair — must reset to
835
+ * `false` whenever the texture is swapped or released, or a stale `true`
836
+ * would skip the re-registration that triggers `Texture.load()`.
837
+ */
838
+ private textureOwnership = false;
839
+
813
840
  /**
814
841
  * True while this node should render its `placeholderColor` instead of its
815
842
  * texture: `placeholderColor` is non-zero, a texture is set, and that
@@ -1066,6 +1093,7 @@ export class CoreNode extends EventEmitter {
1066
1093
  texture.off('failed', this.onTextureFailed);
1067
1094
  texture.off('freed', this.onTextureFreed);
1068
1095
  texture.setRenderableOwner(this._id, false);
1096
+ this.textureOwnership = false;
1069
1097
  }
1070
1098
 
1071
1099
  protected onTextureLoaded: TextureLoadedEventHandler = (_, dimensions) => {
@@ -1453,7 +1481,10 @@ export class CoreNode extends EventEmitter {
1453
1481
  }
1454
1482
 
1455
1483
  if (updateType & UpdateType.WorldAlpha) {
1456
- this.worldAlpha = parent.worldAlpha * this.props.alpha;
1484
+ this.worldAlpha =
1485
+ props.ignoreParentAlpha === true
1486
+ ? props.alpha
1487
+ : parent.worldAlpha * props.alpha;
1457
1488
  updateType |=
1458
1489
  UpdateType.PremultipliedColors |
1459
1490
  UpdateType.Children |
@@ -1896,6 +1927,10 @@ export class CoreNode extends EventEmitter {
1896
1927
  * Changes the renderable state of the node.
1897
1928
  */
1898
1929
  updateTextureOwnership(isRenderable: boolean) {
1930
+ if (this.textureOwnership === isRenderable) {
1931
+ return;
1932
+ }
1933
+ this.textureOwnership = isRenderable;
1899
1934
  this.texture?.setRenderableOwner(this._id, isRenderable);
1900
1935
  }
1901
1936
 
@@ -2180,7 +2215,7 @@ export class CoreNode extends EventEmitter {
2180
2215
  }
2181
2216
 
2182
2217
  sortChildren() {
2183
- this.children.sort(compareZIndex);
2218
+ sortByZIndexStable(this.children);
2184
2219
  this.stage.requestRenderListUpdate();
2185
2220
  }
2186
2221
 
@@ -2523,6 +2558,24 @@ export class CoreNode extends EventEmitter {
2523
2558
  this.childUpdateType |= UpdateType.WorldAlpha;
2524
2559
  }
2525
2560
 
2561
+ get ignoreParentAlpha(): boolean {
2562
+ return this.props.ignoreParentAlpha;
2563
+ }
2564
+
2565
+ set ignoreParentAlpha(value: boolean) {
2566
+ if (this.props.ignoreParentAlpha === value) {
2567
+ return;
2568
+ }
2569
+ this.props.ignoreParentAlpha = value;
2570
+ this.setUpdateType(
2571
+ UpdateType.PremultipliedColors |
2572
+ UpdateType.WorldAlpha |
2573
+ UpdateType.Children |
2574
+ UpdateType.IsRenderable,
2575
+ );
2576
+ this.childUpdateType |= UpdateType.WorldAlpha;
2577
+ }
2578
+
2526
2579
  get autosize(): boolean {
2527
2580
  return this.props.autosize;
2528
2581
  }
@@ -3060,6 +3113,7 @@ export class CoreNode extends EventEmitter {
3060
3113
  this.autosizer.setMode(AutosizeMode.Texture); // Set to texture size mode
3061
3114
  }
3062
3115
  value.setRenderableOwner(this._id, this.isRenderable);
3116
+ this.textureOwnership = this.isRenderable;
3063
3117
  this.loadTexture();
3064
3118
  }
3065
3119
 
@@ -17,6 +17,7 @@ const defaultProps = (
17
17
  ): CoreTextNodeProps => ({
18
18
  // CoreNodeProps
19
19
  alpha: 1,
20
+ ignoreParentAlpha: false,
20
21
  autosize: false,
21
22
  boundsMargin: null,
22
23
  clipping: false,
@@ -266,8 +266,9 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
266
266
  this.setRenderable(false);
267
267
 
268
268
  if (this.renderState > CoreNodeRenderState.OutOfBounds) {
269
- // We do want the texture to load immediately
270
- this.texture.setRenderableOwner(this._id, true);
269
+ // We do want the texture to load immediately. Routed through
270
+ // updateTextureOwnership so the ownership cache stays in sync.
271
+ this.updateTextureOwnership(true);
271
272
  }
272
273
  }
273
274
  }
@@ -409,9 +409,9 @@ export class CoreTextureManager extends EventEmitter {
409
409
 
410
410
  // Anything that arrived before initialization completed is now safe to
411
411
  // process. Without this, queued textures would sit until the next frame
412
- // tick happens to call processSome().
412
+ // tick happens to drain them.
413
413
  if (this.uploadTextureQueue.size > 0) {
414
- this.processSome(Infinity).catch((err) => {
414
+ this.processUntil(Infinity).catch((err) => {
415
415
  console.error('Failed to drain pre-init texture queue:', err);
416
416
  });
417
417
  }
@@ -587,11 +587,41 @@ export class CoreTextureManager extends EventEmitter {
587
587
  }
588
588
 
589
589
  /**
590
- * Process a limited number of uploads.
590
+ * Upload a single queued texture to the GPU.
591
591
  *
592
- * @param maxProcessingTime - The maximum processing time in milliseconds
592
+ * @remarks
593
+ * Used while animations are running so uploads don't steal time from the
594
+ * animation. If the dequeued texture already died (failed/freed), nothing is
595
+ * uploaded this frame and the next call handles the following one.
596
+ */
597
+ async processOne(): Promise<void> {
598
+ if (this.initialized === false) {
599
+ return;
600
+ }
601
+
602
+ const texture = this.uploadTextureQueue.shift();
603
+ if (texture === undefined) {
604
+ return;
605
+ }
606
+
607
+ await this.uploadQueued(texture);
608
+ }
609
+
610
+ /**
611
+ * Upload queued textures to the GPU until the per-frame time budget runs out.
612
+ *
613
+ * @remarks
614
+ * Called once per frame when idle. Textures are uploaded one-by-one; after
615
+ * each, the elapsed time is rechecked and processing stops once it exceeds
616
+ * `maxProcessingTime`, leaving the rest queued for the next frame.
617
+ *
618
+ * In normal operation a queued texture's data is already decoded
619
+ * (`loadTexture` awaits `getTextureData` before enqueuing), so this budgets
620
+ * GPU upload time. Pass `Infinity` to drain the whole queue.
621
+ *
622
+ * @param maxProcessingTime - The time budget for this frame, in milliseconds
593
623
  */
594
- async processSome(maxProcessingTime: number): Promise<void> {
624
+ async processUntil(maxProcessingTime: number): Promise<void> {
595
625
  if (this.initialized === false) {
596
626
  return;
597
627
  }
@@ -599,79 +629,54 @@ export class CoreTextureManager extends EventEmitter {
599
629
  const platform = this.platform;
600
630
  const startTime = platform.getTimeStamp();
601
631
 
602
- // Decode / fetch ("getTextureData") is IO-bound and parallelisable across
603
- // image workers, while GPU upload is effectively serial. Keep a small
604
- // sliding window of in-flight data fetches so the next decode runs while
605
- // we're uploading the current one.
606
- const prefetchLimit = Math.max(1, this.numImageWorkers);
607
- const pending: Array<{ texture: Texture; data: Promise<unknown> }> = [];
608
-
609
- // Helper avoids TS narrowing `texture.state` permanently after the first
610
- // discriminated check — the property is mutable and can transition across
611
- // awaits, so we need to re-read it freshly each time.
612
- const isDead = (texture: Texture): boolean =>
613
- texture.state === 'failed' || texture.state === 'freed';
614
-
615
- const fillPrefetch = () => {
616
- while (pending.length < prefetchLimit) {
617
- const texture = this.uploadTextureQueue.shift();
618
- if (texture === undefined) break;
619
-
620
- if (isDead(texture)) {
621
- continue;
622
- }
623
-
624
- // Swallow the rejection here so an early failure doesn't surface as
625
- // an unhandled promise rejection while it sits in the prefetch
626
- // window; we re-check state after awaiting.
627
- const data =
628
- texture.textureData === null
629
- ? texture.getTextureData().catch((err) => {
630
- console.error('Failed to fetch texture data:', err);
631
- return null;
632
- })
633
- : Promise.resolve(texture.textureData);
634
-
635
- pending.push({ texture, data });
632
+ while (platform.getTimeStamp() - startTime < maxProcessingTime) {
633
+ const texture = this.uploadTextureQueue.shift();
634
+ if (texture === undefined) {
635
+ // Queue drained.
636
+ break;
636
637
  }
637
- };
638
638
 
639
- fillPrefetch();
639
+ await this.uploadQueued(texture);
640
+ }
641
+ }
640
642
 
641
- while (
642
- pending.length > 0 &&
643
- platform.getTimeStamp() - startTime < maxProcessingTime
644
- ) {
645
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
646
- const next = pending.shift()!;
647
- // Top up the prefetch window before awaiting — the next decode starts
648
- // now and overlaps with this upload.
649
- fillPrefetch();
643
+ /**
644
+ * Decode (if needed) and upload a single already-dequeued texture.
645
+ *
646
+ * @remarks
647
+ * Shared by {@link processOne} and {@link processUntil}. Dead (failed/freed)
648
+ * textures and upload failures are skipped without throwing.
649
+ */
650
+ private async uploadQueued(texture: Texture): Promise<void> {
651
+ if (this.isTextureDead(texture)) {
652
+ return;
653
+ }
650
654
 
651
- if (isDead(next.texture)) {
652
- continue;
655
+ try {
656
+ if (texture.textureData === null) {
657
+ await texture.getTextureData();
653
658
  }
654
-
655
- try {
656
- await next.data;
657
- if (isDead(next.texture)) {
658
- continue;
659
- }
660
- await this.uploadTexture(next.texture);
661
- } catch (error) {
662
- console.error('Failed to upload texture:', error);
663
- // Continue with next texture instead of stopping entire queue
659
+ if (this.isTextureDead(texture)) {
660
+ return;
664
661
  }
662
+ await this.uploadTexture(texture);
663
+ } catch (error) {
664
+ console.error('Failed to upload texture:', error);
665
+ // Skip this texture instead of stalling the queue.
665
666
  }
667
+ }
666
668
 
667
- // Time ran out before we got to these. Put them back so we don't lose
668
- // them their getTextureData() is already in flight and will populate
669
- // `textureData` for the next tick.
670
- for (const { texture } of pending) {
671
- if (!isDead(texture)) {
672
- this.uploadTextureQueue.add(texture);
673
- }
674
- }
669
+ /**
670
+ * A texture is "dead" once it has failed or been freed — both terminal for
671
+ * the upload pipeline.
672
+ *
673
+ * @remarks
674
+ * Kept as a method rather than an inline check so TypeScript doesn't
675
+ * permanently narrow `state` after the first comparison: the property is
676
+ * mutable and can transition across the awaits in {@link uploadQueued}.
677
+ */
678
+ private isTextureDead(texture: Texture): boolean {
679
+ return texture.state === 'failed' || texture.state === 'freed';
675
680
  }
676
681
 
677
682
  public hasUpdates(): boolean {
package/src/core/Stage.ts CHANGED
@@ -379,6 +379,7 @@ export class Stage {
379
379
  w: appWidth,
380
380
  h: appHeight,
381
381
  alpha: 1,
382
+ ignoreParentAlpha: false,
382
383
  autosize: false,
383
384
  boundsMargin: null,
384
385
  clipping: false,
@@ -594,11 +595,16 @@ export class Stage {
594
595
  // Process some textures asynchronously but don't block the frame
595
596
  // Use a background task to prevent frame drops
596
597
  if (this.txManager.hasUpdates() === true) {
597
- const timeLimit = hasActiveAnimations
598
- ? this.options.textureProcessingTimeLimit / 2
599
- : this.options.textureProcessingTimeLimit;
598
+ // While animating, upload at most one texture per frame so uploads don't
599
+ // steal time from the animation; otherwise fill the per-frame time budget.
600
+ const processing =
601
+ hasActiveAnimations === true
602
+ ? this.txManager.processOne()
603
+ : this.txManager.processUntil(
604
+ this.options.textureProcessingTimeLimit,
605
+ );
600
606
 
601
- this.txManager.processSome(timeLimit).catch((err) => {
607
+ processing.catch((err) => {
602
608
  console.error('Error processing textures:', err);
603
609
  });
604
610
  }
@@ -1039,6 +1045,7 @@ export class Stage {
1039
1045
  w: props.w ?? 0,
1040
1046
  h: props.h ?? 0,
1041
1047
  alpha: props.alpha ?? 1,
1048
+ ignoreParentAlpha: props.ignoreParentAlpha ?? false,
1042
1049
  autosize: props.autosize ?? false,
1043
1050
  boundsMargin: props.boundsMargin ?? null,
1044
1051
  clipping: props.clipping ?? false,
@@ -175,7 +175,7 @@ function freedCachedTexture(cacheKey: string): Texture & {
175
175
  state: 'freed',
176
176
  type: TextureType.image,
177
177
  preventCleanup: false,
178
- renderableOwners: [],
178
+ renderableOwners: new Set<string | number>(),
179
179
  cacheKey,
180
180
  free: vi.fn(),
181
181
  destroy: vi.fn(),
@@ -206,7 +206,7 @@ describe('TextureMemoryManager — orphaned freed texture eviction', () => {
206
206
  it('keeps a freed texture that still has renderable owners', () => {
207
207
  const { mgr, keyCache } = makeManager();
208
208
  const texture = freedCachedTexture('img:poster.png');
209
- (texture.renderableOwners as unknown[]).push(1);
209
+ texture.renderableOwners.add(1);
210
210
  keyCache.set('img:poster.png', texture);
211
211
 
212
212
  mgr.cleanup();