diff --git a/examples/tests/alpha-ignore-parent.ts b/examples/tests/alpha-ignore-parent.ts new file mode 100644 index 0000000..c802525 --- /dev/null +++ b/examples/tests/alpha-ignore-parent.ts @@ -0,0 +1,160 @@ +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + // Checkerboard background so partial transparency is visible + const tile = 100; + for (let row = 0; row < 8; row++) { + for (let col = 0; col < 13; col++) { + renderer.createNode({ + x: col * tile, + y: row * tile, + w: tile, + h: tile, + color: (row + col) % 2 === 0 ? 0x113355ff : 0xddaa33ff, + parent: testRoot, + }); + } + } + + // 1. Parent at alpha 0.4: normal child fades with it, + // ignoreParentAlpha child renders at its own alpha (1) + const fadedParent = renderer.createNode({ + x: 40, + y: 40, + w: 560, + h: 200, + alpha: 0.4, + parent: testRoot, + }); + renderer.createNode({ + x: 0, + y: 0, + w: 260, + h: 200, + color: 0xff0000ff, + parent: fadedParent, + }); + renderer.createNode({ + x: 300, + y: 0, + w: 260, + h: 200, + color: 0xff0000ff, + ignoreParentAlpha: true, + parent: fadedParent, + }); + + // 2. Parent at alpha 0.05: normal child is nearly invisible, + // ignoreParentAlpha child remains fully visible. (At alpha exactly 0 + // the whole subtree is culled from rendering, by design.) + const invisibleParent = renderer.createNode({ + x: 700, + y: 40, + w: 560, + h: 200, + alpha: 0.05, + parent: testRoot, + }); + renderer.createNode({ + x: 0, + y: 0, + w: 260, + h: 200, + color: 0x00ff00ff, + parent: invisibleParent, + }); + renderer.createNode({ + x: 300, + y: 0, + w: 260, + h: 200, + color: 0x00ff00ff, + ignoreParentAlpha: true, + parent: invisibleParent, + }); + + // 3. The ignoring node's own alpha still applies (0.5), and its + // descendants inherit its world alpha as usual (0.5 * 0.5 = 0.25) + const fadedParent2 = renderer.createNode({ + x: 40, + y: 320, + w: 560, + h: 200, + alpha: 0.1, + parent: testRoot, + }); + const halfAlphaChild = renderer.createNode({ + x: 0, + y: 0, + w: 260, + h: 200, + color: 0x0000ffff, + alpha: 0.5, + ignoreParentAlpha: true, + parent: fadedParent2, + }); + renderer.createNode({ + x: 300, + y: 0, + w: 260, + h: 200, + color: 0x0000ffff, + alpha: 0.5, + parent: halfAlphaChild, + }); + + // 4. Text node child: ignoreParentAlpha keeps text readable while the + // parent fades + const textParent = renderer.createNode({ + x: 700, + y: 320, + w: 560, + h: 200, + alpha: 0.1, + parent: testRoot, + }); + renderer.createTextNode({ + x: 0, + y: 0, + text: 'Faded with parent', + fontFamily: 'Ubuntu', + fontSize: 40, + color: 0x000000ff, + parent: textParent, + }); + renderer.createTextNode({ + x: 0, + y: 80, + text: 'ignoreParentAlpha', + fontFamily: 'Ubuntu', + fontSize: 40, + color: 0x000000ff, + ignoreParentAlpha: true, + parent: textParent, + }); + + // 5. Toggling back off behaves like a normal child again + const toggledParent = renderer.createNode({ + x: 40, + y: 600, + w: 560, + h: 200, + alpha: 0.4, + parent: testRoot, + }); + const toggled = renderer.createNode({ + x: 0, + y: 0, + w: 260, + h: 200, + color: 0xff00ffff, + ignoreParentAlpha: true, + parent: toggledParent, + }); + toggled.ignoreParentAlpha = false; +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index d9787e1..6ed1235 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -13,6 +13,7 @@ import { premultiplyColorABGR } from '../utils.js'; describe('set color()', () => { const defaultProps = (overrides?: Partial): CoreNodeProps => ({ alpha: 0, + ignoreParentAlpha: false, autosize: false, boundsMargin: null, clipping: false, @@ -264,6 +265,112 @@ describe('set color()', () => { }); }); + describe('ignoreParentAlpha', () => { + const makeParent = (worldAlpha: number) => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = worldAlpha; + return parent; + }; + + it('multiplies parent world alpha by default', () => { + const parent = makeParent(0.5); + const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 })); + + node.update(0, clippingRect); + + expect(node.worldAlpha).toBeCloseTo(0.4); + }); + + it('uses own alpha only when enabled', () => { + const parent = makeParent(0.5); + const node = new CoreNode( + stage, + defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }), + ); + + node.update(0, clippingRect); + + expect(node.worldAlpha).toBe(0.8); + }); + + it('keeps its own world alpha while the parent fades toward 0', () => { + const parent = makeParent(0.01); + const node = new CoreNode( + stage, + defaultProps({ parent, alpha: 1, ignoreParentAlpha: true }), + ); + node.w = 100; + node.h = 100; + node.color = 0xffffffff; + + node.update(0, clippingRect); + + expect(node.worldAlpha).toBe(1); + expect(node.isRenderable).toBe(true); + }); + + it('premultiplies colors with the node own alpha when enabled', () => { + const parent = makeParent(0.25); + const node = new CoreNode( + stage, + defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }), + ); + node.w = 100; + node.h = 100; + node.color = 0xff0000ff; + + node.update(0, clippingRect); + + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0xff0000ff, 0.8), + ); + }); + + it('toggling the setter recomputes world alpha', () => { + const parent = makeParent(0.5); + const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 })); + + node.update(0, clippingRect); + expect(node.worldAlpha).toBeCloseTo(0.4); + + node.ignoreParentAlpha = true; + node.update(1, clippingRect); + expect(node.worldAlpha).toBe(0.8); + + node.ignoreParentAlpha = false; + node.update(2, clippingRect); + expect(node.worldAlpha).toBeCloseTo(0.4); + }); + + it('setting the same value does not flag an update', () => { + const node = new CoreNode(stage, defaultProps()); + const updateTypeBefore = node.updateType; + + node.ignoreParentAlpha = false; + + expect(node.updateType).toBe(updateTypeBefore); + }); + + it('descendants inherit the node world alpha as usual', () => { + const parent = makeParent(0.5); + const node = new CoreNode( + stage, + defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }), + ); + const child = new CoreNode( + stage, + defaultProps({ parent: node, alpha: 0.5 }), + ); + + node.update(0, clippingRect); + child.update(0, clippingRect); + + expect(node.worldAlpha).toBe(0.8); + expect(child.worldAlpha).toBeCloseTo(0.4); + }); + }); + describe('autosize system', () => { it('should initialize with autosize disabled', () => { const node = new CoreNode(stage, defaultProps()); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 62476aa..001d2e8 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -136,6 +136,7 @@ export enum UpdateType { * @remarks * CoreNode Properties Updated: * - `worldAlpha` = `parent.worldAlpha` * `alpha` + * (or just `alpha` when `ignoreParentAlpha` is enabled) */ WorldAlpha = 64, @@ -250,6 +251,29 @@ export interface CoreNodeProps { * @default `1` */ alpha: number; + /** + * When enabled, the Node's world alpha is computed from its own + * {@link alpha} only, ignoring the alpha inherited from its ancestors. + * + * @remarks + * Normally `worldAlpha = parent.worldAlpha * alpha`, so fading a parent + * fades every descendant with it. With `ignoreParentAlpha` enabled this + * Node keeps rendering at its own alpha while its parent (and the rest of + * the subtree) fades. + * + * Subtrees whose world alpha reaches exactly 0 are culled from rendering + * entirely, so this Node still disappears once an ancestor hits alpha 0 — + * the prop only has an effect while every ancestor's alpha is above 0. + * This keeps the fully-transparent subtree cull free of bookkeeping. + * + * Descendants of this Node inherit from its world alpha as usual. + * + * Has no effect inside a render-to-texture subtree: the RTT root's + * composited quad is still faded as a single unit by its own world alpha. + * + * @default `false` + */ + ignoreParentAlpha: boolean; /** * Autosize * @@ -1453,7 +1477,10 @@ export class CoreNode extends EventEmitter { } if (updateType & UpdateType.WorldAlpha) { - this.worldAlpha = parent.worldAlpha * this.props.alpha; + this.worldAlpha = + props.ignoreParentAlpha === true + ? props.alpha + : parent.worldAlpha * props.alpha; updateType |= UpdateType.PremultipliedColors | UpdateType.Children | @@ -2523,6 +2550,24 @@ export class CoreNode extends EventEmitter { this.childUpdateType |= UpdateType.WorldAlpha; } + get ignoreParentAlpha(): boolean { + return this.props.ignoreParentAlpha; + } + + set ignoreParentAlpha(value: boolean) { + if (this.props.ignoreParentAlpha === value) { + return; + } + this.props.ignoreParentAlpha = value; + this.setUpdateType( + UpdateType.PremultipliedColors | + UpdateType.WorldAlpha | + UpdateType.Children | + UpdateType.IsRenderable, + ); + this.childUpdateType |= UpdateType.WorldAlpha; + } + get autosize(): boolean { return this.props.autosize; } diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index 4168d8f..42cd324 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -17,6 +17,7 @@ const defaultProps = ( ): CoreTextNodeProps => ({ // CoreNodeProps alpha: 1, + ignoreParentAlpha: false, autosize: false, boundsMargin: null, clipping: false, diff --git a/src/core/Stage.ts b/src/core/Stage.ts index eee60f4..53be64d 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -379,6 +379,7 @@ export class Stage { w: appWidth, h: appHeight, alpha: 1, + ignoreParentAlpha: false, autosize: false, boundsMargin: null, clipping: false, @@ -1039,6 +1040,7 @@ export class Stage { w: props.w ?? 0, h: props.h ?? 0, alpha: props.alpha ?? 1, + ignoreParentAlpha: props.ignoreParentAlpha ?? false, autosize: props.autosize ?? false, boundsMargin: props.boundsMargin ?? null, clipping: props.clipping ?? false, diff --git a/visual-regression/certified-snapshots/chromium-ci/alpha-ignore-parent-1.png b/visual-regression/certified-snapshots/chromium-ci/alpha-ignore-parent-1.png new file mode 100644 index 0000000..025b0ae Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/alpha-ignore-parent-1.png differ