@lightningjs/renderer
Version:
Lightning 3 Renderer
523 lines (434 loc) • 17.5 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 {
CoreNode,
CoreNodeRenderState,
UpdateType,
type CoreNodeProps,
} from '../../CoreNode.js';
import type { Stage } from '../../Stage.js';
import { WebGlRenderer } from './WebGlRenderer.js';
import { makeMockStage, makeNodeProps } from '../../../../test/mockStage.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const makeDefaultProps = (): CoreNodeProps =>
makeNodeProps({
pivot: 0.5,
pivotX: 0.5,
pivotY: 0.5,
h: 100,
w: 100,
});
/**
* Minimal mock stage that satisfies CoreNode's constructor requirements.
*/
const makeStage = (): Stage =>
makeMockStage({
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,
clipRadius: 0,
};
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();
});
});