Table of contents
Open Table of contents
What Is Ray Tracing?
Ray tracing is a rendering technique that simulates how light actually works. Instead of approximating lighting (like rasterization), ray tracing follows individual light rays as they bounce around a scene.
Think of it like this: rasterization draws what you see from your viewpoint. Ray tracing figures out what light does, then shows you the result.
How Ray Tracing Works
The Basic Idea
For every pixel on screen, cast a ray from the camera through that pixel into the scene. Find what object it hits, then cast more rays from that point to figure out lighting, shadows, and reflections.
Camera
│
│ Ray 1
│ ─────→ [Hits sphere]
│ │
│ │ Shadow ray → [Hits light?]
│ │
│ │ Reflection ray → [Hits another object]
│
│ Ray 2
│ ─────→ [Hits floor]
│ │
│ │ Shadow ray → [Hits light?]
The Math
Every ray is defined by an origin and direction:
struct Ray {
Vec3 origin;
Vec3 direction;
};
// Primary ray from camera
Ray primaryRay(int x, int y) {
Vec3 dir = Vec3(
(2.0f * x / width - 1.0f) * aspect,
1.0f - 2.0f * y / height,
-1.0f // FOV
).normalized();
return Ray{cameraPosition, dir};
}
Ray-sphere intersection:
bool intersectSphere(Ray ray, Sphere sphere, float& t) {
Vec3 oc = ray.origin - sphere.center;
float a = ray.direction.dot(ray.direction);
float b = 2.0f * oc.dot(ray.direction);
float c = oc.dot(oc) - sphere.radius * sphere.radius;
float discriminant = b * b - 4 * a * c;
if (discriminant < 0) return false;
t = (-b - sqrt(discriminant)) / (2.0f * a);
return t > 0;
}
Building the Engine
Project Structure
raytracing/
├── src/
│ ├── main.cpp
│ ├── ray.h
│ ├── vec3.h
│ ├── sphere.h
│ ├── scene.h
│ ├── renderer.h
│ └── gl_utils.h
├── shaders/
│ ├── vertex.glsl
│ └── fragment.glsl
├── CMakeLists.txt
└── README.md
The Renderer
class Renderer {
GLuint framebuffer;
GLuint texture;
GLuint shaderProgram;
std::vector<Vec3> framebuffer_data;
public:
void init(int width, int height) {
// Create framebuffer texture
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB32F, width, height, 0,
GL_RGB, GL_FLOAT, nullptr);
// Create framebuffer
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, texture, 0);
framebuffer_data.resize(width * height);
}
void render(const Scene& scene, const Camera& camera) {
int width = 1920, height = 1080;
#pragma omp parallel for
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
Ray ray = camera.primaryRay(x, y, width, height);
framebuffer_data[y * width + x] = trace(ray, scene);
}
}
// Upload to GPU
glBindTexture(GL_TEXTURE_2D, texture);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height,
GL_RGB, GL_FLOAT, framebuffer_data.data());
// Draw fullscreen quad
drawQuad();
}
Vec3 trace(Ray ray, const Scene& scene, int depth = 0) {
if (depth > 5) return Vec3(0, 0, 0); // Recursion limit
HitInfo hit;
if (!scene.intersect(ray, hit)) {
return scene.backgroundColor; // Miss — return sky color
}
Vec3 color = hit.material.emission;
Vec3 lightDir = (scene.lightPos - hit.point).normalized();
// Diffuse lighting
float diffuse = std::max(0.0f, hit.normal.dot(lightDir));
color += hit.material.albedo * diffuse * scene.lightColor;
// Shadows
Ray shadowRay{hit.point + hit.normal * 0.001f, lightDir};
if (scene.intersectShadow(shadowRay)) {
color *= 0.2f; // In shadow — darken
}
// Reflections
if (hit.material.reflectivity > 0) {
Vec3 reflectDir = ray.direction.reflect(hit.normal);
Ray reflectRay{hit.point + hit.normal * 0.001f, reflectDir};
Vec3 reflectColor = trace(reflectRay, scene, depth + 1);
color = color.lerp(reflectColor, hit.material.reflectivity);
}
return color;
}
};
Optimizations
Bounding Volume Hierarchy (BVH):
struct BVHNode {
AABB bounds;
BVHNode* left;
BVHNode* right;
std::vector<Triangle>* triangles; // Only for leaf nodes
bool intersect(Ray ray, HitInfo& hit) {
if (!bounds.intersect(ray)) return false;
if (left == nullptr) {
// Leaf node — test all triangles
bool hitSomething = false;
for (auto& tri : *triangles) {
if (tri.intersect(ray, hit)) hitSomething = true;
}
return hitSomething;
}
// Internal node — test children
bool hitLeft = left->intersect(ray, hit);
bool hitRight = right->intersect(ray, hit);
return hitLeft || hitRight;
}
};
Without BVH, every ray tests every object (O(n)). With BVH, it’s O(log n).
Multi-threading:
#pragma omp parallel for
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// Each thread handles different rows
}
}
Results
The engine renders at interactive frame rates for simple scenes:
- 1 sphere + 1 light: 60 FPS
- 100 spheres + 10 lights: 30 FPS
- Complex scene with reflections: 15 FPS
What I Learned
- Math matters: Ray tracing is all linear algebra and geometry
- Optimization is key: BVH and multi-threading made the difference between 1 FPS and 30 FPS
- GPU is faster: For production, you’d use CUDA or Vulkan compute shaders
- Lighting is everything: A scene with good lighting looks 10x better than a complex scene with bad lighting
Next Steps
- Add refraction (glass materials)
- Implement path tracing (global illumination)
- Port to GPU with Vulkan compute shaders
- Add mesh loading (OBJ files)
The code is on GitHub.