Building a Basic Ray Tracer in C++ - A Step-by-Step Guide

Learn to build a basic ray tracer with C++, from setup to rendering a simple scene, exploring the core concepts of computer graphics.


Ray tracing is a technique for generating an image by tracing the path of light through pixels in an image plane and simulating the effects of its encounters with virtual objects. Let's build a basic ray tracer from scratch using C++ and CMake.

Step 1: Setting Up the Project

Tools You'll Need:

  • C++17 compatible compiler (GCC, Clang, etc.)
  • CMake (version 3.10 or higher)
  • A code editor or IDE of your choice

Create Project Structure

mkdir RayTracer
cd RayTracer
mkdir src include build

Initialize CMake

In the build directory:

cmake ..

CMakeLists.txt:

Create a CMakeLists.txt in the project root:

cmake_minimum_required(VERSION 3.10)
project(RayTracer)
 
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
 
add_subdirectory(src)

Step 2: Basic Structure and Vector Math

Vector Operations

Create include/vector.h:

#ifndef VECTOR_H
#define VECTOR_H
 
#include <cmath>
 
class Vector3 {
 public:
  double x, y, z;
 
  Vector3(double x_ = 0, double y_ = 0, double z_ = 0) : x(x_), y(y_), z(z_) {}
 
  Vector3 operator+(const Vector3 &v) const {
    return Vector3(x + v.x, y + v.y, z + v.z);
  }
  Vector3 operator*(double t) const { return Vector3(x * t, y * t, z * t); }
  Vector3 &operator*=(double t) {
    x *= t;
    y *= t;
    z *= t;
    return *this;
  }
  double length() const { return std::sqrt(x * x + y * y + z * z); }
  Vector3 normalize() const { return *this * (1 / this->length()); }
};
 
Vector3 operator*(double t, const Vector3 &v) { return v * t; }
 
#endif  // VECTOR_H

Step 3: Define Ray and Sphere Classes

Ray Class

Create include/ray.h:

#ifndef RAY_H
#define RAY_H
 
#include "vector.h"
 
class Ray {
public:
    Vector3 origin, direction;
    Ray(const Vector3& o, const Vector3& d) : origin(o), direction(d.normalize()) {}
};
 
#endif // RAY_H

Sphere Class

Create include/sphere.h:

#ifndef SPHERE_H
#define SPHERE_H
 
#include "vector.h"
#include "ray.h"
 
class Sphere {
public:
    Vector3 center;
    double radius;
    Sphere(const Vector3& c, double r) : center(c), radius(r) {}
 
    bool intersect(const Ray& ray, double& t) const {
        Vector3 oc = ray.origin - center;
        auto a = dot(ray.direction, ray.direction);
        auto b = 2.0 * dot(oc, ray.direction);
        auto c = dot(oc, oc) - radius * radius;
        auto discriminant = b*b - 4*a*c;
        if (discriminant < 0) return false;
        t = (-b - std::sqrt(discriminant)) / (2.0*a);
        return t > 0;
    }
 
private:
    double dot(const Vector3 &a, const Vector3 &b) const { return a.x * b.x + a.y * b.y + a.z * b.z; }
};
 
#endif // SPHERE_H

Step 4: Scene and Main Loop

Scene Management

Create include/scene.h:

#ifndef SCENE_H
#define SCENE_H
 
#include <vector>
#include "ray.h"
#include "sphere.h"
 
class Scene {
public:
    std::vector<Sphere> objects;
 
    void addSphere(const Sphere& s) { objects.push_back(s); }
 
    Vector3 ray_color(const Ray& r) {
        double t;
        for(const auto& obj : objects) {
            if (obj.intersect(r, t)) {
                return Vector3(0.5, 0.5, 0.5); // Simple shading
            }
        }
        Vector3 unit_direction = r.direction.normalize();
        t = 0.5*(unit_direction.y + 1.0);
        return (1.0-t)*Vector3(1.0, 1.0, 1.0) + t*Vector3(0.5, 0.7, 1.0); // Sky color
    }
};
 
#endif // SCENE_H

Main Function

Create src/main.cpp:

#include <iostream>
#include <fstream>
#include "vector.h"
#include "scene.h"
 
void writeColor(std::ostream &out, Vector3 pixel_color) {
    out << static_cast<int>(255.999 * pixel_color.x) << ' '
        << static_cast<int>(255.999 * pixel_color.y) << ' '
        << static_cast<int>(255.999 * pixel_color.z) << '\n';
}
 
int main() {
    const int image_width = 200;
    const int image_height = 100;
 
    std::ofstream outfile("./image.ppm");
    outfile << "P3\n" << image_width << ' ' << image_height << "\n255\n";
 
    Scene scene;
    scene.addSphere(Sphere(Vector3(0,0,-1), 0.5));
 
    for (int j = image_height-1; j >= 0; --j) {
        for (int i = 0; i < image_width; ++i) {
            auto u = double(i) / (image_width-1);
            auto v = double(j) / (image_height-1);
            Ray ray(Vector3(0,0,0), Vector3(u-0.5, v-0.5, -1));
            Vector3 pixel_color = scene.ray_color(ray);
            writeColor(outfile, pixel_color);
        }
    }
 
    outfile.close();
    std::cout << "Image generated in image.ppm\n";
    return 0;
}

Step 5: Build and Run

src/CMakeLists.txt:

add_executable(RayTracer main.cpp)
target_include_directories(RayTracer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../include)

Back in the build directory:

cmake ..
make
./RayTracer

This will create an image.ppm file in your project directory, which you can view with an image viewer capable of reading PPM files.

Conclusion

Congratulations! You've now built a basic ray tracer. This simple project can be expanded with more complex scenes, lighting, textures, reflections, and refractions for more realistic images. Remember, ray tracing in the real world involves a lot more optimizations and complex algorithms, but this gives you a starting point to explore and understand the fundamentals of how light interacts with virtual worlds.


Note: This guide provides a starting framework. For a production-ready ray tracer, you would need to handle many edge cases, optimize for performance, and possibly implement parallel processing or GPU acceleration.