In the previous lesson, you made a significant leap forward in your ray tracing journey. You moved from rendering a single sphere to rendering a complete scene with multiple objects. You implemented the closest-hit algorithm, which correctly determines which object is visible at each pixel by finding the intersection nearest to the camera. You created a hit_record structure to organize intersection data, and you added logic to distinguish between front-facing and back-facing surfaces. The result was a scene showing a small sphere sitting on what appears to be a ground plane, with proper occlusion between the objects.
However, if you look closely at the code you wrote in the previous lesson, you'll notice some limitations that will become increasingly problematic as your ray tracer grows. All of your scene logic lives in the ray_color() function, where you manually loop through arrays of sphere centers and radii. The hit_sphere() function is hardcoded to work only with spheres. If you wanted to add a different type of shape to your scene — say, a plane, a box, or a triangle — you would need to write a completely separate hit_plane() or hit_box() function, then add more loops in ray_color() to test against those new shape types. As you add more shapes, your code would become increasingly tangled and difficult to maintain.
This is where abstraction becomes essential. Abstraction is a fundamental principle in software design that involves hiding complex implementation details and exposing only the essential features that different objects share. In your ray tracer, all objects — whether spheres, planes, boxes, or triangles — share one essential feature: they can be hit by a ray. By defining this shared behavior in an abstract interface, you can write code that works with any type of object without knowing the specific details of how each shape calculates its intersections.
In this lesson, you'll transform your ray tracer from a hardcoded sphere renderer into an extensible framework that can easily accommodate new shape types. You'll create an abstract hittable interface that defines what it means for an object to be hit by a ray. You'll convert your sphere intersection logic into a class that implements this interface. You'll build a container that can hold any mix of different hittable objects and find the closest hit among them. Finally, you'll refactor your main program to use these new abstractions, resulting in cleaner, more maintainable code that's ready to grow with your ray tracer.
The Hittable Interface: Defining What All Objects Share
The first step in creating an extensible ray tracer is to identify what all renderable objects have in common. Whether you're dealing with a sphere, a plane, a box, or any other shape, they all need to answer the same fundamental question: "Does this ray hit you, and if so, where?" This shared behavior is what we'll capture in an abstract interface called hittable.
In C++, we create abstract interfaces using abstract base classes. An abstract base class is a class that defines one or more pure virtual functions — functions that have no implementation in the base class and must be implemented by any class that inherits from it. The syntax for a pure virtual function uses = 0 after the function declaration. This tells the compiler that this function is abstract and that any concrete class inheriting from this base class must provide its own implementation.
Before we define the hittable interface, we need to move the hit_record structure from the previous lesson into a more appropriate location. You might recall that hit_record stores information about a ray-surface intersection: the hit point, the surface normal, the distance along the ray, and whether we hit the front or back face. This structure isn't specific to spheres — any hittable object will need to provide this same information when it's hit. Therefore, hit_record belongs at the interface level, not buried inside sphere-specific code.
Let's create a new header file called src/hittable.h that will contain both the hit_record structure and the hittable interface. We'll start with the header guards, which prevent the file from being included multiple times during compilation:
Sphere as a Hittable: Concrete Implementation
Now that we have the abstract hittable interface, we can create a concrete implementation for spheres. This involves taking the hit_sphere() function from the previous lesson and transforming it into a proper class that inherits from hittable. This transformation will make your code more organized and will allow spheres to work seamlessly with the rest of your abstraction-based architecture.
Let's create a new header file called src/sphere.h. This file will contain the complete sphere class, including both its data (the center point and radius) and its behavior (the hit testing logic):
Let's walk through this code section by section. The file starts with the standard header guards and includes. We include <cmath> because we need std::sqrt() for the quadratic formula, and we include "hittable.h" because our sphere class inherits from the hittable interface.
Managing Multiple Objects: The hittable_list Container
Now that we have an abstract hittable interface and a concrete sphere implementation, we need a way to manage collections of hittable objects. In the previous lesson, you used simple arrays to store sphere centers and radii, then looped through them manually. This approach doesn't work well with our new abstraction-based design because different hittable objects might be different types (spheres, planes, boxes, etc.) and different sizes in memory.
The solution is to create a container class called hittable_list that can hold any mix of different hittable objects. This container will itself be a hittable object, implementing the same hit() interface. This design pattern, where a container implements the same interface as the objects it contains, is called the composite pattern, and it's extremely powerful. It means that a hittable_list can contain other hittable_list objects, allowing you to build hierarchical scene structures if needed.
To safely manage objects of different types, we'll use std::shared_ptr, which is C++'s smart pointer for shared ownership. A shared_ptr automatically manages the lifetime of the object it points to, deleting the object when the last shared_ptr to it is destroyed. This eliminates the need for manual memory management and prevents memory leaks. When you create a hittable object and store it in a shared_ptr, you don't need to worry about deleting it later — the shared_ptr handles that automatically.
Let's create a new header file called src/hittable_list.h:
Putting It All Together: Refactoring main.cc
Now that we have all the pieces of our abstraction-based architecture — the hittable interface, the sphere class, and the hittable_list container — we can refactor the main program to use them. This refactoring will dramatically simplify your code and make it much more maintainable and extensible.
Let's start by looking at what we need to remove from main.cc. In the previous lesson, you had the hit_record struct, the set_face_normal() function, and the hit_sphere() function all defined directly in the main file. All of that code is now in the appropriate header files, so we can delete it from main.cc. Instead, we'll include the new headers at the top of the file:
Notice that we only need to include hittable_list.h and sphere.h. We don't need to explicitly include hittable.h because sphere.h already includes it, and the compiler will see that inclusion. This is one benefit of organizing your code into header files — the dependencies are managed automatically through the include directives.
Now let's look at the refactored ray_color() function. This is where the power of abstraction becomes most apparent:
Summary: Your Extensible Ray Tracer Architecture
In this lesson, you refactored your ray tracer from a hardcoded sphere renderer into a flexible, extensible framework using object-oriented principles. You created an abstract hittable interface to define what it means for an object to be hit by a ray, and moved the hit_record structure to the interface level. You encapsulated sphere logic in a sphere class that implements the hittable interface, and built a hittable_list container to manage collections of any hittable objects using the composite pattern and std::shared_ptr for safe memory management.
You then refactored your main program to use these abstractions, resulting in much cleaner and more maintainable code. The ray_color() function and scene setup are now simple and declarative, making it easy to add new objects or shape types. This architecture makes your ray tracer ready for future growth: adding new shapes or features will require minimal changes, thanks to the power of abstraction and polymorphism.
In the upcoming practice exercises, you’ll use this new architecture to build more complex scenes and see firsthand how extensible your ray tracer has become.
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
sphere
hittable_list
The beauty of this approach is that once you've set up these abstractions, adding a new shape type becomes remarkably simple. You'll just create a new class that implements the hittable interface, and all of your existing code — the closest-hit algorithm, the scene management, the rendering loop — will automatically work with the new shape without any modifications. This is the power of abstraction: it allows your code to be both flexible and maintainable, adapting to new requirements without requiring extensive rewrites.
Let's examine this code carefully. The hit_record structure is identical to what you used in the previous lesson, with one important addition: we've moved the set_face_normal() helper function inside the structure as a member function. This makes sense because setting the face normal is an operation that belongs to the hit record itself. The function is marked inline, which is a hint to the compiler that it should try to insert the function's code directly at the call site rather than making a function call. This can improve performance for small, frequently called functions like this one.
The logic of set_face_normal() remains the same as before. It takes a ray and an outward-pointing normal (the normal that always points out of the surface), then determines whether the ray hit the front or back face by checking the dot product between the ray direction and the outward normal. If the dot product is negative, the ray and normal point in opposite directions, meaning we hit the front face from outside. The function sets the front_face boolean accordingly and then sets the normal field to either point outward (for front faces) or inward (for back faces).
Now let's look at the hittable class itself. This is an abstract base class that defines the interface all hittable objects must implement. The class has two members, both declared in the public section because they need to be accessible to code that uses hittable objects.
The first member is the destructor: virtual ~hittable() = default;. This might look unusual if you haven't worked much with C++ inheritance. The virtual keyword is crucial here — it tells the compiler that when deleting a hittable object through a pointer to the base class, it should call the destructor of the actual derived class, not just the base class destructor. This ensures proper cleanup of resources. The = default syntax tells the compiler to generate the default destructor implementation, which is fine for our purposes since the base class doesn't manage any resources directly.
The second member is the pure virtual hit() method: virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec) const = 0;. The = 0 at the end makes this a pure virtual function, which means any class that inherits from hittable must provide its own implementation of this method. The method signature defines exactly what information a hit test needs: a ray to test, a valid range of t values (t_min to t_max), and a reference to a hit_record where intersection information should be stored. The method returns a boolean indicating whether a hit occurred within the valid range. The const at the end indicates that calling this method doesn't modify the object being tested — hit testing is a read-only operation.
This interface is elegant in its simplicity. It captures the essential behavior that all hittable objects share without imposing any constraints on how different shapes implement their intersection tests. A sphere will use quadratic equation solving, a plane will use different math, and a triangle will use yet another approach, but they'll all present the same interface to the rest of your ray tracer.
The class declaration begins with class sphere : public hittable. The : public hittable part is the inheritance syntax in C++. It means that sphere is a subclass of hittable and will inherit all of its interface requirements. The public keyword means that the public members of hittable remain public in sphere, which is the standard way to implement interfaces in C++.
In the public section, we define two constructors. The first, sphere() {}, is a default constructor that creates a sphere with uninitialized center and radius. This is useful in some situations where you need to create a sphere object before you know its parameters. The second constructor, sphere(point3 cen, double r) : center(cen), radius(r) {}, takes a center point and radius as parameters. The syntax : center(cen), radius(r) is called a member initializer list, and it's the preferred way to initialize member variables in C++. This constructor allows you to write code like sphere(point3(0,0,-1), 0.5) to create a sphere at a specific location with a specific size.
The heart of the class is the hit() method. Notice the keywords in its declaration: virtual indicates that this method can be overridden by subclasses (though we don't have any sphere subclasses in this ray tracer), const indicates that the method doesn't modify the sphere object, and override is a safety feature introduced in modern C++ that tells the compiler to verify that this method actually overrides a virtual method from the base class. If you accidentally misspell the method name or get the parameters wrong, the compiler will catch the error thanks to the override keyword.
The implementation of the hit() method is nearly identical to the hit_sphere() function from the previous lesson. We compute the vector from the sphere center to the ray origin, then solve the quadratic equation to find where the ray intersects the sphere. The discriminant tells us whether intersections exist at all. If they do, we calculate both roots and check whether either falls within the valid range [t_min, t_max]. We prefer the nearer root (the one with the minus sign in the quadratic formula), but if that's outside the valid range, we try the farther root.
When we find a valid intersection, we fill in the hit_record with all the necessary information. We store the t value, compute the hit point using r.at(rec.t), calculate the outward normal by dividing the vector from center to hit point by the radius (which normalizes it because the radius is the length of that vector), and then call rec.set_face_normal() to set both the normal and front_face fields correctly. Finally, we return true to indicate success.
The private section contains the sphere's data: its center point and radius. These are private because external code doesn't need direct access to them — the sphere's behavior is entirely captured by its hit() method. This encapsulation is good practice because it allows you to change the internal representation of a sphere later without affecting any code that uses spheres.
Notice how clean this design is. The sphere class is self-contained — it knows everything about how to test ray-sphere intersections, and it presents a simple, standard interface to the rest of your ray tracer. Any code that works with hittable objects will automatically work with spheres, without needing to know anything about quadratic equations or sphere-specific math.
Let's examine this code carefully. The file includes <vector> for the standard vector container and <memory> for std::shared_ptr. The class declaration shows that hittable_list inherits from hittable, making it a hittable object itself.
The class has one public data member: std::vector<std::shared_ptr<hittable>> objects. This is a vector (a dynamic array) that holds shared pointers to hittable objects. The vector can grow as you add objects, and each element is a shared_ptr that points to some hittable object — it might be a sphere, or it might be another hittable_list, or it might be some other shape type you add in the future. Making this member public is a pragmatic choice that allows external code to directly access the list of objects if needed, though in a larger system you might prefer to keep it private and provide accessor methods.
The class provides two constructors. The default constructor hittable_list() {} creates an empty list. The second constructor takes a single hittable object and adds it to the list. The explicit keyword prevents the compiler from using this constructor for implicit type conversions, which is generally good practice for single-argument constructors. This constructor allows you to write code like hittable_list world(some_sphere) to create a list that starts with one object.
The clear() method empties the list by calling the vector's clear() method. This is useful when you want to reset your scene. The add() method appends a new object to the list using the vector's push_back() method. These helper methods make scene management straightforward — you can build up your scene by repeatedly calling add() with different objects.
The hit() method is where the magic happens. This method implements the closest-hit algorithm you learned in the previous lesson, but now it works with any mix of hittable objects through the power of polymorphism. Let's walk through the logic step by step.
We start by declaring a temporary hit_record called temp that we'll use to store intersection information as we test each object. We initialize hit_anything to false, which tracks whether we've found any intersection at all. We initialize closest to t_max, which represents the farthest distance we're willing to consider. As we find closer intersections, we'll update this value.
The loop for (const auto& obj : objects) is a range-based for loop that iterates through all objects in the list. The const auto& syntax means we're getting a constant reference to each element, which avoids copying the shared_ptr and is more efficient. For each object, we call its hit() method, passing the ray, the valid range from t_min to closest, and our temporary hit record.
Here's where polymorphism shines. We don't know what type of object obj points to — it might be a sphere, or it might be some other shape type. But because all hittable objects implement the same hit() interface, we can call the method without knowing the specific type. The C++ runtime will automatically call the correct implementation based on the actual type of the object. This is called dynamic dispatch, and it's what makes object-oriented programming so powerful.
If the object reports a hit, we update our tracking variables. We set hit_anything to true, update closest to the new intersection distance, and copy the temporary hit record into our main rec. Notice how closest serves double duty: it's both the upper limit for valid intersections and the value we update when we find a closer hit. This ensures that as we test subsequent objects, we only accept intersections that are closer than the closest one we've found so far.
After testing all objects, we return hit_anything to indicate whether we found any intersection at all. If we did, rec contains information about the closest intersection. This is exactly the same algorithm you implemented in the previous lesson, but now it's packaged in a reusable class that works with any type of hittable object.
Compare this to the version from the previous lesson. All of the manual looping through arrays of sphere centers and radii is gone. All of the closest-hit tracking logic is gone. The function is now remarkably simple: it takes a ray and a reference to a hittable object (which will be our hittable_list containing the scene), tests whether the ray hits anything in the scene, and returns the appropriate color.
The key line is if (world.hit(r, 0.001, 1e30, rec)). This single method call encapsulates all of the complexity of testing the ray against every object in the scene and finding the closest hit. The world parameter has type const hittable&, which means it's a reference to any hittable object. In practice, we'll pass a hittable_list, but the function doesn't need to know that. It just knows that world is something that can be hit by a ray, and it calls the hit() method to test for intersections.
The range [0.001, 1e30] defines the valid intersection distances. The lower bound of 0.001 prevents shadow acne (self-intersection artifacts), and the upper bound of 1e30 (which is essentially infinity for our purposes) means we'll accept any intersection no matter how far away. If we find a hit, we use the normal from the hit record to compute the color, just as we did in the previous lesson. If we don't find a hit, we return the gradient background color.
Now let's look at how we set up the scene in the main() function. This is where we create the objects and add them to the world:
The scene setup is now beautifully clean and declarative. We create a hittable_list called world, then add objects to it using the add() method. The std::make_shared<sphere>(...) syntax creates a new sphere object wrapped in a shared_ptr. The make_shared function is the recommended way to create shared pointers because it's more efficient than creating the object separately and then wrapping it in a shared pointer.
The first sphere we add is the small sphere at position (0, 0, -1) with a radius of 0.5. The second sphere is the large ground sphere at position (0, -100.5, -1) with a radius of 100. These are the same two spheres from the previous lesson, but now they're created as proper objects and managed through the hittable_list container.
The rest of the main function is unchanged from the previous lesson. We set up the camera parameters, then loop through each pixel, generate a ray, call ray_color() to determine the pixel's color, and write the result to the output file. The rendering loop doesn't need to know anything about how the scene is structured or what types of objects it contains — it just passes the ray to ray_color(), which passes it to the world's hit() method, which handles all the details.
When you compile and run this refactored program, you'll see the same progress output as before:
The rendered image will be identical to what you produced in the previous lesson — a small sphere sitting on a ground plane, with color-coded normals showing the surface orientation. But now your code is organized in a way that makes it trivial to add new features. Want to add a third sphere? Just add another line: world.add(std::make_shared<sphere>(point3(1,0,-1), 0.3));. Want to add a different type of shape? Create a new class that implements the hittable interface, and you can add it to the world in exactly the same way.