From 95d537b8cd152b49b447095d8c918bcf83a0a533 Mon Sep 17 00:00:00 2001 From: Etienne Lachance-Perreault Date: Wed, 17 Jun 2026 13:19:35 -0400 Subject: [PATCH 1/2] test --- src/core/CoreNode.ts | 435 +++++++++++++++----- src/core/renderers/canvas/CanvasRenderer.ts | 26 +- src/core/renderers/webgl/WebGlRenderer.ts | 28 +- 3 files changed, 382 insertions(+), 107 deletions(-) diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 4d5e2f5..d5fe52c 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -46,7 +46,7 @@ import type { IAnimationController } from '../common/IAnimationController.js'; import { createAnimation } from './animations/CoreAnimation.js'; import type { CoreShaderNode } from './renderers/CoreShaderNode.js'; import { AutosizeMode, Autosizer } from './Autosizer.js'; -import { removeChild, sortByZIndexStable } from './lib/collectionUtils.js'; +import { removeChild } from './lib/collectionUtils.js'; export enum CoreNodeRenderState { Init = 0, @@ -63,6 +63,11 @@ const NO_CLIPPING_RECT: RectWithValid = { valid: false, }; +// Hoisted so `sortChildren` doesn't allocate a fresh comparator closure on +// every z-index reorder. +const compareZIndex = (a: CoreNode, b: CoreNode): number => + a.props.zIndex - b.props.zIndex; + const CoreNodeRenderStateMap: Map = new Map(); CoreNodeRenderStateMap.set(CoreNodeRenderState.Init, 'init'); CoreNodeRenderStateMap.set(CoreNodeRenderState.OutOfBounds, 'outOfBounds'); @@ -131,7 +136,6 @@ export enum UpdateType { * @remarks * CoreNode Properties Updated: * - `worldAlpha` = `parent.worldAlpha` * `alpha` - * (or just `alpha` when `ignoreParentAlpha` is enabled) */ WorldAlpha = 64, @@ -246,29 +250,6 @@ export interface CoreNodeProps { * @default `1` */ alpha: number; - /** - * When enabled, the Node's world alpha is computed from its own - * {@link alpha} only, ignoring the alpha inherited from its ancestors. - * - * @remarks - * Normally `worldAlpha = parent.worldAlpha * alpha`, so fading a parent - * fades every descendant with it. With `ignoreParentAlpha` enabled this - * Node keeps rendering at its own alpha while its parent (and the rest of - * the subtree) fades. - * - * Subtrees whose world alpha reaches exactly 0 are culled from rendering - * entirely, so this Node still disappears once an ancestor hits alpha 0 — - * the prop only has an effect while every ancestor's alpha is above 0. - * This keeps the fully-transparent subtree cull free of bookkeeping. - * - * Descendants of this Node inherit from its world alpha as usual. - * - * Has no effect inside a render-to-texture subtree: the RTT root's - * composited quad is still faded as a single unit by its own world alpha. - * - * @default `false` - */ - ignoreParentAlpha: boolean; /** * Autosize * @@ -428,6 +409,41 @@ export interface CoreNodeProps { * @default `0` */ placeholderColor: number; + /** + * Placeholder image shown while the Node's texture is not yet loaded. + * + * @remarks + * Like {@link placeholderColor}, but renders an image instead of a solid + * rectangle while the Node's texture loads (and while a freed texture + * reloads, and after a permanent failure). The image is stretched to the + * Node's dimensions and renders through the Node's shader, so rounded + * corners and borders apply. + * + * The image is loaded once, shared by every Node using the same URL, and + * pinned in memory (`preventCleanup`) so it is always available — use a + * small number of distinct placeholder images (e.g. one per poster size), + * not per-item artwork. + * + * While the placeholder image itself is still loading, the Node falls back + * to {@link placeholderColor} if set, otherwise renders nothing. Once the + * placeholder image is showing it is rendered untinted; `placeholderColor` + * only colors the fallback rectangle. + * + * @default `null` + */ + placeholderImage: string | null; + + /** + * Fallback image shown when the node failed to load the primary texture. + * + * The image is loaded once, shared by every Node using the same URL, and + * pinned in memory (`preventCleanup`) so it is always available — use a + * small number of distinct placeholder images (e.g. one per poster size), + * not per-item artwork. + * + * @default `null` + */ + fallbackImage: string | null; /** * The Node's parent Node. * @@ -830,21 +846,47 @@ export class CoreNode extends EventEmitter { public textureLoaded = false; /** - * Last ownership value sent to the current texture via - * {@link updateTextureOwnership}. Per (node, texture) pair — must reset to - * `false` whenever the texture is swapped or released, or a stale `true` - * would skip the re-registration that triggers `Texture.load()`. + * True while this node should render a placeholder instead of its texture: + * a texture is set, it is not loaded, and a placeholder is available + * (non-zero `placeholderColor`, or a loaded `placeholderImage`). Read by + * the renderers' quad path to substitute the placeholder texture — the + * loaded placeholder image, or the stage's default (1x1 white) texture + * tinted by `placeholderColor`. Maintained by + * {@link updatePlaceholderActive} — never written elsewhere. */ - private textureOwnership = false; + public placeholderActive = false; + public hasTextureFailed = false; /** - * True while this node should render its `placeholderColor` instead of its - * texture: `placeholderColor` is non-zero, a texture is set, and that - * texture is not loaded. Read by the renderers' quad path to substitute the - * stage's default (1x1 white) texture. Maintained by - * {@link updatePlaceholderActive} — never written elsewhere. + * Shared, pinned (`preventCleanup`) texture for {@link placeholderImage}, + * or `null`. Owned by the placeholderImage setter. */ - public placeholderActive = false; + public placeholderTexture: Texture | null = null; + + /** + * Cached `placeholderTexture.state === 'loaded'` (avoids per-quad string + * compares). Maintained by the placeholder texture event handlers. + */ + public placeholderTextureLoaded = false; + + /** + * True while the fallback texture is being shown: texture permanently + * failed AND the fallback image has loaded. Maintained by + * {@link updateFallbackActive} — never written elsewhere. + */ + public fallbackActive = false; + + /** + * Shared, pinned texture for {@link fallbackImage}, or `null`. + * Owned by the fallbackImage setter. + */ + public fallbackTexture: Texture | null = null; + + /** + * Cached `fallbackTexture.state === 'loaded'`. Maintained by the fallback + * texture event handlers. + */ + public fallbackTextureLoaded = false; public updateType = UpdateType.All; public childUpdateType = UpdateType.None; @@ -931,7 +973,16 @@ export class CoreNode extends EventEmitter { // creates a fresh object with a consistent shape. Save fields that are // re-applied through setters, then null them on props so the setters // detect the change. - const { texture, shader, src, rtt, boundsMargin, parent } = props; + const { + texture, + shader, + src, + rtt, + boundsMargin, + parent, + placeholderImage, + fallbackImage, + } = props; const p = (this.props = props); p.texture = null; p.shader = null; @@ -939,6 +990,8 @@ export class CoreNode extends EventEmitter { p.rtt = false; p.boundsMargin = null; p.scale = null; + p.placeholderImage = null; + p.fallbackImage = null; //check if any color props are set for premultiplied color updates if ( @@ -982,6 +1035,12 @@ export class CoreNode extends EventEmitter { if (src !== null) { this.src = src; } + if (placeholderImage !== null && placeholderImage !== undefined) { + this.placeholderImage = placeholderImage; + } + if (fallbackImage !== null && fallbackImage !== undefined) { + this.fallbackImage = fallbackImage; + } if (rtt !== false) { this.rtt = rtt; } @@ -1019,9 +1078,10 @@ export class CoreNode extends EventEmitter { */ private updatePlaceholderActive(): void { const active = - this.props.placeholderColor !== 0 && this.props.texture !== null && - this.textureLoaded === false; + this.textureLoaded === false && + (this.props.placeholderColor !== 0 || + this.placeholderTextureLoaded === true); if (active !== this.placeholderActive) { this.placeholderActive = active; @@ -1031,6 +1091,163 @@ export class CoreNode extends EventEmitter { } } + /** + * Assign or clear the shared placeholder image texture. + * + * @remarks + * The texture is pinned (`preventCleanup`) so the memory manager never + * frees it, and loaded eagerly with priority so it is available before the + * first poster needs it. Listeners stay attached for the lifetime of the + * assignment: `loaded`/`failed` drive the fallback state machine, and + * `freed` self-heals the rare out-of-band free (context loss, or another + * node's textureOptions unpinning the shared texture) by re-pinning and + * reloading. They are removed on swap and in {@link destroy} so a + * destroyed node does not leak via the long-lived texture. + */ + private setPlaceholderTexture(value: Texture | null): void { + const old = this.placeholderTexture; + if (old === value) { + return; + } + + if (old !== null) { + old.off('loaded', this.onPlaceholderTexLoaded); + old.off('failed', this.onPlaceholderTexFailed); + old.off('freed', this.onPlaceholderTexFreed); + } + + this.placeholderTexture = value; + this.placeholderTextureLoaded = value !== null && value.state === 'loaded'; + + if (value !== null) { + value.preventCleanup = true; + value.on('loaded', this.onPlaceholderTexLoaded); + value.on('failed', this.onPlaceholderTexFailed); + value.on('freed', this.onPlaceholderTexFreed); + + // Eager priority load. Only from idle states — 'loading'/'fetching' + // means another node already kicked it off and a duplicate call would + // start a second fetch of the same source. + const state = value.state; + if (state === 'initial' || state === 'freed') { + void this.stage.txManager.loadTexture(value, true); + } + } + + this.updatePlaceholderActive(); + // The shown placeholder may have changed shape (image <-> color rect) + // without toggling active. + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + } + + private updateFallbackActive(): void { + const active = + this.hasTextureFailed === true && this.fallbackTextureLoaded === true; + + if (active !== this.fallbackActive) { + this.fallbackActive = active; + this.setUpdateType( + UpdateType.PremultipliedColors | UpdateType.IsRenderable, + ); + } + } + + private setFallbackImageTexture(texture: Texture | null): void { + const old = this.fallbackTexture; + if (old === texture) { + return; + } + + if (old !== null) { + old.off('loaded', this.onFallbackTexLoaded); + old.off('failed', this.onFallbackTexFailed); + old.off('freed', this.onFallbackTexFreed); + } + + this.fallbackTexture = texture; + this.fallbackTextureLoaded = texture !== null && texture.state === 'loaded'; + + if (texture !== null) { + texture.preventCleanup = true; + texture.on('loaded', this.onFallbackTexLoaded); + texture.on('failed', this.onFallbackTexFailed); + texture.on('freed', this.onFallbackTexFreed); + + const state = texture.state; + if (state === 'initial' || state === 'freed') { + console.log("failed texture, loading fallback texture"); + void this.stage.txManager.loadTexture(texture, true); + } + } + + this.updateFallbackActive(); + } + + private onFallbackTexLoaded: TextureLoadedEventHandler = () => { + this.fallbackTextureLoaded = true; + this.updateFallbackActive(); + if (this.fallbackActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + this.stage.requestRender(); + } + }; + + private onFallbackTexFailed: TextureFailedEventHandler = () => { + this.fallbackTextureLoaded = false; + this.updateFallbackActive(); + }; + + private onFallbackTexFreed: TextureFreedEventHandler = () => { + this.fallbackTextureLoaded = false; + this.updateFallbackActive(); + + const texture = this.fallbackTexture; + if (texture !== null && texture.state === 'freed') { + texture.preventCleanup = true; + console.log('loading fallback texture'); + void this.stage.txManager.loadTexture(texture, true); + } + }; + + private onPlaceholderTexLoaded: TextureLoadedEventHandler = () => { + this.placeholderTextureLoaded = true; + this.updatePlaceholderActive(); + if (this.placeholderActive === true) { + // Switch from the color-rect fallback to the image: vertex colors go + // to untinted white and the quad's texture changes. + this.setUpdateType(UpdateType.PremultipliedColors); + // The RAF loop may have stopped while the placeholder loaded. + this.stage.requestRender(); + } + }; + + private onPlaceholderTexFailed: TextureFailedEventHandler = () => { + this.placeholderTextureLoaded = false; + this.updatePlaceholderActive(); + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + }; + + private onPlaceholderTexFreed: TextureFreedEventHandler = () => { + this.placeholderTextureLoaded = false; + this.updatePlaceholderActive(); + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + + // A pinned texture was freed out-of-band — re-pin and reload. The state + // guard makes only the first notified node start the reload; the rest + // see 'loading'. + const texture = this.placeholderTexture; + if (texture !== null && texture.state === 'freed') { + texture.preventCleanup = true; + void this.stage.txManager.loadTexture(texture, true); + } + }; + loadTexture(): void { if (this.props.texture === null) { return; @@ -1093,7 +1310,6 @@ export class CoreNode extends EventEmitter { texture.off('failed', this.onTextureFailed); texture.off('freed', this.onTextureFreed); texture.setRenderableOwner(this._id, false); - this.textureOwnership = false; } protected onTextureLoaded: TextureLoadedEventHandler = (_, dimensions) => { @@ -1155,6 +1371,8 @@ export class CoreNode extends EventEmitter { this.texture !== null && this.texture.retryCount > this.texture.maxRetryCount ) { + this.hasTextureFailed = true; + this.updateFallbackActive(); this.emit('failed', { type: 'texture', error, @@ -1481,10 +1699,7 @@ export class CoreNode extends EventEmitter { } if (updateType & UpdateType.WorldAlpha) { - this.worldAlpha = - props.ignoreParentAlpha === true - ? props.alpha - : parent.worldAlpha * props.alpha; + this.worldAlpha = parent.worldAlpha * this.props.alpha; updateType |= UpdateType.PremultipliedColors | UpdateType.Children | @@ -1515,11 +1730,24 @@ export class CoreNode extends EventEmitter { if (updateType & UpdateType.PremultipliedColors) { const alpha = this.worldAlpha; - if (this.placeholderActive === true) { - // Placeholder rendering: all four corners take the placeholder color. - // The quad samples the stage's default 1x1 white texture, so this is - // exactly the color-rect path. - const merged = premultiplyColorABGR(props.placeholderColor, alpha); + if (this.fallbackActive === true) { + // Fallback image: render untinted white so the image shows through. + const merged = premultiplyColorABGR(0xffffffff, alpha); + this.premultipliedColorTl = + this.premultipliedColorTr = + this.premultipliedColorBl = + this.premultipliedColorBr = + merged; + } else if (this.placeholderActive === true) { + // Placeholder rendering: all four corners take the same color. With + // the placeholder image loaded, the image renders untinted (white); + // otherwise the quad samples the stage's default 1x1 white texture + // tinted by placeholderColor — exactly the color-rect path. + const color = + this.placeholderTextureLoaded === true + ? 0xffffffff + : props.placeholderColor; + const merged = premultiplyColorABGR(color, alpha); this.premultipliedColorTl = this.premultipliedColorTr = this.premultipliedColorBl = @@ -1862,21 +2090,19 @@ export class CoreNode extends EventEmitter { // and will prevent further checks until the texture is reloaded or retry is reset on the texture if (this.texture.retryCount > this.texture.maxRetryCount) { // texture has failed to load, we cannot render the texture itself — - // but a placeholder color still renders in its place + // but a placeholder or fallback still renders in its place this.updateTextureOwnership(false); - this.setRenderable( - this.placeholderActive === true && - (this.stage.renderOnlyInViewport === false || - this.renderState === CoreNodeRenderState.InViewport), - ); + this.setRenderable(this.placeholderActive || this.fallbackActive); return; } needsTextureOwnership = true; // Use cached boolean instead of string comparison; a placeholder - // renders while the texture is loading + // renders while the texture is loading; fallback if permanently failed newIsRenderable = - this.textureLoaded === true || this.placeholderActive === true; + this.textureLoaded === true || + this.placeholderActive === true || + this.fallbackActive === true; } else if ( // check shader (this.props.shader !== this.stage.renderer.getDefaultShaderNode() || @@ -1888,17 +2114,6 @@ export class CoreNode extends EventEmitter { newIsRenderable = true; } - // renderOnlyInViewport: nodes in the preload margin keep texture - // ownership above (so loading proceeds) but stay out of the render list - // until they actually intersect the viewport. - if ( - newIsRenderable === true && - this.stage.renderOnlyInViewport === true && - this.renderState !== CoreNodeRenderState.InViewport - ) { - newIsRenderable = false; - } - this.updateTextureOwnership(needsTextureOwnership); this.setRenderable(newIsRenderable); } @@ -1927,10 +2142,6 @@ export class CoreNode extends EventEmitter { * Changes the renderable state of the node. */ updateTextureOwnership(isRenderable: boolean) { - if (this.textureOwnership === isRenderable) { - return; - } - this.textureOwnership = isRenderable; this.texture?.setRenderableOwner(this._id, isRenderable); } @@ -2135,6 +2346,11 @@ export class CoreNode extends EventEmitter { this.removeAllListeners(); this.unloadTexture(); + // Detach from the long-lived, shared placeholder texture so it does not + // retain this node's handlers (the texture itself stays pinned/cached). + this.setPlaceholderTexture(null); + // Detach from the long-lived, shared fallback texture. + this.setFallbackImageTexture(null); this.isRenderable = false; if (this.hasShaderTimeFn === true) { @@ -2178,7 +2394,13 @@ export class CoreNode extends EventEmitter { } get renderTexture(): Texture | null { + if (this.fallbackActive === true) { + return this.fallbackTexture; + } if (this.placeholderActive === true) { + if (this.placeholderTextureLoaded === true) { + return this.placeholderTexture; + } return this.stage.defaultTexture; } return this.props.texture || this.stage.defaultTexture; @@ -2215,7 +2437,7 @@ export class CoreNode extends EventEmitter { } sortChildren() { - sortByZIndexStable(this.children); + this.children.sort(compareZIndex); this.stage.requestRenderListUpdate(); } @@ -2558,24 +2780,6 @@ export class CoreNode extends EventEmitter { this.childUpdateType |= UpdateType.WorldAlpha; } - get ignoreParentAlpha(): boolean { - return this.props.ignoreParentAlpha; - } - - set ignoreParentAlpha(value: boolean) { - if (this.props.ignoreParentAlpha === value) { - return; - } - this.props.ignoreParentAlpha = value; - this.setUpdateType( - UpdateType.PremultipliedColors | - UpdateType.WorldAlpha | - UpdateType.Children | - UpdateType.IsRenderable, - ); - this.childUpdateType |= UpdateType.WorldAlpha; - } - get autosize(): boolean { return this.props.autosize; } @@ -2686,6 +2890,50 @@ export class CoreNode extends EventEmitter { } } + get placeholderImage(): string | null { + return this.props.placeholderImage; + } + + set placeholderImage(value: string | null) { + const p = this.props; + if (p.placeholderImage === value) return; + + p.placeholderImage = value; + + if (value === null) { + this.setPlaceholderTexture(null); + return; + } + + // src-only props: every node using the same URL — regardless of node + // dimensions — resolves to the same cached, shared texture instance. + this.setPlaceholderTexture( + this.stage.txManager.createTexture('ImageTexture', { src: value }), + ); + } + + get fallbackImage() { + return this.props.fallbackImage; + } + + set fallbackImage(value: string | null) { + const p = this.props; + if (p.fallbackImage === value) return; + + p.fallbackImage = value; + + if (value === null) { + this.setFallbackImageTexture(null); + return; + } + + // src-only props: every node using the same URL — regardless of node + // dimensions — resolves to the same cached, shared texture instance. + this.setFallbackImageTexture( + this.stage.txManager.createTexture('ImageTexture', { src: value }), + ); + } + get colorTop(): number { return this.props.colorTop; } @@ -3106,6 +3354,10 @@ export class CoreNode extends EventEmitter { this.textureCoords = undefined; this.props.texture = value; this.textureLoaded = value !== null && value.state === 'loaded'; + if (value !== null && this.hasTextureFailed === true) { + this.hasTextureFailed = false; + this.updateFallbackActive(); + } this.updatePlaceholderActive(); if (value !== null) { @@ -3113,7 +3365,6 @@ export class CoreNode extends EventEmitter { this.autosizer.setMode(AutosizeMode.Texture); // Set to texture size mode } value.setRenderableOwner(this._id, this.isRenderable); - this.textureOwnership = this.isRenderable; this.loadTexture(); } diff --git a/src/core/renderers/canvas/CanvasRenderer.ts b/src/core/renderers/canvas/CanvasRenderer.ts index c995611..646bf7f 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -52,13 +52,20 @@ export class CanvasRenderer extends CoreRenderer { const ctx = this.context; const { tx, ty, ta, tb, tc, td } = node.globalTransform!; const clippingRect = node.clippingRect; - // While a placeholder is showing, render the color-rect path (the default - // ColorTexture) tinted by the node's premultiplied placeholder color. - let texture = ( - node.placeholderActive === true - ? this.stage.defaultTexture - : node.props.texture || this.stage.defaultTexture - ) as Texture; + // While a placeholder is showing, render the node's loaded placeholder + // image, or the color-rect path (the default ColorTexture) tinted by the + // node's premultiplied placeholder color. + let texture; + if (node.fallbackActive === true) { + texture = node.fallbackTexture as Texture; + } else if (node.placeholderActive === true) { + texture = + node.placeholderTextureLoaded === true + ? (node.placeholderTexture as Texture) + : (this.stage.defaultTexture as Texture); + } else { + texture = (node.props.texture || this.stage.defaultTexture) as Texture; + } // The Canvas2D renderer only supports image textures, no textures are used for color blocks if (texture !== null) { const textureType = texture.type; @@ -175,7 +182,10 @@ export class CanvasRenderer extends CoreRenderer { this.context.globalAlpha = tintColor.a ?? node.worldAlpha; - const txCoords = node.textureCoords; + // node.textureCoords belongs to the main texture (resizeMode, flips) — + // a placeholder image must be drawn whole. + const txCoords = + node.placeholderActive === true ? undefined : node.textureCoords; if (txCoords) { const ix = imageWidth; const iy = imageHeight; diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index 8fbaccc..307aba4 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -471,12 +471,21 @@ export class WebGlRenderer extends CoreRenderer { } const props = node.props; - // While a placeholder is showing, the quad samples the shared 1x1 white - // texture tinted by the node's premultiplied placeholder color. - let tx = - node.placeholderActive === true - ? this.stage.defaultTexture! - : props.texture || this.stage.defaultTexture!; + // While a placeholder is showing, the quad samples the node's loaded + // placeholder image, or the shared 1x1 white texture tinted by the + // node's premultiplied placeholder color. + // When the primary texture has permanently failed, show the fallback image. + let tx; + if (node.fallbackActive === true) { + tx = node.fallbackTexture!; + } else if (node.placeholderActive === true) { + tx = + node.placeholderTextureLoaded === true + ? node.placeholderTexture! + : this.stage.defaultTexture!; + } else { + tx = props.texture || this.stage.defaultTexture!; + } if (tx.type === TextureType.subTexture) { tx = (tx as SubTexture).parentTexture; @@ -535,7 +544,12 @@ export class WebGlRenderer extends CoreRenderer { } const rc = node.renderCoords!; - const tc = node.textureCoords || this.defaultTextureCoords; + // node.textureCoords belongs to the main texture (resizeMode, flips) — + // a placeholder or fallback must sample its full texture. + const tc = + node.placeholderActive === true || node.fallbackActive === true + ? this.defaultTextureCoords + : node.textureCoords || this.defaultTextureCoords; const cTl = node.premultipliedColorTl; const cTr = node.premultipliedColorTr; From 3a6a750fa9a30dd2f84cfe6f10349c701864e61b Mon Sep 17 00:00:00 2001 From: Etienne Lachance-Perreault Date: Wed, 17 Jun 2026 13:24:58 -0400 Subject: [PATCH 2/2] add missing files --- src/core/CoreNode.test.ts | 586 ++++++++++++++------------------- src/core/CoreTextNode.test.ts | 64 +--- src/core/CoreTextureManager.ts | 145 ++++---- src/core/Stage.ts | 26 +- 4 files changed, 335 insertions(+), 486 deletions(-) diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 167fa5e..6753261 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -13,7 +13,6 @@ import { premultiplyColorABGR } from '../utils.js'; describe('set color()', () => { const defaultProps = (overrides?: Partial): CoreNodeProps => ({ alpha: 0, - ignoreParentAlpha: false, autosize: false, boundsMargin: null, clipping: false, @@ -27,6 +26,8 @@ describe('set color()', () => { colorTop: 0, colorTr: 0, placeholderColor: 0, + placeholderImage: null, + fallbackImage: null, h: 0, mount: 0, mountX: 0, @@ -265,112 +266,6 @@ describe('set color()', () => { }); }); - describe('ignoreParentAlpha', () => { - const makeParent = (worldAlpha: number) => { - const parent = new CoreNode(stage, defaultProps()); - parent.globalTransform = Matrix3d.identity(); - parent.worldAlpha = worldAlpha; - return parent; - }; - - it('multiplies parent world alpha by default', () => { - const parent = makeParent(0.5); - const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 })); - - node.update(0, clippingRect); - - expect(node.worldAlpha).toBeCloseTo(0.4); - }); - - it('uses own alpha only when enabled', () => { - const parent = makeParent(0.5); - const node = new CoreNode( - stage, - defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }), - ); - - node.update(0, clippingRect); - - expect(node.worldAlpha).toBe(0.8); - }); - - it('keeps its own world alpha while the parent fades toward 0', () => { - const parent = makeParent(0.01); - const node = new CoreNode( - stage, - defaultProps({ parent, alpha: 1, ignoreParentAlpha: true }), - ); - node.w = 100; - node.h = 100; - node.color = 0xffffffff; - - node.update(0, clippingRect); - - expect(node.worldAlpha).toBe(1); - expect(node.isRenderable).toBe(true); - }); - - it('premultiplies colors with the node own alpha when enabled', () => { - const parent = makeParent(0.25); - const node = new CoreNode( - stage, - defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }), - ); - node.w = 100; - node.h = 100; - node.color = 0xff0000ff; - - node.update(0, clippingRect); - - expect(node.premultipliedColorTl).toBe( - premultiplyColorABGR(0xff0000ff, 0.8), - ); - }); - - it('toggling the setter recomputes world alpha', () => { - const parent = makeParent(0.5); - const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 })); - - node.update(0, clippingRect); - expect(node.worldAlpha).toBeCloseTo(0.4); - - node.ignoreParentAlpha = true; - node.update(1, clippingRect); - expect(node.worldAlpha).toBe(0.8); - - node.ignoreParentAlpha = false; - node.update(2, clippingRect); - expect(node.worldAlpha).toBeCloseTo(0.4); - }); - - it('setting the same value does not flag an update', () => { - const node = new CoreNode(stage, defaultProps()); - const updateTypeBefore = node.updateType; - - node.ignoreParentAlpha = false; - - expect(node.updateType).toBe(updateTypeBefore); - }); - - it('descendants inherit the node world alpha as usual', () => { - const parent = makeParent(0.5); - const node = new CoreNode( - stage, - defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }), - ); - const child = new CoreNode( - stage, - defaultProps({ parent: node, alpha: 0.5 }), - ); - - node.update(0, clippingRect); - child.update(0, clippingRect); - - expect(node.worldAlpha).toBe(0.8); - expect(child.worldAlpha).toBeCloseTo(0.4); - }); - }); - describe('autosize system', () => { it('should initialize with autosize disabled', () => { const node = new CoreNode(stage, defaultProps()); @@ -1571,310 +1466,339 @@ describe('set color()', () => { }); }); - describe('renderOnlyInViewport', () => { - // Viewport is 0..200; the preload (bounds-margin) ring extends to 400. - // A node at x=250 is InBounds (margin ring); at x=50 it is InViewport; - // at x=500 it is OutOfBounds. - function boundsStage(renderOnlyInViewport: boolean): Stage { - return mock({ + describe('placeholderImage', () => { + // The placeholderImage setter resolves URLs through txManager, so this + // suite uses a stage mock with an explicit txManager stub. + function placeholderStage() { + const createTexture = vi.fn(); + const loadTexture = vi.fn(); + const stage = mock({ strictBound: createBound(0, 0, 200, 200), - preloadBound: createBound(0, 0, 400, 200), + preloadBound: createBound(0, 0, 200, 200), defaultTexture: { state: 'loaded', }, renderer: mock() as CoreRenderer, - renderOnlyInViewport, + txManager: { + createTexture, + loadTexture, + } as unknown as Stage['txManager'], }); + return { stage, createTexture, loadTexture }; } - function loadedTextureNode(stage: Stage, x: number): CoreNode { + function emittingTexture(state: string): ImageTexture & { + emit: (event: string, data?: unknown) => void; + preventCleanup: boolean; + } { + return Object.assign(new EventEmitter(), { + state, + preventCleanup: false, + retryCount: 0, + maxRetryCount: 1, + dimensions: { w: 100, h: 100 }, + setRenderableOwner: vi.fn(), + }) as unknown as ImageTexture & { + emit: (event: string, data?: unknown) => void; + preventCleanup: boolean; + }; + } + + function visibleNode(stage: Stage): CoreNode { const parent = new CoreNode(stage, defaultProps()); parent.globalTransform = Matrix3d.identity(); parent.worldAlpha = 1; const node = new CoreNode(stage, defaultProps({ parent })); node.alpha = 1; - node.x = x; + node.x = 0; node.y = 0; node.w = 100; node.h = 100; - node.texture = mock({ - state: 'loaded', - setRenderableOwner: vi.fn(), - }); - node.textureLoaded = true; return node; } - it('off: a margin-ring node is renderable (legacy behavior)', () => { - const node = loadedTextureNode(boundsStage(false), 250); + it('pins and eagerly loads the placeholder image on assignment', () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); - node.update(0, clippingRect); + node.placeholderImage = 'placeholder-poster.png'; - expect(node.isRenderable).toBe(true); + expect(createTexture).toHaveBeenCalledWith('ImageTexture', { + src: 'placeholder-poster.png', + }); + expect(placeholder.preventCleanup).toBe(true); + expect(loadTexture).toHaveBeenCalledWith(placeholder, true); + expect(node.placeholderTextureLoaded).toBe(false); }); - it('on: a margin-ring node is not renderable but still owns its texture', () => { - const node = loadedTextureNode(boundsStage(true), 250); + it('uses an already-loaded shared placeholder immediately, untinted', () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); node.update(0, clippingRect); - expect(node.isRenderable).toBe(false); - // Ownership is the load trigger and cleanup protection — it must stay. - expect(node.texture!.setRenderableOwner).toHaveBeenCalledWith( - expect.anything(), - true, + expect(loadTexture).not.toHaveBeenCalled(); + expect(node.placeholderActive).toBe(true); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(placeholder); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0xffffffff, 1), ); }); - it('on: a viewport node is renderable', () => { - const node = loadedTextureNode(boundsStage(true), 50); + it('falls back to the placeholderColor rect until the image loads', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + node.placeholderColor = 0x336699ff; + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); node.update(0, clippingRect); - expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0x336699ff, 1), + ); + + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + + expect(node.renderTexture).toBe(placeholder); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0xffffffff, 1), + ); }); - it('on: a node becomes renderable when it crosses into the viewport', () => { - const node = loadedTextureNode(boundsStage(true), 250); + it('renders nothing until an image-only placeholder loads', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); node.update(0, clippingRect); expect(node.isRenderable).toBe(false); - // Scroll it in. - node.x = 50; + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); node.update(1, clippingRect); - expect(node.isRenderable).toBe(true); - // And back out into the ring. - node.x = 250; - node.update(2, clippingRect); - expect(node.isRenderable).toBe(false); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(placeholder); }); - it('on: an out-of-bounds node releases texture ownership', () => { - const node = loadedTextureNode(boundsStage(true), 500); + it('the loaded main texture wins over the placeholder', async () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + node.color = 0xffffffff; + node.placeholderImage = 'placeholder-poster.png'; + const main = emittingTexture('initial'); + node.texture = main; node.update(0, clippingRect); + expect(node.renderTexture).toBe(placeholder); - expect(node.isRenderable).toBe(false); - expect(node.texture!.setRenderableOwner).not.toHaveBeenCalledWith( - expect.anything(), - true, - ); - }); + await Promise.resolve(); // flush loadTextureTask so listeners attach + (main as { state: string }).state = 'loaded'; + main.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); - it('on: a margin-ring placeholder is gated the same way', () => { - const stage = boundsStage(true); - const parent = new CoreNode(stage, defaultProps()); - parent.globalTransform = Matrix3d.identity(); - parent.worldAlpha = 1; + expect(node.placeholderActive).toBe(false); + expect(node.renderTexture).toBe(main); + }); - const node = new CoreNode(stage, defaultProps({ parent })); - node.alpha = 1; - node.x = 250; - node.y = 0; - node.w = 100; - node.h = 100; - node.placeholderColor = 0x336699ff; - node.texture = mock({ - state: 'initial', - setRenderableOwner: vi.fn(), - }); + it('shows the placeholder image again while a freed main texture reloads', async () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + node.placeholderImage = 'placeholder-poster.png'; + const main = emittingTexture('initial'); + node.texture = main; node.update(0, clippingRect); - expect(node.placeholderActive).toBe(true); - expect(node.isRenderable).toBe(false); - node.x = 50; + await Promise.resolve(); + (main as { state: string }).state = 'loaded'; + main.emit('loaded', { w: 100, h: 100 }); node.update(1, clippingRect); - expect(node.isRenderable).toBe(true); - }); + expect(node.placeholderActive).toBe(false); - it('on: color-only nodes in the margin ring are gated too', () => { - const stage = boundsStage(true); - const parent = new CoreNode(stage, defaultProps()); - parent.globalTransform = Matrix3d.identity(); - parent.worldAlpha = 1; + (main as { state: string }).state = 'freed'; + main.emit('freed'); + node.update(2, clippingRect); - const node = new CoreNode(stage, defaultProps({ parent })); - node.alpha = 1; - node.x = 250; - node.y = 0; - node.w = 100; - node.h = 100; - node.color = 0xff0000ff; + expect(node.placeholderActive).toBe(true); + expect(node.renderTexture).toBe(placeholder); + }); + + it('a failed placeholder image falls back to the color rect', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + node.placeholderColor = 0x336699ff; + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); node.update(0, clippingRect); - expect(node.isRenderable).toBe(false); - node.x = 50; + (placeholder as { state: string }).state = 'failed'; + placeholder.emit('failed', new Error('404')); node.update(1, clippingRect); + + expect(node.placeholderTextureLoaded).toBe(false); expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0x336699ff, 1), + ); }); - }); - describe('texture ownership cache', () => { - // Same shape as the placeholderColor texture fake: a real EventEmitter so - // loadTextureTask subscribes and we can drive freed/loaded by emitting. - function emittingTexture(state: string): ImageTexture & { - emit: (event: string, data?: unknown) => void; - } { - return Object.assign(new EventEmitter(), { - state, - preventCleanup: false, - retryCount: 0, - maxRetryCount: 1, - dimensions: { w: 100, h: 100 }, - setRenderableOwner: vi.fn(), - }) as unknown as ImageTexture & { - emit: (event: string, data?: unknown) => void; - }; - } + it('self-heals an out-of-band freed placeholder: re-pins and reloads', () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); - function visibleNode(): CoreNode { - const parent = new CoreNode(stage, defaultProps()); - parent.globalTransform = Matrix3d.identity(); - parent.worldAlpha = 1; + node.placeholderImage = 'placeholder-poster.png'; + expect(node.placeholderTextureLoaded).toBe(true); - const node = new CoreNode(stage, defaultProps({ parent })); - node.alpha = 1; - node.x = 0; - node.y = 0; - node.w = 100; - node.h = 100; - return node; - } + // e.g. context loss, or another node's textureOptions unpinned it + placeholder.preventCleanup = false; + (placeholder as { state: string }).state = 'freed'; + placeholder.emit('freed'); - const flushMicrotasks = () => Promise.resolve(); + expect(node.placeholderTextureLoaded).toBe(false); + expect(placeholder.preventCleanup).toBe(true); + expect(loadTexture).toHaveBeenCalledWith(placeholder, true); + }); - it('repeated updates with unchanged state call setRenderableOwner once', () => { - const node = visibleNode(); - const texture = emittingTexture('loaded'); - node.texture = texture; - node.textureLoaded = true; + it('destroy detaches the node from the shared placeholder texture', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); - // The texture setter registers ownership with the node's current - // isRenderable (false here), so the cache starts false. - expect(texture.setRenderableOwner).toHaveBeenCalledTimes(1); - expect(texture.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - false, - ); + node.placeholderImage = 'placeholder-poster.png'; + expect(placeholder.hasListeners()).toBe(true); - node.update(0, clippingRect); - expect(texture.setRenderableOwner).toHaveBeenCalledTimes(2); - expect(texture.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - true, - ); + node.destroy(); - // Steady-state scroll: ownership unchanged, no further calls. - node.update(1, clippingRect); - node.update(2, clippingRect); - expect(texture.setRenderableOwner).toHaveBeenCalledTimes(2); + expect(placeholder.hasListeners()).toBe(false); }); - it('moving out of bounds releases ownership once, returning re-adds', () => { - const node = visibleNode(); - const texture = emittingTexture('loaded'); - node.texture = texture; - node.textureLoaded = true; + it('swapping placeholderImage moves listeners to the new texture', () => { + const { stage, createTexture } = placeholderStage(); + const first = emittingTexture('initial'); + const second = emittingTexture('initial'); + createTexture.mockReturnValueOnce(first).mockReturnValueOnce(second); + const node = visibleNode(stage); - node.update(0, clippingRect); - expect(texture.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - true, - ); - const callsAfterFirstUpdate = ( - texture.setRenderableOwner as ReturnType - ).mock.calls.length; + node.placeholderImage = 'placeholder-a.png'; + expect(first.hasListeners()).toBe(true); - // Out of the 200x200 stage bounds entirely. - node.x = 1000; - node.update(1, clippingRect); - expect(texture.setRenderableOwner).toHaveBeenCalledTimes( - callsAfterFirstUpdate + 1, - ); - expect(texture.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - false, - ); + node.placeholderImage = 'placeholder-b.png'; - // Still out of bounds: no repeated release. - node.update(2, clippingRect); - expect(texture.setRenderableOwner).toHaveBeenCalledTimes( - callsAfterFirstUpdate + 1, - ); - - // Back in view: re-registered exactly once. - node.x = 0; - node.update(3, clippingRect); - expect(texture.setRenderableOwner).toHaveBeenCalledTimes( - callsAfterFirstUpdate + 2, - ); - expect(texture.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - true, - ); + expect(first.hasListeners()).toBe(false); + expect(second.hasListeners()).toBe(true); + expect(node.placeholderTexture).toBe(second); }); - it('swapping textures releases the old owner and registers the new one', () => { - const node = visibleNode(); - const textureA = emittingTexture('loaded'); - node.texture = textureA; - node.textureLoaded = true; - node.update(0, clippingRect); - expect(textureA.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - true, - ); - - const textureB = emittingTexture('loaded'); - node.texture = textureB; - - // A is released via unloadTexture. - expect(textureA.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - false, - ); - // B is registered with the node's current renderable state (true), - // proving the cache reset on swap — a stale cache would skip this. - expect(textureB.setRenderableOwner).toHaveBeenCalledWith( - expect.anything(), - true, - ); - }); + it('clearing placeholderImage detaches and deactivates', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); - it('freed texture is re-registered on the next update (reload trigger)', async () => { - const node = visibleNode(); - const texture = emittingTexture('initial'); - node.texture = texture; + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); node.update(0, clippingRect); + expect(node.placeholderActive).toBe(true); - await flushMicrotasks(); - (texture as { state: string }).state = 'loaded'; - texture.emit('loaded', { w: 100, h: 100 }); + node.placeholderImage = null; node.update(1, clippingRect); - expect(texture.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - true, - ); - // Memory manager frees the texture: the node must drop ownership... - (texture as { state: string }).state = 'freed'; - texture.emit('freed'); - expect(texture.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - false, - ); + expect(placeholder.hasListeners()).toBe(false); + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(false); + }); - // ...and the next update pass re-adds it, which is what triggers - // Texture.load() for the reload. A stale cache would skip this call. - node.update(2, clippingRect); - expect(texture.setRenderableOwner).toHaveBeenLastCalledWith( - expect.anything(), - true, - ); + it('many nodes share one placeholder texture and transition independently', async () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + // Mimic the real loadTexture: setState('loading') happens synchronously + // before the first await, which is what dedupes subsequent callers. + loadTexture.mockImplementation(() => { + (placeholder as { state: string }).state = 'loading'; + }); + + const a = visibleNode(stage); + const b = visibleNode(stage); + const c = visibleNode(stage); + a.placeholderImage = 'placeholder-poster.png'; + b.placeholderImage = 'placeholder-poster.png'; + c.placeholderImage = 'placeholder-poster.png'; + + // One shared instance, one fetch. + expect(a.placeholderTexture).toBe(placeholder); + expect(b.placeholderTexture).toBe(placeholder); + expect(c.placeholderTexture).toBe(placeholder); + expect(loadTexture).toHaveBeenCalledTimes(1); + + const mainA = emittingTexture('initial'); + const mainB = emittingTexture('initial'); + const mainC = emittingTexture('initial'); + a.texture = mainA; + b.texture = mainB; + c.texture = mainC; + + // The shared placeholder loads: every node is notified and shows it. + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + a.update(0, clippingRect); + b.update(0, clippingRect); + c.update(0, clippingRect); + expect(a.renderTexture).toBe(placeholder); + expect(b.renderTexture).toBe(placeholder); + expect(c.renderTexture).toBe(placeholder); + + // Node A's poster arrives — A switches, B and C keep the placeholder. + await Promise.resolve(); // flush loadTextureTask so main listeners attach + (mainA as { state: string }).state = 'loaded'; + mainA.emit('loaded', { w: 100, h: 100 }); + a.update(1, clippingRect); + b.update(1, clippingRect); + expect(a.placeholderActive).toBe(false); + expect(a.renderTexture).toBe(mainA); + expect(b.renderTexture).toBe(placeholder); + expect(c.renderTexture).toBe(placeholder); + + // Node B is destroyed mid-load — C is unaffected and the texture only + // loses B's listeners. + b.destroy(); + expect(placeholder.hasListeners()).toBe(true); + expect(c.renderTexture).toBe(placeholder); + + // C's poster arrives last. + (mainC as { state: string }).state = 'loaded'; + mainC.emit('loaded', { w: 100, h: 100 }); + c.update(2, clippingRect); + expect(c.renderTexture).toBe(mainC); }); }); }); diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index 42cd324..a6db09d 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js'; -import { CoreNodeRenderState } from './CoreNode.js'; import { Stage } from './Stage.js'; import { CoreRenderer } from './renderers/CoreRenderer.js'; import { createBound } from './lib/utils.js'; @@ -17,7 +16,6 @@ const defaultProps = ( ): CoreTextNodeProps => ({ // CoreNodeProps alpha: 1, - ignoreParentAlpha: false, autosize: false, boundsMargin: null, clipping: false, @@ -31,6 +29,8 @@ const defaultProps = ( colorTop: 0xffffffff, colorTr: 0xffffffff, placeholderColor: 0, + placeholderImage: null, + fallbackImage: null, h: 0, mount: 0, mountX: 0, @@ -146,63 +146,3 @@ describe('CoreTextNode (canvas) clearing text', () => { expect(node.isRenderable).toBe(false); }); }); - -describe('CoreTextNode (sdf) renderOnlyInViewport', () => { - const makeSdfRenderer = (): TextRenderer => { - const font = mock({ type: 'sdf' }); - return { - type: 'sdf', - font, - renderText: vi.fn(), - addQuads: vi.fn(), - renderQuads: vi.fn(), - init: vi.fn(), - } as unknown as TextRenderer; - }; - - function sdfNodeWithLayout(renderOnlyInViewport: boolean): CoreTextNode { - const stage = mock({ - strictBound: createBound(0, 0, 200, 200), - preloadBound: createBound(0, 0, 400, 200), - defaultTexture: { state: 'loaded' } as never, - defShaderNode: null as never, - renderer: mock() as CoreRenderer, - renderOnlyInViewport, - }); - const node = new CoreTextNode( - stage, - defaultProps({ text: 'Hello' }), - makeSdfRenderer(), - ); - (node as unknown as { _cachedLayout: object })._cachedLayout = {}; - node.worldAlpha = 1; - return node; - } - - it('off: margin-ring SDF text is renderable (legacy behavior)', () => { - const node = sdfNodeWithLayout(false); - node.renderState = CoreNodeRenderState.InBounds; - - node.updateIsRenderable(); - - expect(node.isRenderable).toBe(true); - }); - - it('on: margin-ring SDF text stays out of the render list', () => { - const node = sdfNodeWithLayout(true); - node.renderState = CoreNodeRenderState.InBounds; - - node.updateIsRenderable(); - - expect(node.isRenderable).toBe(false); - }); - - it('on: viewport SDF text is renderable', () => { - const node = sdfNodeWithLayout(true); - node.renderState = CoreNodeRenderState.InViewport; - - node.updateIsRenderable(); - - expect(node.isRenderable).toBe(true); - }); -}); diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index ea48935..d508511 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -409,9 +409,9 @@ export class CoreTextureManager extends EventEmitter { // Anything that arrived before initialization completed is now safe to // process. Without this, queued textures would sit until the next frame - // tick happens to drain them. + // tick happens to call processSome(). if (this.uploadTextureQueue.size > 0) { - this.processUntil(Infinity).catch((err) => { + this.processSome(Infinity).catch((err) => { console.error('Failed to drain pre-init texture queue:', err); }); } @@ -587,41 +587,11 @@ export class CoreTextureManager extends EventEmitter { } /** - * Upload a single queued texture to the GPU. + * Process a limited number of uploads. * - * @remarks - * Used while animations are running so uploads don't steal time from the - * animation. If the dequeued texture already died (failed/freed), nothing is - * uploaded this frame and the next call handles the following one. - */ - async processOne(): Promise { - if (this.initialized === false) { - return; - } - - const texture = this.uploadTextureQueue.shift(); - if (texture === undefined) { - return; - } - - await this.uploadQueued(texture); - } - - /** - * Upload queued textures to the GPU until the per-frame time budget runs out. - * - * @remarks - * Called once per frame when idle. Textures are uploaded one-by-one; after - * each, the elapsed time is rechecked and processing stops once it exceeds - * `maxProcessingTime`, leaving the rest queued for the next frame. - * - * In normal operation a queued texture's data is already decoded - * (`loadTexture` awaits `getTextureData` before enqueuing), so this budgets - * GPU upload time. Pass `Infinity` to drain the whole queue. - * - * @param maxProcessingTime - The time budget for this frame, in milliseconds + * @param maxProcessingTime - The maximum processing time in milliseconds */ - async processUntil(maxProcessingTime: number): Promise { + async processSome(maxProcessingTime: number): Promise { if (this.initialized === false) { return; } @@ -629,54 +599,79 @@ export class CoreTextureManager extends EventEmitter { const platform = this.platform; const startTime = platform.getTimeStamp(); - while (platform.getTimeStamp() - startTime < maxProcessingTime) { - const texture = this.uploadTextureQueue.shift(); - if (texture === undefined) { - // Queue drained. - break; + // Decode / fetch ("getTextureData") is IO-bound and parallelisable across + // image workers, while GPU upload is effectively serial. Keep a small + // sliding window of in-flight data fetches so the next decode runs while + // we're uploading the current one. + const prefetchLimit = Math.max(1, this.numImageWorkers); + const pending: Array<{ texture: Texture; data: Promise }> = []; + + // Helper avoids TS narrowing `texture.state` permanently after the first + // discriminated check — the property is mutable and can transition across + // awaits, so we need to re-read it freshly each time. + const isDead = (texture: Texture): boolean => + texture.state === 'failed' || texture.state === 'freed'; + + const fillPrefetch = () => { + while (pending.length < prefetchLimit) { + const texture = this.uploadTextureQueue.shift(); + if (texture === undefined) break; + + if (isDead(texture)) { + continue; + } + + // Swallow the rejection here so an early failure doesn't surface as + // an unhandled promise rejection while it sits in the prefetch + // window; we re-check state after awaiting. + const data = + texture.textureData === null + ? texture.getTextureData().catch((err) => { + console.error('Failed to fetch texture data:', err); + return null; + }) + : Promise.resolve(texture.textureData); + + pending.push({ texture, data }); } + }; - await this.uploadQueued(texture); - } - } + fillPrefetch(); - /** - * Decode (if needed) and upload a single already-dequeued texture. - * - * @remarks - * Shared by {@link processOne} and {@link processUntil}. Dead (failed/freed) - * textures and upload failures are skipped without throwing. - */ - private async uploadQueued(texture: Texture): Promise { - if (this.isTextureDead(texture)) { - return; - } + while ( + pending.length > 0 && + platform.getTimeStamp() - startTime < maxProcessingTime + ) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const next = pending.shift()!; + // Top up the prefetch window before awaiting — the next decode starts + // now and overlaps with this upload. + fillPrefetch(); - try { - if (texture.textureData === null) { - await texture.getTextureData(); + if (isDead(next.texture)) { + continue; } - if (this.isTextureDead(texture)) { - return; + + try { + await next.data; + if (isDead(next.texture)) { + continue; + } + await this.uploadTexture(next.texture); + } catch (error) { + console.error('Failed to upload texture:', error); + // Continue with next texture instead of stopping entire queue } - await this.uploadTexture(texture); - } catch (error) { - console.error('Failed to upload texture:', error); - // Skip this texture instead of stalling the queue. } - } - /** - * A texture is "dead" once it has failed or been freed — both terminal for - * the upload pipeline. - * - * @remarks - * Kept as a method rather than an inline check so TypeScript doesn't - * permanently narrow `state` after the first comparison: the property is - * mutable and can transition across the awaits in {@link uploadQueued}. - */ - private isTextureDead(texture: Texture): boolean { - return texture.state === 'failed' || texture.state === 'freed'; + // Time ran out before we got to these. Put them back so we don't lose + // them — their getTextureData() is already in flight and will populate + // `textureData` for the next tick. + for (const { texture } of pending) { + if (!isDead(texture)) { + this.uploadTextureQueue.add(texture); + } + } } public hasUpdates(): boolean { diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 50ea82a..f8416aa 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -97,12 +97,6 @@ export class Stage { public readonly renderer: CoreRenderer; public readonly root: CoreNode; public boundsMargin: [number, number, number, number]; - /** - * When true, nodes inside the bounds margin but outside the viewport keep - * loading textures yet stay out of the render list. Read by - * `CoreNode.updateIsRenderable` on the scroll path. - */ - public readonly renderOnlyInViewport: boolean; public readonly defShaderNode: CoreShaderNode | null = null; public strictBound: Bound; public preloadBound: Bound; @@ -214,7 +208,6 @@ export class Stage { setBaselineMode(options.textBaselineMode); this.platform = platform; - this.renderOnlyInViewport = options.renderOnlyInViewport !== false; this.startTime = platform.getTimeStamp(); @@ -379,7 +372,6 @@ export class Stage { w: appWidth, h: appHeight, alpha: 1, - ignoreParentAlpha: false, autosize: false, boundsMargin: null, clipping: false, @@ -391,6 +383,8 @@ export class Stage { colorTl: 0x00000000, colorTr: 0x00000000, placeholderColor: 0x00000000, + placeholderImage: null, + fallbackImage: null, colorBl: 0x00000000, colorBr: 0x00000000, zIndex: 0, @@ -595,16 +589,11 @@ export class Stage { // Process some textures asynchronously but don't block the frame // Use a background task to prevent frame drops if (this.txManager.hasUpdates() === true) { - // While animating, upload at most one texture per frame so uploads don't - // steal time from the animation; otherwise fill the per-frame time budget. - const processing = - hasActiveAnimations === true - ? this.txManager.processOne() - : this.txManager.processUntil( - this.options.textureProcessingTimeLimit, - ); + const timeLimit = hasActiveAnimations + ? this.options.textureProcessingTimeLimit / 2 + : this.options.textureProcessingTimeLimit; - processing.catch((err) => { + this.txManager.processSome(timeLimit).catch((err) => { console.error('Error processing textures:', err); }); } @@ -1045,7 +1034,6 @@ export class Stage { w: props.w ?? 0, h: props.h ?? 0, alpha: props.alpha ?? 1, - ignoreParentAlpha: props.ignoreParentAlpha ?? false, autosize: props.autosize ?? false, boundsMargin: props.boundsMargin ?? null, clipping: props.clipping ?? false, @@ -1059,6 +1047,8 @@ export class Stage { colorBl, colorBr, placeholderColor: props.placeholderColor ?? 0, + placeholderImage: props.placeholderImage ?? null, + fallbackImage: props.fallbackImage ?? null, zIndex: props.zIndex ?? 0, parent: props.parent ?? null, texture: props.texture ?? null,