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); + }); +});