From dd53d041e6be6cd38b766823400c969c6e9ba438 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 10 Jun 2026 08:41:54 -0400 Subject: [PATCH] perf(webgl): skip redundant uniform uploads in bindRenderOp Every render op re-issued its full gl.uniform* sequence even when the program already held identical values - on Chrome each of those calls is serialized into the GPU-process command buffer, so a screen of ~50 single-op shaded cards paid ~600 uniform calls per frame for values that rarely change. bindRenderOp now keeps shadow copies of the program's uniform state and compare-and-sets: u_pixelRatio, u_resolution, u_time, u_alpha and u_dimensions are only re-uploaded when their values differ, and the shader uniform collection pass (loops + gl calls) is skipped entirely when the same collection object is already bound. This is sound because GL uniform values are per-program state that persists across useProgram switches (interleaved ops of other programs don't disturb them), bindRenderOp is the only writer of these uniforms, and uniform collections are created once, filled once, and shared by reference across shader nodes with equal value keys - reference equality implies value equality. The failure direction is conservative: an unrecognized collection causes a redundant upload, never a wrong skip. No batching or adjacency required: ops interleaved with other shaders (default-shader quads between rounded posters) still dedupe fully. Co-Authored-By: Claude Fable 5 --- .../renderers/webgl/WebGlShaderProgram.ts | 93 +++++-- .../WebGlShaderProgram.uniformDedup.test.ts | 233 ++++++++++++++++++ 2 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts diff --git a/src/core/renderers/webgl/WebGlShaderProgram.ts b/src/core/renderers/webgl/WebGlShaderProgram.ts index 9d3edad..3055bd7 100644 --- a/src/core/renderers/webgl/WebGlShaderProgram.ts +++ b/src/core/renderers/webgl/WebGlShaderProgram.ts @@ -30,6 +30,35 @@ export class WebGlShaderProgram implements CoreShaderProgram { public isDestroyed = false; supportsIndexedTextures = false; + /** + * Shadow copies of this program's GL uniform state, used by + * {@link bindRenderOp} to skip redundant gl.uniform* calls. + * + * @remarks + * GL uniform values are state on the program object and persist across + * useProgram switches, and bindRenderOp is the only writer of these + * uniforms, so the shadows always mirror GL truth — even when ops using + * other programs are interleaved between two ops of this program. + * + * `lastBoundUniforms` tracks the shader node's uniform collection by + * identity: collections are created once, filled once, and shared by + * reference across shader nodes with the same value key (see + * WebGlShaderNode.update), so reference equality implies value equality. + * The failure direction is safe — a new collection holding identical + * values just causes a redundant upload, never a wrong skip. + * + * Sentinels: -1 never collides with real pixel ratios, resolutions, + * alphas, dimensions, or time values (all >= 0). + */ + protected lastBoundUniforms: unknown = null; + protected lastPixelRatio = -1; + protected lastResolutionW = -1; + protected lastResolutionH = -1; + protected lastAlpha = -1; + protected lastDimensionsW = -1; + protected lastDimensionsH = -1; + protected lastTime = -1; + /** * Cached Vertex Array Objects, keyed by the buffer collection they capture. * @@ -198,44 +227,78 @@ export class WebGlShaderProgram implements CoreShaderProgram { return; } - // Bind render texture framebuffer dimensions as resolution - // if the parent has a render texture + // Resolve target pixel ratio / resolution, then compare-and-set against + // the program's shadow state. Each gl.uniform* call below crosses into + // the GPU-process command buffer, so skipping value-identical re-uploads + // is a real per-op CPU saving on embedded targets. + let pixelRatio: number; + let resolutionW: number; + let resolutionH: number; if (USE_RTT && parentHasRenderTexture === true && framebufferDimensions) { - const { w, h } = framebufferDimensions; // Force pixel ratio to 1.0 for render textures since they are always 1:1 // the final render texture will be rendered to the screen with the correct pixel ratio - this.glw.uniform1f('u_pixelRatio', 1.0); - + pixelRatio = 1.0; // Set resolution to the framebuffer dimensions - this.glw.uniform2f('u_resolution', w, h); + resolutionW = framebufferDimensions.w; + resolutionH = framebufferDimensions.h; } else { - this.glw.uniform1f('u_pixelRatio', renderOp.stage.pixelRatio); + pixelRatio = renderOp.stage.pixelRatio; + resolutionW = this.glw.canvas.width; + resolutionH = this.glw.canvas.height; + } - this.glw.uniform2f( - 'u_resolution', - this.glw.canvas.width, - this.glw.canvas.height, - ); + if (pixelRatio !== this.lastPixelRatio) { + this.glw.uniform1f('u_pixelRatio', pixelRatio); + this.lastPixelRatio = pixelRatio; } - if (this.useTimeValue === true) { + if ( + resolutionW !== this.lastResolutionW || + resolutionH !== this.lastResolutionH + ) { + this.glw.uniform2f('u_resolution', resolutionW, resolutionH); + this.lastResolutionW = resolutionW; + this.lastResolutionH = resolutionH; + } + + if (this.useTimeValue === true && renderOp.time !== this.lastTime) { this.glw.uniform1f('u_time', renderOp.time); + this.lastTime = renderOp.time; } - if (this.useSystemAlpha === true) { + if ( + this.useSystemAlpha === true && + renderOp.worldAlpha !== this.lastAlpha + ) { this.glw.uniform1f('u_alpha', renderOp.worldAlpha); + this.lastAlpha = renderOp.worldAlpha; } - if (this.useSystemDimensions === true) { + if ( + this.useSystemDimensions === true && + (renderOp.w !== this.lastDimensionsW || + renderOp.h !== this.lastDimensionsH) + ) { this.glw.uniform2f('u_dimensions', renderOp.w, renderOp.h); + this.lastDimensionsW = renderOp.w; + this.lastDimensionsH = renderOp.h; } const shader = renderOp.shader as WebGlShaderNode; if (shader.props !== undefined) { /** * loop over all precalculated uniform types + * + * Collections are immutable after being filled and shared by reference + * across shader nodes with equal value keys, so when the same object is + * already bound the GL program still holds exactly these values and the + * whole pass (loops + gl calls) can be skipped. */ const uniforms = shader.uniforms; + if ((uniforms as unknown) === this.lastBoundUniforms) { + return; + } + this.lastBoundUniforms = uniforms; for (const key in uniforms.single) { const { method, value } = uniforms.single[key]!; diff --git a/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts b/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts new file mode 100644 index 0000000..2937f44 --- /dev/null +++ b/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WebGlShaderProgram } from './WebGlShaderProgram.js'; + +/** + * Tests for the redundant-uniform-upload skip in bindRenderOp. + * + * The program instance is created without running the constructor (which + * compiles GLSL against a live GL context); the fields bindRenderOp touches + * are populated manually, and bindTextures/bindBufferCollection are stubbed. + */ + +type FakeGlw = { + uniform1f: ReturnType; + uniform2f: ReturnType; + uniform4f: ReturnType; + canvas: { width: number; height: number }; +}; + +const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => { + const glw: FakeGlw = { + uniform1f: vi.fn(), + uniform2f: vi.fn(), + uniform4f: vi.fn(), + canvas: { width: 1920, height: 1080 }, + }; + + const program = Object.create( + WebGlShaderProgram.prototype, + ) as WebGlShaderProgram; + const p = program as unknown as Record; + p['glw'] = glw; + p['useSystemAlpha'] = true; + p['useSystemDimensions'] = true; + p['useTimeValue'] = false; + p['lastBoundUniforms'] = null; + p['lastPixelRatio'] = -1; + p['lastResolutionW'] = -1; + p['lastResolutionH'] = -1; + p['lastAlpha'] = -1; + p['lastDimensionsW'] = -1; + p['lastDimensionsH'] = -1; + p['lastTime'] = -1; + // Stub the buffer/texture binding done before the uniform pass + p['bindTextures'] = vi.fn(); + p['bindBufferCollection'] = vi.fn(); + return { program, glw }; +}; + +const makeUniforms = () => ({ + single: { + u_borderGap: { method: 'uniform1f', value: 0 }, + }, + vec2: {}, + vec3: {}, + vec4: { + u_radius: { method: 'uniform4f', value: [16, 16, 16, 16] }, + }, +}); + +const makeOp = ( + uniforms: ReturnType, + overrides?: Record, +) => ({ + isCoreNode: true, + rtt: false, + parentHasRenderTexture: false, + parentFramebufferDimensions: null, + framebufferDimensions: null, + stage: { pixelRatio: 2 }, + time: 0, + worldAlpha: 1, + w: 200, + h: 100, + renderOpTextures: [], + quadBufferCollection: {}, + shader: { props: {}, uniforms }, + ...overrides, +}); + +const totalCalls = (glw: FakeGlw): number => + glw.uniform1f.mock.calls.length + + glw.uniform2f.mock.calls.length + + glw.uniform4f.mock.calls.length; + +describe('bindRenderOp uniform dedup', () => { + it('should upload everything on the first bind', () => { + const { program, glw } = makeProgram(); + const uniforms = makeUniforms(); + + program.bindRenderOp(makeOp(uniforms) as never); + + // u_pixelRatio, u_alpha, u_borderGap (1f) + u_resolution, u_dimensions (2f) + u_radius (4f) + expect(glw.uniform1f.mock.calls.length).toBe(3); + expect(glw.uniform2f.mock.calls.length).toBe(2); + expect(glw.uniform4f.mock.calls.length).toBe(1); + }); + + it('should issue zero uniform calls on an identical re-bind', () => { + const { program, glw } = makeProgram(); + const uniforms = makeUniforms(); + + program.bindRenderOp(makeOp(uniforms) as never); + glw.uniform1f.mockClear(); + glw.uniform2f.mockClear(); + glw.uniform4f.mockClear(); + + program.bindRenderOp(makeOp(uniforms) as never); + + expect(totalCalls(glw)).toBe(0); + }); + + it('should skip across interleaved ops (shared collection, different op objects)', () => { + const { program, glw } = makeProgram(); + const uniforms = makeUniforms(); + + // Two distinct ops (different cards) sharing the value-cached collection + program.bindRenderOp(makeOp(uniforms) as never); + glw.uniform1f.mockClear(); + glw.uniform2f.mockClear(); + glw.uniform4f.mockClear(); + + // GL uniform state persists on the program across useProgram switches, + // so an op of another program in between changes nothing here. + program.bindRenderOp(makeOp(uniforms) as never); + + expect(totalCalls(glw)).toBe(0); + }); + + it('should re-upload only the changed system uniform', () => { + const { program, glw } = makeProgram(); + const uniforms = makeUniforms(); + + program.bindRenderOp(makeOp(uniforms) as never); + glw.uniform1f.mockClear(); + glw.uniform2f.mockClear(); + glw.uniform4f.mockClear(); + + program.bindRenderOp(makeOp(uniforms, { worldAlpha: 0.5 }) as never); + + expect(glw.uniform1f.mock.calls.length).toBe(1); + expect(glw.uniform1f.mock.calls[0]![0]).toBe('u_alpha'); + expect(glw.uniform1f.mock.calls[0]![1]).toBe(0.5); + expect(glw.uniform2f.mock.calls.length).toBe(0); + expect(glw.uniform4f.mock.calls.length).toBe(0); + }); + + it('should re-upload dimensions when the op size differs', () => { + const { program, glw } = makeProgram(); + const uniforms = makeUniforms(); + + program.bindRenderOp(makeOp(uniforms) as never); + glw.uniform2f.mockClear(); + + program.bindRenderOp(makeOp(uniforms, { w: 300, h: 150 }) as never); + + expect(glw.uniform2f.mock.calls.length).toBe(1); + expect(glw.uniform2f.mock.calls[0]![0]).toBe('u_dimensions'); + }); + + it('should re-run the collection pass for a different collection object', () => { + const { program, glw } = makeProgram(); + const uniformsA = makeUniforms(); + const uniformsB = makeUniforms(); // identical values, distinct identity + + program.bindRenderOp(makeOp(uniformsA) as never); + glw.uniform1f.mockClear(); + glw.uniform4f.mockClear(); + + program.bindRenderOp(makeOp(uniformsB) as never); + + // Conservative direction: new object => redundant upload, never a skip + expect(glw.uniform1f.mock.calls.length).toBe(1); // u_borderGap + expect(glw.uniform4f.mock.calls.length).toBe(1); // u_radius + }); + + it('should re-upload pixel ratio and resolution across an RTT flip', () => { + const { program, glw } = makeProgram(); + const uniforms = makeUniforms(); + + program.bindRenderOp(makeOp(uniforms) as never); + glw.uniform1f.mockClear(); + glw.uniform2f.mockClear(); + + // RTT op: pixelRatio forced to 1, resolution = framebuffer dimensions + program.bindRenderOp( + makeOp(uniforms, { + isCoreNode: false, + parentHasRenderTexture: true, + framebufferDimensions: { w: 512, h: 256 }, + }) as never, + ); + + const calls1f = glw.uniform1f.mock.calls; + const calls2f = glw.uniform2f.mock.calls; + expect(calls1f.some((c) => c[0] === 'u_pixelRatio' && c[1] === 1)).toBe( + true, + ); + expect(calls2f.some((c) => c[0] === 'u_resolution' && c[1] === 512)).toBe( + true, + ); + + glw.uniform1f.mockClear(); + glw.uniform2f.mockClear(); + + // Back to screen: values flip back and must re-upload + program.bindRenderOp(makeOp(uniforms) as never); + expect( + glw.uniform1f.mock.calls.some( + (c) => c[0] === 'u_pixelRatio' && c[1] === 2, + ), + ).toBe(true); + expect( + glw.uniform2f.mock.calls.some( + (c) => c[0] === 'u_resolution' && c[1] === 1920, + ), + ).toBe(true); + }); + + it('should skip the collection pass entirely for propless shaders', () => { + const { program, glw } = makeProgram(); + + program.bindRenderOp( + makeOp(makeUniforms(), { + shader: { props: undefined, uniforms: makeUniforms() }, + }) as never, + ); + + // Only system uniforms: pixelRatio + alpha (1f), resolution + dimensions (2f) + expect(glw.uniform1f.mock.calls.length).toBe(2); + expect(glw.uniform2f.mock.calls.length).toBe(2); + expect(glw.uniform4f.mock.calls.length).toBe(0); + }); +});