diff --git a/examples/tests/texture-placeholder-color.ts b/examples/tests/texture-placeholder-color.ts new file mode 100644 index 0000000..f3c5553 --- /dev/null +++ b/examples/tests/texture-placeholder-color.ts @@ -0,0 +1,129 @@ +import type { INode } from '@lightningjs/renderer'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +import rockoPng from '../assets/rocko.png'; + +/** + * Visual test for `placeholderColor`: a node with a texture renders a solid + * color rect (through its shader, so rounded corners apply) until the texture + * loads, instead of rendering nothing. + * + * Deterministic states captured in the snapshot: + * 1. Rounded placeholder for a permanently failed src (placeholder remains). + * 2. RoundedWithBorder placeholder for a permanently failed src. + * 3. A loaded image with placeholderColor set — the image shows untinted, + * proving the placeholder does not leak into the loaded state. + * 4. Control: failed src without placeholderColor renders nothing. + */ + +const MISSING_SRC = '/does-not-exist-placeholder-test.png'; + +function waitForEvent( + 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 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() (loaded/failed events already awaited), + // so the 'idle' transition may have already fired — don't wait for it. + // Force a final frame and let it draw instead. + settings.renderer.rerender(); + await delay(100); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + renderer.createTextNode({ + fontFamily: 'Ubuntu', + text: 'placeholderColor', + 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 stays visible for a permanently failed texture (rounded). + const failedRounded = renderer.createNode({ + x: 20, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderColor: 0x336699ff, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 2. Same with a border shader. + const failedBordered = renderer.createNode({ + x: 250, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderColor: 0x993311ff, + shader: renderer.createShader('RoundedWithBorder', { + radius: [20], + 'border-w': 8, + }), + parent: testRoot, + }); + + // 3. A successfully loaded image with a placeholder configured must show + // the image, untinted. + const loadedImage = renderer.createNode({ + x: 480, + y: 80, + w: 181, + h: 218, + src: rockoPng, + placeholderColor: 0x336699ff, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 4. Control: a failed texture without a placeholder renders nothing. + renderer.createNode({ + x: 710, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + parent: testRoot, + }); + + const settled = await Promise.all([ + waitForEvent(failedRounded, 'failed', 10000), + waitForEvent(failedBordered, 'failed', 10000), + waitForEvent(loadedImage, 'loaded', 10000), + ]); + + if (settled[0] === false || settled[1] === false || settled[2] === false) { + console.error('[texture-placeholder-color] scene did not settle', settled); + return false; + } + + console.log('[texture-placeholder-color] scene settled'); + return true; +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index bbcc44f..7411be1 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -7,6 +7,8 @@ import { type TextureOptions } from './CoreTextureManager.js'; import { createBound } from './lib/utils.js'; import { ImageTexture } from './textures/ImageTexture.js'; import { Matrix3d } from './lib/Matrix3d.js'; +import { EventEmitter } from '../common/EventEmitter.js'; +import { premultiplyColorABGR } from '../utils.js'; describe('set color()', () => { const defaultProps = (overrides?: Partial): CoreNodeProps => ({ @@ -23,6 +25,7 @@ describe('set color()', () => { colorTl: 0, colorTop: 0, colorTr: 0, + placeholderColor: 0, h: 0, mount: 0, mountX: 0, @@ -1263,4 +1266,201 @@ describe('set color()', () => { expect(shader.update).toHaveBeenCalledTimes(2); }); }); + + describe('placeholderColor', () => { + // A texture fake on a real EventEmitter so CoreNode's loadTextureTask + // subscribes for real and we can drive the loaded/freed/failed handler + // chain by emitting, like the engine does. + 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; + }; + } + + function visibleNode(): 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; + } + + // Flush the queueMicrotask(loadTextureTask) so listeners attach. + const flushMicrotasks = () => Promise.resolve(); + + it('renders the placeholder while the texture is loading', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + node.texture = emittingTexture('initial'); + + node.update(0, clippingRect); + + expect(node.placeholderActive).toBe(true); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + + const expected = premultiplyColorABGR(0x336699ff, 1); + expect(node.premultipliedColorTl).toBe(expected); + expect(node.premultipliedColorTr).toBe(expected); + expect(node.premultipliedColorBl).toBe(expected); + expect(node.premultipliedColorBr).toBe(expected); + }); + + it('is inactive without a placeholderColor (loading renders nothing)', () => { + const node = visibleNode(); + node.texture = emittingTexture('initial'); + + node.update(0, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(false); + }); + + it('is inactive without a texture (color-only nodes are unaffected)', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + + node.update(0, clippingRect); + + expect(node.placeholderActive).toBe(false); + }); + + it('switches to the texture and regular colors once loaded', async () => { + const node = visibleNode(); + node.color = 0xffffffff; + node.placeholderColor = 0x336699ff; + const texture = emittingTexture('initial'); + node.texture = texture; + node.update(0, clippingRect); + expect(node.renderTexture).toBe(stage.defaultTexture); + + await flushMicrotasks(); + (texture as { state: string }).state = 'loaded'; + texture.emit('loaded', { w: 100, h: 100 }); + node.isQuadDirty = false; + node.update(1, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(texture); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0xffffffff, 1), + ); + // The color switch must reach the GPU quad buffer + expect(node.isQuadDirty).toBe(true); + }); + + it('shows the placeholder again while a freed texture reloads', async () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + const texture = emittingTexture('initial'); + node.texture = texture; + node.update(0, clippingRect); + + await flushMicrotasks(); + (texture as { state: string }).state = 'loaded'; + texture.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + expect(node.placeholderActive).toBe(false); + + (texture as { state: string }).state = 'freed'; + texture.emit('freed'); + node.update(2, clippingRect); + + expect(node.placeholderActive).toBe(true); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0x336699ff, 1), + ); + }); + + it('keeps the placeholder when the texture permanently fails', async () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + const texture = emittingTexture('initial'); + node.texture = texture; + node.update(0, clippingRect); + + await flushMicrotasks(); + (texture as { state: string }).state = 'failed'; + (texture as { retryCount: number }).retryCount = 2; // > maxRetryCount (1) + texture.emit('failed', new Error('404')); + node.update(1, clippingRect); + + expect(node.placeholderActive).toBe(true); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + }); + + it('a permanently failed texture without a placeholder stays non-renderable', async () => { + const node = visibleNode(); + const texture = emittingTexture('initial'); + node.texture = texture; + node.update(0, clippingRect); + + await flushMicrotasks(); + (texture as { state: string }).state = 'failed'; + (texture as { retryCount: number }).retryCount = 2; + texture.emit('failed', new Error('404')); + node.update(1, clippingRect); + + expect(node.isRenderable).toBe(false); + }); + + it('deactivates when placeholderColor is cleared while loading', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + expect(node.isRenderable).toBe(true); + + node.placeholderColor = 0; + node.update(1, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(false); + }); + + it('updates the shown color when placeholderColor changes while active', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + + node.placeholderColor = 0x993311ff; + node.update(1, clippingRect); + + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0x993311ff, 1), + ); + }); + + it('does not activate when the assigned texture is already loaded', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + const texture = emittingTexture('loaded'); + node.texture = texture; + + node.update(0, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.renderTexture).toBe(texture); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 6f1386c..321076c 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -392,6 +392,23 @@ export interface CoreNodeProps { * rendering. */ colorBl: number; + /** + * Placeholder color shown while the Node's texture is not yet loaded. + * + * @remarks + * When set to a non-zero color and the Node has a texture (e.g. {@link src}), + * the Node renders a solid rectangle of this color until the texture + * finishes loading, instead of rendering nothing. The placeholder renders + * through the Node's shader, so rounded corners and borders apply to it. + * It also shows again if the texture is freed under memory pressure (until + * the reload completes) and remains if the texture permanently fails. + * + * The color value is a number in the format 0xRRGGBBAA. Set to `0` to + * disable (default). Has no effect on Nodes without a texture. + * + * @default `0` + */ + placeholderColor: number; /** * The Node's parent Node. * @@ -793,6 +810,15 @@ export class CoreNode extends EventEmitter { private hasColorProps = false; 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 + * {@link updatePlaceholderActive} — never written elsewhere. + */ + public placeholderActive = false; + public updateType = UpdateType.All; public childUpdateType = UpdateType.None; @@ -953,6 +979,31 @@ export class CoreNode extends EventEmitter { } //#region Textures + /** + * Recompute {@link placeholderActive} after any of its inputs changed + * (placeholderColor, texture, textureLoaded). + * + * @remarks + * On a toggle this raises `PremultipliedColors` (the quad's vertex colors + * switch between the placeholder color and the regular color props — this + * also marks the quad dirty) and `IsRenderable` (a loading texture with a + * placeholder is renderable). Both are processed in the same frame's update + * pass, before quads are submitted. + */ + private updatePlaceholderActive(): void { + const active = + this.props.placeholderColor !== 0 && + this.props.texture !== null && + this.textureLoaded === false; + + if (active !== this.placeholderActive) { + this.placeholderActive = active; + this.setUpdateType( + UpdateType.PremultipliedColors | UpdateType.IsRenderable, + ); + } + } + loadTexture(): void { if (this.props.texture === null) { return; @@ -1023,6 +1074,7 @@ export class CoreNode extends EventEmitter { } this.textureLoaded = true; + this.updatePlaceholderActive(); this.setUpdateType(UpdateType.IsRenderable); // Texture was loaded. In case the RAF loop has already stopped, we request @@ -1057,9 +1109,12 @@ export class CoreNode extends EventEmitter { private onTextureFailed: TextureFailedEventHandler = (_, error) => { // immediately set isRenderable to false, so that we handle the error - // without waiting for the next frame loop + // without waiting for the next frame loop. With a placeholder set, the + // same frame's update pass recomputes this to true and renders the + // placeholder instead. this.textureLoaded = false; this.isRenderable = false; + this.updatePlaceholderActive(); this.updateTextureOwnership(false); this.setUpdateType(UpdateType.IsRenderable); @@ -1081,9 +1136,12 @@ export class CoreNode extends EventEmitter { private onTextureFreed: TextureFreedEventHandler = () => { // immediately set isRenderable to false, so that we handle the error - // without waiting for the next frame loop + // without waiting for the next frame loop. With a placeholder set, the + // same frame's update pass recomputes this to true and renders the + // placeholder while the texture reloads. this.textureLoaded = false; this.isRenderable = false; + this.updatePlaceholderActive(); this.updateTextureOwnership(false); this.setUpdateType(UpdateType.IsRenderable); @@ -1426,27 +1484,39 @@ export class CoreNode extends EventEmitter { if (updateType & UpdateType.PremultipliedColors) { const alpha = this.worldAlpha; - const tl = props.colorTl; - const tr = props.colorTr; - const bl = props.colorBl; - const br = props.colorBr; - - // Fast equality check (covers all 4 corners) - const same = tl === tr && tl === bl && tl === br; - - const merged = premultiplyColorABGR(tl, alpha); - - this.premultipliedColorTl = merged; - - if (same === true) { - this.premultipliedColorTr = + 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); + this.premultipliedColorTl = + this.premultipliedColorTr = this.premultipliedColorBl = this.premultipliedColorBr = merged; } else { - this.premultipliedColorTr = premultiplyColorABGR(tr, alpha); - this.premultipliedColorBl = premultiplyColorABGR(bl, alpha); - this.premultipliedColorBr = premultiplyColorABGR(br, alpha); + const tl = props.colorTl; + const tr = props.colorTr; + const bl = props.colorBl; + const br = props.colorBr; + + // Fast equality check (covers all 4 corners) + const same = tl === tr && tl === bl && tl === br; + + const merged = premultiplyColorABGR(tl, alpha); + + this.premultipliedColorTl = merged; + + if (same === true) { + this.premultipliedColorTr = + this.premultipliedColorBl = + this.premultipliedColorBr = + merged; + } else { + this.premultipliedColorTr = premultiplyColorABGR(tr, alpha); + this.premultipliedColorBl = premultiplyColorABGR(bl, alpha); + this.premultipliedColorBr = premultiplyColorABGR(br, alpha); + } } } @@ -1760,15 +1830,18 @@ export class CoreNode extends EventEmitter { // preemptive check for failed textures this will mark the current node as non-renderable // 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 + // texture has failed to load, we cannot render the texture itself — + // but a placeholder color still renders in its place this.updateTextureOwnership(false); - this.setRenderable(false); + this.setRenderable(this.placeholderActive); return; } needsTextureOwnership = true; - // Use cached boolean instead of string comparison - newIsRenderable = this.textureLoaded; + // Use cached boolean instead of string comparison; a placeholder + // renders while the texture is loading + newIsRenderable = + this.textureLoaded === true || this.placeholderActive === true; } else if ( // check shader (this.props.shader !== this.stage.renderer.getDefaultShaderNode() || @@ -2055,6 +2128,9 @@ export class CoreNode extends EventEmitter { } get renderTexture(): Texture | null { + if (this.placeholderActive === true) { + return this.stage.defaultTexture; + } return this.props.texture || this.stage.defaultTexture; } @@ -2524,6 +2600,24 @@ export class CoreNode extends EventEmitter { this.setUpdateType(UpdateType.PremultipliedColors); } + get placeholderColor(): number { + return this.props.placeholderColor; + } + + set placeholderColor(value: number) { + const p = this.props; + if (p.placeholderColor === value) return; + + p.placeholderColor = value; + this.updatePlaceholderActive(); + + // If the placeholder is (still) showing, the new color must reach the + // quad buffer even though the active state did not toggle. + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + } + get colorTop(): number { return this.props.colorTop; } @@ -2944,6 +3038,7 @@ export class CoreNode extends EventEmitter { this.textureCoords = undefined; this.props.texture = value; this.textureLoaded = value !== null && value.state === 'loaded'; + this.updatePlaceholderActive(); if (value !== null) { if (this.autosizer !== null) { diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index 8031a6b..c1edaf6 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -28,6 +28,7 @@ const defaultProps = ( colorTl: 0xffffffff, colorTop: 0xffffffff, colorTr: 0xffffffff, + placeholderColor: 0, h: 0, mount: 0, mountX: 0, diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 946c3fc..39941e2 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -382,6 +382,7 @@ export class Stage { colorRight: 0x00000000, colorTl: 0x00000000, colorTr: 0x00000000, + placeholderColor: 0x00000000, colorBl: 0x00000000, colorBr: 0x00000000, zIndex: 0, @@ -1043,6 +1044,7 @@ export class Stage { colorTr, colorBl, colorBr, + placeholderColor: props.placeholderColor ?? 0, 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 b13b436..c995611 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -52,7 +52,13 @@ export class CanvasRenderer extends CoreRenderer { const ctx = this.context; const { tx, ty, ta, tb, tc, td } = node.globalTransform!; const clippingRect = node.clippingRect; - let texture = (node.props.texture || this.stage.defaultTexture) as Texture; + // 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; // The Canvas2D renderer only supports image textures, no textures are used for color blocks if (texture !== null) { const textureType = texture.type; diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index cf21100..8fbaccc 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -471,7 +471,12 @@ export class WebGlRenderer extends CoreRenderer { } const props = node.props; - let tx = props.texture || this.stage.defaultTexture!; + // 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!; if (tx.type === TextureType.subTexture) { tx = (tx as SubTexture).parentTexture; diff --git a/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-color-1.png b/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-color-1.png new file mode 100644 index 0000000..ce49ba7 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-color-1.png differ