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
93 changes: 78 additions & 15 deletions src/core/renderers/webgl/WebGlShaderProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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]!;
Expand Down
233 changes: 233 additions & 0 deletions src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
uniform2f: ReturnType<typeof vi.fn>;
uniform4f: ReturnType<typeof vi.fn>;
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<string, unknown>;
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<typeof makeUniforms>,
overrides?: Record<string, unknown>,
) => ({
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);
});
});
Loading