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 7411be1..56fe3bb 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -26,6 +26,7 @@ describe('set color()', () => { colorTop: 0, colorTr: 0, placeholderColor: 0, + placeholderImage: null, h: 0, mount: 0, mountX: 0, @@ -1463,4 +1464,340 @@ describe('set color()', () => { expect(node.renderTexture).toBe(texture); }); }); + + 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, 200, 200), + defaultTexture: { + state: 'loaded', + }, + renderer: mock() as CoreRenderer, + txManager: { + createTexture, + loadTexture, + } as unknown as Stage['txManager'], + }); + 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; + }; + } + + 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 detaches the node from the shared placeholder texture', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + expect(placeholder.hasListeners()).toBe(true); + + node.destroy(); + + expect(placeholder.hasListeners()).toBe(false); + }); + + 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.placeholderImage = 'placeholder-a.png'; + expect(first.hasListeners()).toBe(true); + + node.placeholderImage = 'placeholder-b.png'; + + expect(first.hasListeners()).toBe(false); + expect(second.hasListeners()).toBe(true); + expect(node.placeholderTexture).toBe(second); + }); + + it('clearing placeholderImage detaches 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(placeholder.hasListeners()).toBe(false); + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).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 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/CoreNode.ts b/src/core/CoreNode.ts index 321076c..60f7d9c 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -409,6 +409,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. * @@ -811,14 +834,28 @@ export class CoreNode extends EventEmitter { public textureLoaded = 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`. Owned by the placeholderImage setter. + */ + public placeholderTexture: Texture | null = null; + + /** + * Cached `placeholderTexture.state === 'loaded'` (avoids per-quad string + * compares). Maintained by the placeholder texture event handlers. + */ + public placeholderTextureLoaded = false; + public updateType = UpdateType.All; public childUpdateType = UpdateType.None; @@ -904,7 +941,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; @@ -912,6 +957,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 ( @@ -955,6 +1001,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; } @@ -992,9 +1041,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; @@ -1004,6 +1054,94 @@ 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 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; @@ -1485,10 +1623,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 = @@ -2085,6 +2228,9 @@ 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); this.isRenderable = false; if (this.hasShaderTimeFn === true) { @@ -2129,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; @@ -2618,6 +2767,28 @@ 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 colorTop(): number { return this.props.colorTop; } diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index c1edaf6..03917e8 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -29,6 +29,7 @@ const defaultProps = ( colorTop: 0xffffffff, colorTr: 0xffffffff, placeholderColor: 0, + placeholderImage: null, h: 0, mount: 0, mountX: 0, diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 39941e2..264a08a 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -383,6 +383,7 @@ export class Stage { colorTl: 0x00000000, colorTr: 0x00000000, placeholderColor: 0x00000000, + placeholderImage: null, colorBl: 0x00000000, colorBr: 0x00000000, zIndex: 0, @@ -1045,6 +1046,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 c995611..04b51f5 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -52,13 +52,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; + 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 +180,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..6ee0c09 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -471,12 +471,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; + 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 +541,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/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