diff --git a/examples/tests/texture-placeholder-image.ts b/examples/tests/texture-placeholder-image.ts new file mode 100644 index 0000000..fe25d26 --- /dev/null +++ b/examples/tests/texture-placeholder-image.ts @@ -0,0 +1,167 @@ +import type { INode, Texture } from '@lightningjs/renderer'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +import rockoPng from '../assets/rocko.png'; +import lightningPng from '../assets/lightning.png'; + +/** + * Visual test for `placeholderImage`: a node with a texture renders a shared, + * pinned placeholder image (through its shader) until the texture loads. + * + * Deterministic states captured in the snapshot: + * 1. Placeholder image for a permanently failed src, rounded. + * 2. Same shared placeholder image under RoundedWithBorder. + * 3. Placeholder image that itself 404s -> placeholderColor rect fallback. + * 4. A loaded image with placeholderImage set — the image shows. + * 5. Control: failed src + failed placeholder + no color renders nothing. + */ + +const MISSING_SRC = '/does-not-exist-placeholder-test.png'; +const MISSING_PLACEHOLDER = '/does-not-exist-placeholder-image.png'; + +function waitForNodeEvent( + node: INode, + event: 'loaded' | 'failed', + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), timeoutMs); + node.once(event, () => { + clearTimeout(timeout); + resolve(true); + }); + }); +} + +function waitForTextureState( + texture: Texture, + state: 'loaded' | 'failed', + timeoutMs: number, +): Promise { + if (texture.state === state) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), timeoutMs); + texture.once(state, () => { + clearTimeout(timeout); + resolve(true); + }); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function automation(settings: ExampleSettings) { + await test(settings); + // The scene settled inside test() (events already awaited) — force a final + // frame instead of waiting for an 'idle' that may have already fired. + settings.renderer.rerender(); + await delay(100); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + renderer.createTextNode({ + fontFamily: 'Ubuntu', + text: 'placeholderImage', + fontSize: 30, + color: 0xffffffff, + x: 20, + y: 20, + parent: testRoot, + }); + + // Fail fast and permanently: maxRetryCount 0 = one attempt, no retries. + const missingTexture = renderer.createTexture('ImageTexture', { + src: MISSING_SRC, + maxRetryCount: 0, + }); + + // 1. Placeholder image shows for a permanently failed src (rounded). + const failedRounded = renderer.createNode({ + x: 20, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderImage: lightningPng, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 2. Same shared placeholder image, border shader. + const failedBordered = renderer.createNode({ + x: 250, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderImage: lightningPng, + shader: renderer.createShader('RoundedWithBorder', { + radius: [20], + 'border-w': 8, + }), + parent: testRoot, + }); + + // 3. Placeholder image that itself 404s -> placeholderColor rect fallback. + const fallbackRect = renderer.createNode({ + x: 480, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderImage: MISSING_PLACEHOLDER, + placeholderColor: 0x993311ff, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 4. A successfully loaded image with a placeholder configured must show + // the image. + const loadedImage = renderer.createNode({ + x: 710, + y: 80, + w: 181, + h: 218, + src: rockoPng, + placeholderImage: lightningPng, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 5. Control: failed src + failed placeholder + no color renders nothing. + renderer.createNode({ + x: 940, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderImage: MISSING_PLACEHOLDER, + parent: testRoot, + }); + + const placeholderTexture = failedRounded.placeholderTexture as Texture; + const fallbackPlaceholderTexture = fallbackRect.placeholderTexture as Texture; + + const settled = await Promise.all([ + waitForNodeEvent(failedRounded, 'failed', 10000), + waitForNodeEvent(failedBordered, 'failed', 10000), + waitForNodeEvent(loadedImage, 'loaded', 10000), + waitForTextureState(placeholderTexture, 'loaded', 10000), + waitForTextureState(fallbackPlaceholderTexture, 'failed', 10000), + ]); + + for (let i = 0; i < settled.length; i++) { + if (settled[i] === false) { + console.error('[texture-placeholder-image] did not settle', settled); + return false; + } + } + + console.log('[texture-placeholder-image] scene settled'); + return true; +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 167fa5e..660bbde 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -9,6 +9,7 @@ import { ImageTexture } from './textures/ImageTexture.js'; import { Matrix3d } from './lib/Matrix3d.js'; import { EventEmitter } from '../common/EventEmitter.js'; import { premultiplyColorABGR } from '../utils.js'; +import { PlaceholderManager } from './PlaceholderManager.js'; describe('set color()', () => { const defaultProps = (overrides?: Partial): CoreNodeProps => ({ @@ -27,6 +28,7 @@ describe('set color()', () => { colorTop: 0, colorTr: 0, placeholderColor: 0, + placeholderImage: null, h: 0, mount: 0, mountX: 0, @@ -1571,6 +1573,430 @@ describe('set color()', () => { }); }); + describe('placeholderImage', () => { + // placeholderImage delegates its whole texture lifecycle to the stage's + // PlaceholderManager, so this suite wires a real manager onto a stage mock + // with a stubbed txManager. Using the real manager (rather than mocking it) + // is the point: the sharing and subscribe/release behavior under test lives + // there, not on CoreNode. + function placeholderStage() { + const createTexture = vi.fn(); + const loadTexture = vi.fn(); + const stage = mock({ + strictBound: createBound(0, 0, 200, 200), + preloadBound: createBound(0, 0, 200, 200), + defaultTexture: { + state: 'loaded', + }, + renderer: mock() as CoreRenderer, + txManager: { + createTexture, + loadTexture, + } as unknown as Stage['txManager'], + }); + (stage as { placeholderManager: PlaceholderManager }).placeholderManager = + new PlaceholderManager(stage); + return { stage, createTexture, loadTexture }; + } + + 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; + }; + } + + // How many handlers are registered for one event. The manager's whole + // point is that this stays at 1 no matter how many nodes subscribe. + function listenerCount(texture: unknown, event: string): number { + const map = (texture as { eventListeners: Record }) + .eventListeners; + if (map === null || map === undefined) { + return 0; + } + const listeners = map[event]; + return listeners === undefined ? 0 : listeners.length; + } + + 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 = 0; + node.y = 0; + node.w = 100; + node.h = 100; + return node; + } + + 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.placeholderImage = 'placeholder-poster.png'; + + expect(createTexture).toHaveBeenCalledWith('ImageTexture', { + src: 'placeholder-poster.png', + }); + expect(placeholder.preventCleanup).toBe(true); + expect(loadTexture).toHaveBeenCalledWith(placeholder, true); + expect(node.placeholderTextureLoaded).toBe(false); + }); + + 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(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('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.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('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); + + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(placeholder); + }); + + 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); + + 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); + + expect(node.placeholderActive).toBe(false); + expect(node.renderTexture).toBe(main); + }); + + 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); + + await Promise.resolve(); + (main as { state: string }).state = 'loaded'; + main.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + expect(node.placeholderActive).toBe(false); + + (main as { state: string }).state = 'freed'; + main.emit('freed'); + node.update(2, clippingRect); + + 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); + + (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), + ); + }); + + 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); + + node.placeholderImage = 'placeholder-poster.png'; + expect(node.placeholderTextureLoaded).toBe(true); + + // e.g. context loss, or another node's textureOptions unpinned it + placeholder.preventCleanup = false; + (placeholder as { state: string }).state = 'freed'; + placeholder.emit('freed'); + + expect(node.placeholderTextureLoaded).toBe(false); + expect(placeholder.preventCleanup).toBe(true); + expect(loadTexture).toHaveBeenCalledWith(placeholder, true); + }); + + it('destroy unsubscribes the node from the shared placeholder', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + expect(node.placeholderEntry).not.toBe(null); + + node.destroy(); + expect(node.placeholderEntry).toBe(null); + + // The shared texture outlives the node (it stays pinned and cached), so + // the real check is that a later transition no longer reaches it. + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + expect(node.placeholderTextureLoaded).toBe(false); + }); + + it('swapping placeholderImage moves the node to the new entry', () => { + const { stage, createTexture } = placeholderStage(); + const first = emittingTexture('initial'); + const second = emittingTexture('initial'); + createTexture.mockReturnValueOnce(first).mockReturnValueOnce(second); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-a.png'; + expect(node.placeholderTexture).toBe(first); + + node.placeholderImage = 'placeholder-b.png'; + expect(node.placeholderTexture).toBe(second); + + // The old entry must no longer drive this node. + (first as { state: string }).state = 'loaded'; + first.emit('loaded', { w: 100, h: 100 }); + expect(node.placeholderTextureLoaded).toBe(false); + + (second as { state: string }).state = 'loaded'; + second.emit('loaded', { w: 100, h: 100 }); + expect(node.placeholderTextureLoaded).toBe(true); + }); + + it('clearing placeholderImage unsubscribes and deactivates', () => { + const { stage, createTexture } = 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.placeholderActive).toBe(true); + + node.placeholderImage = null; + node.update(1, clippingRect); + + expect(node.placeholderEntry).toBe(null); + expect(node.placeholderTexture).toBe(null); + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(false); + }); + + it('subscribes N nodes to one URL with a single listener set', () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + + const nodes: CoreNode[] = []; + for (let i = 0; i < 50; i++) { + const node = visibleNode(stage); + node.placeholderImage = 'placeholder-poster.png'; + nodes.push(node); + } + + // One texture, one fetch, and — the point of the manager — one listener + // trio total rather than one per node. + expect(createTexture).toHaveBeenCalledTimes(1); + expect(loadTexture).toHaveBeenCalledTimes(1); + expect(listenerCount(placeholder, 'loaded')).toBe(1); + expect(listenerCount(placeholder, 'failed')).toBe(1); + expect(listenerCount(placeholder, 'freed')).toBe(1); + + // Every node is still driven by that single listener. + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + for (let i = 0; i < nodes.length; i++) { + expect(nodes[i]!.placeholderTextureLoaded).toBe(true); + } + }); + + it('releasing out of order keeps every remaining node subscribed', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + + const nodes: CoreNode[] = []; + for (let i = 0; i < 6; i++) { + const node = visibleNode(stage); + node.placeholderImage = 'placeholder-poster.png'; + nodes.push(node); + } + + // Release from the middle and from the front: the swap-pop must keep the + // survivors' indices consistent, or a later release corrupts the list. + nodes[2]!.destroy(); + nodes[0]!.destroy(); + nodes[4]!.destroy(); + + const survivors = [nodes[1]!, nodes[3]!, nodes[5]!]; + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + + for (let i = 0; i < survivors.length; i++) { + expect(survivors[i]!.placeholderTextureLoaded).toBe(true); + } + expect(nodes[2]!.placeholderTextureLoaded).toBe(false); + expect(nodes[0]!.placeholderTextureLoaded).toBe(false); + expect(nodes[4]!.placeholderTextureLoaded).toBe(false); + }); + + 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 shared entry + // only loses B. + b.destroy(); + expect(b.placeholderEntry).toBe(null); + expect(c.placeholderEntry).not.toBe(null); + 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); + }); + }); + 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; diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index c165cfe..14db7cf 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -12,6 +12,7 @@ import type { WebGlCtxTexture } from './renderers/webgl/WebGlCtxTexture.js'; import type { BufferCollection } from './renderers/webgl/internal/BufferCollection.js'; import type { CoreRenderer } from './renderers/CoreRenderer.js'; import type { Stage } from './Stage.js'; +import type { PlaceholderEntry } from './PlaceholderManager.js'; import { Texture, TextureType, @@ -428,6 +429,29 @@ 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; /** * The Node's parent Node. * @@ -838,14 +862,42 @@ export class CoreNode extends EventEmitter { private textureOwnership = 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 + * 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. */ public placeholderActive = false; + /** + * Shared, pinned (`preventCleanup`) texture for {@link placeholderImage}, + * or `null`. Written by {@link PlaceholderManager}; cached here so the + * per-quad path reads a direct field instead of chasing the entry. + */ + public placeholderTexture: Texture | null = null; + + /** + * Cached `placeholderTexture.state === 'loaded'` (avoids per-quad string + * compares). Written by {@link PlaceholderManager}. + */ + public placeholderTextureLoaded = false; + + /** + * The {@link PlaceholderManager} entry this node subscribes to, or `null`. + * Manager-owned bookkeeping — never write it elsewhere. + */ + public placeholderEntry: PlaceholderEntry | null = null; + + /** + * This node's slot in {@link placeholderEntry}'s subscriber list, or `-1`. + * Lets the manager unsubscribe in O(1); without it, tearing down a row of + * nodes sharing one placeholder would be quadratic. + */ + public placeholderIndex = -1; + public updateType = UpdateType.All; public childUpdateType = UpdateType.None; @@ -931,7 +983,15 @@ 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, + } = props; const p = (this.props = props); p.texture = null; p.shader = null; @@ -939,6 +999,7 @@ export class CoreNode extends EventEmitter { p.rtt = false; p.boundsMargin = null; p.scale = null; + p.placeholderImage = null; //check if any color props are set for premultiplied color updates if ( @@ -982,6 +1043,9 @@ export class CoreNode extends EventEmitter { if (src !== null) { this.src = src; } + if (placeholderImage !== null && placeholderImage !== undefined) { + this.placeholderImage = placeholderImage; + } if (rtt !== false) { this.rtt = rtt; } @@ -1019,9 +1083,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 +1096,25 @@ export class CoreNode extends EventEmitter { } } + /** + * Called by {@link PlaceholderManager} when the shared placeholder texture + * this node subscribes to finishes loading, fails, or is freed. + * + * @remarks + * A plain method, not a bound handler: the manager holds one listener trio + * per URL and walks its subscriber list, so nothing here is allocated per + * node. `loaded` toggling flips the quad between the untinted image and the + * `placeholderColor` rect, which is a vertex-color change — hence + * `PremultipliedColors` (which also marks the quad dirty). + */ + onPlaceholderStateChange(loaded: boolean): void { + this.placeholderTextureLoaded = loaded; + this.updatePlaceholderActive(); + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + } + loadTexture(): void { if (this.props.texture === null) { return; @@ -1516,10 +1600,15 @@ export class CoreNode extends EventEmitter { 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); + // 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 = @@ -2135,6 +2224,13 @@ export class CoreNode extends EventEmitter { this.removeAllListeners(); this.unloadTexture(); + // Unsubscribe from the long-lived, shared placeholder so it does not + // retain this node (the texture itself stays pinned/cached). Guarded so + // the overwhelming majority of nodes — which never set placeholderImage — + // skip the call entirely on teardown. + if (this.placeholderEntry !== null) { + this.stage.placeholderManager.release(this); + } this.isRenderable = false; if (this.hasShaderTimeFn === true) { @@ -2179,6 +2275,9 @@ export class CoreNode extends EventEmitter { get renderTexture(): Texture | null { if (this.placeholderActive === true) { + if (this.placeholderTextureLoaded === true) { + return this.placeholderTexture; + } return this.stage.defaultTexture; } return this.props.texture || this.stage.defaultTexture; @@ -2686,6 +2785,29 @@ 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; + + this.stage.placeholderManager.release(this); + if (value !== null) { + this.stage.placeholderManager.acquire(value, this); + } + + this.updatePlaceholderActive(); + // The shown placeholder may have changed shape (image <-> color rect) + // without toggling active. + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + } + get colorTop(): number { return this.props.colorTop; } diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index 42cd324..e09a7a5 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -31,6 +31,7 @@ const defaultProps = ( colorTop: 0xffffffff, colorTr: 0xffffffff, placeholderColor: 0, + placeholderImage: null, h: 0, mount: 0, mountX: 0, diff --git a/src/core/PlaceholderManager.ts b/src/core/PlaceholderManager.ts new file mode 100644 index 0000000..ee90b2f --- /dev/null +++ b/src/core/PlaceholderManager.ts @@ -0,0 +1,166 @@ +import type { CoreNode } from './CoreNode.js'; +import type { Stage } from './Stage.js'; +import type { Texture } from './textures/Texture.js'; + +/** + * One shared, pinned placeholder image, plus every Node currently showing it. + * + * @remarks + * Allocated once per distinct URL — never per Node. The event handlers that + * drive the fallback state machine are created here and subscribe to the + * texture exactly once, so a 500-poster scroll list puts 3 listeners on the + * texture instead of 1500. + */ +export interface PlaceholderEntry { + texture: Texture; + /** + * Cached `texture.state === 'loaded'`. Mirrored onto each subscribed Node as + * `placeholderTextureLoaded` so the per-quad path never chases this pointer. + */ + loaded: boolean; + /** + * Subscribed Nodes, in no particular order. Maintained by swap-pop against + * each Node's `placeholderIndex`, so releasing is O(1) — a plain + * `indexOf`/`splice` would make tearing down a full row quadratic. + */ + nodes: CoreNode[]; +} + +/** + * Owns the texture lifecycle behind {@link CoreNode.placeholderImage}. + * + * @remarks + * Placeholder images are a small, fixed set (typically one per poster size + * class) shared across many Nodes, so the texture is resolved once per URL and + * pinned (`preventCleanup`) for the lifetime of the app: the memory manager + * never frees it and orphan eviction never touches it. The cost is bounded by + * distinct URLs, not by Node count. + * + * Nodes subscribe and unsubscribe; they never touch the texture, its listeners, + * or its load state themselves. Everything a Node keeps is a cache this manager + * writes (`placeholderTexture`, `placeholderTextureLoaded`) plus two slots of + * bookkeeping (`placeholderEntry`, `placeholderIndex`). + */ +export class PlaceholderManager { + private readonly stage: Stage; + private readonly entries: Map = new Map(); + + constructor(stage: Stage) { + this.stage = stage; + } + + /** + * Subscribe `node` to the shared placeholder for `url`, creating and + * eagerly loading it on first use. + */ + acquire(url: string, node: CoreNode): void { + let entry = this.entries.get(url); + if (entry === undefined) { + entry = this.createEntry(url); + } + + const nodes = entry.nodes; + node.placeholderEntry = entry; + node.placeholderIndex = nodes.length; + node.placeholderTexture = entry.texture; + node.placeholderTextureLoaded = entry.loaded; + nodes.push(node); + } + + /** + * Unsubscribe `node`. The entry itself — texture, pin and listeners — stays + * alive for the next Node that asks for the same URL. + */ + release(node: CoreNode): void { + const entry = node.placeholderEntry; + if (entry === null) { + return; + } + + // Swap-pop: move the last subscriber into this node's slot and fix up its + // index. Same O(1) removal trick as Texture's renderable-owner tracking. + const nodes = entry.nodes; + const index = node.placeholderIndex; + const last = nodes.length - 1; + const moved = nodes[last] as CoreNode; + nodes[index] = moved; + moved.placeholderIndex = index; + nodes.length = last; + + node.placeholderEntry = null; + node.placeholderIndex = -1; + node.placeholderTexture = null; + node.placeholderTextureLoaded = false; + } + + private createEntry(url: string): PlaceholderEntry { + // src-only props: every Node using this URL — regardless of Node + // dimensions — resolves to the same cached texture instance. + const texture = this.stage.txManager.createTexture('ImageTexture', { + src: url, + }); + texture.preventCleanup = true; + + const entry: PlaceholderEntry = { + texture, + loaded: texture.state === 'loaded', + nodes: [], + }; + this.entries.set(url, entry); + + // One listener trio per URL, allocated once here. They live for the rest + // of the session, which is exactly as long as the pinned texture does. + texture.on('loaded', () => { + this.notify(entry, true); + // The RAF loop may have stopped while the placeholder loaded. + this.stage.requestRender(); + }); + texture.on('failed', () => { + this.notify(entry, false); + }); + texture.on('freed', () => { + this.notify(entry, false); + // A pinned texture was freed out-of-band — in practice, a Node whose + // `src` happens to match this URL, since `loadTextureTask` writes its + // own `textureOptions.preventCleanup` onto the shared instance. Re-pin + // and reload. (GL context loss is terminal here — the Stage emits + // `contextLost` and the app reloads — so it is not a case this covers.) + texture.preventCleanup = true; + this.load(entry); + }); + + this.load(entry); + return entry; + } + + /** + * Eager priority load, from idle states only. `'loading'`/`'fetching'` means + * a load is already in flight and a second call would start a duplicate + * fetch of the same source; `'failed'` is terminal and never retried. + */ + private load(entry: PlaceholderEntry): void { + const state = entry.texture.state; + if (state === 'initial' || state === 'freed') { + void this.stage.txManager.loadTexture(entry.texture, true); + } + } + + /** + * Push a state change to every subscriber. + * + * @remarks + * Walks the live array with a cached length — no defensive copy, which is + * the whole point of owning the fan-out here rather than paying + * `EventEmitter.emit`'s per-dispatch spread. That is only sound because + * `onPlaceholderStateChange` cannot re-enter the manager: it just sets flags + * and raises an update type. If it ever grows a path that destroys a node or + * reassigns `placeholderImage`, this loop has to become mutation-safe. + */ + private notify(entry: PlaceholderEntry, loaded: boolean): void { + entry.loaded = loaded; + const nodes = entry.nodes; + for (let i = 0, n = nodes.length; i < n; i++) { + nodes[i]!.onPlaceholderStateChange(loaded); + } + } +} diff --git a/src/core/Stage.ts b/src/core/Stage.ts index d1826a3..677171f 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -17,6 +17,7 @@ import { type TextureOptions, } from './CoreTextureManager.js'; import { CoreShaderManager } from './CoreShaderManager.js'; +import { PlaceholderManager } from './PlaceholderManager.js'; import { type FontHandler, type FontLoadOptions, @@ -94,6 +95,7 @@ export class Stage { public readonly textRenderers: Record = {}; public readonly fontHandlers: Record = {}; public readonly shManager: CoreShaderManager; + public readonly placeholderManager: PlaceholderManager; public readonly renderer: CoreRenderer; public readonly root: CoreNode; public boundsMargin: [number, number, number, number]; @@ -270,6 +272,7 @@ export class Stage { }); this.shManager = new CoreShaderManager(this); + this.placeholderManager = new PlaceholderManager(this); this.defShaderNode = this.renderer.getDefaultShaderNode(); this.calculateTextureCoord = this.renderer.getTextureCoords !== undefined; @@ -393,6 +396,7 @@ export class Stage { colorTl: 0x00000000, colorTr: 0x00000000, placeholderColor: 0x00000000, + placeholderImage: null, colorBl: 0x00000000, colorBr: 0x00000000, zIndex: 0, @@ -1071,6 +1075,7 @@ export class Stage { colorBl, colorBr, placeholderColor: props.placeholderColor ?? 0, + placeholderImage: props.placeholderImage ?? null, zIndex: props.zIndex ?? 0, parent: props.parent ?? null, texture: props.texture ?? null, diff --git a/src/core/renderers/canvas/CanvasRenderer.ts b/src/core/renderers/canvas/CanvasRenderer.ts index 02cbd62..cb4dc0f 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -67,13 +67,18 @@ 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: Texture; + 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; @@ -188,7 +193,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 050377a..ba8954b 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -500,12 +500,18 @@ 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. + let tx: Texture; + 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; @@ -564,7 +570,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 must sample its full texture. + const tc = + node.placeholderActive === true + ? this.defaultTextureCoords + : node.textureCoords || this.defaultTextureCoords; const cTl = node.premultipliedColorTl; const cTr = node.premultipliedColorTr; diff --git a/visual-regression/certified-snapshots/chromium-ci-canvas/texture-placeholder-image-1.png b/visual-regression/certified-snapshots/chromium-ci-canvas/texture-placeholder-image-1.png new file mode 100644 index 0000000..fe680ff Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci-canvas/texture-placeholder-image-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-image-1.png b/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-image-1.png new file mode 100644 index 0000000..371440f Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-image-1.png differ