Welcome back! In the previous module, we implemented a significant performance optimization: view frustum culling. By using a Quadtree, you ensured that your application only processes and attempts to draw strokes that are currently within the camera's viewport. This changed your rendering complexity from being dependent on the total number of strokes to being dependent on the visible number of strokes.
This lesson introduces the next major optimization in our pursuit of a high-performance renderer. While culling prevents us from processing objects outside the screen, what about the content inside the screen that doesn't change frame-to-frame? For your whiteboard application, this is a very common scenario: a user might draw a small line, but the rest of the canvas—containing potentially thousands of other strokes and a PDF background—remains static. Redrawing everything is wasteful.
Today, your goal is to learn how to track modified screen areas using 'dirty rectangles' to identify regions that need redrawing. This technique is fundamental to efficient 2D graphics and UI frameworks, as it allows the renderer to focus its work only where it's needed.
Let's start with a simple visual.

The Problem: The High Cost of Full-Screen Repainting
In our last lesson, we optimized our loop to be approximately , where is the number of visible strokes. This is a huge improvement, but there's a hidden cost. Even for the strokes that are visible and static, your current render loop will still instruct Skia to redraw them every single frame.
On a high-resolution display, this means telling the GPU to repaint millions of pixels just to put the exact same color values back where they were. This consumes memory bandwidth, GPU cycles, and power—all critical resources in a performance-sensitive application. For a tool like a whiteboard, where most of the screen is static most of the time, this is an unacceptable inefficiency.
The problem is simple: a naive render loop redraws the entire window on every frame, regardless of how small the change was.
SkiaSharp Rendering Deep Dive: How OpenMaui Draws Your UI - OpenMaui Linux Platform
This article from the OpenMaui project explains how they use SkiaSharp for cross-platform UI. Their section on dirty region optimization clearly defines the problem we're solving.
Please read the subsection titled Dirty Region Optimization. Focus on the first two paragraphs under the heading, specifically the part about the problem with naive redraws. This perfectly summarizes the wastefulness of repainting the entire screen when only a small part, like a button's background, has changed.
The Solution: Tracking What's "Dirty"
The solution is to maintain a list of regions—rectangles—that have been "dirtied" since the last frame was drawn. A region is considered dirty if its content has changed. This could happen for several reasons:
- A user draws a new stroke.
- An animation updates an object's position or color.
- Text is being typed into a box.
- The view is panned or zoomed (in this case, the whole screen becomes dirty).
For each frame, the process becomes:
- Start with an empty list of dirty rectangles.
- As the application state changes, add the bounding boxes of the modified areas to this list.
- At render time, instead of clearing and redrawing the whole screen, only redraw the portions covered by these dirty rectangles.
This confines all drawing operations to the minimal set of pixels that actually need to be updated.
Strategy 1: Explicit Invalidation
The most common way to track dirty regions, especially in UI frameworks, is through explicit invalidation. When an object's state changes in a way that affects its appearance, it becomes responsible for notifying the rendering engine that its screen area is now dirty.
Imagine you have a Stroke object. If its color property is changed, the object would call a function like invalidate(). This function would calculate the stroke's bounding box on the screen and add it to a central manager.
The OpenMaui article provides a clear, practical example of this approach.
SkiaSharp Rendering Deep Dive: How OpenMaui Draws Your UI - OpenMaui Linux Platform
Let's revisit the OpenMaui article to see how they implement this invalidation mechanism.
Continue reading the section on Dirty Region Optimization. Pay close attention to the subsections Automatic Dirty Region Detection. Notice how a property change on a control triggers InvalidateVisual(), which gets the control's bounds and registers it with a central DirtyRectManager. This manager is responsible for collecting all the dirty regions for a single frame.
For your whiteboard, this would look like:
- Adding a stroke: As the user draws, each new segment of the
SkPathdirties its own small bounding box. - Moving an object: This requires dirtying two rectangles: one for the object's old position (to erase it) and one for its new position (to draw it).
- Changing a property: If you change a stroke's color or thickness, you dirty its entire bounding box.
Strategy 2: Frame-to-Frame Comparison
An alternative approach, often seen in game engines or systems where changes are less explicit, involves comparing rendering instructions between frames. This requires a "deferred" rendering architecture, which is a powerful concept in its own right.
- Record: In Frame N, don't execute draw calls immediately. Instead, record them and all their parameters (e.g., path data, paint properties) into a list. At the end of the frame, execute this list to draw the screen.
- Store: Keep a copy of this list of draw calls.
- Compare: In Frame N+1, generate a new list of draw calls. Compare it to the list from Frame N.
- Identify: If a draw call has been added, removed, or changed, calculate its bounding box and mark that rectangle as dirty.
This method is more automatic but can have higher overhead due to the storage and comparison of draw call lists. A developer blog for the ScummVM project (which uses Skia for some backends) details this approach.
This developer blog provides a different perspective on implementing dirty rectangles, based on deferred rendering and draw call comparison.
First, read Dirty Rectangle System Pt 1 to understand the core requirement: a system that defers draw calls. Next, jump to Dirty Rectangle System Pt 4 and read the description of the second task. The author explains that their method was to compare the current frame's draw calls with the previous one's to find differences and mark the affected rectangles as dirty.
While the explicit invalidation method is likely more suitable for your whiteboard, understanding this alternative is valuable from a systems design perspective.
Managing and Coalescing Dirty Regions
Regardless of how you identify dirty regions, you will end up with a list of rectangles for each frame. Drawing each small rectangle individually can be inefficient due to the overhead of setting up each draw operation. It's often better to combine (or "coalesce") overlapping or adjacent rectangles into a single, larger rectangle.
The DirtyRectManager from the OpenMaui article demonstrates this perfectly. When a new region is marked as dirty, the manager checks if it intersects with any already in the list. If it does, it replaces the existing rectangle and the new one with their union.
// Conceptual C++ implementation of a DirtyRectManager
class DirtyRectManager {
public:
void markDirty(const SkRect& rect) {
if (rect.isEmpty()) return;
for (auto it = m_dirtyRegions.begin(); it != m_dirtyRegions.end(); ) {
if (SkRect::Intersects(*it, rect)) {
// Union the new rect with the existing one
it->join(rect);
// After joining, we need to check if this new, larger rect
// now intersects with others. For simplicity, we restart.
// A more optimized version would continue intelligently.
coalesce();
return;
}
++it;
}
m_dirtyRegions.push_back(rect);
}
const std::vector<SkRect>& getDirtyRegions() const {
return m_dirtyRegions;
}
void clear() {
m_dirtyRegions.clear();
}
private:
void coalesce() {
// A simple (but not most efficient) coalesce implementation
bool changed = true;
while(changed) {
changed = false;
for (size_t i = 0; i < m_dirtyRegions.size(); ++i) {
for (size_t j = i + 1; j < m_dirtyRegions.size(); ++j) {
if (SkRect::Intersects(m_dirtyRegions[i], m_dirtyRegions[j])) {
m_dirtyRegions[i].join(m_dirtyRegions[j]);
m_dirtyRegions.erase(m_dirtyRegions.begin() + j);
changed = true;
goto next_pass; // Restart the process
}
}
}
next_pass:;
}
}
std::vector<SkRect> m_dirtyRegions;
};
This process ensures that by the time you're ready to render, you have a minimal list of non-overlapping regions to update.
The System-Level View
At a low level, graphics drivers and compositors use similar techniques. The following diagram illustrates how dirty rectangles work in a double-buffered system.

This shows the ultimate goal: minimizing the amount of data that needs to be copied to the screen. By tracking dirty regions, we align our application's logic with the optimizations happening at the driver and OS level.
Conclusion
In this lesson, you've learned the theory and core strategies behind one of the most effective 2D rendering optimizations. By tracking which parts of the screen have changed, you can avoid the massive waste of redrawing a static scene on every frame.
Key Takeaways:
- The Problem: Naive, full-screen redraws are incredibly wasteful, consuming CPU, GPU, and power, especially when only small parts of the screen are changing.
- The Solution: Track "dirty rectangles"—bounding boxes of changed content—and only redraw those areas.
- Tracking Strategies: You can track changes via explicit invalidation (objects mark themselves as dirty) or frame-to-frame comparison (a deferred renderer compares draw call lists).
- Region Management: A central manager should collect and coalesce overlapping dirty rectangles to create a minimal list of regions to redraw.
- Performance Trade-off: This technique is most effective for applications like your whiteboard where changes are frequent but localized. It has its own overhead, so it's not a universal solution for scenes with constant, full-screen animation.
You now understand how to identify the regions that need redrawing. The next logical step is to use this information. In our next lesson, we will modify the render loop to clip drawing operations to the union of all dirty rectangles, fully realizing the performance benefits of this technique.