Skip to content

perf(layers): draw static paint layers from a cached raster - #867

Merged
hm21 merged 4 commits into
stablefrom
perf/paint-layer-raster-cache
Sep 11, 2026
Merged

hm21 merged 4 commits into
stablefrom
perf/paint-layer-raster-cache

Conversation

@hm21

@hm21 hm21 commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Description

Every paint layer is a vector path the engine re-strokes on each frame it appears in, and Impeller keeps nothing between frames. On a video canvas the background changes every frame, so a doodle-heavy edit re-renders all of its ink on every one of them. Measured on a Galaxy S25 Ultra with 150 freestyle strokes of 300 points: ~7–10 ms of raster per frame, whether the strokes sit in one merged layer or in a layer each, and regardless of point density — the cost tracks the amount of ink, not the layer count. Older phones spend their whole frame budget on it.

This adds an opt-in MainEditorConfigs.enablePaintLayerRasterCache. Consecutive static paint layers (a z-run; a text or sticker layer between two drawings splits them, so stacking order is untouched) are composited into one ui.Image and drawn from it until a member changes.

What stays live, so nothing ever looks worse than before:

  • a layer that is selected or being dragged, scaled or rotated — excluded from the pointer-down on, so it is pixel-exact during the gesture and rejoins the cache once released;
  • a layer with a timeline animation or enter/exit fade, and one outside its timeline window;
  • everything while the editor is zoomed (the image is rendered for scale 1), while a sub-editor opens (hero flights need painted sources), during a partial erase in the paint editor (the eraser mutates strokes per pointer move), and during every layer capture — captureAllLayersWithMeta and captureLayersOnDone suspend the cache and wait for a frame in which every layer painted into its own repaint boundary.

Layers keep their widgets: hit-testing, selection, the interaction overlay and the repaint-boundary keys are unchanged; only DrawPaintItem.paint is skipped. Rasterization is asynchronous and throttled to one render at a time; a run renders live until its image lands, so the canvas is never blank and there is no stale ghost after a change. Images are bounded per run (8 M px) and in total (16 M px, LRU). The LayerStack used by the sub-editors takes part too, so drawing on top of many existing strokes gets the same relief.

Off by default — a direct Layer.captureAsPng() on a mounted, cached layer would read an empty boundary, so apps opt in and capture through the editor's capture methods, which handle it.

Measured (real ProImageEditor.video, 150 strokes × 300 points): raster 3.1 → 1.0 ms per frame during playback and 1.7 → 0.6 ms while retiming a layer. The composite is verified pixel-identical to the live LayerWidget render (0.0 % mismatch across translated, scaled, rotated, flipped and translucent layers).

The same change is on perf/paint-layer-raster-cache-13x (v13.5.0 + #863), for a 13.6.0 release that apps still on the pre-material_ui line can take.

Related Issue: Closes #none — motivated by divinevideo/divine-mobile#8032.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

Every paint layer is a vector path the engine re-strokes on each frame it
appears in, and Impeller keeps nothing between frames. On a video canvas the
background changes every frame, so a doodle-heavy edit re-renders all of its
ink on every one of them, whether the strokes sit in one merged layer or in a
layer each: the cost tracks the amount of ink, not the layer count.

Behind the opt-in `MainEditorConfigs.enablePaintLayerRasterCache`, consecutive
static paint layers are composited into one `ui.Image` per z-run and drawn
from it until a member changes. Layers keep their widgets — hit-testing,
selection, the interaction overlay and the repaint-boundary keys are
untouched — only their painter is told to skip. A layer that is selected or
being dragged, scaled or rotated, one that is mid-animation, and one outside
its timeline window render live, so a moved layer is pixel-exact while it
moves and rejoins the cache once released. The cache steps aside while the
editor is zoomed, during a partial erase (which mutates strokes per pointer
move), while a sub-editor opens, and for every layer capture, so
`captureAllLayersWithMeta` and `captureLayersOnDone` read live boundaries.

Rasterization is asynchronous and throttled to one render at a time; a run
renders live until its image lands, so nothing ever goes blank. Images are
bounded per run and in total, and evicted oldest-first.

Measured with 150 freestyle strokes of 300 points on a video canvas: raster
3.1ms -> 1.0ms per frame during playback and 1.7ms -> 0.6ms while retiming a
layer, with the composite verified pixel-identical to the live render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hm21 hm21 self-assigned this Sep 11, 2026
… holding images under a sub-editor

A run whose render failed — one past the GPU's texture limit, say — was
attempted again on every build, each attempt as expensive as a live frame
and each reported as an error. Failed keys are now remembered and the run
stays live; a run whose image would exceed 4096 px on a side is not
planned in the first place, since Flutter does not expose the GPU's limit.

The main editor also drops its cached images when a sub-editor opens: the
layers render live for the hero flight anyway and the sub-editor's
LayerStack brings a cache of its own, so the images only sat in GPU memory
until it closed.
…ched paint layers

Captures read the widget tree, and a cached layer paints nothing into its own
repaint boundary while the run image is rendered at the device pixel ratio.
The recorder now asks for a frame in which every layer paints live before it
reads the container, which covers state-history screenshots, the final image,
thumbnails and every sub-editor; Layer.captureAsPng renders a cached layer
from its model. That replaces the capture depth counter in the main editor.

Both hosts share a PaintLayerRasterCacheHost mixin that also steps aside
while the route animates, so hero shuttles are not doubled by the image. The
run key hashes the stroke content instead of counting points, the bounds
come from PaintedModel.bounds with room for miter joins, eviction never
touches the current plan and a plan stays within maxTotalPixels, a custom
timeline transition keeps its layers live, and the crop editor renders live
under its animated transforms.

The paint canvas closes a partial-erase session on every discarded gesture
and no longer opens one for the object eraser, which also removes the empty
undo step that mode left behind.
@hm21
hm21 marked this pull request as ready for review September 11, 2026 15:12
@hm21
hm21 merged commit ec47e7c into stable Sep 11, 2026
3 checks passed
@hm21
hm21 deleted the perf/paint-layer-raster-cache branch September 11, 2026 15:13
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