Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest';
import { WebGlRenderer } from './WebGlRenderer.js';

/**
* Tests for the shader-node batching gate in WebGlRenderer.reuseRenderOp.
*
* A full renderer needs a live GL context, so the method is exercised on a
* minimal fake `this` holding only curRenderOp. Nodes and shader nodes are
* plain objects with the fields reuseRenderOp reads.
*/

const clippingRect = { x: 0, y: 0, w: 0, h: 0, valid: false };

type FakeShaderNode = {
shaderKey: string;
program: { reuseRenderOp: ReturnType<typeof vi.fn> };
uniforms: object;
resolvedProps: Record<string, unknown>;
};

const makeShaderNode = (
program: FakeShaderNode['program'],
uniforms: object,
shaderKey = 'Rounded',
): FakeShaderNode => ({
shaderKey,
program,
uniforms,
resolvedProps: { radius: [16, 16, 16, 16] },
});

const makeProgram = (batches = true) => ({
reuseRenderOp: vi.fn(() => batches),
});

// Node shape shared by the op anchor and the incoming quad
const makeNode = (shader: FakeShaderNode) => ({
isCoreNode: true,
parentHasRenderTexture: false,
parentFramebufferDimensions: null,
rtt: false,
clippingRect,
props: { rtt: false, shader },
shader,
});

const reuse = (curRenderOp: unknown, node: unknown): boolean =>
WebGlRenderer.prototype.reuseRenderOp.call(
{ curRenderOp } as WebGlRenderer,
node as never,
);

describe('WebGlRenderer.reuseRenderOp shader batching', () => {
it('rejects when there is no current render op', () => {
const shader = makeShaderNode(makeProgram(), {});
expect(reuse(null, makeNode(shader))).toBe(false);
});

it('batches two quads using the same shader node', () => {
const shader = makeShaderNode(makeProgram(), {});

expect(reuse(makeNode(shader), makeNode(shader))).toBe(true);
expect(shader.program.reuseRenderOp.mock.calls.length).toBe(1);
});

it('batches distinct shader nodes sharing program and uniform collection', () => {
const program = makeProgram();
const uniforms = {};
const shaderA = makeShaderNode(program, uniforms);
const shaderB = makeShaderNode(program, uniforms);

expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(true);
});

it('rejects distinct shader nodes with different uniform collections', () => {
const program = makeProgram();
const shaderA = makeShaderNode(program, {});
const shaderB = makeShaderNode(program, {});

expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
// Gate rejects before the program-level prop compare runs
expect(program.reuseRenderOp.mock.calls.length).toBe(0);
});

it('rejects equal uniform collections from different programs', () => {
// The shader value key does not include the shader type, so a collection
// shared across programs (key collision) must never merge draw calls.
const uniforms = {};
const shaderA = makeShaderNode(makeProgram(), uniforms, 'TypeA');
const shaderB = makeShaderNode(makeProgram(), uniforms, 'TypeB');

expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
});

it('batches default-shader quads without consulting the program', () => {
const shaderA = makeShaderNode(makeProgram(), {}, 'default');
const shaderB = makeShaderNode(makeProgram(), {}, 'default');

expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(true);
expect(shaderA.program.reuseRenderOp.mock.calls.length).toBe(0);
});

it('rejects when clipping rects differ', () => {
const shader = makeShaderNode(makeProgram(), {});
const node = makeNode(shader);
node.clippingRect = { x: 0, y: 0, w: 100, h: 100, valid: true };

expect(reuse(makeNode(shader), node)).toBe(false);
});

it('defers to the program-level check for batched shader nodes', () => {
const program = makeProgram(false);
const uniforms = {};
const shaderA = makeShaderNode(program, uniforms);
const shaderB = makeShaderNode(program, uniforms);

// Same program + shared collection passes the gate, but the program can
// still veto (e.g. worldAlpha mismatch on a u_alpha shader).
expect(reuse(makeNode(shaderA), makeNode(shaderB))).toBe(false);
expect(program.reuseRenderOp.mock.calls.length).toBe(1);
});
});
16 changes: 14 additions & 2 deletions src/core/renderers/webgl/WebGlRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,9 +640,21 @@ export class WebGlRenderer extends CoreRenderer {
return true;
}

// Check if the shader is the same
// Distinct shader nodes can still batch when they resolve to the same
// program and the value-key cache handed both the same uniform collection:
// collections are immutable after fill and shared by reference across
// equal value keys, so reference equality implies value equality. This is
// the common TV-rail case — many same-size cards, each app-created with
// its own shader node but equal props. Program identity is required
// because the value key does not include the shader type, so equal
// collections from different programs must not merge.
if (curShader !== shader) {
return false;
if (
curShader.program !== shader.program ||
curShader.uniforms !== shader.uniforms
) {
return false;
}
}

if (
Expand Down
150 changes: 150 additions & 0 deletions src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest';
import { WebGlShaderProgram } from './WebGlShaderProgram.js';

/**
* Tests for the batch test in WebGlShaderProgram.reuseRenderOp.
*
* The program instance is created without running the constructor (which
* compiles GLSL against a live GL context); only the fields reuseRenderOp
* touches are populated manually.
*/

type ProgramFlags = {
useSystemAlpha?: boolean;
useSystemDimensions?: boolean;
useTimeValue?: boolean;
canBatch?: (node: unknown, op: unknown) => boolean;
};

const makeProgram = (flags: ProgramFlags = {}): WebGlShaderProgram => {
const program = Object.create(
WebGlShaderProgram.prototype,
) as WebGlShaderProgram;
const p = program as unknown as Record<string, unknown>;
p['useSystemAlpha'] = flags.useSystemAlpha === true;
p['useSystemDimensions'] = flags.useSystemDimensions === true;
p['useTimeValue'] = flags.useTimeValue === true;
p['lifecycle'] = { canBatch: flags.canBatch, update: undefined };
return program;
};

type FakeShaderNode = {
uniforms: object;
resolvedProps: Record<string, unknown> | undefined;
};

const makeShaderNode = (
uniforms: object,
resolvedProps?: Record<string, unknown>,
): FakeShaderNode => ({ uniforms, resolvedProps });

const makeNode = (
shader: FakeShaderNode,
overrides?: Record<string, unknown>,
) => ({
worldAlpha: 1,
w: 200,
h: 100,
props: { shader },
...overrides,
});

const makeOp = (shader: FakeShaderNode, overrides?: Record<string, unknown>) =>
({
time: 0,
worldAlpha: 1,
w: 200,
h: 100,
shader,
...overrides,
} as never);

describe('WebGlShaderProgram.reuseRenderOp', () => {
it('batches the same shader node without comparing props', () => {
const program = makeProgram();
const shader = makeShaderNode({}, { radius: 16 });

expect(
program.reuseRenderOp(makeNode(shader) as never, makeOp(shader)),
).toBe(true);
});

it('batches distinct shader nodes that share a uniform collection', () => {
const program = makeProgram();
const uniforms = {};
// Distinct resolvedProps objects — reference-inequal props must not matter
// when the value-key cache handed both nodes the same collection.
const shaderA = makeShaderNode(uniforms, { radius: [16, 16, 16, 16] });
const shaderB = makeShaderNode(uniforms, { radius: [16, 16, 16, 16] });

expect(
program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderB)),
).toBe(true);
});

it('falls back to a prop-value compare for distinct collections', () => {
const program = makeProgram();
const shaderA = makeShaderNode({}, { radius: 16, gap: 2 });
const shaderB = makeShaderNode({}, { radius: 16, gap: 2 });
const shaderC = makeShaderNode({}, { radius: 16, gap: 4 });

expect(
program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderB)),
).toBe(true);
expect(
program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderC)),
).toBe(false);
});

it('rejects on system alpha mismatch before any shader compare', () => {
const program = makeProgram({ useSystemAlpha: true });
const shader = makeShaderNode({});

expect(
program.reuseRenderOp(
makeNode(shader, { worldAlpha: 0.5 }) as never,
makeOp(shader),
),
).toBe(false);
});

it('rejects on dimension mismatch when the program uses u_dimensions', () => {
const program = makeProgram({ useSystemDimensions: true });
const shader = makeShaderNode({});

expect(
program.reuseRenderOp(
makeNode(shader, { w: 300 }) as never,
makeOp(shader),
),
).toBe(false);
});

it('does not read node.time when the program has no time uniform', () => {
const program = makeProgram();
const shader = makeShaderNode({});
const node = makeNode(shader);
let timeReads = 0;
Object.defineProperty(node, 'time', {
get: () => {
timeReads++;
return 0;
},
});

program.reuseRenderOp(node as never, makeOp(shader));

expect(timeReads).toBe(0);
});

it('delegates to the shader type canBatch hook when defined', () => {
const canBatch = vi.fn(() => false);
const program = makeProgram({ canBatch });
const shader = makeShaderNode({});

expect(
program.reuseRenderOp(makeNode(shader) as never, makeOp(shader)),
).toBe(false);
expect(canBatch.mock.calls.length).toBe(1);
});
});
38 changes: 25 additions & 13 deletions src/core/renderers/webgl/WebGlShaderProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,40 +158,52 @@ export class WebGlShaderProgram implements CoreShaderProgram {
return this.lifecycle.canBatch(node, currentRenderOp);
}

const { time, worldAlpha, w, h } = node;

// Read node getters only for the system uniforms this program actually
// uses — node.time in particular is a getter that runs per call.
if (this.useTimeValue === true) {
if (time !== currentRenderOp.time) {
if (node.time !== currentRenderOp.time) {
return false;
}
}

if (this.useSystemAlpha === true) {
if (worldAlpha !== currentRenderOp.worldAlpha) {
if (node.worldAlpha !== currentRenderOp.worldAlpha) {
return false;
}
}

if (this.useSystemDimensions === true) {
if (w !== currentRenderOp.w || h !== currentRenderOp.h) {
if (node.w !== currentRenderOp.w || node.h !== currentRenderOp.h) {
return false;
}
}

let shaderPropsA: Record<string, unknown> | undefined = undefined;
let shaderPropsB: Record<string, unknown> | undefined = undefined;
const shader = node.props.shader as WebGlShaderNode | null;
const opShader = currentRenderOp.shader as WebGlShaderNode | null;

const shader = node.props.shader;
// Same shader node — same resolved props by definition.
if (shader === opShader) {
return true;
}

if (shader !== null) {
shaderPropsA = (shader as WebGlShaderNode).resolvedProps;
if (shader === null || opShader === null) {
return false;
}

const opShader = currentRenderOp.shader;
if (opShader !== null) {
shaderPropsB = (opShader as WebGlShaderNode).resolvedProps;
// Shader nodes with equal value keys share their uniform collection by
// reference (see WebGlShaderNode.update); reference equality implies the
// resolved prop values match without a key-by-key compare.
if (shader.uniforms === opShader.uniforms) {
return true;
}

const shaderPropsA = shader.resolvedProps as
| Record<string, unknown>
| undefined;
const shaderPropsB = opShader.resolvedProps as
| Record<string, unknown>
| undefined;

if (
(shaderPropsA === undefined && shaderPropsB !== undefined) ||
(shaderPropsA !== undefined && shaderPropsB === undefined)
Expand Down
Loading