Skip to Content
Course content

184: Building a Basic Ray Tracer in C++

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Ray tracing is one of those projects that feels like magic the first time you see a sphere actually look 3D on your screen. But if you're coming at this from a standard OOP background, it's very easy to build a system that is computationally expensive and a nightmare to maintain. The goal here is to figure out where a ray—a line starting at a point and heading in a direction—hits an object in your scene, and then use that information to determine the color of a pixel.

The Cost of Only Knowing "If" It Hit

When I first started with this, I did what most people do: I wrote a function that returned a bool. I had a Sphere class with a method like bool intersects(const Ray& ray). It was clean, it was intuitive, and it was terribly inefficient. The problem is that in ray tracing, knowing that you hit something is almost useless without knowing where and how you hit it.

// The naive way: Boolean returns
if (sphere.intersects(ray)) {
    // Wait, now I need the distance (t) to know if this is the closest object.
    // Now I need the normal vector to calculate the reflection.
    // Now I need the exact hit point for the lighting.
    float t = sphere.calculateDistance(ray); 
    Vec3 normal = sphere.calculateNormal(t, ray);
    // I'm basically running the intersection math three times!
}

If you do this, you're recalculating the quadratic formula for the sphere intersection over and over for every single single pixel. In a 1080p image, you're doing this millions of times. Your CPU will hate you, and your render times will crawl. You're treating the intersection as a question of existence, when you should be treating it as a data-gathering event.

Bundling Context with a Hit Record

The professional way to handle this is to decouple the intersection logic from the object itself using a HitRecord. Instead of asking the object "did I hit you?", you tell the object "if I hit you, fill this record with everything I need to know."

By passing a small struct by reference, you perform the heavy math once and store the results. I usually include the distance (t), the point of intersection, the surface normal, and a pointer to the material of the object. This way, when your main loop iterates through every object in the scene, it only cares about the HitRecord with the smallest positive t value—the object closest to the camera.

struct HitRecord {
    float t;
    Vec3 p;
    Vec3 normal;
    Material* mat_ptr;
};

// The better way: Filling a record
bool Sphere::hit(const Ray& r, float t_min, float t_max, HitRecord& rec) const {
    // ... perform quadratic formula once ...
    if (hit_found) {
        rec.t = root;
        rec.p = r.at(root);
        rec.normal = (rec.p - center) / radius;
        rec.mat_ptr = this->material;
        return true;
    }
    return false;
}

This approach transforms your inner loop from a series of redundant calculations into a simple "find the minimum" search. It's a classic example of why data-oriented thinking beats pure "object-oriented" thinking in graphics. You aren't just asking objects about their state; you're piping data through a pipeline.

Managing the Scene Graph without the Leak

Once you have your HitRecord, you'll likely want a Scene class that holds a collection of objects. A common mistake here is using a std::vector<Object*> and manually calling new for every sphere or plane you add. In a small project, you might get away with it, but the moment you start implementing dynamic scenes or recursive reflections, you'll find a memory leak that's nearly impossible to track down.

Since we're in modern C++, just use std::unique_ptr. Your scene should own the objects, and the ray-intersection loop should just borrow them. I prefer a std::vector<std::unique_ptr<Hittable>>. It keeps the memory contiguous enough for the pointer array and ensures that when the scene is destroyed, everything is cleaned up without you having to write a tedious destructor. It's a small change that saves you hours of debugging with Valgrind later on.




📋 Practical Task

Implementing the HitRecord Pipeline for Mixed Geometry

You have been provided with a basic Ray class and a Vec3 math library. Your task is to implement a system that can determine the closest intersection point between a ray and a scene containing both a Sphere and a Plane.

Requirements:

  • Define a HitRecord struct that stores the distance t, the intersection point p, and the surface normal.
  • Implement a Hittable base class with a virtual hit method that takes a Ray and a HitRecord&.
  • Create a Sphere class and a Plane class that inherit from Hittable. Both must implement the hit method such that all necessary data is packed into the HitRecord in a single pass.
  • Write a function Vec3 findClosestIntersection(const Ray& ray, const std::vector<std::unique_ptr<Hittable>>& scene) that iterates through the scene and returns the p (intersection point) of the object closest to the ray origin. If no object is hit, return a sentinel value or a specific "background" color.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.