Scene Setup in main.cpp
The main entry point constructs a complete scene with geometric objects and light sources:
#include "Scene.hpp"
#include "Sphere.hpp"
#include "Triangle.hpp"
#include "Light.hpp"
#include "Renderer.hpp"
int main()
{
Scene scene(1280, 960);
auto sphere1 = std::make_unique<Sphere>(Vector3f(-1, 0, -12), 2.0f);
sphere1->materialType = DIFFUSE_AND_GLOSSY;
sphere1->diffuseColor = Vector3f(0.6, 0.7, 0.8);
auto sphere2 = std::make_unique<Sphere>(Vector3f(0.5, -0.5, -8), 1.5f);
sphere2->ior = 1.5f;
sphere2->materialType = REFLECTION_AND_REFRACTION;
scene.Add(std::move(sphere1));
scene.Add(std::move(sphere2));
Vector3f vertices[4] = {{-5, -3, -6}, {5, -3, -6}, {5, -3, -16}, {-5, -3, -16}};
uint32_t indices[6] = {0, 1, 3, 1, 2, 3};
Vector2f texCoords[4] = {{0, 0}, {1, 0}, {1, 1}, {0, 1}};
auto mesh = std::make_unique<MeshTriangle>(vertices, indices, 2, texCoords);
mesh->materialType = DIFFUSE_AND_GLOSSY;
scene.Add(std::move(mesh));
scene.Add(std::make_unique<Light>(Vector3f(-20, 70, 20), 0.5f));
scene.Add(std::make_unique<Light>(Vector3f(30, 50, -12), 0.5f));
Renderer renderer;
renderer.Render(scene);
return 0;
}
The scene contains four objects: a glossy diffuse sphere, a transparant sphere with index of refraction 1.5, a floor mesh composed of two triangles, and two point lights. The floor plane lies at y = -3, creating a flat surface. Despite appearing as a 10×10 grid due to interpolated coloring, it's actually a single large quad with interpolated texture coordinates.
The light intensity values of 0.5 are relatively low. While the rendering uses Blinn-Phong shading, there's no distance-based attenuation. Since the lights are positioned far from the objects, the intensity remains fairly consistent across the scene, resulting in relatively uniform illumination without dramatic shadows.
Rendering Pipeline in render.cpp
The render module handles ray generation, intersection testing, and color computation.
Angle Conversion Helper
inline float degreesToRadians(const float degrees)
{
return degrees * static_cast<float>(M_PI) / 180.0f;
}
The inline specifier suggests the compiler should substitute the function body directly at call sites. However, modern compilers typically perform this optimization automatically for small functions, making explicit inline declarations less critical in contemporary C++.
Reflection and Refraction Computation
A critical distinction between ray tracing and rasterization concerns the incident ray direction convention. In ray tracing, the incoming ray direcsion points from the surface toward the object, meaning the dot product between the incident direction and surface normal is negative.
Vector3f computeReflection(const Vector3f& incident, const Vector3f& normal)
{
return incident - 2.0f * dotProduct(incident, normal) * normal;
}
Vector3f computeRefraction(const Vector3f& incident, const Vector3f& normal, const float& refractiveIndex)
{
float cosTheta = clamp(-1.0f, 1.0f, dotProduct(incident, normal));
float eta1 = 1.0f, eta2 = refractiveIndex;
Vector3f n = normal;
if (cosTheta < 0.0f) {
cosTheta = -cosTheta;
} else {
std::swap(eta1, eta2);
n = -normal;
}
float ratio = eta1 / eta2;
float discriminant = 1.0f - ratio * ratio * (1.0f - cosTheta * cosTheta);
return discriminant < 0.0f ? Vector3f(0.0f) :
ratio * incident + (ratio * cosTheta - std::sqrtf(discriminant)) * n;
}
The refraction formula derives from Snell's law:
$$r = \frac{\eta_1}{\eta_2}(i + \cos\theta_i \cdot n) - \sqrt{1 - (\frac{\eta_1}{\eta_2})^2(1 - \cos^2\theta_i)} \cdot n$$
Total internal reflection occurs when light travels from a higher to lower refractive index medium and the incident angle exceeds the critical angle. At the critical angle, the refracted ray becomes evanescent (propagates along the interface), and the discriminant $k$ equals zero. As the incident angle increases further, $k$ becomes negative, indicating no real refraction solution—energy reflects entirely.
The code handles both ray entering and exiting the material by checking the cosine sign. When the cosine is negative, the ray is inside the object, so the refractive indices are swapped and the normal is inverted.
The Fresnel Equations
When a ray hits a reflective and refractive surface, it splits in to two rays: reflected and refracted. Conservation of energy requires these two rays to proportionally divide the original ray's energy.
float computeFresnel(const Vector3f& incident, const Vector3f& normal, const float& ior)
{
float cosTheta = clamp(-1.0f, 1.0f, dotProduct(incident, normal));
float eta1 = 1.0f, eta2 = ior;
if (cosTheta > 0.0f) {
std::swap(eta1, eta2);
}
float sinThetaT = eta1 / eta2 * std::sqrtf(std::fmaxf(0.0f, 1.0f - cosTheta * cosTheta));
if (sinThetaT >= 1.0f) {
return 1.0f;
}
float cosThetaT = std::sqrtf(std::fmaxf(0.0f, 1.0f - sinThetaT * sinThetaT));
cosTheta = std::fabsf(cosTheta);
float rs = ((eta2 * cosTheta) - (eta1 * cosThetaT)) /
((eta2 * cosTheta) + (eta1 * cosThetaT));
float rp = ((eta1 * cosTheta) - (eta2 * cosThetaT)) /
((eta1 * cosTheta) + (eta2 * cosThetaT));
return (rs * rs + rp * rp) / 2.0f;
}
The Fresnel equations determine the reflection coefficient based on viewing angle. When looking at a shallow angle (near horizontal), more light refracts into the material—you can see clearly into water. When looking at a steep angle, more light reflects—you see reflections on the water surface. The Schlick approximation provides a practical way to compute this, though the full derivation uses perpendicular and parallel polarization components.
Recursive Ray Tracing Implementation
std::optional<IntersectionData> intersectRay(
const Vector3f& origin,
const Vector3f& direction,
const std::vector<std::unique_ptr<Object>>& objects)
{
float closestDistance = kInfinity;
std::optional<IntersectionData> result;
for (const auto& object : objects)
{
float currentDistance = kInfinity;
uint32_t triangleIndex;
Vector2f barycentricCoords;
if (object->intersect(origin, direction, currentDistance, triangleIndex, barycentricCoords)
&& currentDistance < closestDistance)
{
result.emplace();
result->hitObject = object.get();
result->tNear = currentDistance;
result->index = triangleIndex;
result->uv = barycentricCoords;
closestDistance = currentDistance;
}
}
return result;
}
The intersectRay function tests a single ray against all objects in the scene, returning the closest intersection. The computational complexity is O(pixels × objects × bounces)—for each pixel, rays must test intersections against every object, and each bounce requires the same process. This is why acceleration structures like BVH are essential for practical ray tracing.
In contrast, rasterization iterates over objects (triangles), while ray tracing iterates over pixels. This fundamental difference drives the algorithmic trade-offs between the two approaches.
Vector3f traceRay(
const Vector3f& origin,
const Vector3f& direction,
const Scene& scene,
int currentDepth)
{
if (currentDepth > scene.maxDepth) {
return Vector3f(0.0f);
}
Vector3f color = scene.backgroundColor;
if (auto intersection = intersectRay(origin, direction, scene.get_objects()))
{
Vector3f hitPoint = origin + direction * intersection->tNear;
Vector3f surfaceNormal;
Vector2f textureCoords;
intersection->hitObject->getSurfaceProperties(
hitPoint, direction, intersection->index,
intersection->uv, surfaceNormal, textureCoords);
switch (intersection->hitObject->materialType)
{
case REFLECTION_AND_REFRACTION:
{
Vector3f reflectDir = normalize(computeReflection(direction, surfaceNormal));
Vector3f refractDir = normalize(computeRefraction(direction, surfaceNormal,
intersection->hitObject->ior));
Vector3f reflectOrigin = (dotProduct(reflectDir, surfaceNormal) < 0.0f) ?
hitPoint - surfaceNormal * scene.epsilon :
hitPoint + surfaceNormal * scene.epsilon;
Vector3f refractOrigin = (dotProduct(refractDir, surfaceNormal) < 0.0f) ?
hitPoint - surfaceNormal * scene.epsilon :
hitPoint + surfaceNormal * scene.epsilon;
Vector3f reflectColor = traceRay(reflectOrigin, reflectDir, scene, currentDepth + 1);
Vector3f refractColor = traceRay(refractOrigin, refractDir, scene, currentDepth + 1);
float fresnelRatio = computeFresnel(direction, surfaceNormal,
intersection->hitObject->ior);
color = reflectColor * fresnelRatio + refractColor * (1.0f - fresnelRatio);
break;
}
case REFLECTION:
{
float fresnelRatio = computeFresnel(direction, surfaceNormal,
intersection->hitObject->ior);
Vector3f reflectDir = computeReflection(direction, surfaceNormal);
Vector3f reflectOrigin = (dotProduct(reflectDir, surfaceNormal) < 0.0f) ?
hitPoint + surfaceNormal * scene.epsilon :
hitPoint - surfaceNormal * scene.epsilon;
color = traceRay(reflectOrigin, reflectDir, scene, currentDepth + 1) * fresnelRatio;
break;
}
default:
{
Vector3f illumination = Vector3f(0.0f);
Vector3f specular = Vector3f(0.0f);
Vector3f shadowOrigin = (dotProduct(direction, surfaceNormal) < 0.0f) ?
hitPoint + surfaceNormal * scene.epsilon :
hitPoint - surfaceNormal * scene.epsilon;
for (const auto& light : scene.get_lights())
{
Vector3f lightVector = light->position - hitPoint;
float lightDistanceSquared = dotProduct(lightVector, lightVector);
Vector3f lightDir = normalize(lightVector);
float nDotL = std::fmaxf(0.0f, dotProduct(lightDir, surfaceNormal));
auto shadowCheck = intersectRay(shadowOrigin, lightDir, scene.get_objects());
bool occluded = shadowCheck &&
(shadowCheck->tNear * shadowCheck->tNear < lightDistanceSquared);
illumination += occluded ? Vector3f(0.0f) : light->intensity * nDotL;
Vector3f reflectDir = computeReflection(-lightDir, surfaceNormal);
specular += std::powf(std::fmaxf(0.0f, -dotProduct(reflectDir, direction)),
intersection->hitObject->specularExponent) * light->intensity;
}
color = illumination * intersection->hitObject->evalDiffuseColor(textureCoords)
* intersection->hitObject->Kd +
specular * intersection->hitObject->Ks;
break;
}
}
}
return color;
}
The recursive ray tracing algorithm follows the Whitted-style light transport: E [S*] (D|G) L. The function accepts a ray origin and direction, computing the resulting color by checking for intersections. Three material types are handled: fully reflective and refractive surfaces (glass), purely reflective surfaces (mirrors), and diffuse/glossy surfaces (standard materials).
The recursion terminates when either no intersection occurs (returning background color) or the maximum bounce depth is exceeded. The depth parameter controls recursion depth, typically limited to 5 bounces.
Floating-Point Precision Considerations
Floating-point arithmetic introduces subtle errors. A tolerance of 1e-6 (one part in a million) accounts for these inaccuracies. When computing ray-triangle intersections, the comparison variables—especially those tracking intersection distances—must accommodate this tolerance. Without proper tolerance handling, numerical errors appear at triangle edges and shared vertices, causing visible artifacts like unrendered floor pixels.
>>> 0.1 + 0.2
0.30000000000000004
This classic example demonstrates how decimal fractions often cannot be represented exactly in binary floating-point, necessitating epsilon-based comparisons in geometric algorithms.
Camera Transformation in Ray Tracing
To render from arbitrary viewpoints, camera translation and rotation are expressed as a 4×4 transformation matrix—the camera-to-world matrix. Applying this matrix transforms rays from camera space to world space.
float aspectRatio = imageWidth / static_cast<float>(imageHeight);
float normalizedX = (2.0f * ((pixelX + 0.5f) / imageWidth) - 1.0f)
* std::tan(fov * 0.5f * degreesToRadians(1.0f)) * aspectRatio;
float normalizedY = (1.0f - 2.0f * ((pixelY + 0.5f) / imageHeight))
* std::tan(fov * 0.5f * degreesToRadians(1.0f));
Vector3f cameraOrigin = Vector3f(0.0f, 0.0f, 0.0f);
Matrix44f cameraToWorld;
cameraToWorld.setIdentity();
Vector3f worldOrigin, worldPoint;
cameraToWorld.transformVector(cameraOrigin, worldOrigin);
cameraToWorld.transformVector(Vector3f(normalizedX, normalizedY, -1.0f), worldPoint);
Vector3f rayDirection = worldPoint - worldOrigin;
rayDirection = normalize(rayDirection);
The camera-to-world matrix defines the coordinate system relative to world space. Transforming the camera position and the image plane point through this matrix yields world-space coordinates for ray generation. The ray direction is computed as the difference between the transformed image plane point and camera position, then normalized since only direction matters for rays.