Welcome back. In our last lesson, you implemented the query method for your Quadtree, creating an efficient tool to find all strokes within a given rectangular area. You now have the ability to ask, "Which strokes are inside this box?" and get an answer in logarithmic time. This lesson is where that work pays off.
Our goal today is to modify the render loop to draw only the subset of strokes that are inside the current viewport. We will connect the camera's view, the Quadtree's query capability, and Skia's drawing commands into a single, high-performance rendering pipeline. This is the final and most critical step in implementing view frustum culling.
The Problem: The Cost of Off-Screen Drawing
Your current render loop, like any naive implementation, likely iterates through every single stroke in your document. While Skia is highly optimized and will clip drawing operations that fall outside the viewport, it doesn't do so for free. For every off-screen stroke, your CPU still spends time iterating, performing checks, and dispatching a draw call to the GPU, even if that call results in zero pixels being drawn.
As you're keenly aware from your interest in high-performance systems, this leads to an unacceptable performance characteristic: the cost to render a frame scales linearly, , with the total number of strokes . When a user is zoomed into a small section of a document containing thousands of strokes, the application should not be wasting cycles processing the vast majority of strokes that are off-screen.
The following article from Grida's engineering blog explains this exact problem. Even though they use an R-tree, the principle is identical for our Quadtree.
Rendering Optimization Strategies | Grida
This document details rendering optimization strategies used in a high-performance 2D graphics application. We'll focus on the section explaining the benefit of culling before the draw loop.
Read the section Draw-Loop Viewport Culling (ViewportCull). Pay close attention to the opening paragraph which states that the draw loop previously iterated over everything, relying on Skia's internal clipping. The author notes that each draw call has a non-zero overhead, making the frame cost dependent on the total number of objects, not just the visible ones. This is the core inefficiency we are about to eliminate.
The key takeaway is that we must avoid telling the renderer about off-screen objects in the first place.
The Solution: Query, Then Draw
The optimized render loop fundamentally changes the flow of data. Instead of pushing all strokes to the renderer and letting it discard them, we first ask the Quadtree for a minimal set of potentially visible strokes and then provide only that set to the renderer.
The new process for each frame is as follows:
- Get Viewport: Determine the world-space rectangle that corresponds to the user's current view (the camera's viewport).
- Query Quadtree: Pass this viewport rectangle to the
Quadtree::query()method you built in the previous lesson. This returns a small list containing only the strokes that intersect the viewport. - Draw Visible Set: Iterate over this much smaller list of visible strokes and issue draw commands to Skia.
The time complexity of this approach is approximately , where is the total number of strokes and is the number of visible strokes. For a typical zoomed-in view where , this is a dramatic improvement over the naive approach.

Modifying the Render Loop
Let's look at how this logic is integrated. This change happens at the highest level of your rendering code, where you orchestrate the drawing for each frame.
The Handmade Hero series provides an excellent, low-level walkthrough of this exact process. Casey Muratori discusses how to use a spatial partition to decide which entities to bring into the "high-frequency" set for processing and rendering—this is precisely what we are doing with our strokes.
Handmade Hero Day 058 - Using the Spatial Partition
This video from Handmade Hero, "Using the Spatial Partition," details the practical implementation of connecting a spatial partition to a camera and render loop.
First, watch the introduction where Casey sets the stage for using the spatial partition to accelerate spatial queries like finding objects within the camera's view. Next, focus on his discussion of the alignment between chunks and the camera. He explains that you first find all the broad regions (our Quadtree nodes) that overlap the camera's rectangle, and then you check the entities within those regions against the precise camera bounds. Our Quadtree::query method encapsulates both of these steps for us. Finally, in the Q&A, a viewer asks about zooming out. Casey's answer highlights a key point about our new architecture: the render loop now only draws 'High entities' (our visible set). For a debug view or a "show all" feature, you would need a separate render path that intentionally bypasses the culling query.
Inspired by this, let's sketch out what our modified render function would look like. Assume you have a DocumentLayer class that holds all the strokes and the Quadtree, and a Camera class that can provide its current view bounds.
// In your main rendering class
void Application::onPaint(SkCanvas* canvas) {
// Clear the canvas for the new frame
canvas->clear(SK_ColorWHITE);
// Save the current canvas state
canvas->save();
// Apply the camera's transformation (pan/zoom)
canvas->setMatrix(m_camera.getTransformMatrix());
// --- The Optimization ---
// 1. Get the camera's visible bounds in world coordinates
SkRect viewBounds = m_camera.getViewBounds();
// 2. Query the document layer for visible strokes using the Quadtree
std::vector<StrokeIterator> visibleStrokes = m_documentLayer.queryVisibleStrokes(viewBounds);
// 3. Draw ONLY the visible strokes
for (const auto& strokeIter : visibleStrokes) {
// Dereference the iterator to get the stroke object
const Stroke& stroke = *strokeIter;
// Use your existing function to draw a single stroke
drawStroke(canvas, stroke);
}
// Restore the canvas state
canvas->restore();
// (Optional) Draw UI overlays, debug info, etc. in screen space
drawDebugInfo(canvas, m_documentLayer.getTotalStrokeCount(), visibleStrokes.size());
}
Notice how the for loop no longer iterates over m_documentLayer.getAllStrokes(). Instead, it iterates over the visibleStrokes returned by our highly efficient query.
The LearnOpenGL article on frustum culling provides another simple, clear example of this pattern in a render loop.
This article provides a thorough guide to implementing 3D frustum culling. The final code snippet is directly relevant to our 2D case.
In the provided code, find the function drawSelfAndChild. The core logic is a simple if statement: an object is drawn only if it passes the visibility test. This if check inside the loop is precisely what our Quadtree::query allows us to do before the loop even begins, making it much more efficient.
The Performance Payoff
By implementing this change, you have fundamentally altered the performance characteristics of your application. The Grida article quantifies this impact beautifully.
Rendering Optimization Strategies | Grida
Let's return to the Grida article to see the measured impact of this optimization.
Focus on the table under Measured impact. In the scenario with 50,000 nodes where only 1% are visible (a very common case when zoomed in), the frame time drops from 8.75 ms to 2.30 ms—a 74% improvement. This is the direct result of moving from an O(N) to an O(\log N + k) render loop.
This is not a micro-optimization; it is a foundational architectural change necessary for any high-performance graphics application dealing with large datasets. It ensures that your application remains responsive and fluid, regardless of how complex the user's document becomes.
As a final point, consider an easy-to-implement edge case: what if the user is zoomed all the way out and the entire document is visible? In this scenario, running the Quadtree query is unnecessary work. A simple optimization is to first check if the camera's viewport fully contains the bounding box of the entire document. If it does, you can bypass the query and revert to the simple loop that draws every stroke. This is an O(1) check that saves you an O(log N) query in a common scenario.
Conclusion
In this lesson, you've connected all the pieces of your spatial indexing system. By modifying the main render loop to first query the Quadtree for visible strokes, you have successfully implemented view frustum culling. This ensures that your application's rendering performance scales with the number of visible objects, not the total number of objects in the scene.
Key Takeaways:
- Optimized Render Loop: The performant approach is to first query for a set of visible objects, then iterate and draw only that small set.
- CPU Overhead: Bypassing draw calls for off-screen objects saves significant CPU time that would otherwise be spent on iteration and function dispatch.
- Scalability: Your renderer's performance is no longer linearly tied to the total document complexity, enabling it to handle massive scenes while maintaining interactivity.
- Architectural Shift: View frustum culling is a fundamental change from a "draw everything" model to a "draw only what's necessary" model, crucial for performance-oriented systems.
You have now completed the core implementation of spatial indexing for culling. The next module, Advanced Rendering Optimizations, will build on this foundation. We will explore techniques like using "dirty rectangles" to avoid redrawing unchanged parts of the screen and implementing a command-based renderer to enable further GPU-side optimizations like batching.