Skip to content

fix(renderer): smooth zoomed drawing and erasing without the memory cost#39

Open
markm39 wants to merge 3 commits into
mainfrom
fix/zoom-tool-performance
Open

fix(renderer): smooth zoomed drawing and erasing without the memory cost#39
markm39 wants to merge 3 commits into
mainfrom
fix/zoom-tool-performance

Conversation

@markm39

@markm39 markm39 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Closes #36.

What this fixes

Issue #36 reported laggy drawing when zoomed in, especially with highlighter and pixel eraser, plus highlighter strokes showing visible spline segments ("tabs").

Highlighter/marker now render as a single constant-width stroked centerline instead of per-segment multiply-blended quads. This removes the visible segment tabs at high zoom and the per-segment draw cost. The rule is named once (isCenterlineStrokedToolType) and shared by live preview, stroke commit, and tile rendering, so preview and final go through the same pipeline.

Pixel eraser no longer updates stroke metadata, tiles, or the cached snapshot on every input sample. During the drag it only applies a cheap kClear sweep to the stroke surface (1x path) or clips the pending erase circles out of the stroke layer (zoomed path, so the real background shows through on every paper type). All bookkeeping — per-stroke circle assignment, the undo delta, visibility-cache refresh, and tile invalidation bounded to the erased rect — happens once at pen-up.

Pen/crayon/calligraphy at zoom keep the incremental O(1)-per-frame active-stroke surface, but the surface is now anchored to the visible viewport at engine dimensions (constant ~14 MB) instead of allocating canvas x zoom pixels. The previous approach allocated up to 25x canvas memory (~330 MB on iPad at 5x zoom) mid-gesture, with a jetsam risk and a first-frame allocation hitch; on allocation failure live ink silently disappeared. If a consumer sets renderScale without viewport ratios, the renderer degrades to the identity mapping rather than anchoring to the wrong region. Finished strokes drawn while zoomed are re-rendered from vector data at 1x instead of nearest-neighbor downsampling the magnified preview.

Cleanups folded in

  • Stroke::hasVisiblePoints() is now the single visibility check (erase commit, deserialize; removed a dead third copy in DrawingSelection)
  • Zoom range constants and the identity-scale threshold hoisted to DrawingTypes.h, shared by the engine and ActiveStrokeRenderer
  • cachedStrokeSnapshot_ invalidated at the mutation site instead of a tool-protocol special case in render()
  • Removed dead code: recordPixelEraseCircleAdded, appendPixelEraseCircleToDelta, drawCalligraphyPathTail, unreachable centerline branches, constant lastHalfWidth_, the constant-true useIncrementalActiveSurface parameter

Verification

  • All 17 C++ translation units pass clang++ -fsyntax-only -std=c++17 against the vendored Skia headers
  • npm run typecheck, jest (57/57), and npm run test:native:smoke (compiles, links, and runs the serialization smoke binary) all pass
  • /simplify (4-angle review, 17 findings applied) and /security (no findings) both run
  • Device build and on-device verification still pending (drawing at max zoom with each tool, long pixel-erase scrubs on grid/PDF paper, memory gauge while drawing zoomed, undo after erase, zoom-out after drawing zoomed)

🤖 Generated with Claude Code

markm39 added 3 commits May 24, 2026 15:39
- keep the active-stroke surface at engine dimensions and anchor it to
  the visible viewport when zoomed, instead of allocating logical-size
  times scale pixels (up to 25x canvas memory at 5x zoom, allocated
  mid-gesture)
- re-render finished strokes from vector data when zoomed instead of
  nearest-neighbor downsampling the magnified preview onto the 1x
  stroke surface
- invalidate only the erased region's stroke tiles after a pixel-erase
  gesture instead of clearing the whole tile cache
- refresh cachedHasVisiblePoints when committing a pixel-erase drag so
  fully-erased strokes stop responding to selection
- cache the pixel-eraser cutout path instead of rebuilding it from every
  pending circle on each frame
- remove dead recordPixelEraseCircleAdded/appendPixelEraseCircleToDelta
  and unreachable centerline preview branches
Simplify pass over the zoom performance changes:

- replace the pixel-eraser background cutout with a kDifference clip of
  the stroke layer, so every background type shows through without
  re-deriving background compositing policy (fourth copy removed)
- invalidate cachedStrokeSnapshot_ at the mutation site in
  applyPixelEraserAt instead of special-casing the eraser drag in render()
- name the highlighter/marker rule once (isCenterlineStrokedToolType) and
  share one drawCenterlineStrokePath helper between stroke rendering and
  the active preview
- merge finishStroke's duplicated render-from-geometry branches
- hoist kMinimumRenderScale/kMaximumRenderScale and the identity-scale
  threshold into DrawingTypes.h, shared by the engine and
  ActiveStrokeRenderer
- promote the stroke visibility check to Stroke::hasVisiblePoints and use
  it from erase, deserialize, and drop the dead DrawingSelection copy
- derive erase bounds from the pending eraser path, precompute per-circle
  bounds once, and use an O(1) stroke outset at pen-up
- reset pending pixel-erase state through one helper; make
  applyPixelEraserAt void and use applyPendingPixelEraseToStrokes' return
- wrap active-surface transforms in SkAutoCanvasRestore; drop the
  constant lastHalfWidth_ member and orphaned drawCalligraphyPathTail
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.

[Bug]: Lag when zoomed in

1 participant