From ed300523cd958c6f9e2c34ac65da01b0a8ea8268 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 11 Jun 2026 21:52:11 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20ignoreParentAlpha=20=E2=80=94?= =?UTF-8?q?=20render=20a=20node=20at=20its=20own=20alpha=20while=20ancesto?= =?UTF-8?q?rs=20fade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an ignoreParentAlpha boolean prop to CoreNode. When enabled, the node's world alpha is computed from its own alpha only instead of parent.worldAlpha * alpha, so the node stays visible while its parent (and the rest of the subtree) fades — including at parent alpha 0. The node's own alpha still applies and its descendants inherit its world alpha as usual. The render list builder culls whole subtrees at worldAlpha === 0, which would make an ignoring child disappear exactly when a fade-out completes. To keep that cull correct, each node now maintains an ignoreParentAlphaCount (nodes in its subtree, itself included, with the prop enabled), updated on the cold paths — prop setter, addChild/ removeChild, and construction — so the hot-path cull only gains one comparison that short-circuits unless worldAlpha is already 0. Counts err toward traversal (conservative skip). No effect inside RTT subtrees (the composited quad still fades as one unit); documented on the prop. Includes unit tests for the alpha math, renderability under an alpha-0 parent, premultiplied colors, toggling, and counter propagation through attach/toggle/reparent, plus a visual regression test with certified snapshot covering rects and text nodes. Co-Authored-By: Claude Fable 5 --- examples/tests/alpha-ignore-parent.ts | 159 ++++++++++++++++++ src/core/CoreNode.test.ts | 135 +++++++++++++++ src/core/CoreNode.ts | 78 ++++++++- src/core/CoreTextNode.test.ts | 1 + src/core/Stage.ts | 7 +- .../chromium-ci/alpha-ignore-parent-1.png | Bin 0 -> 12592 bytes 6 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 examples/tests/alpha-ignore-parent.ts create mode 100644 visual-regression/certified-snapshots/chromium-ci/alpha-ignore-parent-1.png diff --git a/examples/tests/alpha-ignore-parent.ts b/examples/tests/alpha-ignore-parent.ts new file mode 100644 index 0000000..15fdf6f --- /dev/null +++ b/examples/tests/alpha-ignore-parent.ts @@ -0,0 +1,159 @@ +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: normal child is invisible, + // ignoreParentAlpha child remains fully visible + const invisibleParent = renderer.createNode({ + x: 700, + y: 40, + w: 560, + h: 200, + alpha: 0, + 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..40f4028 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,140 @@ 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('stays renderable when the parent world alpha is 0', () => { + const parent = makeParent(0); + 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('maintains ancestor subtree counts through attach, toggle, and detach', () => { + const root = makeParent(1); + const mid = new CoreNode(stage, defaultProps({ parent: root })); + const leaf = new CoreNode( + stage, + defaultProps({ parent: mid, ignoreParentAlpha: true }), + ); + + // Construction with the prop set propagates up the chain + expect(leaf.ignoreParentAlphaCount).toBe(1); + expect(mid.ignoreParentAlphaCount).toBe(1); + expect(root.ignoreParentAlphaCount).toBe(1); + + // Toggling off clears the chain + leaf.ignoreParentAlpha = false; + expect(leaf.ignoreParentAlphaCount).toBe(0); + expect(mid.ignoreParentAlphaCount).toBe(0); + expect(root.ignoreParentAlphaCount).toBe(0); + + // Reparenting moves the count from the old chain to the new one + leaf.ignoreParentAlpha = true; + const otherRoot = makeParent(1); + leaf.parent = otherRoot; + expect(mid.ignoreParentAlphaCount).toBe(0); + expect(root.ignoreParentAlphaCount).toBe(0); + expect(otherRoot.ignoreParentAlphaCount).toBe(1); + }); + + 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..a2444a3 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,24 @@ 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 — including when the parent's alpha reaches 0. + * + * 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 * @@ -867,6 +886,17 @@ export class CoreNode extends EventEmitter { public _globalIsTranslate = true; public worldAlpha = 1; + /** + * Number of nodes in this subtree (this node included) with + * {@link CoreNodeProps.ignoreParentAlpha} enabled. + * + * @remarks + * Maintained on the cold paths (prop setter, attach/detach) so the + * render-list `worldAlpha === 0` subtree cull can keep traversing into + * faded subtrees that still contain visible nodes, at the cost of a single + * extra comparison that only evaluates for fully transparent nodes. + */ + public ignoreParentAlphaCount = 0; public premultipliedColorTl = 0; public premultipliedColorTr = 0; public premultipliedColorBl = 0; @@ -906,6 +936,11 @@ export class CoreNode extends EventEmitter { // detect the change. const { texture, shader, src, rtt, boundsMargin, parent } = props; const p = (this.props = props); + // Must be set before the parent.addChild() below so the subtree count + // propagates to ancestors on attach. + if (p.ignoreParentAlpha === true) { + this.ignoreParentAlphaCount = 1; + } p.texture = null; p.shader = null; p.src = null; @@ -1453,7 +1488,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 | @@ -2184,7 +2222,23 @@ export class CoreNode extends EventEmitter { this.stage.requestRenderListUpdate(); } + /** + * Adds `delta` to the `ignoreParentAlphaCount` of this node and every + * ancestor up to the root. + */ + adjustIgnoreParentAlphaCount(delta: number): void { + this.ignoreParentAlphaCount += delta; + let node: CoreNode | null = this.props.parent; + while (node !== null) { + node.ignoreParentAlphaCount += delta; + node = node.props.parent; + } + } + removeChild(node: CoreNode, targetParent: CoreNode | null = null) { + if (node.ignoreParentAlphaCount !== 0) { + this.adjustIgnoreParentAlphaCount(-node.ignoreParentAlphaCount); + } if (targetParent === null) { if ( USE_RTT && @@ -2206,6 +2260,9 @@ export class CoreNode extends EventEmitter { } addChild(node: CoreNode, previousParent: CoreNode | null = null) { + if (node.ignoreParentAlphaCount !== 0) { + this.adjustIgnoreParentAlphaCount(node.ignoreParentAlphaCount); + } const inRttCluster = USE_RTT && (this.props.rtt === true || this.parentHasRenderTexture === true); @@ -2523,6 +2580,25 @@ 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.adjustIgnoreParentAlphaCount(value === true ? 1 : -1); + 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..6752bf2 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, @@ -753,9 +754,10 @@ export class Stage { for (let i = 0; i < len; i++) { const child = children[i] as CoreNode; - // Skip invisible subtrees + // Skip invisible subtrees — unless a descendant ignores parent alpha + // and may still be visible inside the faded subtree if ( - child.worldAlpha === 0 || + (child.worldAlpha === 0 && child.ignoreParentAlphaCount === 0) || child.renderState === CoreNodeRenderState.OutOfBounds ) { continue; @@ -1039,6 +1041,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 0000000000000000000000000000000000000000..adf9ffd4abd43d9f03be505f96e88069d633191c GIT binary patch literal 12592 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 Date: Thu, 11 Jun 2026 22:13:49 -0400 Subject: [PATCH 2/2] =?UTF-8?q?refactor(core):=20drop=20ignoreParentAlphaC?= =?UTF-8?q?ount=20=E2=80=94=20cull=20ignoring=20nodes=20at=20worldAlpha=20?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the subtree counter that let the render-list builder traverse into fully transparent subtrees containing ignoreParentAlpha nodes. The worldAlpha === 0 cull now applies unconditionally: ignoreParentAlpha only has an effect while every ancestor's alpha is above 0, and the node disappears with the subtree once an ancestor reaches exactly 0. This trades the alpha-0 edge case for zero bookkeeping on the prop setter, attach/detach, and construction paths, and restores the original single-comparison cull. Documented on the prop's TSDoc; unit and visual tests updated to pin the new semantics. Co-Authored-By: Claude Fable 5 --- examples/tests/alpha-ignore-parent.ts | 7 +-- src/core/CoreNode.test.ts | 32 +------------ src/core/CoreNode.ts | 43 +++--------------- src/core/Stage.ts | 5 +- .../chromium-ci/alpha-ignore-parent-1.png | Bin 12592 -> 12657 bytes 5 files changed, 14 insertions(+), 73 deletions(-) diff --git a/examples/tests/alpha-ignore-parent.ts b/examples/tests/alpha-ignore-parent.ts index 15fdf6f..c802525 100644 --- a/examples/tests/alpha-ignore-parent.ts +++ b/examples/tests/alpha-ignore-parent.ts @@ -49,14 +49,15 @@ export default async function test({ renderer, testRoot }: ExampleSettings) { parent: fadedParent, }); - // 2. Parent at alpha 0: normal child is invisible, - // ignoreParentAlpha child remains fully visible + // 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, + alpha: 0.05, parent: testRoot, }); renderer.createNode({ diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 40f4028..6ed1235 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -294,8 +294,8 @@ describe('set color()', () => { expect(node.worldAlpha).toBe(0.8); }); - it('stays renderable when the parent world alpha is 0', () => { - const parent = makeParent(0); + 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 }), @@ -352,34 +352,6 @@ describe('set color()', () => { expect(node.updateType).toBe(updateTypeBefore); }); - it('maintains ancestor subtree counts through attach, toggle, and detach', () => { - const root = makeParent(1); - const mid = new CoreNode(stage, defaultProps({ parent: root })); - const leaf = new CoreNode( - stage, - defaultProps({ parent: mid, ignoreParentAlpha: true }), - ); - - // Construction with the prop set propagates up the chain - expect(leaf.ignoreParentAlphaCount).toBe(1); - expect(mid.ignoreParentAlphaCount).toBe(1); - expect(root.ignoreParentAlphaCount).toBe(1); - - // Toggling off clears the chain - leaf.ignoreParentAlpha = false; - expect(leaf.ignoreParentAlphaCount).toBe(0); - expect(mid.ignoreParentAlphaCount).toBe(0); - expect(root.ignoreParentAlphaCount).toBe(0); - - // Reparenting moves the count from the old chain to the new one - leaf.ignoreParentAlpha = true; - const otherRoot = makeParent(1); - leaf.parent = otherRoot; - expect(mid.ignoreParentAlphaCount).toBe(0); - expect(root.ignoreParentAlphaCount).toBe(0); - expect(otherRoot.ignoreParentAlphaCount).toBe(1); - }); - it('descendants inherit the node world alpha as usual', () => { const parent = makeParent(0.5); const node = new CoreNode( diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index a2444a3..001d2e8 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -259,7 +259,12 @@ export interface CoreNodeProps { * 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 — including when the parent's alpha reaches 0. + * 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. * @@ -886,17 +891,6 @@ export class CoreNode extends EventEmitter { public _globalIsTranslate = true; public worldAlpha = 1; - /** - * Number of nodes in this subtree (this node included) with - * {@link CoreNodeProps.ignoreParentAlpha} enabled. - * - * @remarks - * Maintained on the cold paths (prop setter, attach/detach) so the - * render-list `worldAlpha === 0` subtree cull can keep traversing into - * faded subtrees that still contain visible nodes, at the cost of a single - * extra comparison that only evaluates for fully transparent nodes. - */ - public ignoreParentAlphaCount = 0; public premultipliedColorTl = 0; public premultipliedColorTr = 0; public premultipliedColorBl = 0; @@ -936,11 +930,6 @@ export class CoreNode extends EventEmitter { // detect the change. const { texture, shader, src, rtt, boundsMargin, parent } = props; const p = (this.props = props); - // Must be set before the parent.addChild() below so the subtree count - // propagates to ancestors on attach. - if (p.ignoreParentAlpha === true) { - this.ignoreParentAlphaCount = 1; - } p.texture = null; p.shader = null; p.src = null; @@ -2222,23 +2211,7 @@ export class CoreNode extends EventEmitter { this.stage.requestRenderListUpdate(); } - /** - * Adds `delta` to the `ignoreParentAlphaCount` of this node and every - * ancestor up to the root. - */ - adjustIgnoreParentAlphaCount(delta: number): void { - this.ignoreParentAlphaCount += delta; - let node: CoreNode | null = this.props.parent; - while (node !== null) { - node.ignoreParentAlphaCount += delta; - node = node.props.parent; - } - } - removeChild(node: CoreNode, targetParent: CoreNode | null = null) { - if (node.ignoreParentAlphaCount !== 0) { - this.adjustIgnoreParentAlphaCount(-node.ignoreParentAlphaCount); - } if (targetParent === null) { if ( USE_RTT && @@ -2260,9 +2233,6 @@ export class CoreNode extends EventEmitter { } addChild(node: CoreNode, previousParent: CoreNode | null = null) { - if (node.ignoreParentAlphaCount !== 0) { - this.adjustIgnoreParentAlphaCount(node.ignoreParentAlphaCount); - } const inRttCluster = USE_RTT && (this.props.rtt === true || this.parentHasRenderTexture === true); @@ -2589,7 +2559,6 @@ export class CoreNode extends EventEmitter { return; } this.props.ignoreParentAlpha = value; - this.adjustIgnoreParentAlphaCount(value === true ? 1 : -1); this.setUpdateType( UpdateType.PremultipliedColors | UpdateType.WorldAlpha | diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 6752bf2..53be64d 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -754,10 +754,9 @@ export class Stage { for (let i = 0; i < len; i++) { const child = children[i] as CoreNode; - // Skip invisible subtrees — unless a descendant ignores parent alpha - // and may still be visible inside the faded subtree + // Skip invisible subtrees if ( - (child.worldAlpha === 0 && child.ignoreParentAlphaCount === 0) || + child.worldAlpha === 0 || child.renderState === CoreNodeRenderState.OutOfBounds ) { continue; 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 index adf9ffd4abd43d9f03be505f96e88069d633191c..025b0aeeca3269e1fc8cdeb1f2d693e09afe7e45 100644 GIT binary patch literal 12657 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