diff --git a/src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts b/src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts new file mode 100644 index 0000000..8bb2f5a --- /dev/null +++ b/src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts @@ -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 }; + uniforms: object; + resolvedProps: Record; +}; + +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); + }); +}); diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index 8bffcf8..f1bbfa9 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -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 ( diff --git a/src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts b/src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts new file mode 100644 index 0000000..da10bd1 --- /dev/null +++ b/src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts @@ -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; + 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 | undefined; +}; + +const makeShaderNode = ( + uniforms: object, + resolvedProps?: Record, +): FakeShaderNode => ({ uniforms, resolvedProps }); + +const makeNode = ( + shader: FakeShaderNode, + overrides?: Record, +) => ({ + worldAlpha: 1, + w: 200, + h: 100, + props: { shader }, + ...overrides, +}); + +const makeOp = (shader: FakeShaderNode, overrides?: Record) => + ({ + 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); + }); +}); diff --git a/src/core/renderers/webgl/WebGlShaderProgram.ts b/src/core/renderers/webgl/WebGlShaderProgram.ts index da7e503..166c59a 100644 --- a/src/core/renderers/webgl/WebGlShaderProgram.ts +++ b/src/core/renderers/webgl/WebGlShaderProgram.ts @@ -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 | undefined = undefined; - let shaderPropsB: Record | 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 + | undefined; + const shaderPropsB = opShader.resolvedProps as + | Record + | undefined; + if ( (shaderPropsA === undefined && shaderPropsB !== undefined) || (shaderPropsA !== undefined && shaderPropsB === undefined)