From 29ca0d43e826bbc395f37f6626b3a23e14a74ce2 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 1 Jul 2026 23:13:39 -0400 Subject: [PATCH] perf(webgl): batch distinct shader nodes with equal uniform collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render-op batch test rejected any two quads whose shader NODES were different instances (curShader !== shader), but apps create one shader node per card — so a rail of identical rounded cards never batched: one draw op per card, each paying useShader + bindRenderOp + scissor + drawElements dispatch on the driver CPU. Shader nodes with equal value keys already share their uniform collection by reference (the shader-value cache), and collections are immutable after fill — so reference equality implies value equality. Batch when the shader nodes resolve to the same program AND the same uniform collection. Program identity is required because the value key does not include the shader type, so an equal collection reached from a different program must not merge. WebGlShaderProgram.reuseRenderOp uses the same one-reference compare in place of the per-node for..in over resolvedProps (kept as fallback for distinct collections), and no longer invokes the node.time getter for programs without a time uniform. Measured on stress-tv, 200 rounded-rect cards (tier "rounded rect only"): 205 draws -> 6 draws for the identical scene. Different-props nodes (shader-rounded example) still split correctly. Co-Authored-By: Claude Fable 5 --- .../webgl/WebGlRenderer.reuseRenderOp.test.ts | 122 ++++++++++++++ src/core/renderers/webgl/WebGlRenderer.ts | 16 +- .../WebGlShaderProgram.reuseRenderOp.test.ts | 150 ++++++++++++++++++ .../renderers/webgl/WebGlShaderProgram.ts | 38 +++-- 4 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts create mode 100644 src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts 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)