@lightningjs/renderer
Version:
Lightning 3 Renderer
506 lines (447 loc) • 16.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.
*/
/**
* Dirty quad buffer (C2) unit tests for WebGlRenderer.
*
* Validates the surgical `bufferSubData` upload path: permanent slot
* assignment in addQuad, the full-vs-surgical decision in render(), the
* structural invalidation hook, and the dedicated RTT buffer. The renderer is
* created via Object.create so no WebGL context is required; only the fields
* under test are wired up.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CoreNode, CoreNodeRenderState } from '../../CoreNode.js';
import { TextureType, type Texture } from '../../textures/Texture.js';
import type { Stage } from '../../Stage.js';
import { WebGlRenderer } from './WebGlRenderer.js';
import { makeMockStage, makeNodeProps } from '../../../../test/mockStage.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const clippingRect = {
x: 0,
y: 0,
w: 200,
h: 200,
valid: false,
clipRadius: 0,
};
const makeTexture = (): Texture =>
({
type: TextureType.image,
ctxTexture: {},
state: 'loaded',
setRenderableOwner: vi.fn(),
retryCount: 0,
maxRetryCount: 0,
} as unknown as Texture);
interface GlwMock {
arrayBufferData: ReturnType<typeof vi.fn>;
arrayBufferSubData: ReturnType<typeof vi.fn>;
bindFramebuffer: ReturnType<typeof vi.fn>;
viewport: ReturnType<typeof vi.fn>;
clearColor: ReturnType<typeof vi.fn>;
STATIC_DRAW: number;
DYNAMIC_DRAW: number;
}
interface RendererHarness {
renderer: WebGlRenderer;
glw: GlwMock;
stage: Stage;
}
const makeRenderer = (nodes: CoreNode[] = []): RendererHarness => {
const glw = {
arrayBufferData: vi.fn(),
arrayBufferSubData: vi.fn(),
bindFramebuffer: vi.fn(),
viewport: vi.fn(),
clearColor: vi.fn(),
STATIC_DRAW: 0x88e4,
DYNAMIC_DRAW: 0x88e8,
setScissorTest: vi.fn(),
setStencilTest: vi.fn(),
clear: vi.fn(),
canvas: { width: 1920, height: 1080 },
};
const quadBuffer = new ArrayBuffer(80 * 20); // 20-quad capacity
const stage = makeMockStage({
renderListNodes: nodes,
renderListLen: nodes.length,
options: { quadBufferSize: quadBuffer.byteLength, enableClear: true },
// A concrete default shader so reuseRenderOp batches via the
// shaderKey==='default' fast path instead of touching shader programs.
defShaderNode: {
shaderKey: 'default',
program: {},
} as unknown as Stage['defShaderNode'],
} as Partial<Stage>);
const renderer = Object.create(WebGlRenderer.prototype) as WebGlRenderer;
(renderer as any).glw = glw;
(renderer as any).stage = stage;
(renderer as any).quadBuffer = quadBuffer;
(renderer as any).fQuadBuffer = new Float32Array(quadBuffer);
(renderer as any).uiQuadBuffer = new Uint32Array(quadBuffer);
(renderer as any).renderOps = [];
(renderer as any).rttNodes = [];
(renderer as any).activeRttNode = null;
(renderer as any).renderToTextureActive = false;
(renderer as any).curBufferIdx = 0;
(renderer as any).curRenderOp = null;
(renderer as any).dirtyQuadCount = 0;
(renderer as any).needsFullUpload = true;
(renderer as any).lastUploadedBufferSize = 0;
(renderer as any).rttQuadBuffer = null;
(renderer as any).fRttQuadBuffer = null;
(renderer as any).uiRttQuadBuffer = null;
(renderer as any).clearColor = { raw: 0, normalized: [0, 0, 0, 0] };
(renderer as any).quadBufferCollection = {
getBuffer: () => 'quad-buffer',
};
(renderer as any).defaultTextureCoords = { x1: 0, y1: 0, x2: 1, y2: 1 };
const scratch = new Float32Array(20);
(renderer as any)._quadScratchBuffer = scratch.buffer;
(renderer as any)._quadScratchF = scratch;
// Shared SDF text buffers (unused by these tests; sized so the upload paths
// early-return). Same mock style as `quadBufferCollection` above.
const sdfBuffer = {
idx: 0,
changed: true,
lastUploadedSize: 0,
clear: vi.fn(),
quadBufferCollection: { getBuffer: () => 'sdf-buffer' },
};
(renderer as any).sdfBufferPlain = sdfBuffer;
(renderer as any).sdfBufferRich = sdfBuffer;
(renderer as any).curSdfRenderOp = null;
return { renderer, glw, stage };
};
const makeNode = (stage: Stage): CoreNode => {
const node = new CoreNode(stage, makeNodeProps({ w: 100, h: 100 }));
(node as any).props.texture = makeTexture();
// The draw loop calls op.draw(this); unit tests focus on the quad buffer
// bookkeeping, so draw is stubbed out here.
vi.spyOn(node, 'draw').mockReturnValue(undefined);
node.update(0, clippingRect);
return node;
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('WebGlRenderer dirty quad buffer — slot assignment', () => {
let harness: RendererHarness;
let renderer: WebGlRenderer;
let stage: Stage;
let a: CoreNode;
let b: CoreNode;
let c: CoreNode;
beforeEach(() => {
harness = makeRenderer();
renderer = harness.renderer;
stage = harness.stage;
a = makeNode(stage);
b = makeNode(stage);
c = makeNode(stage);
stage.renderListNodes = [a, b, c];
stage.renderListLen = 3;
});
it('assigns contiguous permanent slots and advances curBufferIdx by 20 per quad', () => {
renderer.reset();
renderer.addQuad(a);
expect(a.quadBufferIndex).toBe(0);
expect(renderer.curBufferIdx).toBe(20);
renderer.addQuad(b);
expect(b.quadBufferIndex).toBe(20);
expect(renderer.curBufferIdx).toBe(40);
renderer.addQuad(c);
expect(c.quadBufferIndex).toBe(40);
expect(renderer.curBufferIdx).toBe(60);
expect(renderer.fQuadBuffer[0]).toBe(a.renderCoords!.x1);
expect(renderer.fQuadBuffer[20]).toBe(b.renderCoords!.x1);
expect(renderer.fQuadBuffer[40]).toBe(c.renderCoords!.x1);
});
it('re-assigns the same slots on a stable replay frame', () => {
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
const slots = [a.quadBufferIndex, b.quadBufferIndex, c.quadBufferIndex];
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
expect([a.quadBufferIndex, b.quadBufferIndex, c.quadBufferIndex]).toEqual(
slots,
);
});
it('counts dirty main-scene nodes in dirtyQuadCount during the pass', () => {
a.isQuadDirty = false;
b.isQuadDirty = true;
c.isQuadDirty = false;
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
expect(renderer.dirtyQuadCount).toBe(1);
});
it('does not count RTT nodes in dirtyQuadCount', () => {
b.isQuadDirty = true;
renderer.renderToTextureActive = true;
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
expect(renderer.dirtyQuadCount).toBe(0);
renderer.renderToTextureActive = false;
});
});
describe('WebGlRenderer dirty quad buffer — render() upload decision', () => {
let harness: RendererHarness;
let renderer: WebGlRenderer;
let glw: GlwMock;
let stage: Stage;
let a: CoreNode;
let b: CoreNode;
let c: CoreNode;
beforeEach(() => {
harness = makeRenderer();
renderer = harness.renderer;
glw = harness.glw;
stage = harness.stage;
a = makeNode(stage);
b = makeNode(stage);
c = makeNode(stage);
stage.renderListNodes = [a, b, c];
stage.renderListLen = 3;
});
it('full uploads on the first frame with DYNAMIC_DRAW and clears dirty flags', () => {
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render();
expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
const [buffer, data, usage] = glw.arrayBufferData.mock.calls[0]!;
expect(buffer).toBe('quad-buffer');
expect((data as Float32Array).length).toBe(60);
expect(usage).toBe(glw.DYNAMIC_DRAW);
expect(glw.arrayBufferSubData).not.toHaveBeenCalled();
expect(a.isQuadDirty).toBe(false);
expect(renderer.needsFullUpload).toBe(false);
expect(renderer.lastUploadedBufferSize).toBe(60);
});
it('surgically uploads only the dirty slot via arrayBufferSubData', () => {
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render(); // full upload frame 1
expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
// Frame 2: only node b changed.
b.isQuadDirty = true;
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render();
expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
expect(glw.arrayBufferSubData).toHaveBeenCalledTimes(1);
const [buffer, byteOffset, data] = glw.arrayBufferSubData.mock.calls[0]!;
expect(buffer).toBe('quad-buffer');
expect(byteOffset).toBe(b.quadBufferIndex * 4);
expect((data as Float32Array).length).toBe(20);
expect(b.isQuadDirty).toBe(false);
expect(a.isQuadDirty).toBe(false);
expect(c.isQuadDirty).toBe(false);
});
it('falls back to a full upload when more than 40% of nodes are dirty', () => {
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render(); // full upload frame 1
expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
// Frame 2: 2 of 3 nodes dirty → 2 > 1.2 → full upload.
a.isQuadDirty = true;
b.isQuadDirty = true;
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render();
expect(glw.arrayBufferData).toHaveBeenCalledTimes(2);
expect(glw.arrayBufferSubData).not.toHaveBeenCalled();
expect(a.isQuadDirty).toBe(false);
expect(b.isQuadDirty).toBe(false);
});
it('full uploads when the buffer grows past the last uploaded size', () => {
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render(); // full upload, lastUploadedBufferSize = 60
expect(renderer.lastUploadedBufferSize).toBe(60);
// Scene grows: two more nodes join this frame.
stage.renderListNodes = [a, b, c, makeNode(stage), makeNode(stage)];
stage.renderListLen = stage.renderListNodes.length;
renderer.reset();
for (let i = 0; i < stage.renderListLen; i++) {
renderer.addQuad(stage.renderListNodes[i]!);
}
renderer.render();
expect(glw.arrayBufferData).toHaveBeenCalledTimes(2);
const [, data] = glw.arrayBufferData.mock.calls[1]!;
expect((data as Float32Array).length).toBe(100);
expect(renderer.lastUploadedBufferSize).toBe(100);
});
it('uses the preallocated scratch buffer for surgical uploads', () => {
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render();
b.isQuadDirty = true;
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render();
const [, , data] = glw.arrayBufferSubData.mock.calls[0]!;
expect(data).toBe((renderer as any)._quadScratchF);
// The scratch holds the slot's fresh data.
expect((data as Float32Array)[0]).toBe(
renderer.fQuadBuffer[b.quadBufferIndex],
);
});
});
describe('WebGlRenderer dirty quad buffer — invalidateQuadBuffer', () => {
let harness: RendererHarness;
let renderer: WebGlRenderer;
let stage: Stage;
let a: CoreNode;
let b: CoreNode;
let c: CoreNode;
beforeEach(() => {
harness = makeRenderer();
renderer = harness.renderer;
stage = harness.stage;
a = makeNode(stage);
b = makeNode(stage);
c = makeNode(stage);
stage.renderListNodes = [a, b, c];
stage.renderListLen = 3;
});
it('resets all slots, marks everything dirty and forces a full upload', () => {
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.addQuad(c);
renderer.render();
expect(renderer.needsFullUpload).toBe(false);
renderer.invalidateQuadBuffer();
expect(a.quadBufferIndex).toBe(-1);
expect(b.quadBufferIndex).toBe(-1);
expect(c.quadBufferIndex).toBe(-1);
expect(a.isQuadDirty).toBe(true);
expect(b.isQuadDirty).toBe(true);
expect(c.isQuadDirty).toBe(true);
expect(renderer.needsFullUpload).toBe(true);
expect(renderer.curBufferIdx).toBe(0);
expect(renderer.lastUploadedBufferSize).toBe(0);
});
it('reassigns compact slots from 0 on the next frame', () => {
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
renderer.render();
renderer.invalidateQuadBuffer();
renderer.reset();
renderer.addQuad(a);
renderer.addQuad(b);
expect(a.quadBufferIndex).toBe(0);
expect(b.quadBufferIndex).toBe(20);
});
});
describe('WebGlRenderer dirty quad buffer — RTT isolation', () => {
let harness: RendererHarness;
let renderer: WebGlRenderer;
let glw: GlwMock;
let stage: Stage;
let child: CoreNode;
beforeEach(() => {
harness = makeRenderer();
renderer = harness.renderer;
glw = harness.glw;
stage = harness.stage;
child = makeNode(stage);
});
it('writes RTT quads into the dedicated RTT buffer without touching main slots', () => {
child.quadBufferIndex = 123; // simulate an existing main-scene slot
renderer.renderToTextureActive = true;
renderer.addQuad(child);
expect(renderer.rttQuadBuffer).not.toBeNull();
expect(renderer.curBufferIdx).toBe(20);
expect(child.quadBufferIndex).toBe(123);
expect((renderer.fRttQuadBuffer as Float32Array)[0]).toBe(
child.renderCoords!.x1,
);
// Main CPU buffer is untouched.
expect(renderer.fQuadBuffer[0]).toBe(0);
renderer.renderToTextureActive = false;
});
it('renderRTT full uploads from the RTT buffer with STATIC_DRAW', () => {
const drawSpy = vi.spyOn(child, 'draw').mockReturnValue(undefined);
renderer.renderToTextureActive = true;
renderer.addQuad(child);
(renderer as any).renderRTT();
expect(glw.arrayBufferData).toHaveBeenCalledTimes(1);
const [buffer, data, usage] = glw.arrayBufferData.mock.calls[0]!;
expect(buffer).toBe('quad-buffer');
expect((data as Float32Array).length).toBe(20);
expect(usage).toBe(glw.STATIC_DRAW);
expect(drawSpy).toHaveBeenCalledTimes(1);
renderer.renderToTextureActive = false;
});
it('renderRTTNodes restores the main buffer state and forces a full re-upload', () => {
const rttNode = new CoreNode(stage, makeNodeProps({ rtt: false }));
(rttNode as any).props.texture = {
type: TextureType.renderToTexture,
state: 'loaded',
ctxTexture: { framebuffer: {}, w: 100, h: 100 },
};
rttNode.hasRTTupdates = true;
rttNode.worldAlpha = 1;
rttNode.renderState = CoreNodeRenderState.InBounds;
(stage as any).addSubtreeQuads = vi.fn();
renderer.rttNodes = [rttNode];
renderer.curBufferIdx = 999;
renderer.needsFullUpload = false;
renderer.lastUploadedBufferSize = 999;
(renderer as any).rttQuadBuffer = new ArrayBuffer(80);
(renderer as any).fRttQuadBuffer = new Float32Array(
(renderer as any).rttQuadBuffer,
);
(renderer as any).uiRttQuadBuffer = new Uint32Array(
(renderer as any).rttQuadBuffer,
);
renderer.renderRTTNodes();
expect(renderer.curBufferIdx).toBe(999);
expect(renderer.needsFullUpload).toBe(true);
expect(renderer.lastUploadedBufferSize).toBe(0);
expect(renderer.renderToTextureActive).toBe(false);
});
});