From Paint to Pixels — The Raster Thread and Impeller
Part 2 of 3: Follow the layer tree from the paint phase through SceneBuilder, across the thread boundary, and into Impeller's GPU command generation. Understand double buffering and why precompiled shaders eliminated first-run jank.
In Part 1, we traced the VSync pulse through Flutter’s engine to the Dart framework, watched setState() mark elements dirty, and saw layout calculate sizes. The UI thread’s work ended at layout. Now we enter the territory where many Flutter developers get fuzzy: paint, compositing, and the raster thread.
By the end of this part, you’ll understand exactly what happens between flushPaint() and pixels appearing on the display — and why Impeller fixed a class of jank that Skia couldn’t.
Rung 4: Paint and the Layer Tree
Paint Is Recording, Not Drawing
Here’s the key insight: when a RenderObject’s paint() method runs, it doesn’t draw pixels to the screen. It records drawing commands into a data structure.
@overridevoid paint(PaintingContext context, Offset offset) { // This doesn't draw — it records the command context.canvas.drawRect( offset & size, Paint()..color = Colors.blue, );}The canvas here isn’t connected to the screen. It’s a Canvas that wraps a PictureRecorder. Every draw call adds an operation to an internal list. When painting finishes, endRecording() produces a Picture — an immutable, replayable recording of all those commands.
Why record instead of draw directly? Three reasons:
- Caching — A
Picturecan be replayed multiple times without re-executing the Dart paint code - Compositing — Pictures can be layered, transformed, and blended by the compositor
- Thread boundary — The
Picturecan be shipped to the raster thread while the UI thread moves on
The PaintingContext
When flushPaint() runs, it walks the dirty render objects and calls their paint() methods. Each gets a PaintingContext which provides:
- canvas — The recording canvas
- pushLayer() — Create a new compositing layer
- paintChild() — Paint a child render object
// Simplified paint method@overridevoid paint(PaintingContext context, Offset offset) { // Draw our own content context.canvas.drawRect(offset & size, _paint);
// Paint children if (child != null) { context.paintChild(child!, offset); }}Layer Types
Not everything goes into a single Picture. Some operations require separate layers that the compositor handles specially:
PictureLayer — Holds a Picture. This is where most drawing commands land.
TransformLayer — Applies a matrix transform to all descendants. Used by Transform widgets.
OpacityLayer — Applies alpha blending. Used by the Opacity widget. This requires saveLayer internally, which is expensive.
ClipRectLayer / ClipPathLayer — Clips descendants to a rectangle or arbitrary path.
BackdropFilter — Applies effects to content behind this layer. Very expensive because it must read back previously rendered content.
OffsetLayer — Created by RepaintBoundary. Marks a subtree that can be composited independently.
RepaintBoundary
When you wrap a widget in RepaintBoundary, Flutter creates an OffsetLayer at that point in the tree. This layer holds its own Picture that’s composited as a unit.
The benefit: when only that subtree needs repainting, Flutter re-records just its Picture and re-composites. Siblings don’t repaint.
// An animation inside a RepaintBoundary doesn't cause// the rest of the page to repaintRepaintBoundary( child: SpinningWidget(), // Only this repaints each frame)The cost: each layer consumes memory and adds a compositing step. Don’t wrap everything — wrap things that repaint on a different cadence than their surroundings.
Teach-test: Explain when adding a RepaintBoundary hurts performance.
Answer: RepaintBoundary hurts when the wrapped content repaints in sync with its siblings anyway (you added a layer for no isolation benefit), when the content is simple enough that repainting is cheaper than the compositing overhead, or when you create so many layers that memory and compositor time dominate. Profile with DevTools’ “Highlight repaints” to see actual paint regions before adding boundaries.
Rung 5: The Handoff — Compositing to Scene
After flushPaint(), we have a tree of layers. Now compositeFrame() must turn this into a Scene and hand it to the engine.
The SceneBuilder API
SceneBuilder is the low-level dart:ui API for building a Scene. It’s a stack-based builder:
// This is what compositeFrame() does internallyfinal builder = SceneBuilder();
// Push a transform for device pixel ratiobuilder.pushTransform(devicePixelRatioMatrix);
// Add layers by walking the treerootLayer.addToScene(builder);
// Pop the transformbuilder.pop();
// Build the scenefinal scene = builder.build();Each layer type knows how to add itself:
// PictureLayer@overridevoid addToScene(SceneBuilder builder) { builder.addPicture(offset, picture);}
// OpacityLayer@overridevoid addToScene(SceneBuilder builder) { builder.pushOpacity(alpha); for (final child in children) { child.addToScene(builder); } builder.pop();}The render() Call
After building the Scene, RenderView.compositeFrame() calls:
_view.render(scene);This is FlutterView.render() — the function that ships the scene to the engine. Here’s what the API docs say:
“Updates the application’s rendering on the GPU with the newly provided Scene.”
This is the boundary. Before this call: Dart code on the UI thread. After: C++ engine code, eventually running on the raster thread. No more Dart executes for this frame.
What Crosses the Boundary
The Scene isn’t literally sent across threads — it’s a handle to native engine structures. When you call render(scene):
- The Dart
Sceneobject wraps a pointer to a C++LayerTree render()posts thisLayerTreeto the raster thread’s task queue- The UI thread can now start work on the next frame
- The raster thread picks up the
LayerTreewhen ready
This pipelining means the UI thread and raster thread can work in parallel on different frames.
Teach-test: Name the one function call that marks the UI→raster handoff.
Answer: FlutterView.render(scene) (historically window.render(scene)). This is the last Dart code for the frame. Everything after runs in C++ on the raster thread.
Rung 6: The Raster Thread and Impeller
Now we’re in engine territory. The raster thread receives the LayerTree and must turn it into GPU commands.
What the Raster Thread Does
- Walks the LayerTree — Each layer type has a corresponding C++ class
- Builds a DisplayList — Impeller’s internal command format, more efficient than the layer representation
- Encodes GPU commands — Translates the DisplayList into Metal (iOS) or Vulkan (Android) commands
- Submits to GPU — Hands the command buffer to the graphics driver
The raster thread runs on the CPU — it’s preparing GPU work, not running on the GPU itself. The actual pixel-pushing happens when the GPU executes those commands.
Double Buffering
While the raster thread prepares commands, the display is simultaneously scanning out a previous frame. This works through double buffering:
Front Buffer — The image currently being read by the display, scanned top-to-bottom, left-to-right.
Back Buffer — The buffer the GPU is writing to. Not visible until the swap.
When the display finishes scanning the front buffer (reaching the bottom-right corner), it enters the Vertical Blanking Interval (VBI). During this brief pause, the system swaps the buffer pointers: back becomes front, front becomes back.
The swap is just a pointer change — no pixels are copied. The GPU can immediately start writing to the new back buffer while the display scans the new front buffer.
Why VSync matters: Without synchronising to VSync, the GPU might write to the front buffer while it’s being scanned. Result: the top half shows one frame, the bottom half shows another — tearing. VSync forces the GPU to wait for the swap point.
Modern systems often use triple buffering: front + back1 + back2. While the display scans front and back1 is queued for the next VSync, the GPU writes to back2. This keeps the GPU busy and smooths frame pacing, at the cost of slightly more latency.
Why Skia Had Shader Jank
Here’s the problem Impeller solved. Skia (Flutter’s previous graphics backend) compiled shaders at runtime:
- Your app starts with basic effects — shaders compile quickly, no visible jank
- First time a blur effect appears → Skia must compile the blur shader
- Shader compilation takes 20-100ms → the frame misses its 16.6ms deadline → jank
- Subsequent frames are smooth because the shader is cached
This “first-run jank” was maddening. The app would jank the first time you used a feature, then be smooth forever. Profiling showed the issue, but you couldn’t fix it from Dart — the compilation happened in the graphics driver.
How Impeller Fixes It
Impeller takes a fundamentally different approach: ahead-of-time (AOT) shader compilation.
- At
flutter buildtime, Impeller compiles all its shaders to SPIRV/Metal - The compiled shaders are bundled with the app
- At runtime, there’s no shader compilation — the GPU just executes the pre-built programs
The shader library is small because Impeller uses a fixed set of composable shaders, not the infinite variety Skia generated dynamically.
Current Status (June 2026)
- iOS: Impeller only. Skia removed. No opt-out.
- Android API 29+: Impeller default with Vulkan. Opt-out deprecated in 3.38.
- Android < API 29: Legacy Skia with OpenGL fallback.
- Web: Still Skia (canvaskit/skwasm). Impeller for web is in progress.
Teach-test: Explain, mechanism-level, why Skia produced first-run animation jank and why precompiling shaders removes it.
Answer: Skia used a just-in-time model: when the rasteriser encountered a new combination of effects (gradient + blur + blend mode), it asked the GPU driver to compile a shader program. Driver shader compilation is synchronous and can take tens of milliseconds — far beyond the 16.67ms frame budget. The frame stalls waiting for the compile, so it drops. Once compiled, the shader is cached, so subsequent frames are fast. Impeller precompiles a fixed shader library at build time, so the GPU never needs to compile during app execution. No runtime compilation → no compilation stalls → no first-run jank.
Putting It Together: A Frame’s Journey Through Raster
Let’s trace a single frame through Part 2’s territory:
1. flushPaint() walks dirty RenderObjects → Each calls paint(), recording to Canvas → Canvas is wrapping a PictureRecorder → Pictures accumulate in PictureLayers
2. Layer tree is complete → TransformLayer at root (device pixel ratio) → OffsetLayers from RepaintBoundaries → OpacityLayers, ClipLayers for effects → PictureLayers holding the actual draws
3. compositeFrame() runs → Creates SceneBuilder → Each layer calls addToScene() → builder.build() produces Scene (native handle) → FlutterView.render(scene) posts to raster thread → UI thread is done, can start next frame
4. Raster thread picks up LayerTree → Walks layers, builds DisplayList → Impeller encodes Metal/Vulkan commands → Commands submitted to GPU
5. GPU executes commands → Draws to back buffer → Waits for VSync → Buffers swap → Display scans new frameIf either the UI thread or the raster thread takes longer than 16.6ms, the frame drops. But they’re different fixes:
- UI thread slow → reduce build/paint work in Dart
- Raster thread slow → reduce layer complexity, avoid expensive composite ops
That distinction is the key to Part 3.
Key Takeaways
-
Paint records commands into Pictures, not pixels. The recording is what allows caching, compositing, and threading.
-
The layer tree is the output of paint. Each layer type (Picture, Transform, Opacity, Clip) has specific composite behaviour.
-
RepaintBoundary creates isolation. It’s a trade-off: memory for a layer vs. avoiding sibling repaints.
-
FlutterView.render(scene)is the boundary. Before: Dart on UI thread. After: C++ on raster thread. -
Double/triple buffering prevents tearing. The swap happens during VBI; VSync synchronises GPU writes to display reads.
-
Impeller precompiles shaders at build time. This eliminates Skia’s runtime shader compilation jank.
Next in Part 3: Now that you understand the full pipeline, we diagnose when it breaks. How to read the DevTools timeline. What makes the UI track tall versus the raster track. When isolates help and when they’re useless. And the full arsenal of short-circuits Flutter uses to avoid redundant work.