Introduction: From Hardcoded to Extensible

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.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal