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.