Skip to content

perf: cache canvas dimensions and debounce render-list invalidation#119

Merged
chiefcll merged 1 commit into
mainfrom
perf/hot-path-canvas-dims-and-renderlist-dedupe
Jul 2, 2026
Merged

perf: cache canvas dimensions and debounce render-list invalidation#119
chiefcll merged 1 commit into
mainfrom
perf/hot-path-canvas-dims-and-renderlist-dedupe

Conversation

@chiefcll

@chiefcll chiefcll commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What

Two render-loop hot-path fixes from a review of the standard TV-app scroll scenario (clipped viewport, rows of image/text cards translating in both axes):

1. Cache canvas.width/canvas.height on WebGlContextWrapper (canvasW/canvasH)

On HTMLCanvasElement these are Blink DOM getters, and they were being read in the hottest per-op paths:

  • WebGlShaderProgram.bindRenderOpu_resolution resolution, 2 reads per render op per frame
  • CoreNode.draw / SdfRenderOp.draw — scissor math, 1 read per clipped op per frame

On Chrome-38-class embedded targets each read crosses into the DOM layer. The canvas is only ever resized through Renderer.updateAppDimensions()renderer.updateViewport(), so updateViewport is the single refresh point for the cache (updateCanvasDimensions()). Constructor seeding is safe because Renderer sizes the canvas before the wrapper is constructed.

Also types the previously implicit-any canvas field as HTMLCanvasElement | OffscreenCanvas.

2. Debounce Stage.requestRenderListUpdate()

invalidateQuadBuffer() walks the entire render list resetting per-node buffer-slot assignments, and was invoked once per node whose renderable state flipped. When a whole row scrolls out of the viewport, every card in it flips isRenderable in the same frame → N full-list walks per frame. Now the first call performs the invalidation and subsequent calls early-return until drawFrame rebuilds the list. This is safe because the render list cannot change between the dirty flag being raised and the rebuild in the same frame's drawFrame. requestRender() still runs unconditionally, so render scheduling is byte-for-byte unchanged.

Reviewer notes

  • The scissor math change (glw.canvasH instead of options.canvas.height) is the riskiest consumer — verified visually against the clipping example: all three clipping sections render pixel-correct, SDF text (same scissor path) included.
  • The uniformDedup test mock was updated from canvas: {width, height} to canvasW/canvasH to match the new read.

Testing

  • pnpm build (tsc) clean; changed files Prettier/ESLint clean
  • Full unit suite: 273 tests pass
  • New tests: WebGlContextWrapper dimension-cache seeding/refresh (2), Stage.requestRenderListUpdate dedupe semantics incl. canvas-backend without invalidateQuadBuffer (4)
  • Manual: examples dev server, ?test=clipping, screenshot verified + clean console

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@chiefcll chiefcll merged commit a262650 into main Jul 2, 2026
1 check passed
@chiefcll chiefcll deleted the perf/hot-path-canvas-dims-and-renderlist-dedupe branch July 2, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant