From c3ace54519cb71a107b68655abf2efb36897cfbb Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 1 Jul 2026 22:31:30 -0400 Subject: [PATCH] perf: cache canvas dimensions and debounce render-list invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two render-loop hot-path fixes: - Cache canvas.width/height as plain numbers on WebGlContextWrapper (canvasW/canvasH). On HTMLCanvasElement these are Blink DOM getters, too slow to read per render op per frame on embedded targets — they were read in every bindRenderOp (u_resolution) and in the scissor math of every clipped quad/SDF draw. The canvas is only resized via Renderer.updateAppDimensions -> updateViewport, which now refreshes the cache. Also types the previously implicit-any canvas field. - Debounce Stage.requestRenderListUpdate: invalidateQuadBuffer walks the entire render list resetting slot assignments, and was invoked once per node whose renderable state flipped. When a whole row scrolls out of the viewport that meant N full-list walks in one frame; now the first call invalidates and the rest early-return until drawFrame rebuilds the list. requestRender still runs on every call so scheduling is unchanged. Co-Authored-By: Claude Fable 5 --- src/core/CoreNode.ts | 4 +- src/core/Stage.renderListUpdate.test.ts | 73 +++++++++++++++++++ src/core/Stage.ts | 12 ++- src/core/lib/WebGlContextWrapper.test.ts | 24 +++++- src/core/lib/WebGlContextWrapper.ts | 24 +++++- src/core/renderers/webgl/SdfRenderOp.ts | 4 +- src/core/renderers/webgl/WebGlRenderer.ts | 9 ++- .../renderers/webgl/WebGlShaderProgram.ts | 4 +- .../WebGlShaderProgram.uniformDedup.test.ts | 6 +- 9 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 src/core/Stage.renderListUpdate.test.ts diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 4d5e2f5..c165cfe 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -3243,7 +3243,7 @@ export class CoreNode extends EventEmitter { } draw(renderer: WebGlRenderer) { - const { glw, options, stage } = renderer; + const { glw, stage } = renderer; const shader = this.props.shader as any; stage.shManager.useShader(shader.program); @@ -3258,7 +3258,7 @@ export class CoreNode extends EventEmitter { const clipWidth = Math.round(this.clippingRect.w * pixelRatio); const clipHeight = Math.round(this.clippingRect.h * pixelRatio); let clipY = Math.round( - options.canvas.height - clipHeight - this.clippingRect.y * pixelRatio, + glw.canvasH - clipHeight - this.clippingRect.y * pixelRatio, ); // if parent has render texture, we need to adjust the scissor rect // to be relative to the parent's framebuffer diff --git a/src/core/Stage.renderListUpdate.test.ts b/src/core/Stage.renderListUpdate.test.ts new file mode 100644 index 0000000..182f871 --- /dev/null +++ b/src/core/Stage.renderListUpdate.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Stage } from './Stage.js'; + +/** + * Tests for the requestRenderListUpdate dedupe. + * + * A full Stage needs a live renderer, so the method is exercised on a minimal + * fake via the prototype. Only the fields requestRenderListUpdate touches are + * provided: renderer.invalidateQuadBuffer, renderListDirty, requestRender. + */ + +type FakeStage = { + renderer: { invalidateQuadBuffer?: ReturnType }; + renderListDirty: boolean; + requestRender: ReturnType; +}; + +const makeStage = (hasInvalidate = true): FakeStage => ({ + renderer: hasInvalidate ? { invalidateQuadBuffer: vi.fn() } : {}, + renderListDirty: false, + requestRender: vi.fn(), +}); + +const requestRenderListUpdate = (stage: FakeStage) => + Stage.prototype.requestRenderListUpdate.call(stage as unknown as Stage); + +describe('Stage.requestRenderListUpdate dedupe', () => { + it('invalidates the quad buffer and marks the list dirty on first call', () => { + const stage = makeStage(); + + requestRenderListUpdate(stage); + + expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(1); + expect(stage.renderListDirty).toBe(true); + expect(stage.requestRender.mock.calls.length).toBe(1); + }); + + it('skips repeat invalidations while a rebuild is already pending', () => { + const stage = makeStage(); + + requestRenderListUpdate(stage); + requestRenderListUpdate(stage); + requestRenderListUpdate(stage); + + // One structural invalidation for the whole burst (e.g. a row of cards + // flipping renderable in the same frame), but a render is requested each + // time so no frame is ever dropped. + expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(1); + expect(stage.requestRender.mock.calls.length).toBe(3); + expect(stage.renderListDirty).toBe(true); + }); + + it('invalidates again after drawFrame clears the dirty flag', () => { + const stage = makeStage(); + + requestRenderListUpdate(stage); + // drawFrame rebuilds the render list and clears the flag + stage.renderListDirty = false; + requestRenderListUpdate(stage); + + expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(2); + expect(stage.renderListDirty).toBe(true); + }); + + it('handles renderers without invalidateQuadBuffer (canvas backend)', () => { + const stage = makeStage(false); + + requestRenderListUpdate(stage); + + expect(stage.renderListDirty).toBe(true); + expect(stage.requestRender.mock.calls.length).toBe(1); + }); +}); diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 50ea82a..2e7a385 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -737,6 +737,17 @@ export class Stage { } requestRenderListUpdate() { + this.requestRender(); + + // A rebuild is already pending: the invalidation below ran when the flag + // was first raised, and the render list doesn't change until drawFrame + // rebuilds it. Skipping the repeat matters when many nodes flip renderable + // state in one frame (e.g. a whole row scrolling out of the viewport) — + // invalidateQuadBuffer walks the entire render list on every call. + if (this.renderListDirty === true) { + return; + } + // Notify the renderer that the render list is structurally changing. // For the WebGL renderer this resets per-node buffer slot assignments // and schedules a full GPU buffer re-upload on the next frame. @@ -744,7 +755,6 @@ export class Stage { this.renderer.invalidateQuadBuffer(); } this.renderListDirty = true; - this.requestRender(); } buildRenderList(node: CoreNode) { diff --git a/src/core/lib/WebGlContextWrapper.test.ts b/src/core/lib/WebGlContextWrapper.test.ts index 1c4c36f..47969e7 100644 --- a/src/core/lib/WebGlContextWrapper.test.ts +++ b/src/core/lib/WebGlContextWrapper.test.ts @@ -19,7 +19,7 @@ function mockWebGl1(hasVaoExtension: boolean): WebGLRenderingContext { deleteVertexArrayOES: (_vao: unknown) => undefined, }; const target: Record = { - canvas: {}, + canvas: { width: 1920, height: 1080 }, drawingBufferWidth: 100, drawingBufferHeight: 100, getParameter: () => 8, @@ -56,3 +56,25 @@ describe('WebGlContextWrapper VAO support', () => { expect(glw.isWebGl2).toBe(false); }); }); + +describe('WebGlContextWrapper canvas dimension cache', () => { + it('seeds canvasW/canvasH from the canvas at construction', () => { + const glw = new WebGlContextWrapper(mockWebGl1(true)); + expect(glw.canvasW).toBe(1920); + expect(glw.canvasH).toBe(1080); + }); + + it('does not track canvas resizes until updateCanvasDimensions is called', () => { + const glw = new WebGlContextWrapper(mockWebGl1(true)); + const canvas = glw.canvas as { width: number; height: number }; + + canvas.width = 1280; + canvas.height = 720; + expect(glw.canvasW).toBe(1920); + expect(glw.canvasH).toBe(1080); + + glw.updateCanvasDimensions(); + expect(glw.canvasW).toBe(1280); + expect(glw.canvasH).toBe(720); + }); +}); diff --git a/src/core/lib/WebGlContextWrapper.ts b/src/core/lib/WebGlContextWrapper.ts index c0896f2..78c9724 100644 --- a/src/core/lib/WebGlContextWrapper.ts +++ b/src/core/lib/WebGlContextWrapper.ts @@ -69,7 +69,18 @@ export class WebGlContextWrapper { //#endregion Vertex Array Objects //#region Canvas - public readonly canvas; + public readonly canvas: HTMLCanvasElement | OffscreenCanvas; + /** + * Cached copies of `canvas.width`/`canvas.height`. + * + * @remarks + * On HTMLCanvasElement these are DOM getters that cross into Blink — too + * slow to read per render op per frame on embedded targets. The canvas is + * only resized via Renderer.updateAppDimensions, which refreshes this cache + * through {@link updateCanvasDimensions}. + */ + public canvasW: number; + public canvasH: number; //#endregion Canvas //#region WebGL Enums @@ -170,6 +181,8 @@ export class WebGlContextWrapper { this.isWebGl2 = this.gl2 !== null; this.canvas = gl.canvas; + this.canvasW = gl.canvas.width; + this.canvasH = gl.canvas.height; // Extract GLenums this.MAX_RENDERBUFFER_SIZE = gl.MAX_RENDERBUFFER_SIZE; @@ -447,6 +460,15 @@ export class WebGlContextWrapper { this.gl.viewport(x, y, width, height); } + /** + * Refresh {@link canvasW}/{@link canvasH} from the canvas element. + * Must be called after the canvas is resized. + */ + updateCanvasDimensions() { + this.canvasW = this.canvas.width; + this.canvasH = this.canvas.height; + } + /** * ``` * gl.clearColor(red, green, blue, alpha); diff --git a/src/core/renderers/webgl/SdfRenderOp.ts b/src/core/renderers/webgl/SdfRenderOp.ts index a6bcfe9..c42727d 100644 --- a/src/core/renderers/webgl/SdfRenderOp.ts +++ b/src/core/renderers/webgl/SdfRenderOp.ts @@ -60,7 +60,7 @@ export class SdfRenderOp extends CoreRenderOp { } draw() { - const { glw, options, stage } = this.renderer; + const { glw, stage } = this.renderer; stage.shManager.useShader(this.shader.program); this.shader.program.bindRenderOp(this); @@ -73,7 +73,7 @@ export class SdfRenderOp extends CoreRenderOp { const clipWidth = Math.round(this.clippingRect.w * pixelRatio); const clipHeight = Math.round(this.clippingRect.h * pixelRatio); let clipY = Math.round( - options.canvas.height - clipHeight - this.clippingRect.y * pixelRatio, + glw.canvasH - clipHeight - this.clippingRect.y * pixelRatio, ); // if parent has render texture, we need to adjust the scissor rect // to be relative to the parent's framebuffer diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index 393bf2d..8bffcf8 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -212,7 +212,7 @@ export class WebGlRenderer extends CoreRenderer { gl, options.disableVertexArrayObject, )); - glw.viewport(0, 0, options.canvas.width, options.canvas.height); + glw.viewport(0, 0, glw.canvasW, glw.canvasH); this.attachContextLossListeners(options.canvas); @@ -1264,7 +1264,7 @@ export class WebGlRenderer extends CoreRenderer { // Bind the default framebuffer glw.bindFramebuffer(null); - glw.viewport(0, 0, this.glw.canvas.width, this.glw.canvas.height); + glw.viewport(0, 0, glw.canvasW, glw.canvasH); this.renderToTextureActive = false; } @@ -1331,7 +1331,10 @@ export class WebGlRenderer extends CoreRenderer { } updateViewport(): void { - this.glw.viewport(0, 0, this.glw.canvas.width, this.glw.canvas.height); + // Called after the canvas is resized (Renderer.updateAppDimensions) — + // refresh the wrapper's cached dimensions before they're used again. + this.glw.updateCanvasDimensions(); + this.glw.viewport(0, 0, this.glw.canvasW, this.glw.canvasH); } removeRTTNode(node: CoreNode) { diff --git a/src/core/renderers/webgl/WebGlShaderProgram.ts b/src/core/renderers/webgl/WebGlShaderProgram.ts index 3055bd7..da7e503 100644 --- a/src/core/renderers/webgl/WebGlShaderProgram.ts +++ b/src/core/renderers/webgl/WebGlShaderProgram.ts @@ -243,8 +243,8 @@ export class WebGlShaderProgram implements CoreShaderProgram { resolutionH = framebufferDimensions.h; } else { pixelRatio = renderOp.stage.pixelRatio; - resolutionW = this.glw.canvas.width; - resolutionH = this.glw.canvas.height; + resolutionW = this.glw.canvasW; + resolutionH = this.glw.canvasH; } if (pixelRatio !== this.lastPixelRatio) { diff --git a/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts b/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts index 2937f44..41d0fae 100644 --- a/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +++ b/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts @@ -13,7 +13,8 @@ type FakeGlw = { uniform1f: ReturnType; uniform2f: ReturnType; uniform4f: ReturnType; - canvas: { width: number; height: number }; + canvasW: number; + canvasH: number; }; const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => { @@ -21,7 +22,8 @@ const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => { uniform1f: vi.fn(), uniform2f: vi.fn(), uniform4f: vi.fn(), - canvas: { width: 1920, height: 1080 }, + canvasW: 1920, + canvasH: 1080, }; const program = Object.create(