UNPKG

@lightningjs/renderer

Version:
552 lines (463 loc) 18.3 kB
/* * If not stated otherwise in this file or this component's LICENSE file the * following copyright and licenses apply: * * Copyright 2023 Comcast Cable Communications Management, LLC. * * Licensed under the Apache License, Version 2.0 (the License); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * RTT (Render To Texture) pipeline unit tests for WebGlRenderer. * * These tests validate rttNodes ordering, dirty-flag lifecycle, and skip * conditions without requiring a real WebGL context. The WebGlRenderer is * tested at the level of its internal bookkeeping methods only. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CoreNode, CoreNodeRenderState, UpdateType, type CoreNodeProps, } from '../../CoreNode.js'; import type { Stage } from '../../Stage.js'; import type { CoreRenderer } from '../CoreRenderer.js'; import { createBound } from '../../lib/utils.js'; import type { TextureOptions } from '../../CoreTextureManager.js'; import { Texture } from '../../textures/Texture.js'; import { WebGlRenderer } from './WebGlRenderer.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const makeDefaultProps = (): CoreNodeProps => ({ alpha: 1, autosize: false, boundsMargin: null, clipping: false, color: 0xffffffff, colorBl: 0xffffffff, colorBottom: 0xffffffff, colorBr: 0xffffffff, colorLeft: 0xffffffff, colorRight: 0xffffffff, colorTl: 0xffffffff, colorTop: 0xffffffff, colorTr: 0xffffffff, h: 100, mount: 0, mountX: 0, mountY: 0, parent: null, pivot: 0.5, pivotX: 0.5, pivotY: 0.5, rotation: 0, rtt: false, scale: 1, scaleX: 1, scaleY: 1, shader: null, src: '', texture: null, textureOptions: {} as TextureOptions, w: 100, x: 0, y: 0, zIndex: 0, }); /** * Minimal mock stage that satisfies CoreNode's constructor requirements. */ const makeStage = (): Stage => mock<Stage>({ strictBound: createBound(0, 0, 1920, 1080), preloadBound: createBound(0, 0, 1920, 1080), defaultTexture: { state: 'loaded' } as unknown as Texture, renderer: mock<CoreRenderer>() as CoreRenderer, txManager: { createTexture: vi.fn().mockReturnValue({ state: 'loaded', ctxTexture: { framebuffer: {} }, }), } as unknown as Stage['txManager'], }); // --------------------------------------------------------------------------- // RTT ordering tests — exercised via renderToTexture() on a real // WebGlRenderer instance created with Object.create so no GL context is needed. // --------------------------------------------------------------------------- /** * Creates a minimal WebGlRenderer instance with only `rttNodes` initialised. * Object.create skips the constructor so no GL context is required. * insertRTTNodeInOrder / findMaxChildRTTIndex / renderToTexture only access * `this.rttNodes`, so this stub is sufficient to exercise the real production * code paths. */ function makeOrderer(): WebGlRenderer { const r = Object.create(WebGlRenderer.prototype) as WebGlRenderer; r.rttNodes = []; return r; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('RTT — rttNodes insertion ordering', () => { let stage: Stage; let orderer: WebGlRenderer; beforeEach(() => { stage = makeStage(); orderer = makeOrderer(); }); it('adds a single RTT node to an empty list', () => { const node = new CoreNode(stage, makeDefaultProps()); orderer.renderToTexture(node); expect(orderer.rttNodes.length).toBe(1); expect(orderer.rttNodes[0]).toBe(node); }); it('does not add the same node twice', () => { const node = new CoreNode(stage, makeDefaultProps()); orderer.renderToTexture(node); orderer.renderToTexture(node); expect(orderer.rttNodes.length).toBe(1); }); it('places a child RTT node BEFORE its RTT parent', () => { const parent = new CoreNode(stage, makeDefaultProps()); const child = new CoreNode(stage, makeDefaultProps()); child.parent = parent; // Parent added first, then child — child must end up before parent orderer.renderToTexture(parent); orderer.renderToTexture(child); const parentIdx = orderer.rttNodes.indexOf(parent); const childIdx = orderer.rttNodes.indexOf(child); expect(childIdx).toBeLessThan(parentIdx); }); it('places a parent RTT node AFTER an already-registered child', () => { const parent = new CoreNode(stage, makeDefaultProps()); const child = new CoreNode(stage, makeDefaultProps()); child.parent = parent; // Child added first, then parent — parent must end up after child orderer.renderToTexture(child); orderer.renderToTexture(parent); const parentIdx = orderer.rttNodes.indexOf(parent); const childIdx = orderer.rttNodes.indexOf(child); expect(childIdx).toBeLessThan(parentIdx); }); it('handles 3-level nested RTT ordering: grandchild < child < parent', () => { const grandparent = new CoreNode(stage, makeDefaultProps()); const parent = new CoreNode(stage, makeDefaultProps()); const grandchild = new CoreNode(stage, makeDefaultProps()); parent.parent = grandparent; grandchild.parent = parent; // Insert in arbitrary order orderer.renderToTexture(grandparent); orderer.renderToTexture(grandchild); orderer.renderToTexture(parent); const gpIdx = orderer.rttNodes.indexOf(grandparent); const pIdx = orderer.rttNodes.indexOf(parent); const gcIdx = orderer.rttNodes.indexOf(grandchild); expect(gcIdx).toBeLessThan(pIdx); expect(pIdx).toBeLessThan(gpIdx); }); it('inserts sibling RTT nodes in insertion order (a before b)', () => { const root = new CoreNode(stage, makeDefaultProps()); const a = new CoreNode(stage, makeDefaultProps()); const b = new CoreNode(stage, makeDefaultProps()); a.parent = root; b.parent = root; orderer.renderToTexture(a); orderer.renderToTexture(b); expect(orderer.rttNodes.length).toBe(2); // Siblings have no ordering constraint relative to each other, so the // expected behaviour is that insertion order is preserved. expect(orderer.rttNodes.indexOf(a)).toBeLessThan( orderer.rttNodes.indexOf(b), ); }); }); // --------------------------------------------------------------------------- // RTT dirty-flag lifecycle — exercised directly on CoreNode fields // (no GL context needed) // --------------------------------------------------------------------------- describe('RTT — hasRTTupdates dirty-flag lifecycle', () => { let stage: Stage; beforeEach(() => { stage = makeStage(); }); it('hasRTTupdates starts as false on a new node', () => { const node = new CoreNode(stage, makeDefaultProps()); expect(node.hasRTTupdates).toBe(false); }); it('notifyParentRTTOfUpdate sets hasRTTupdates on the RTT ancestor', () => { const rttParent = new CoreNode(stage, { ...makeDefaultProps(), rtt: false, }); const child = new CoreNode(stage, makeDefaultProps()); child.parent = rttParent; // Manually wire rttParent flag used by notifyParentRTTOfUpdate path // (normally set by markChildrenWithRTT when rtt is enabled on rttParent) // Simulate the state that exists after rtt=true is set on rttParent rttParent['props'].rtt = true; child.parentHasRenderTexture = true; child.rttParent = rttParent; rttParent.hasRTTupdates = false; // Call the protected method via cast ( child as unknown as { notifyParentRTTOfUpdate(): void } ).notifyParentRTTOfUpdate(); expect(rttParent.hasRTTupdates).toBe(true); }); it('notifyParentRTTOfUpdate is a no-op when node has no RTT ancestor', () => { const node = new CoreNode(stage, makeDefaultProps()); // No parent — should not throw expect(() => { ( node as unknown as { notifyParentRTTOfUpdate(): void } ).notifyParentRTTOfUpdate(); }).not.toThrow(); expect(node.hasRTTupdates).toBe(false); }); it('hasRTTupdates can be reset to false', () => { const node = new CoreNode(stage, makeDefaultProps()); node.hasRTTupdates = true; node.hasRTTupdates = false; expect(node.hasRTTupdates).toBe(false); }); }); // --------------------------------------------------------------------------- // RTT parentHasRenderTexture propagation // --------------------------------------------------------------------------- describe('RTT — parentHasRenderTexture propagation', () => { let stage: Stage; beforeEach(() => { stage = makeStage(); }); it('parentHasRenderTexture is false by default', () => { const node = new CoreNode(stage, makeDefaultProps()); expect(node.parentHasRenderTexture).toBe(false); }); it('markChildrenWithRTT sets parentHasRenderTexture=true on direct children', () => { const parent = new CoreNode(stage, makeDefaultProps()); const child = new CoreNode(stage, makeDefaultProps()); child.parent = parent; // Call private method directly ( parent as unknown as { markChildrenWithRTT(): void } ).markChildrenWithRTT(); expect(child.parentHasRenderTexture).toBe(true); }); it('markChildrenWithRTT propagates to grandchildren', () => { const parent = new CoreNode(stage, makeDefaultProps()); const child = new CoreNode(stage, makeDefaultProps()); const grandchild = new CoreNode(stage, makeDefaultProps()); child.parent = parent; grandchild.parent = child; ( parent as unknown as { markChildrenWithRTT(): void } ).markChildrenWithRTT(); expect(child.parentHasRenderTexture).toBe(true); expect(grandchild.parentHasRenderTexture).toBe(true); }); }); // --------------------------------------------------------------------------- // RTT parentRenderTexture getter — uncached parent-chain walk // (this is Bottleneck 1 in the perf analysis) // --------------------------------------------------------------------------- describe('RTT — parentRenderTexture getter', () => { let stage: Stage; beforeEach(() => { stage = makeStage(); }); it('returns null when there is no RTT ancestor', () => { const node = new CoreNode(stage, makeDefaultProps()); expect(node.parentRenderTexture).toBe(null); }); it('returns the nearest RTT ancestor', () => { const rttAncestor = new CoreNode(stage, makeDefaultProps()); rttAncestor['props'].rtt = true; const child = new CoreNode(stage, makeDefaultProps()); child.parent = rttAncestor; expect(child.parentRenderTexture).toBe(rttAncestor); }); it('returns the NEAREST RTT ancestor in a nested chain', () => { const outer = new CoreNode(stage, makeDefaultProps()); const inner = new CoreNode(stage, makeDefaultProps()); const leaf = new CoreNode(stage, makeDefaultProps()); outer['props'].rtt = true; inner['props'].rtt = true; inner.parent = outer; leaf.parent = inner; // leaf's nearest RTT ancestor is inner, not outer expect(leaf.parentRenderTexture).toBe(inner); }); }); // --------------------------------------------------------------------------- // RTT — renderRTTNodes skip conditions // Validated via a lightweight simulation of the skip logic // --------------------------------------------------------------------------- describe('RTT — renderRTTNodes skip conditions', () => { let stage: Stage; beforeEach(() => { stage = makeStage(); }); /** * Simulate the exact skip gates in renderRTTNodes() for a single node. * Returns true if the node would be rendered, false if skipped. */ const wouldRender = ( node: CoreNode, texture: { state: string } | null, ): boolean => { if (node.hasRTTupdates === false) return false; if (node.worldAlpha === 0) return false; if (node.renderState === CoreNodeRenderState.OutOfBounds) return false; if (texture === null || texture.state !== 'loaded') return false; return true; }; it('skips a node when hasRTTupdates is false', () => { const node = new CoreNode(stage, makeDefaultProps()); node.hasRTTupdates = false; node.worldAlpha = 1; node.renderState = CoreNodeRenderState.InBounds; expect(wouldRender(node, { state: 'loaded' })).toBe(false); }); it('renders a node when hasRTTupdates is true and all other conditions pass', () => { const node = new CoreNode(stage, makeDefaultProps()); node.hasRTTupdates = true; node.worldAlpha = 1; node.renderState = CoreNodeRenderState.InBounds; expect(wouldRender(node, { state: 'loaded' })).toBe(true); }); it('skips a node when worldAlpha is 0', () => { const node = new CoreNode(stage, makeDefaultProps()); node.hasRTTupdates = true; node.worldAlpha = 0; node.renderState = CoreNodeRenderState.InBounds; expect(wouldRender(node, { state: 'loaded' })).toBe(false); }); it('skips a node when renderState is OutOfBounds', () => { const node = new CoreNode(stage, makeDefaultProps()); node.hasRTTupdates = true; node.worldAlpha = 1; node.renderState = CoreNodeRenderState.OutOfBounds; expect(wouldRender(node, { state: 'loaded' })).toBe(false); }); it('skips a node when texture is null', () => { const node = new CoreNode(stage, makeDefaultProps()); node.hasRTTupdates = true; node.worldAlpha = 1; node.renderState = CoreNodeRenderState.InBounds; expect(wouldRender(node, null)).toBe(false); }); it('skips a node when texture is not loaded', () => { const node = new CoreNode(stage, makeDefaultProps()); node.hasRTTupdates = true; node.worldAlpha = 1; node.renderState = CoreNodeRenderState.InBounds; expect(wouldRender(node, { state: 'loading' })).toBe(false); }); }); // --------------------------------------------------------------------------- // RTT_NOTIFY_MASK — gate on notifyParentRTTOfUpdate() inside update() // // The mask ensures RTT surfaces are re-rendered only when a visually relevant // UpdateType flag is present. Non-visual cascade flags (Children, RenderBounds, // RenderState, ParentRenderTexture, Autosize) must NOT trigger re-renders. // --------------------------------------------------------------------------- describe('RTT — RTT_NOTIFY_MASK gate in update()', () => { // Minimal clipping rect required by CoreNode.update() const clippingRect = { x: 0, y: 0, w: 1920, h: 1080, valid: false }; let stage: Stage; // Builds a child node wired into an RTT parent so the // `parentHasRenderTexture === true` branch in update() is reachable. function makeRttChild() { const rttParent = new CoreNode(stage, makeDefaultProps()); rttParent['props'].rtt = true; const child = new CoreNode(stage, makeDefaultProps()); child.parent = rttParent; child.parentHasRenderTexture = true; child.rttParent = rttParent; child.hasRTTupdates = false; return { rttParent, child }; } beforeEach(() => { stage = makeStage(); }); it('fires notifyParentRTTOfUpdate for a visual flag (PremultipliedColors)', () => { const { rttParent, child } = makeRttChild(); const spy = vi.spyOn( child as unknown as { notifyParentRTTOfUpdate(): void }, 'notifyParentRTTOfUpdate', ); rttParent.hasRTTupdates = false; child.updateType = UpdateType.PremultipliedColors; child.update(0, clippingRect); expect(spy).toHaveBeenCalledOnce(); }); it('fires notifyParentRTTOfUpdate for a visual flag (WorldAlpha)', () => { const { child } = makeRttChild(); const spy = vi.spyOn( child as unknown as { notifyParentRTTOfUpdate(): void }, 'notifyParentRTTOfUpdate', ); child.updateType = UpdateType.WorldAlpha; child.update(0, clippingRect); expect(spy).toHaveBeenCalledOnce(); }); it('does NOT fire notifyParentRTTOfUpdate for Children flag alone', () => { const { child } = makeRttChild(); const spy = vi.spyOn( child as unknown as { notifyParentRTTOfUpdate(): void }, 'notifyParentRTTOfUpdate', ); // Children is not in RTT_NOTIFY_MASK and hasRTTupdates is false child.updateType = UpdateType.Children; child.hasRTTupdates = false; child.update(0, clippingRect); expect(spy).not.toHaveBeenCalled(); }); it('does NOT fire notifyParentRTTOfUpdate for Autosize flag alone', () => { const { child } = makeRttChild(); const spy = vi.spyOn( child as unknown as { notifyParentRTTOfUpdate(): void }, 'notifyParentRTTOfUpdate', ); child.updateType = UpdateType.Autosize; child.hasRTTupdates = false; child.update(0, clippingRect); expect(spy).not.toHaveBeenCalled(); }); it('fires notifyParentRTTOfUpdate when hasRTTupdates=true even for non-visual flag (Children)', () => { const { child } = makeRttChild(); const spy = vi.spyOn( child as unknown as { notifyParentRTTOfUpdate(): void }, 'notifyParentRTTOfUpdate', ); // hasRTTupdates=true short-circuits the mask check child.updateType = UpdateType.Children; child.hasRTTupdates = true; child.update(0, clippingRect); expect(spy).toHaveBeenCalledOnce(); }); it('does NOT fire notifyParentRTTOfUpdate when parentHasRenderTexture is false, even with visual flag', () => { const { child } = makeRttChild(); const spy = vi.spyOn( child as unknown as { notifyParentRTTOfUpdate(): void }, 'notifyParentRTTOfUpdate', ); child.parentHasRenderTexture = false; child.updateType = UpdateType.PremultipliedColors; child.update(0, clippingRect); expect(spy).not.toHaveBeenCalled(); }); });