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.