@lightningjs/renderer
Version:
Lightning 3 Renderer
552 lines (463 loc) • 18.3 kB
text/typescript
/*
* 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();
});
});