Skip to content

perf(canvas): remove per-frame canvas reset and frame-loop allocations#123

Merged
chiefcll merged 1 commit into
mainfrom
perf/canvas-frame-loop-allocations
Jul 2, 2026
Merged

perf(canvas): remove per-frame canvas reset and frame-loop allocations#123
chiefcll merged 1 commit into
mainfrom
perf/canvas-frame-loop-allocations

Conversation

@chiefcll

@chiefcll chiefcll commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Items 3 and 5 from the Canvas2D hot-path review (docs/canvas-renderer-review.md) — the per-frame tax every node pays on the canvas backend, which is the fallback path for the weakest target devices.

reset() no longer reallocates the canvas (item 3)

  • The canvas.width = canvas.width reset trick forced the browser to tear down and reinitialize the canvas backing store every frame — one of the most expensive single operations in Canvas2D on embedded Blink. Replaced with setTransform(1,0,0,1,0,0) + clearRect, which is equivalent because the frame's save/restore pairs are balanced and globalAlpha is restored after each image draw.
  • The clear-color fill was effectively always on: if (this.clearColor) is always true since normalizeCanvasColor(0x00000000) returns the non-empty string "rgba(0,0,0,0)" — so the default transparent clear color still paid a full-screen fillRect every frame. The fill is now gated on the color's alpha byte, tracked in a new clearColorAlpha field kept in sync by updateClearColor.

Frame-loop allocations killed (item 5)

  • Path2D per clipped node in addQuadbeginPath() / rect() / clip(), allocation-free. In the standard TV layout every card sits under the viewport clip, so this was one allocation per node per frame.
  • renderContext closure per shader node per frame → one preallocated arrow function field reading shaderContextNode / shaderContextTexture scratch fields, set before the shader's render() call and nulled after so a destroyed node or its texture isn't pinned.
  • parseColor() object per non-white node → returns a module-level scratch object, documented as consume-synchronously-never-retain. The only caller chain (CanvasRenderer.renderContextCanvasTexture.getImage) reads it synchronously and retains only the derived formatRgba string key.
  • Rounded-clip Path2Ds in Rounded, RoundedWithShadow, and roundedRectWithBorderbeginPath on the context. The border's evenodd fill keeps its Path2D since fill(path, 'evenodd') needs a path object.

Deliberately out of scope

  • Clip-run hoisting (the review's "better still": clip once per run of consecutive nodes sharing a rect) — a structural change to addQuad's save/restore model, better as its own PR.
  • Review items 4 (tint-cache keying on premultiplied color) and 6 (smaller cleanups).

Tests

  • 6 new CanvasRenderer unit tests: reset semantics (including asserting zero canvas.width writes via a setter spy), transparent-clear skip, opaque-clear fill, updateClearColor sync, allocation-free clipping calls, and shader-callback reference identity across nodes.
  • New colorParser.test.ts covering the scratch-reuse contract (same object across calls, white singleton untouched).
  • Full unit suite passes (326 tests); tsc --build clean.
  • Per the review's success criteria the visual regression suite must stay pixel-identical — relying on CI's pnpm test:visual for that.

🤖 Generated with Claude Code

Items 3 and 5 from the Canvas2D hot-path review — the frame tax every
node pays on the canvas backend:

- reset() no longer tears down the canvas backing store via the
  `canvas.width = canvas.width` trick; setTransform + clearRect does
  the same job (frame save/restore pairs are balanced). The clear-color
  fill is skipped when the clear color's alpha byte is 0 — previously
  the default transparent color still paid a full-screen fillRect.

- addQuad clips with beginPath/rect/clip instead of allocating a
  Path2D per clipped node per frame.

- The shader renderContext closure is replaced with one preallocated
  arrow reading node/texture scratch fields set around the shader
  render() call.

- parseColor returns a shared scratch object instead of allocating per
  non-white node; its only caller chain consumes it synchronously.

- The Rounded / RoundedWithShadow / roundedRectWithBorder clip paths
  use beginPath on the context instead of Path2D. The evenodd border
  fill keeps its Path2D (fill(path, 'evenodd') needs a path object).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chiefcll chiefcll merged commit fa48ba4 into main Jul 2, 2026
1 check passed
@chiefcll chiefcll deleted the perf/canvas-frame-loop-allocations branch July 2, 2026 22:25
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