Camera Abstraction and Ray Generation

Introduction: The Case for Camera Abstraction

In the previous lesson, you made a significant architectural improvement to your ray tracer by introducing abstraction. You created the hittable interface that defines what all renderable objects share, implemented the sphere class as a concrete example, and built the hittable_list container to manage collections of objects. This transformation made your code dramatically more maintainable and extensible. Adding new shape types became straightforward, and your rendering logic became cleaner and more focused.

However, if you look closely at your current main.cc file, you'll notice that while the scene management is now beautifully abstracted, the camera logic remains scattered throughout the main function. You have variables for viewport dimensions, focal length, camera origin, and coordinate system vectors all declared inline. The ray generation logic is embedded directly in the rendering loop, where you compute the ray direction for each pixel using these scattered variables. This works fine for a simple static camera, but it creates several problems as your ray tracer grows in complexity.

First, the camera parameters are not organized into a cohesive unit. If you want to change the camera's field of view or move it to a different position, you need to hunt through the main function to find and modify the relevant variables. Second, the ray generation logic is tangled with the rendering loop, making it harder to understand what each part of the code is responsible for. Third, if you want to add features like camera movement, different projection types, or multiple cameras rendering the same scene from different angles, you would need to duplicate or significantly complicate the code in main().

The solution follows the same principle you applied to scene objects in the previous lesson: abstraction and encapsulation. In this lesson, you'll create a dedicated camera class that owns all camera-related data and provides a clean interface for ray generation. The camera will become a self-contained ray generator that knows how to convert pixel coordinates into rays shooting into your scene. This abstraction will make your code cleaner, more maintainable, and ready for future enhancements like camera animation or advanced projection models.

The benefits of this approach mirror what you gained from abstracting hittable objects. Your main rendering loop will become simpler and more focused on its core responsibility: iterating through pixels and determining their colors. The camera logic will be encapsulated in a single class where it's easy to understand and modify. Adding new camera features will require changes only to the camera class, not to the rendering loop. This separation of concerns is a fundamental principle of good software design, and it will serve you well as your ray tracer continues to evolve.

Analyzing Our Current Camera Setup

Before we build the camera class, let's carefully examine the camera code from the previous lesson to understand exactly what it does and why. This understanding will guide our design decisions as we encapsulate this logic into a proper class. Here's the relevant section from your current main() function:

// Simple inline camera
auto viewport_height = 2.0;
auto viewport_width  = aspect_ratio * viewport_height;
auto focal_length    = 1.0;

point3 origin(0,0,0);
vec3 horizontal(viewport_width,0,0);
vec3 vertical(0,viewport_height,0);
point3 lower_left = origin - horizontal/2 - vertical/2 - vec3(0,0,focal_length);

This code sets up a simple pinhole camera model, which is the foundation of ray tracing. Let's break down each component to understand its role in the camera system.

The viewport represents the rectangular window through which we view the scene. Think of it as a physical screen positioned in front of the camera. The viewport_height is set to 2.0 units, which is an arbitrary but convenient choice. The actual value doesn't matter much because we're working in a relative coordinate system, but 2.0 is nice because it makes the math clean. The viewport_width is computed by multiplying the height by the aspect ratio, ensuring that the viewport has the same proportions as the final image. If your image is 16:9, your viewport will also be 16:9, which prevents distortion in the rendered scene.

The focal_length represents the distance from the camera origin to the viewport plane. In a pinhole camera model, all rays originate from a single point (the camera origin) and pass through points on the viewport before continuing into the scene. The focal length of 1.0 unit means the viewport is positioned one unit in front of the camera. This value affects the field of view: a smaller focal length creates a wider field of view (like a wide-angle lens), while a larger focal length creates a narrower field of view (like a telephoto lens). The value of 1.0 gives a reasonable, natural-looking perspective.

The origin is the position of the camera in world space. Currently, it's at (0, 0, 0), meaning the camera sits at the world origin. This is the point from which all rays emanate. In a more advanced ray tracer, you would move this point around to position the camera at different locations in your scene.

The horizontal and vertical vectors define the coordinate system of the viewport. The horizontal vector points from the left edge to the right edge of the viewport, and its length equals the viewport width. The vertical vector points from the bottom edge to the top edge, and its length equals the viewport height. These vectors allow us to parameterize any point on the viewport using two scalar values between 0 and 1.

The lower_left corner is perhaps the most important computed value. It represents the position of the bottom-left corner of the viewport in world space. Let's trace through how it's calculated. We start at the camera origin, then subtract half the horizontal vector to move left, subtract half the vertical vector to move down, and finally subtract the focal length in the z direction to move the viewport in front of the camera. This calculation positions the viewport centered on the camera's viewing direction, which is along the negative z-axis in our coordinate system.

Now let's look at how these values are used in the rendering loop:

for (int i=0;i<image_width;++i) {
    double u = double(i)/(image_width-1);
    double v = double(j)/(image_height-1);
    ray r(origin, lower_left + u*horizontal + v*vertical - origin);
    write_color(out, ray_color(r, world));
}

For each pixel at position (i, j), we compute normalized coordinates u and v that range from 0 to 1. The value u represents the horizontal position across the viewport, and v represents the vertical position. When u is 0, we're at the left edge; when it's 1, we're at the right edge. Similarly, v ranges from 0 at the bottom to 1 at the top.

The ray construction ray r(origin, lower_left + u*horizontal + v*vertical - origin) is where the magic happens. Let's break down the direction calculation. We start at lower_left, which is the bottom-left corner of the viewport. We add u*horizontal to move horizontally across the viewport proportional to the pixel's x position. We add v*vertical to move vertically up the viewport proportional to the pixel's y position. This gives us a point on the viewport corresponding to the current pixel. Finally, we subtract the origin to convert this point into a direction vector from the camera origin to the viewport point. This direction vector, combined with the origin as the ray's starting point, defines the ray that passes through the current pixel.

This system works well, but it's scattered across multiple variables and mixed with the rendering loop logic. Our goal in building the camera class is to encapsulate all of this setup and computation into a single, cohesive unit that provides a simple interface: give me a ray for pixel coordinates (u, v).

Designing the Camera Class Interface

Now that we understand what our camera needs to do, we can design a clean interface for the camera class. Good interface design requires thinking carefully about what data the class should own, what operations it should provide, and what should be exposed publicly versus kept as private implementation details.

Let's start by considering what data the camera needs to own. Looking at our current inline camera code, we have several pieces of information: the aspect ratio, the image dimensions, the viewport dimensions, the focal length, the camera origin, and the coordinate system vectors (horizontal, vertical, and lower_left). All of these are essential to the camera's operation, so they should be member variables of the camera class.

However, not all of this data needs to be provided by the user. Some values can be computed from others. For example, if we know the aspect ratio and the image width, we can compute the image height. If we know the viewport height and aspect ratio, we can compute the viewport width. This leads to an important design principle: the camera's constructor should take only the essential parameters that the user wants to control, and the camera should compute everything else internally.

What are the essential parameters? The aspect ratio is fundamental because it determines the proportions of the image. The image width is also essential because it determines the resolution. From these two values, we can compute the image height, and from the aspect ratio, we can compute the viewport width. The viewport height and focal length can have reasonable default values that work well for most scenes. The camera origin and orientation could be parameters, but for now, we'll keep them as fixed defaults (origin at world origin, looking down the negative z-axis) since we haven't yet covered camera positioning and orientation.

This gives us a constructor signature: camera(double aspect_ratio, int image_width). This is clean and simple. The user specifies the two most important parameters, and the camera handles all the internal setup.

Now let's consider what operations the camera should provide. The primary operation is ray generation: given normalized pixel coordinates (u, v), generate the corresponding ray. This will be a method called get_ray(double u, double v) that returns a ray object. This method encapsulates all the logic we currently have inline in the rendering loop.

The camera should also provide access to the image dimensions, because the rendering loop needs to know how many pixels to iterate through. We'll provide two simple accessor methods: width() and height() that return the image width and height, respectively. These are read-only accessors that simply return the stored values.

What about the internal data? The viewport dimensions, coordinate system vectors, and other computed values are implementation details that external code doesn't need to access. These should be private member variables. This encapsulation means we could change how the camera internally represents its coordinate system without affecting any code that uses the camera class.

Let's sketch out the class structure:

class camera {
public:
    camera(double aspect_ratio, int image_width);
    int width() const;
    int height() const;
    ray get_ray(double u, double v) const;

private:
    double aspect_ratio;
    int image_width;
    int image_height;
    point3 origin;
    vec3 horizontal;
    vec3 vertical;
    point3 lower_left;
};

Notice the const qualifiers on the accessor methods and get_ray(). These indicate that calling these methods doesn't modify the camera object. This is important because it allows the camera to be passed as a const reference to functions, and it documents the intent that these are read-only operations. The camera's state is set up once in the constructor and then remains constant during rendering.

This interface is clean, minimal, and focused. It provides exactly what the rendering loop needs (image dimensions and ray generation) while hiding all the implementation details. This is good object-oriented design: the interface is simple and stable, while the implementation is encapsulated and can be changed without affecting client code.

Building Camera Initialization

Now let's implement the camera constructor, which is responsible for setting up all the camera's internal state. The constructor takes the aspect ratio and image width as parameters and computes everything else needed for ray generation. Create a new file called src/camera.h and let's build it step by step.

We'll start with the header guards and includes:

#ifndef CAMERA_H
#define CAMERA_H

#include "ray.h"

class camera {
public:
    camera(double aspect_ratio = 16.0/9.0, int image_width = 400) {

Notice that we've provided default parameter values in the constructor declaration. The default aspect ratio of 16.0/9.0 and default image width of 400 match what we've been using in previous lessons. This means users can create a camera with camera() and get sensible defaults, or they can specify custom values with camera(16.0/9.0, 800) for a higher resolution image. Default parameters are a convenient C++ feature that makes your classes easier to use.

Now let's implement the constructor body. The first step is to store the parameters and compute the image height:

        this->aspect_ratio = aspect_ratio;
        this->image_width = image_width;
        image_height = std::max(1, int(image_width / aspect_ratio));

We store the aspect ratio and image width in member variables. The this-> prefix is necessary here because the parameter names match the member variable names, and we need to disambiguate which is which. The this->aspect_ratio refers to the member variable, while aspect_ratio alone would refer to the parameter.

The image height calculation divides the width by the aspect ratio and converts the result to an integer. The std::max(1, ...) ensures that the height is at least 1 pixel, even if the division produces a value less than 1. This prevents degenerate cases where you might accidentally create a zero-height image. For a 400-pixel-wide image with a 16:9 aspect ratio, this computes a height of 225 pixels.

Next, we set up the viewport dimensions:

        auto viewport_height = 2.0;
        auto viewport_width  = viewport_height * aspect_ratio;
        auto focal_length = 1.0;

These are local variables, not member variables, because they're only needed during initialization to compute the coordinate system vectors. The viewport height is set to 2.0 units, which is an arbitrary but convenient choice. The viewport width is computed to match the aspect ratio, ensuring the viewport has the same proportions as the image. The focal length is set to 1.0 unit, giving a natural field of view.

Now we can set up the camera's coordinate system:

        origin = point3(0,0,0);
        horizontal = vec3(viewport_width, 0, 0);
        vertical   = vec3(0, viewport_height, 0);
        lower_left = origin - horizontal/2 - vertical/2 - vec3(0,0,focal_length);
    }

The origin is positioned at the world origin. The horizontal vector spans the full width of the viewport along the x-axis. The vertical vector spans the full height along the y-axis. The lower_left corner is computed using the same logic we analyzed earlier: start at the origin, move left by half the viewport width, move down by half the viewport height, and move forward (in the negative z direction) by the focal length. This positions the viewport centered on the camera's viewing direction.

These four vectors (origin, horizontal, vertical, and lower_left) completely define the camera's coordinate system and are all we need to generate rays. They're stored as member variables because the get_ray() method will need them.

Let's add the simple accessor methods:

    int width()  const { return image_width; }
    int height() const { return image_height; }

These one-line methods simply return the stored image dimensions. They're marked const because they don't modify the camera object. The rendering loop will call these methods to determine how many pixels to iterate through.

Finally, we need to declare the private member variables:

private:
    double aspect_ratio;
    int image_width;
    int image_height;

    point3 origin;
    vec3 horizontal;
    vec3 vertical;
    point3 lower_left;
};

#endif // CAMERA_H

The member variables are organized into two groups: the image parameters (aspect ratio and dimensions) and the coordinate system vectors. This organization makes the code easier to read and understand.

The constructor we've built is doing exactly what the inline camera setup code did in the previous lesson, but now it's packaged in a reusable class. Every time you create a camera object, the constructor runs this initialization logic automatically, setting up all the internal state needed for ray generation. This encapsulation means the rendering loop doesn't need to know or care about viewport dimensions, focal lengths, or coordinate system vectors. It just creates a camera and uses it.

The Ray Generator: Implementing get_ray()

The heart of the camera class is the get_ray(u, v) method: it turns normalized pixel coordinates into a ray. Add this method to the public section of your camera class, right after the accessor methods:

    ray get_ray(double u, double v) const {
        return ray(origin, lower_left + u*horizontal + v*vertical - origin);
    }

How it works, in brief:

  • u and v are in [0, 1] and select a point on the viewport.
  • horizontal spans the viewport width, vertical spans the height, and lower_left is the bottom-left corner in world space.
  • The viewport point is lower_left + u*horizontal + v*vertical.
  • The ray starts at origin and points to that viewport point: direction = that point minus origin.

Because origin, horizontal, vertical, and lower_left are precomputed in the constructor, get_ray() is tiny and fast. It’s marked const because ray generation doesn’t mutate camera state. The method is resolution- and aspect-ratio-agnostic: the rendering loop maps pixel indices to (u, v), and the camera maps (u, v) to rays using its precomputed vectors.

In future lessons, when you add features like antialiasing, you'll modify the rendering loop to generate multiple rays per pixel with slightly different (u, v) coordinates. The camera's get_ray() method won't need to change at all; it will continue to faithfully convert whatever coordinates it receives into the corresponding rays. This is another benefit of good abstraction: it makes your code extensible without requiring modifications to existing components.

Refactoring main.cc with the Camera Class

Now that we have a complete camera class, let's refactor the main program to use it. This refactoring will demonstrate how much cleaner and more maintainable your code becomes when camera logic is properly encapsulated. The changes are straightforward, but the impact on code clarity is significant.

First, let's update the includes at the top of src/main.cc. We need to add the camera header:

#include <fstream>
#include <iostream>
#include "vec3.h"
#include "color.h"
#include "ray.h"
#include "hittable_list.h"
#include "sphere.h"
#include "camera.h"

The ray_color() function remains unchanged from the previous lesson. It takes a ray and a hittable world, tests for intersections, and returns the appropriate color. This function doesn't need to know anything about cameras, which is exactly what we want. The separation of concerns means each component has a clear, focused responsibility.

Now let's look at the refactored main() function. We'll build it up section by section to see how each part changes. First, the world setup:

int main() {
    // World
    hittable_list world;
    world.add(std::make_shared<sphere>(point3(0,0,-1), 0.5));
    world.add(std::make_shared<sphere>(point3(0,-100.5,-1), 100.0));

The world setup is identical to the previous lesson. We create a hittable_list and add two spheres: a small sphere at (0, 0, -1) with radius 0.5, and a large ground sphere at (0, -100.5, -1) with radius 100. This code hasn't changed because we haven't modified how scenes are managed; we've only changed how the camera works.

Now comes the camera setup, which is dramatically simpler than before:

    // Camera
    camera cam(16.0/9.0, 400);
    const int image_width = cam.width();
    const int image_height = cam.height();

Compare this to the previous lesson's inline camera code. Instead of declaring separate variables for viewport dimensions, focal length, origin, and coordinate system vectors, we simply create a camera object with the desired aspect ratio and image width. The camera constructor handles all the internal setup automatically. We then retrieve the image dimensions using the camera's accessor methods. These dimensions are stored in const variables because they won't change during rendering.

The file output setup remains the same:

    std::ofstream out("ppm/image.ppm");
    out << "P3\n" << image_width << ' ' << image_height << "\n255\n";

We open the output file and write the PPM header with the image dimensions. This code is unchanged because the file format hasn't changed; we're still generating the same type of image.

Now let's look at the rendering loop, which is where the real benefits of the camera abstraction become apparent:

    for (int j = image_height-1; j >= 0; --j) {
        std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
        for (int i = 0; i < image_width; ++i) {
            double u = double(i) / (image_width-1);
            double v = double(j) / (image_height-1);
            ray r = cam.get_ray(u, v);
            write_color(out, ray_color(r, world));
        }
    }
    std::cerr << "\nDone.                 \n";
}

The structure of the loop is the same as before: we iterate through each pixel from top to bottom and left to right, computing normalized coordinates u and v. The progress reporting is unchanged. But look at the ray generation line: ray r = cam.get_ray(u, v);. This single, clear line replaces the previous inline calculation that involved lower_left, horizontal, vertical, and origin. The intent is immediately obvious: we're asking the camera to generate a ray for the current pixel coordinates.

This is the power of abstraction. The rendering loop no longer needs to know how rays are generated. It doesn't need to understand viewport dimensions, focal lengths, or coordinate system math. It just asks the camera for a ray and uses that ray to determine the pixel color. This separation makes the code easier to read, understand, and maintain.

Let's see the complete refactored main.cc file:

#include <fstream>
#include <iostream>
#include "vec3.h"
#include "color.h"
#include "ray.h"
#include "hittable_list.h"
#include "sphere.h"
#include "camera.h"

color ray_color(const ray& r, const hittable& world) {
    hit_record rec;
    if (world.hit(r, 0.001, 1e30, rec))
        return 0.5 * (rec.normal + color(1,1,1));

    vec3 unit_dir = unit_vector(r.direction());
    double t = 0.5 * (unit_dir.y() + 1.0);
    return (1.0 - t)*color(1,1,1) + t*color(0.5,0.7,1.0);
}

int main() {
    // World
    hittable_list world;
    world.add(std::make_shared<sphere>(point3(0,0,-1), 0.5));
    world.add(std::make_shared<sphere>(point3(0,-100.5,-1), 100.0));

    // Camera
    camera cam(16.0/9.0, 400);
    const int image_width = cam.width();
    const int image_height = cam.height();

    std::ofstream out("ppm/image.ppm");
    out << "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) {
            double u = double(i) / (image_width-1);
            double v = double(j) / (image_height-1);
            ray r = cam.get_ray(u, v);
            write_color(out, ray_color(r, world));
        }
    }
    std::cerr << "\nDone.                 \n";
}

When you compile and run this refactored program, you'll see the same progress output as before:

Scanlines remaining: 224 
Scanlines remaining: 223 
...
Scanlines remaining: 1 
Done.

The rendered image will be identical to what you produced in the previous lesson. The visual output hasn't changed because we haven't changed the camera's behavior; we've only reorganized how that behavior is implemented. You'll still see the small sphere sitting on the ground plane with normal-based coloring showing the surface curvature.

The benefit of this refactoring isn't in the output; it's in the code quality. Your main function is now cleaner and more focused. The camera logic is encapsulated in a reusable class. If you want to change the camera's field of view, you modify the camera class. If you want to add camera movement, you add methods to the camera class. If you want to render the same scene from multiple viewpoints, you create multiple camera objects. The rendering loop remains simple and unchanged through all these enhancements.

This refactoring also makes your code more testable. You could write unit tests for the camera class that verify it generates correct rays for various input coordinates. You could test the camera independently of the rendering loop. This modularity is a hallmark of well-designed software.

Summary: A Cleaner, More Modular Architecture

In this lesson, you refactored your ray tracer to encapsulate camera logic in a dedicated camera class. The camera now owns all parameters and math needed for ray generation, providing a simple interface: just specify the aspect ratio and image width, and use get_ray(u, v) to generate rays for each pixel. This abstraction makes your main rendering loop much cleaner and easier to maintain, as all camera-related details are hidden inside the class.

By following the same principles of abstraction and encapsulation you used for scene objects, your code is now more modular and extensible. Future enhancements—like camera movement, different projections, or depth of field—can be added by modifying only the camera class, leaving the rest of your code untouched. This separation of concerns results in a more robust and flexible architecture, setting a strong foundation for further development of your ray tracer.

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