Skip to content

Responsiveness: fills cut along the scene's tiles in the worker, pencil strokes drawn a piece at a time - #19

Merged
pathanin merged 2 commits into
masterfrom
claude/vibrant-brahmagupta-d1o5m1
Oct 20, 2026
Merged

pathanin merged 2 commits into
masterfrom
claude/vibrant-brahmagupta-d1o5m1

Conversation

@pathanin

Copy link
Copy Markdown
Owner

Makes the slowest everyday operations cheaper in Chrome and Safari. Fills and long pencil strokes do much less work on the main thread and use far less memory. The full bench was not run (your call). I measured with one-off CPU profiles instead.

Fill

The fill stays an object. Writing it into the base as pixels would mean uploading the whole box from the CPU (hundreds of ms at 100 MP) and keeping undo pixels. Drawing it from paths is cheaper.

  • What the worker sends back. It cuts its result along the scene's 512² tiles (Core.fillCells). Each tile holds the region as merged rects plus the soft edge pixels. A dense tile (noise-like masks) holds its pixels instead. The worker no longer returns the pixels and the undo data, which were 800 MB at 100 MP. fillPlan no longer computes before. An older page still gets the old reply.
  • What the page does. It only wraps the cells in an asset (cellAsset, assets.js). A cell's paths are built the first time a tile repaint draws it. Before, building the paths on the main thread took ~60 ms at 16 MP. A tile repaint now draws only the cells it meets: one, for a fill that hasn't moved. The old 256-row bands spanned the fill's whole width.
  • Every fill is paths now, including fills over transparency and Transparent fills. Those used to be two box-sized bitmaps, each with a CPU copy.
    • Pixels that aren't opaque are cleared first, then drawn as rgba() rects. Checked in Chromium: rgba() rects give what putImageData gives, 0 of 16,384 translucent values differ.
    • A dense cell is a canvas of at most 1 MB, rebuilt from its pixels after a context loss.
  • Bug fix, Transparent fills on reopen. On master, a PNG with a Transparent fill reopens with the cleared area white again. The saved bitmap can't tell a cleared pixel from an untouched one. A fill that clears or replaces soft pixels now also saves its mask in the paNt chunk. Checked: master's reopened picture differs, this branch's is pixel-identical.

Strokes

  • Fewer rects. Pencil and eraser stamps are one rect per run of a line's row or column (Core.stampRects). The pixels are the same; a unit test checks against stamping pixel by pixel.
  • Drawn a piece at a time. A live pencil or eraser stroke gets a canvas for each tile it is in. Each new piece is drawn onto that canvas, and only the part it covers is copied in. The tile is no longer composed again every frame (base, the objects under it, every earlier piece of the stroke).
    • Whole-pixel rects come out the same however they are batched, so the cache still equals the scene drawn fresh. objects.js section 33 checks this during and after a long stroke.
    • The brush keeps its full per-tile recompose, because it must stay one anti-aliased path per tile.

Also

  • Picker: reads its pixel through a fresh 1 px GPU canvas (pixelAt). It used a willReadFrequently canvas, which reads all of #image back on every click (~95 ms at 10000², per CLAUDE.md).
  • Group selection: its box is worked out once per selection, not two or three times a frame while hovering.
  • Image assets in Safari: they keep no CPU copy where a 2D context can't be lost, i.e. where canvases have no oncontextlost. That is Safari: Chrome has it since 99, Firefox since 125. There the copy only doubled their memory and was read back at idle.

Measured

One-off CPU profiles in headless Chromium on a software GPU, at 4000 × 4000 (16 MP):

Before After
Fill: main-thread JS + GC 80 ms 24 ms
Fill: heap growth 130 MB 3 MB
400-move pencil scribble: main-thread JS + GC 715 ms 354 ms

The old stroke cost grew with the stroke's length; the new one doesn't.

Testing

  • Unit tests: 87 pass after merging master, including new tests for fillCells and stampRects.
  • e2e, locally in Playwright's Chromium on a software GPU (no Google Chrome in that environment):
    • smoke 313/0, objects 199/0, preview 40/0, alpha 105/0.
    • Same failures as unmodified master, from the software GPU: filledge "batches still equal one path", libs "truecolor + alpha" and history "undo of pencil".
  • Test changes:
    • objects section 32 tested the old fill's box-sized mask canvas. It now checks two things: a soft fill holds no canvas and repaints exactly, and a dense fill's cell canvas comes back exactly after a context loss.
    • objects section 31 expects no CPU copy where contexts can't be lost.
  • Not run in WebKit: Playwright's WebKit download (cdn.playwright.dev, playwright.download.prss.microsoft.com) is blocked by that environment's network policy.

Generated by Claude Code

…il strokes drawn a piece at a time

Fill
- The fill worker sends back its result cut along the scene's 512² tiles
  (Core.fillCells): per tile, the region as merged rects and the soft edge
  pixels, or a dense tile's pixels. It no longer sends the pixels and the
  undo data back (800 MB at 100 MP; fillPlan drops `before`). An older page
  still gets the old reply.
- The page only wraps the cells in an asset (cellAsset, assets.js), and
  builds a tile's paths the first time a tile repaint draws it. Building the
  paths on the main thread took ~60 ms at 16 MP. A tile repaint draws only
  the cells it meets, one for a fill that hasn't moved; the 256-row bands
  spanned the fill's whole width.
- Fills over transparency and Transparent fills are paths too. Pixels that
  aren't opaque are cleared first, then drawn as rgba() rects, which give
  what putImageData gives. These fills were two box-sized bitmaps, each with
  a CPU copy. A dense tile is a canvas of at most 1 MB, rebuilt from its
  pixels after a context loss.
- A fill that clears or replaces soft pixels saves its mask in the paNt
  chunk. Without it, a Transparent fill reopened uncleared.

Strokes
- Pencil and eraser stamps are one rect per run of a line's row or column
  (Core.stampRects), with the same pixels.
- A live pencil or eraser stroke draws each new piece onto a canvas per tile
  it is in, then copies that part in. The tile is no longer composed again
  (base, objects, every earlier piece) each frame. Whole-pixel rects come
  out the same whatever they are batched with, so the cache still equals
  the scene drawn fresh. A brush stays one path per tile.

Also
- The picker reads its pixel through a fresh 1 px GPU canvas (pixelAt). The
  willReadFrequently one read all of #image back on every click.
- The group selection's box is worked out once per selection, not two or
  three times a frame.
- Image assets keep no CPU copy where a 2D context can't be lost (no
  oncontextlost: Safari). The copy doubled their memory and was read back
  at idle.

Measured with one-off CPU profiles in headless Chromium on a software GPU,
at 4000 x 4000:
- one fill: main-thread time 80 -> 24 ms, heap growth 130 -> 3 MB;
- a 400-move pencil scribble: 715 -> 354 ms, and it no longer grows with
  the stroke's length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjaj6ac9XGsjJWsPrMECjo
@pathanin
pathanin marked this pull request as ready for review October 20, 2026 20:39
@pathanin
pathanin merged commit 0bfdfd1 into master Oct 20, 2026
3 checks passed
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.

2 participants