Welcome back. In our previous lessons, we've made significant strides in optimizing your whiteboard's renderer. We first implemented spatial culling with a Quadtree to avoid processing off-screen strokes, and then added dirty-rectangle clipping to prevent the GPU from redrawing unchanged pixels. These are powerful techniques, but they still operate within an "immediate mode" rendering paradigm—we decide whether to draw an object and then immediately issue the draw call to the SkCanvas.
This lesson introduces a fundamental shift in that paradigm. Our goal is to design an intermediate data structure to represent a single Skia draw command. Instead of drawing directly to the canvas, we will first record all our drawing intentions for a frame into a list of these command objects. This moves us towards a "deferred" or "retained mode" architecture, which is the cornerstone of high-performance graphics engines. It unlocks the ability to analyze, reorder, and batch commands before sending them to the GPU, drastically reducing overhead and minimizing expensive state changes.
The Problem with Immediate Mode
Your current render loop, even with culling and clipping, might look something like this:
// Pseudocode for the current render loop
canvas->save();
canvas->clipRegion(dirtyRegion);
for (const auto& stroke : visibleStrokes) {
if (stroke->bounds.intersects(dirtyRegion)) {
canvas->drawPath(stroke->path, stroke->paint);
}
}
canvas->restore();
Consider what happens inside the loop. For each stroke, the CPU tells the GPU: "Use this SkPaint object. Now draw this SkPath." If the next stroke uses a different color, stroke width, or blend mode, that's a new SkPaint object. The GPU must halt, change its internal state (e.g., configure different shader uniforms, blend functions), and then proceed.
These state changes are surprisingly expensive. In a complex scene with many different styles of strokes, you could be forcing the GPU to change state hundreds of times per frame. It's like asking a factory worker to switch tools for every single part they assemble, rather than assembling all the parts that use the same tool at once.
The Solution: A Command List
The solution is to decouple the recording of drawing operations from their execution.
- Record: In the main loop, instead of calling
canvas->drawPath(), we'll create aDrawPathCommandobject containing theSkPathandSkPaint, and add it to astd::vector. - Optimize: Before drawing, we can process this list. The most significant optimization is sorting the list by state. For example, we can sort all commands by their
SkPaintproperties. - Execute: Finally, we iterate through the sorted list and execute the commands. Now, all strokes using the same red pen are drawn one after another, then all strokes with the blue pen, and so on. This drastically reduces state changes.
This concept is often called creating a "display list" or a "command buffer". The diagram below illustrates this architecture. We are designing the "High-level commands" that will be fed into the later "Batching" stage.

Designing a DrawCommand Interface
To implement this in C++, we need a data structure that can represent any type of draw operation. Since we have various commands (drawPath, drawRect, drawTextBlob, etc.), a polymorphic design using a common base class is an effective and extensible pattern.
Let's define a base DrawCommand interface with a virtual execute method.
// A base interface for all rendering commands
struct DrawCommand {
virtual ~DrawCommand() = default;
// Executes the command on a given Skia canvas
virtual void execute(SkCanvas* canvas) const = 0;
};
Now, we can create concrete command types that inherit from this base. Each command will store the specific data it needs.
// A command to draw a path
struct DrawPathCommand : public DrawCommand {
SkPath path;
SkPaint paint;
DrawPathCommand(SkPath p, SkPaint pt) : path(std::move(p)), paint(std::move(pt)) {}
void execute(SkCanvas* canvas) const override {
canvas->drawPath(path, paint);
}
};
// A command to draw a rectangle
struct DrawRectCommand : public DrawCommand {
SkRect rect;
SkPaint paint;
DrawRectCommand(SkRect r, SkPaint pt) : rect(r), paint(std::move(pt)) {}
void execute(SkCanvas* canvas) const override {
canvas->drawRect(rect, paint);
}
};
// The render loop's command list would be a vector of pointers
// std::vector<std::unique_ptr<DrawCommand>> commandList;
This pattern is not just theoretical; it's used in real-world rendering engines. The "Web Browser Engineering" guide demonstrates this exact approach for its Skia-based renderer.
This chapter explains how a browser rendering engine uses Skia. The Python examples directly mirror the C++ structure we're designing.
Focus on the section Rasterizing with Skia. Observe the structure of the classes DrawLine, DrawText, and DrawRect. Each class is an intermediate data structure that stores geometry and style information. They each have an execute method that takes the canvas and performs the actual Skia draw call. This is precisely the pattern we are implementing in C++.
Real-World Validation: Skia's PDF Backend
This approach is so fundamental that Skia itself uses it for its PDF backend. When you draw to a PDF canvas, Skia doesn't write PDF commands immediately. Instead, it records your operations into an internal data structure and processes them later.
PDF Theory of Operation | Skia
This document provides a low-level look at how Skia generates PDF files. It offers a powerful confirmation of our design choice.
First, read the paragraph under the PDF drawing heading. It explicitly states: "SkPDFDevice records each drawing operation into an internal structure (ContentEntry) and only when the content stream is needed does it flatten that list of structures into the final content stream." This "ContentEntry" is Skia's internal version of our DrawCommand. Next, jump to the section Generating a content stream. The first paragraph there is highly relevant, as it describes exactly what goes into this structure: "a new ContentEntry is created, which stores the matrix, clip region, and clip stack as well as the paint parameters." This confirms that our DrawCommand needs to capture not just the geometry and paint, but potentially the full graphics state (transforms, clips) as well.
Expanding the Command Structure
As the Skia documentation implies, a robust command structure needs to handle more than just drawing primitives. It must also represent changes in the graphics state.

This suggests our command list should also include commands for state management:
struct SaveCommand : public DrawCommand {
void execute(SkCanvas* canvas) const override {
canvas->save();
}
};
struct RestoreCommand : public DrawCommand {
void execute(SkCanvas* canvas) const override {
canvas->restore();
}
};
struct TransformCommand : public DrawCommand {
SkMatrix matrix;
TransformCommand(const SkMatrix& m) : matrix(m) {}
void execute(SkCanvas* canvas) const override {
canvas->concat(matrix);
}
};
With this, you can represent an entire, complex frame as a sequence of data objects before a single pixel is ever drawn. This is an incredibly powerful abstraction for optimization. For example, you could concatenate multiple TransformCommand objects into a single one before execution, or identify redundant save/restore pairs.
Storing Command Data
Let's briefly touch on the memory implications, a topic close to your interests in low-level systems. A std::vector<std::unique_ptr<DrawCommand>> stores pointers to heap-allocated command objects. This is flexible but involves heap allocation overhead for every command.
For maximum performance, a more advanced approach would use a custom allocator. A "ring buffer" (or arena allocator) is a common pattern for this scenario. You would pre-allocate a large block of memory for the frame's commands and then simply bump a pointer to "allocate" space for each new command. This is extremely fast as it avoids malloc/free calls and improves data locality.
This is a deep topic, but the following talk provides an excellent overview of such patterns in rendering.
CppCon 2017: Nicolas Guillemot “Design Patterns for Low-Level Real-Time Rendering”
In this CppCon talk, Nicolas Guillemot discusses design patterns for high-performance renderers. The section on ring buffers is particularly relevant.
Watch the segment on ring buffers from this section. The key idea is using a simple, fast allocator to stream data from the CPU to the GPU. The proposed API, which returns both a CPU and GPU address, is a glimpse into how you would manage the memory for the geometry data referenced by your draw commands in a truly low-level, high-performance engine.
For now, std::vector and std::unique_ptr are perfectly suitable, but it's valuable to understand that the data structure we've designed is the first step towards these more advanced memory management and data streaming strategies.
Conclusion
In this lesson, we have laid the groundwork for a more sophisticated, performant rendering architecture. By moving from an immediate-mode to a deferred-mode model, you've unlocked significant optimization potential.
Key Takeaways:
- Immediate vs. Deferred: Immediate-mode rendering (
canvas->draw...) is simple but can be inefficient due to frequent GPU state changes. A deferred approach records commands to a list first, allowing for optimization before execution. - The
DrawCommandInterface: A polymorphic base class with a virtualexecutemethod provides a clean and extensible way to represent different types of drawing and state-management operations. - Command Content: A command must encapsulate all data needed for its execution, primarily geometry (
SkPath,SkRect) and style (SkPaint), but also potentially transformations and clipping state. - Real-World Pattern: This command-list architecture is not just a theoretical concept; it's a fundamental pattern used in professional rendering engines, including Skia's own internal backends.
You now have a design for a data structure that can capture an entire frame's rendering work as a list of objects. Your render loop will no longer draw directly, but will instead populate this command list.
In the next lesson, we will put this structure to work. We will implement a render pass that records, sorts by state, and executes these draw commands, finally realizing the performance benefits of GPU batching.