perf(layers): draw static paint layers from a cached raster - #867
Merged
Merged
Conversation
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>
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 oneui.Imageand drawn from it until a member changes.What stays live, so nothing ever looks worse than before:
captureAllLayersWithMetaandcaptureLayersOnDonesuspend 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.paintis 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). TheLayerStackused 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 liveLayerWidgetrender (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_uiline can take.Related Issue: Closes #none — motivated by divinevideo/divine-mobile#8032.
Type of Change