Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 22

Building a Hittable List

Recommended resources

Hello! Welcome back to our C++ ray tracer project.

In the last two lessons, we established a solid foundation. We created the hittable abstract class, which provides a universal "contract" for any object a ray can intersect. We also designed the hit_record struct, a data packet that carries all the essential details of an intersection—its location, surface normal, and material.

So far, we can only handle objects in isolation. Our goal is to render complex scenes with many objects, like the ones you're used to seeing from production renderers. Today's lesson is the bridge to that goal. We will create a hittable_list class, a container to manage a collection of hittable objects, effectively representing our entire "world" or "scene."

This step combines object-oriented design with the C++ data structures we reviewed in Module 1, moving us significantly closer to rendering interesting images.

Multiple Spheres with Varying Materials
Our goal is to render scenes with multiple objects like this one. To do so, we need a way to manage all the objects and test our rays against the entire collection.

1. The Core Idea: A List of Objects

To render multiple objects, our ray tracer needs to perform a simple but critical task for every ray:

  1. Test the ray for intersection against every object in the scene.
  2. If the ray hits multiple objects, determine which hit is the closest to the camera.
  3. Use the information from that closest hit for shading.

A natural way to manage these objects in code is to store them in a list. The Cherno provides a great conceptual overview of this process.

Rendering Multiple Objects // Ray Tracing series

Let's watch this segment from The Cherno's 'Rendering Multiple Objects' video. He explains the fundamental logic of iterating through objects to find the closest intersection.

Watch from 01:37 to 03:38. Focus on the core algorithm he describes: for a given ray, check all spheres, and if there are multiple hits, use the one with the smallest 't' value.

As he explains, the key is tracking the closest_t value. This ensures that if a ray passes through multiple objects, we only "see" the first one it encounters.


2. Designing the hittable_list Class

We could implement this logic directly in our main rendering loop, but a more elegant and scalable solution is to encapsulate it within a class. Given your computer science background, you might recognize this as an application of the Composite design pattern. We will create a hittable_list class that not only holds other hittable objects but also inherits from hittable itself.

This means we can treat our entire scene (the hittable_list) as just another hittable object. Our main ray_color function won't need to know if it's dealing with a single sphere or a list of a million objects; it just calls the hit() method. This abstraction is incredibly powerful.

Let's look at the implementation from Ray Tracing in One Weekend, which will be our primary guide.

A List of Hittable Objects

Please read this section from 'Ray Tracing in One Weekend'. It introduces the hittable_list class, showing how it inherits from hittable and uses std::vector and std::shared_ptr to manage the collection of objects.

Read the entire section titled 'A List of Hittable Objects'. Pay close attention to the class definition in the hittable_list.h code block and the explanation of std::vector and std::shared_ptr that follows.

Based on this reading, let's create a new header file, hittable_list.h, with the following structure:

#ifndef HITTABLE_LIST_H
#define HITTABLE_LIST_H

#include "hittable.h"

#include <memory>
#include <vector>

class hittable_list : public hittable {
  public:
    std::vector<std::shared_ptr<hittable>> objects;

    hittable_list() {}
    hittable_list(std::shared_ptr<hittable> object) { add(object); }

    void clear() { objects.clear(); }
    void add(std::shared_ptr<hittable> object) { objects.push_back(object); }

    bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const override;
};

#endif

Let's break down the key C++ features here, which connect back to our first module:

  • std::vector<...>: This is a dynamic array from the C++ Standard Library. It will hold our collection of objects and automatically handle resizing as we add more.
  • std::shared_ptr<hittable>: We store smart pointers to our hittable objects.
    • Pointers are necessary because hittable is an abstract base class. We can't store concrete sphere or plane objects directly in a container of hittables. Polymorphism requires pointers or references.
    • Smart pointers (shared_ptr) are used to automate memory management. When the hittable_list is destroyed, the vector is destroyed, and the shared_ptrs will automatically decrement their reference counts, deleting the objects if they are no longer in use anywhere. This helps prevent memory leaks, a common pitfall in C++.

3. Implementing the hit Method

Now for the core logic. The hit method of our hittable_list is where the algorithm we discussed earlier is implemented. It iterates through its objects vector and determines if the ray r hits any of them within the valid range [ray_tmin, ray_tmax].

Here is the implementation of the hit method, which you should add to your hittable_list.h file (or a corresponding .cpp file if you prefer to separate declaration from implementation):

// In hittable_list.h, after the class definition...

bool hittable_list::hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const {
    hit_record temp_rec;
    bool hit_anything = false;
    auto closest_so_far = ray_tmax;

    for (const auto& object : objects) {
        if (object->hit(r, ray_tmin, closest_so_far, temp_rec)) {
            hit_anything = true;
            closest_so_far = temp_rec.t;
            rec = temp_rec;
        }
    }

    return hit_anything;
}

Let's analyze this code step-by-step:

  1. hit_record temp_rec;: A temporary struct to hold intersection data from each object test. We don't want to write to the final rec until we're sure we've found the closest hit.
  2. bool hit_anything = false;: A flag to track whether we've hit anything at all. This will be our return value.
  3. auto closest_so_far = ray_tmax;: This is the most critical variable. It starts as the maximum allowed distance for a valid hit. Each time we find a closer object, we update this value.
  4. for (const auto& object : objects): A simple loop through every hittable object in our list.
  5. if (object->hit(r, ray_tmin, closest_so_far, temp_rec)): We call the hit method on the current object. Notice the third argument is closest_so_far, not ray_tmax. This is a crucial optimization. It tells the object's hit function, "Don't bother reporting any hits that are farther away than the one we've already found."
  6. If a closer hit is found:
    • hit_anything = true;: We update our flag.
    • closest_so_far = temp_rec.t;: We shrink our search space. Future intersection tests in this loop will now use this new, smaller maximum distance.
    • rec = temp_rec;: We copy the data from our temporary record to the final output record.

When the loop finishes, rec will contain the data for the closest object hit by the ray (if any), and the function will return true if any object was hit.

Test your understanding!

In the hittable_list::hit method's loop, what would be the consequence of passing the original ray_tmax to object->hit() every time, instead of the updated closest_so_far?

  1. Would the final rendered image be incorrect?
  2. Would the performance be different? Why?
Show answer
  1. The final image would still be correct. The logic still correctly identifies the closest_so_far t value and updates the final rec with the correct hit_record. The final output is determined by the rec = temp_rec line, which is only executed when a closer hit is found.

  2. The performance would be significantly worse. Using closest_so_far as the new maximum distance is a powerful optimization. It allows the hit function of subsequent objects to perform an "early out". For example, if a ray hits a sphere at t=10, there's no need to calculate the full intersection for another sphere that is at t=20. The second sphere's hit function can return false as soon as it determines its hit t would be greater than closest_so_far (which is 10). Without this optimization, every object along the ray's path would need to be fully tested for intersection, even if it's hidden behind a closer object.

This hittable_list is the final piece of our scene abstraction puzzle for now. We have a generic object interface (hittable), a way to store hit results (hit_record), and now a container that manages a list of objects and can find the closest one (hittable_list).

Ray-Traced Scene with Multiple Primitives
With our `hittable_list`, we now have the architecture to manage scenes containing any number of different 'hittable' primitives, like the spheres, cone, and cylinder shown here.

Conclusion

In this lesson, we built the hittable_list class, a container that is central to building complex scenes.

Key Takeaways:

  • A hittable_list manages a collection of objects that implement the hittable interface.
  • By inheriting from hittable itself, the list can be treated as a single object (the "world"), simplifying the main render loop.
  • We use std::vector<std::shared_ptr<hittable>> as the underlying container, leveraging modern C++ features for flexibility and safe memory management.
  • The hittable_list::hit method implements the crucial logic of iterating through all objects and using a closest_so_far variable to efficiently find the nearest intersection.

Next Steps

We now have all the pieces in place to render our first multi-object scene! In the next lesson, we will update our main() function to create a hittable_list instance, add a few spheres to it, and pass this "world" object to our ray_color function. You will finally see multiple objects appear in your rendered image.