Hello! Welcome back to our final lesson on Bounding Volume Hierarchies.
In our previous lessons, we designed the data structures for our BVH and implemented the crucial traversal algorithm. We now have a complete, efficient method for finding ray intersections in a complex scene. But was all that effort worth it? This lesson is all about answering that question with hard data.
Today, we will focus on the final and most rewarding step of any optimization task: measuring the performance gain. You will learn how to accurately time your C++ code, set up a controlled experiment to compare your renderer's performance with and without the BVH, and analyze the results. For anyone working as a Technical Director, the ability to quantify the impact of an optimization is a critical skill, and today you'll put it into practice.
1. How to Accurately Measure Performance
Before we can compare render times, we need a reliable way to measure them. Simply using a stopwatch isn't an option, and different programming approaches have different levels of precision and correctness. We need a robust in-code timer.
For this, C++ provides the <chrono> library, which offers high-precision clocks perfect for benchmarking. We'll implement a simple but powerful timer class that uses RAII (Resource Acquisition Is Initialization). The timer will automatically start when it's created and stop and print the duration when it goes out of scope.
This video from The Cherno provides an excellent guide to building just such a timer.
BENCHMARKING in C++ (how to measure performance)
Watch this video to understand how to create a simple, scope-based timer class using std::chrono::high_resolution_clock. This is a professional and reusable way to benchmark code snippets.
Please watch the section from 02:29 to 07:36. The presenter walks through creating a Timer class. You can implement a simplified version of this for our ray tracer.
Based on the video, here is a minimal Timer class you can add to a new utility header file (e.g., timer.h):
#include <iostream>
#include <chrono>
class Timer {
public:
Timer() {
m_StartTimePoint = std::chrono::high_resolution_clock::now();
}
~Timer() {
Stop();
}
private:
void Stop() {
auto endTimePoint = std::chrono::high_resolution_clock::now();
auto start = std::chrono::time_point_cast<std::chrono::microseconds>(m_StartTimePoint).time_since_epoch().count();
auto end = std::chrono::time_point_cast<std::chrono::microseconds>(endTimePoint).time_since_epoch().count();
auto duration = end - start;
double ms = duration * 0.001;
std::cout << duration << "us (" << ms << "ms)\n";
}
private:
std::chrono::time_point<std::chrono::high_resolution_clock> m_StartTimePoint;
};
The Most Important Rule of Benchmarking
There is one rule you absolutely must follow: Always benchmark in Release mode.
When you compile in Debug mode, the compiler includes a lot of extra information for debugging and, crucially, performs almost no optimization. When you switch to Release mode, the compiler aggressively optimizes your code, sometimes in ways that can radically change how it executes. Benchmarking unoptimized Debug code will give you misleading and meaningless results.
The same video explains this point perfectly.
BENCHMARKING in C++ (how to measure performance)
This next section of the video is critical. It demonstrates why you must use Release builds for benchmarking by showing how the compiler can completely optimize away the code you think you're measuring.
Watch from 07:36 to 10:29. As someone with a CS background, you'll appreciate the look at the disassembly, which clearly shows the difference between Debug and Release builds.
2. Setting Up the Experiment
Now that we have our Timer and understand the importance of Release builds, we can set up our comparison. The methodology is simple:
- Render a complex scene using your original
hittable_listas the world object. This is the "naive" or "brute-force" method where every ray tests against every object. - Render the exact same scene, but this time, build a
bvh_nodefrom yourhittable_listand use that as your world object.
To make the difference obvious, we need a scene with many objects. The final scene from Ray Tracing in One Weekend, with hundreds of randomly placed spheres, is a perfect candidate.
You'll wrap the main rendering loop with your Timer object. It's important to only time the pixel rendering part, not the file I/O at the end.
Your main.cpp will look something like this:
#include "timer.h" // Don't forget to include your new header
int main() {
hittable_list world;
// ... code to add hundreds of spheres to `world` ...
// --- CONFIGURE YOUR WORLD OBJECT HERE ---
// For Naive version:
// hittable& world_to_render = world;
// For BVH version:
hittable_list bvh_world(make_shared<bvh_node>(world));
hittable& world_to_render = bvh_world;
// -----------------------------------------
camera cam;
// ... configure camera ...
{ // Start a new scope for the timer
Timer timer;
// Render
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = image_height - 1; j >= 0; --j) {
std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
for (int i = 0; i < image_width; ++i) {
color pixel_color(0, 0, 0);
for (int s = 0; s < samples_per_pixel; ++s) {
auto u = (i + random_double()) / (image_width-1);
auto v = (j + random_double()) / (image_height-1);
ray r = cam.get_ray(u, v);
pixel_color += ray_color(r, world_to_render, max_depth);
}
write_color(std::cout, pixel_color, samples_per_pixel);
}
}
} // Timer is destroyed here and prints the duration
std::cerr << "\nDone.\n";
}
Test your understanding!
Why is the Timer object placed inside its own {} scope block in the code above? What would happen if it were declared at the top of main()?
Show answer
The Timer is placed in a scope block that encloses only the rendering loop. This ensures its destructor ~Timer() is called immediately after the loop finishes, measuring just the computation time.
If Timer were declared at the top of main(), its destructor would only be called when main() exits. This would incorrectly include the time taken for std::cerr << "\nDone.\n"; and any other operations after the render loop, polluting our measurement.
3. Running the Comparison and Analyzing the Results
Now, for the moment of truth.
- Configure your code for the naive (
hittable_list) version. - Compile in Release mode.
- Run the executable and record the time reported by your
Timer. - Re-configure your code for the BVH version.
- Compile in Release mode again.
- Run the executable and record the new time.
You should see a dramatic difference. How dramatic? Let's look at some examples from the resources.
Ray Tracing with Bounding Volume Hierarchies
This video gives a visceral sense of the performance difference. The presenter first shows the agonizingly slow render without any acceleration.
Watch from 3:53 to 5:05 to see the baseline performance, where a complex scene took over an hour. Then, watch from 6:12 to 6:50 to see the result with a BVH, which brought the time down to around 10 minutes.
The Ray Tracing in One Weekend series, which our project is based on, also quantifies this gain.
Peter Shirley, the author of the 'Ray Tracing in One Weekend' series, explicitly discusses the performance gain from implementing a BVH. This will give you a good benchmark for your own results.
Read the short section titled 'Another BVH Optimization'. Focus on the sentences where he quantifies the speedup. He mentions a speedup of almost 'six and a half times' and an 'additional 18% render speedup' from further tweaks.
Finally, the blog post from Jacco's Blog provides an excellent example of a professional, iterative approach to performance measurement, showing render times at each stage of optimization.
How to build a BVH – part 2: Faster Rays
This article demonstrates how performance is typically reported in graphics R&D. The author provides concrete numbers (in milliseconds) as he applies different optimization techniques.
Skim through the article and notice how frequently render times are mentioned (e.g., 'renders in 300 milliseconds', 'reduced to 128 milliseconds', 'drops from 128 to 107 milliseconds'). This reinforces the practice of quantifying every improvement.
After running your own tests, calculate the speedup:
This factor tells you how many times faster your BVH-accelerated renderer is. It's a powerful and satisfying metric that validates all the complex work you've put in. In production environments like the ones you're familiar with, this kind of quantitative analysis is essential for making decisions about new features and algorithms.
Conclusion
Congratulations! You have not only implemented one of the most important acceleration structures in computer graphics but have also scientifically proven its value.
Key Takeaways:
- Performance is Measurable: Using tools like
std::chrono, you can get accurate, quantitative data on your code's performance. - Compile Mode is Critical: Benchmarking must always be done in Release mode to get meaningful results that reflect how the code will perform in a deployed state.
- BVHs are a Game Changer: For any scene with more than a handful of objects, a BVH provides a massive, often order-of-magnitude, speedup by changing the intersection complexity from linear, O(N), to logarithmic, O(log N).
Next Steps
With a fast and efficient renderer, we no longer have to wait minutes or hours for our images. This unlocks the door to more computationally intensive—and more realistic—rendering techniques. In our next lesson, we will take the first step into the world of path tracing by implementing emissive materials, allowing objects in our scene to become light sources themselves. This is the foundation for simulating global illumination and creating truly photorealistic images.