Chapter 7.1 built a rendering pipeline that starts from triangles and ends at pixels: vertices go in, a rasterizer figures out which pixels each triangle covers, and a fragment shader colors them. Ray tracing runs that whole process backwards. It starts from a pixel, shoots an imaginary ray out into the 3D scene along that pixel's line of sight, and asks one question over and over: what does this ray hit first? This chapter builds a tiny ray tracer in plain C++, one piece at a time — camera rays, ray-sphere and ray-triangle intersection (reusing the barycentric coordinates from 7.1), shadows, reflections, full path tracing, and the BVH acceleration structure that makes any of this fast enough to matter. It ends with how real GPUs do this in hardware (DXR, Vulkan RT) and how modern games mix rasterization and ray tracing in the same frame.
A rasterizer, the thing you built in 7.1, loops over triangles. For each triangle, it asks "which pixels does this cover?" Ray tracing loops over pixels instead. For each pixel, it asks "which triangle (or sphere, or any other shape) does a ray from the camera, through this pixel, hit first?" Same final picture, opposite direction of the loop.
Why bother with the opposite direction? Because "shoot a ray and see what it hits" is also exactly what light does in the real world, just running backwards (real light goes from a lamp into your eye; a ray tracer starts at the eye/camera and works backwards to find what would have sent light there). That means effects that are painful to fake in a rasterizer — accurate mirror reflections, shadows from any light shape, light bouncing off one wall and lighting up another — fall out almost for free once you can answer "what does this ray hit?" You will build all three of those in this chapter using the exact same one function.
A ray is the simplest possible way to describe a straight line with a starting point and a direction: an origin (a point in 3D space) and a direction (a unit vector, meaning length exactly 1, pointing where the ray travels). Any point along the ray is origin + t * direction, where t is a single number — bigger t means further along the ray. This reuses the Vec3 and vector math from 2.1; nothing new there.
#include <cstdio>
#include <cmath>
struct Vec3 { float x, y, z; };
Vec3 operator+(Vec3 a, Vec3 b) { return { a.x+b.x, a.y+b.y, a.z+b.z }; }
Vec3 operator-(Vec3 a, Vec3 b) { return { a.x-b.x, a.y-b.y, a.z-b.z }; }
Vec3 operator*(Vec3 a, float t) { return { a.x*t, a.y*t, a.z*t }; }
float dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
Vec3 normalize(Vec3 v)
{
float len = sqrtf(dot(v, v));
return { v.x / len, v.y / len, v.z / len };
}
struct Ray
{
Vec3 origin;
Vec3 dir; // must always be unit length (length 1)
};
To render an image, you need one ray per pixel, all starting at the camera and fanning out through an imaginary grid of pixels sitting a fixed distance in front of it (the image plane). This is the mirror image of the projection math from 2.1: instead of turning a 3D point into a 2D pixel, you turn a 2D pixel back into a 3D direction.
Ray makeCameraRay(int px, int py, int width, int height)
{
Vec3 camPos = { 0.0f, 0.0f, 0.0f }; // camera sits at the origin...
float u = (px + 0.5f) / width * 2.0f - 1.0f; // pixel center mapped to -1..1
float v = 1.0f - (py + 0.5f) / height * 2.0f; // flipped so +y is up on screen
Vec3 screenPoint = { u, v, -1.0f }; // ...looking down -z
return { camPos, normalize(screenPoint - camPos) };
}
int main()
{
Ray r = makeCameraRay(0, 0, 4, 4); // top-left pixel of a 4x4 image
printf("origin = (%.2f, %.2f, %.2f)\n", r.origin.x, r.origin.y, r.origin.z);
printf("dir = (%.2f, %.2f, %.2f)\n", r.dir.x, r.dir.y, r.dir.z);
}
Output:
origin = (0.00, 0.00, 0.00)
dir = (-0.51, 0.51, -0.69)
Pixel (0, 0) is the top-left corner, so u and v both land toward the negative/positive extreme of their range, which is exactly what the output shows: a direction pointing left and up (negative x, positive y) and forward into the scene (negative z). A full image just calls makeCameraRay once per pixel in a width * height loop and traces each one independently — this is why ray tracing is often called "embarrassingly parallel": every pixel's ray is completely independent of every other pixel's ray.
Now the actual question: given a ray, does it hit a shape, and where? Spheres are the simplest possible case, and a good warm-up before triangles. A sphere is just "all points at distance radius from center," which as an equation is dot(p - center, p - center) = radius * radius. Substitute p = origin + t * dir and you get a quadratic equation in t — the same a*t^2 + b*t + c = 0 shape from basic algebra, just with vector dot products standing in for the coefficients.
struct Sphere { Vec3 center; float radius; };
// Returns true if the ray hits the sphere; fills t with the distance to
// the closest hit in front of the ray.
bool hitSphere(const Sphere& s, const Ray& r, float& t)
{
Vec3 oc = r.origin - s.center;
float a = dot(r.dir, r.dir); // = 1 if dir is normalized
float b = 2.0f * dot(oc, r.dir);
float c = dot(oc, oc) - s.radius * s.radius;
float disc = b*b - 4*a*c; // the quadratic discriminant
if (disc < 0.0f) return false; // negative -> ray misses the sphere entirely
float sqrtDisc = sqrtf(disc);
float t0 = (-b - sqrtDisc) / (2*a); // the closer of the two roots
float t1 = (-b + sqrtDisc) / (2*a); // the farther of the two roots
if (t0 > 0.001f) { t = t0; return true; } // normal case: hit the near side
if (t1 > 0.001f) { t = t1; return true; } // camera is inside the sphere
return false; // both hits are behind the ray
}
int main()
{
Sphere s = { {0.0f, 0.0f, -5.0f}, 1.0f };
Ray r = { {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f} };
float t;
if (hitSphere(s, r, t))
{
Vec3 hitPoint = r.origin + r.dir * t;
printf("hit at t = %.2f, point = (%.2f, %.2f, %.2f)\n",
t, hitPoint.x, hitPoint.y, hitPoint.z);
}
else
{
printf("miss\n");
}
}
Output:
hit at t = 4.00, point = (0.00, 0.00, -4.00)
The sphere's center sits at z = -5 with radius 1, so its nearest surface point straight down the -z axis is at z = -4 — exactly what the code found. A quadratic has zero, one, or two real roots, which maps directly onto three geometric cases: zero roots (negative discriminant) means the ray's line never touches the sphere at all; two roots means the ray's line pierces straight through, entering at t0 and exiting at t1; a repeated root (discriminant exactly zero) means the ray just grazes the sphere at a single tangent point.
The check t > 0.001f instead of t > 0.0f matters more than it looks. Floating-point rounding means a hit point computed as "exactly on the surface" can end up a tiny fraction inside or outside it. Using a small positive threshold (an epsilon) instead of zero avoids the ray immediately re-hitting the surface it just started from — you will see this exact problem again, worse, in section 7.
Real meshes are made of triangles, not spheres, so this is the intersection test that actually matters for game scenes. Just like the index buffers you built in 7.1 (a list of integers naming which three vertices form each triangle), a ray-traced mesh is a list of vertex positions plus a list of triangles referencing them by index. The algorithm below, called Möller–Trumbore after the two researchers who published it, tests a ray against one triangle's three corner points directly, without ever needing the triangle's plane equation as a separate step.
Vec3 cross(Vec3 a, Vec3 b)
{
return { a.y*b.z - a.z*b.y,
a.z*b.x - a.x*b.z,
a.x*b.y - a.y*b.x };
}
// a, b, c are the triangle's three corner positions. Returns true on a
// hit; fills t (distance along the ray) and u, v (barycentric weights).
bool hitTriangle(Vec3 a, Vec3 b, Vec3 c, const Ray& r,
float& t, float& u, float& v)
{
const float EPS = 1e-7f;
Vec3 edge1 = b - a;
Vec3 edge2 = c - a;
Vec3 pvec = cross(r.dir, edge2);
float det = dot(edge1, pvec);
if (fabsf(det) < EPS) return false; // ray is parallel to the triangle's plane
float invDet = 1.0f / det;
Vec3 tvec = r.origin - a;
u = dot(tvec, pvec) * invDet;
if (u < 0.0f || u > 1.0f) return false; // outside the triangle on this edge
Vec3 qvec = cross(tvec, edge1);
v = dot(r.dir, qvec) * invDet;
if (v < 0.0f || u + v > 1.0f) return false; // outside on one of the other edges
t = dot(edge2, qvec) * invDet;
return t > EPS; // triangle is behind the ray -> not a real hit
}
int main()
{
Vec3 a = { -1.0f, -1.0f, -5.0f };
Vec3 b = { 1.0f, -1.0f, -5.0f };
Vec3 c = { 0.0f, 1.0f, -5.0f };
Ray r = { {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f} };
float t, u, v;
if (hitTriangle(a, b, c, r, t, u, v))
printf("hit at t = %.2f, u = %.2f, v = %.2f\n", t, u, v);
else
printf("miss\n");
}
Output:
hit at t = 5.00, u = 0.25, v = 0.50
The triangle sits flat on the z = -5 plane, so t = 5 is correct immediately. The interesting part is u and v: these are two of the exact same three barycentric weights — wA, wB, wC from 7.1's rasterizer, just renamed (u here is wB, v here is wC, and the weight for vertex a is always 1 - u - v). A rasterizer computes these weights from a pixel's 2D screen position using the edge function; Möller–Trumbore computes the identical weights directly from the 3D ray, as a side effect of finding the hit point. Either way, once you have them, interpolating anything across the triangle's surface — a smooth shading normal, a texture coordinate, a vertex color — is the exact same weighted blend from 7.1, section 7.
u and v are weights for b and c, not a and b. Mixing this up silently swaps two of the three interpolated values (for example, a texture appears mirrored or a shading normal points the wrong way) without ever crashing or failing an assertion, which makes it a slow, annoying bug to spot.A real scene has many objects. A single ray might pass close to several of them, so you cannot just stop at the first one you happen to test — you need the closest one along the ray, since that is the one actually blocking the others from view. The fix is simple: test every object, keep track of the smallest t seen so far, and only keep a hit if it beats the current best.
#include <vector>
struct HitRecord
{
float t;
Vec3 point;
Vec3 normal;
int objectId;
};
bool traceClosest(const Ray& r, const std::vector<Sphere>& spheres, HitRecord& rec)
{
float closestT = 1e30f; // start impossibly far away
bool hitAnything = false;
for (size_t i = 0; i < spheres.size(); i++)
{
float t;
if (hitSphere(spheres[i], r, t) && t < closestT)
{
closestT = t;
hitAnything = true;
rec.t = t;
rec.point = r.origin + r.dir * t;
rec.normal = normalize(rec.point - spheres[i].center);
rec.objectId = (int)i;
}
}
return hitAnything;
}
int main()
{
std::vector<Sphere> spheres = {
{ {0.0f, 0.0f, -5.0f}, 1.0f }, // id 0
{ {0.0f, 0.0f, -3.0f}, 0.5f }, // id 1, closer to the camera
{ {2.0f, 0.0f, -5.0f}, 1.0f } // id 2, off to the side, ray misses it
};
Ray r = { {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f} };
HitRecord rec;
if (traceClosest(r, spheres, rec))
printf("closest hit: object %d at t = %.2f\n", rec.objectId, rec.t);
else
printf("no hit\n");
}
Output:
closest hit: object 1 at t = 2.50
Object 0 would be hit at t = 4 and object 2 is missed entirely (the ray's straight line passes 2 units to the side of its center, farther than its radius of 1), but object 1 wins at t = 2.5 because it is the nearest thing actually in the ray's path. In a real ray tracer, this same loop also checks triangles using hitTriangle from section 4, alongside any spheres — the object that returns the smallest valid t is the one that gets shaded. Every technique in the rest of this chapter (shadows, reflections, path tracing) is built by calling this one function — or its accelerated version from section 12 — over and over.
Finding a hit only tells you where the ray stopped. To turn that into a color, you need the surface normal (the direction perpendicular to the surface at that point, already computed above for spheres as normalize(hitPoint - center)) and a light direction. The simplest lighting model is the same one from 7.3: Lambertian diffuse, brightness proportional to dot(normal, lightDir), clamped at zero so a surface facing away from the light contributes nothing.
Vec3 shade(const HitRecord& rec, Vec3 lightDir, Vec3 baseColor)
{
float ndotl = dot(rec.normal, lightDir);
if (ndotl < 0.0f) ndotl = 0.0f; // light behind the surface adds nothing
return baseColor * ndotl;
}
int main()
{
HitRecord rec;
rec.point = { 0.0f, 0.0f, -2.5f };
rec.normal = { 0.0f, 0.0f, 1.0f }; // points straight back toward the camera
Vec3 lightDir = normalize({1.0f, 1.0f, 1.0f});
Vec3 color = shade(rec, lightDir, {0.8f, 0.2f, 0.2f});
printf("color = (%.3f, %.3f, %.3f)\n", color.x, color.y, color.z);
}
Output:
color = (0.462, 0.115, 0.115)
This is exactly the N dot L term from 7.3, evaluated at a point a ray found instead of a point a rasterizer's edge function interpolated — the math is identical, only how you arrived at the surface point changed. Everything else from 7.3 (specular highlights, PBR's metallic/roughness workflow, Fresnel) plugs into a ray tracer's shading step the same way it plugs into a rasterizer's fragment shader; ray tracing changes how you find the surface, not how you light it once found.
Here is the first place ray tracing feels genuinely different from rasterization. To find out if a point is in shadow, you shoot a second ray — a shadow ray — from the hit point toward the light, and ask: does anything block it before it reaches the light? If yes, the point is in shadow; if no, it is lit. No shadow maps, no separate rendering pass — it is the exact same hitSphere function from section 3, called again.
bool inShadow(Vec3 hitPoint, Vec3 lightPos, const std::vector<Sphere>& spheres)
{
Vec3 toLight = lightPos - hitPoint;
float lightDist = sqrtf(dot(toLight, toLight));
Vec3 shadowDir = normalize(toLight);
// Start a little OFF the surface, not exactly on it -- see the
// warning below for why this matters.
Ray shadowRay = { hitPoint + shadowDir * 0.001f, shadowDir };
for (size_t i = 0; i < spheres.size(); i++)
{
float t;
if (hitSphere(spheres[i], shadowRay, t) && t < lightDist)
return true; // something sits between the point and the light
}
return false;
}
Notice t < lightDist: a blocker past the light does not count, only one strictly between the point and the light. Combine this with section 6's shading to darken shadowed points, for example if (inShadow(rec.point, lightPos, spheres)) color = color * 0.1f; instead of turning them fully black, which fakes a little bit of ambient light bouncing around.
hitPoint instead of nudging it forward by a tiny epsilon (as the code above does with + shadowDir * 0.001f) causes the ray to immediately re-intersect the very surface it just left, due to floating-point rounding on the hit point's coordinates. The visible symptom is a surface that should be evenly lit instead coming out covered in random dark speckles — this artifact even has a name, shadow acne, because that is exactly what it looks like. The fix is always the same: offset the new ray's origin a small distance along the normal or the ray direction before tracing it.A mirror-like surface works the same way as a shadow ray, except instead of aiming at a fixed light, you aim at the direction the incoming ray bounces to. The physics is the same "angle in equals angle out" reflection rule you may know from optics: reflect the incoming direction around the surface normal.
Vec3 reflect(Vec3 d, Vec3 n)
{
return d - n * (2.0f * dot(d, n));
}
Vec3 traceRay(const Ray& r, const std::vector<Sphere>& spheres, int depth)
{
if (depth >= 5) return { 0.0f, 0.0f, 0.0f }; // stop after 5 bounces
HitRecord rec;
if (!traceClosest(r, spheres, rec))
return { 0.4f, 0.6f, 0.9f }; // sky color -- the ray hit nothing
Vec3 direct = shade(rec, normalize({1.0f, 1.0f, 1.0f}), {0.8f, 0.2f, 0.2f});
if (inShadow(rec.point, {5.0f, 5.0f, 5.0f}, spheres)) direct = direct * 0.1f;
Vec3 bounceDir = reflect(r.dir, rec.normal);
Ray bounceRay = { rec.point + bounceDir * 0.001f, bounceDir }; // same epsilon trick as section 7
Vec3 reflected = traceRay(bounceRay, spheres, depth + 1);
return direct * 0.7f + reflected * 0.3f; // mix direct light with the reflection
}
traceRay calls itself — every bounce fires another ray, finds another closest hit, and mixes that result back in. The depth parameter is essential: without a limit, two facing mirrors would bounce a ray back and forth forever and crash the program with a stack overflow (recursion without a base case, exactly the failure mode covered when recursion was first introduced). Capping depth at a small number like 5 keeps every ray's recursion bounded, and in practice a mirror reflecting a mirror reflecting a mirror gets visually darker and less noticeable with each bounce anyway.
Section 8's reflection always bounces in exactly one fixed direction — perfect for a mirror, but most real surfaces (a wall, skin, cloth) scatter light in many directions at once, unevenly. The physically correct description of this is called the rendering equation, and it says the light leaving a point equals whatever that point emits on its own, plus a sum over every possible incoming direction of how much light arrives from there, times how much the surface reflects it, times the same cosine falloff term from section 6.
"Added up over all directions" is an integral over a hemisphere — impossible to compute exactly for anything but the simplest scenes. Path tracing solves it with a trick called Monte Carlo integration: instead of evaluating every direction, pick one random direction, follow the ray, and treat the result as a rough estimate. One sample is a terrible estimate on its own, but the average of many random samples converges toward the true answer — the same law of large numbers that makes a fair coin land close to 50% heads over many flips, even though any single flip is unpredictable.
#include <cstdlib>
Vec3 randomInHemisphere(Vec3 normal)
{
while (true)
{
Vec3 p = { (rand() / (float)RAND_MAX) * 2.0f - 1.0f,
(rand() / (float)RAND_MAX) * 2.0f - 1.0f,
(rand() / (float)RAND_MAX) * 2.0f - 1.0f };
if (dot(p, p) > 1.0f) continue; // reject points outside the unit sphere
p = normalize(p);
if (dot(p, normal) < 0.0f) p = p * -1.0f; // flip to the normal's side
return p;
}
}
Vec3 pathTrace(const Ray& r, const std::vector<Sphere>& spheres, int depth)
{
if (depth >= 8) return { 0.0f, 0.0f, 0.0f };
HitRecord rec;
if (!traceClosest(r, spheres, rec))
return { 0.4f, 0.6f, 0.9f }; // sky is the only light source here
Vec3 bounceDir = randomInHemisphere(rec.normal);
Ray bounceRay = { rec.point + bounceDir * 0.001f, bounceDir };
Vec3 incoming = pathTrace(bounceRay, spheres, depth + 1);
float albedo = 0.8f; // how much light the surface keeps; the rest is absorbed
return incoming * albedo;
}
Vec3 renderPixel(const Ray& camRay, const std::vector<Sphere>& spheres, int samples)
{
Vec3 total = { 0.0f, 0.0f, 0.0f };
for (int s = 0; s < samples; s++)
total = total + pathTrace(camRay, spheres, 0);
return total * (1.0f / samples); // average of all samples = the Monte Carlo estimate
}
Because this depends on rand(), the exact numbers differ run to run and machine to machine, so there is no single "correct" printed output to show here. What matters is the shape of the result as samples grows — a single pixel's true brightness might be 0.50, and here is a realistic pattern of what individual samples and their running average look like:
This is genuinely the same idea from section 8's reflection, generalized: instead of one fixed bounce direction, pick a random one every time and let averaging do the work of covering all the directions a real surface would actually scatter light toward. Run it with enough samples per pixel and the image converges toward a physically accurate picture — full soft shadows, color bleeding between surfaces (a red wall tinting a nearby white floor pink), and reflections all appear automatically, without writing separate code for any of them, because they are all just "light bouncing around and getting averaged."
Averaging fewer samples means a noisier, grainier result — that swing between 0.04 and 0.91 in the table above does not fully cancel out until you have accumulated a lot of samples, and "a lot" for a clean image is often in the hundreds or thousands per pixel. Render with too few and neighboring pixels that should look nearly identical end up with visibly different random brightness, especially anywhere the lighting is indirect (bounced) or the shadow is soft.
Real-time ray tracing does not have a budget for thousands of samples per pixel — as 7.8 covered, a 60 fps game has roughly 16 milliseconds for an entire frame, and even offline film renderers that can spend minutes per frame still use denoising to cut sample counts down. The practical fix is denoising: render with very few samples (often just 1 per pixel per effect) and then run a filter that smooths out the noise afterward, using extra information — the surface normal and depth at each pixel, not just its noisy color — so it blurs within a surface but not across the edge of one, which a plain blur would ruin.
A second, cheap source of extra samples is temporal accumulation: since the camera and most objects only move a little between two consecutive frames, you can reproject last frame's result onto this frame and blend it in, effectively spreading a few new samples this frame across many frames' worth of accumulated history.
Vec3 accumulate(Vec3 previousAverage, Vec3 newSample, int frameCount)
{
// a running average: each new frame's sample nudges the average
// a little less as frameCount grows, without ever storing every
// past sample
return previousAverage + (newSample - previousAverage) * (1.0f / frameCount);
}
Every intersection function so far loops over every object in the scene for every ray. That is fine for three spheres. It falls apart completely for a real game scene.
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
Ray r = makeCameraRay(x, y, width, height);
for (size_t i = 0; i < allTriangles.size(); i++)
{
// test this ray against every single triangle in the WHOLE scene
}
}
}
Do the arithmetic: a 1920x1080 image has about 2,073,600 pixels. A modest game scene can easily have 2,000,000 triangles. Testing every triangle against every primary ray alone is roughly 2,073,600 * 2,000,000, about 4 trillion ray-triangle tests — and that is before a single shadow ray, reflection ray, or path-tracing bounce, each of which needs its own full sweep over every triangle. As 7.8 covered, a 60 fps frame has about 16 milliseconds to work with; even an optimistic billion tests per second would need well over an hour for this one frame. This is not "a bit slow," it is many orders of magnitude too slow to ever run.
The fix is to stop testing individual triangles first and test cheap boxes instead. A BVH (Bounding Volume Hierarchy) wraps groups of triangles in boxes, wraps groups of those boxes in bigger boxes, and so on up to one box containing the whole scene — a tree, exactly like the tree structures from your data structures chapters, just holding 3D boxes instead of numbers. If a ray misses a box, it is guaranteed to miss everything inside it, so the whole subtree underneath gets skipped without testing a single triangle.
Each box is an AABB (Axis-Aligned Bounding Box — a box whose faces line up with the x, y, and z axes, which makes the intersection math cheap). Testing a ray against one is the classic slab test: for each axis, compute the range of t where the ray is between the box's min and max on that axis, then intersect all three ranges. If the ranges do not overlap, the ray misses the box.
struct AABB { Vec3 min, max; };
bool hitAABB(const AABB& box, const Ray& r)
{
float txMin = (box.min.x - r.origin.x) / r.dir.x;
float txMax = (box.max.x - r.origin.x) / r.dir.x;
if (txMin > txMax) { float tmp = txMin; txMin = txMax; txMax = tmp; }
float tyMin = (box.min.y - r.origin.y) / r.dir.y;
float tyMax = (box.max.y - r.origin.y) / r.dir.y;
if (tyMin > tyMax) { float tmp = tyMin; tyMin = tyMax; tyMax = tmp; }
if (txMin > tyMax || tyMin > txMax) return false;
if (tyMin > txMin) txMin = tyMin;
if (tyMax < txMax) txMax = tyMax;
float tzMin = (box.min.z - r.origin.z) / r.dir.z;
float tzMax = (box.max.z - r.origin.z) / r.dir.z;
if (tzMin > tzMax) { float tmp = tzMin; tzMin = tzMax; tzMax = tmp; }
if (txMin > tzMax || tzMin > txMax) return false;
return true;
}
struct BVHNode
{
AABB bounds;
BVHNode* left;
BVHNode* right;
int triangleIndex; // only meaningful on a leaf (left == right == nullptr)
};
bool hitBVH(const BVHNode* node, const Ray& r,
const std::vector<Vec3>& triA, const std::vector<Vec3>& triB,
const std::vector<Vec3>& triC, float& closestT)
{
if (!hitAABB(node->bounds, r)) return false; // box missed -> skip everything inside it
if (node->left == nullptr && node->right == nullptr)
{
// leaf node: a real triangle, test it for real
float t, u, v;
int i = node->triangleIndex;
if (hitTriangle(triA[i], triB[i], triC[i], r, t, u, v) && t < closestT)
{
closestT = t;
return true;
}
return false;
}
bool hitLeft = hitBVH(node->left, r, triA, triB, triC, closestT);
bool hitRight = hitBVH(node->right, r, triA, triB, triC, closestT);
return hitLeft || hitRight;
}
Building a BVH (not shown in full here) is done once when a mesh loads, not per ray: recursively pick the axis where the triangles spread out the most, split them into two roughly equal groups along it, compute a box around each group, and recurse until a group is small enough to become a leaf. With a good BVH, testing a ray against a scene of millions of triangles takes roughly log(triangleCount) box tests instead of triangleCount triangle tests — for 2,000,000 triangles, that is around 21 cheap box checks instead of 2,000,000 expensive triangle checks, which is what actually makes section 11's trillions of tests collapse down into something a frame budget can afford.
Everything above runs fine on a CPU for learning, but modern GPUs have dedicated silicon (RT cores) purpose-built to do the BVH traversal and triangle tests from sections 3, 4, and 12 in hardware. DXR (DirectX Raytracing) on Windows and Vulkan RT on Vulkan/cross-platform are the graphics APIs that expose this hardware. Both organize a scene's BVH into two layers instead of one flat tree: a BLAS (Bottom-Level Acceleration Structure) holding one mesh's actual triangle geometry, and a TLAS (Top-Level Acceleration Structure) holding many instances of BLASes, each with its own transform — so a hundred copies of the same tree mesh share one BLAS and just get a hundred cheap entries in the TLAS, instead of duplicating the geometry a hundred times.
Instead of one function you call per ray like traceClosest above, hardware ray tracing splits the work into GPU shader stages, similar in spirit to the vertex/fragment shader split from 7.1 and 7.2:
makeCameraRay from section 2, and call TraceRay() to hand it to the hardware.shade(). It can also launch more rays itself, for shadows or reflections, the same recursive pattern as section 8.traceClosest.// simplified HLSL-style pseudocode for a DXR ray generation shader --
// illustrating the shape of the API, not a full compilable listing
[shader("raygeneration")]
void RayGenMain()
{
uint2 pixel = DispatchRaysIndex().xy;
RayDesc ray = MakeCameraRay(pixel); // same idea as section 2
RayPayload payload;
TraceRay(SceneTLAS, RAY_FLAG_NONE, 0xFF, 0, 0, 0, ray, payload);
OutputImage[pixel] = payload.color; // filled in by closest-hit or miss
}
There is also an optional any-hit shader (runs on every candidate hit along the ray, not just the closest one — used for things like alpha-tested foliage, where a ray can pass straight through the transparent parts of a leaf texture) and an intersection shader (for custom shapes that are not triangles, like the sphere from section 3). Most games skip both and stick to triangles with the default closest-hit/miss pair.
Because full path tracing (section 9) is still far too expensive for every pixel of every frame at 60 fps, almost every ray-traced game today uses hybrid rendering: rasterize the scene normally first (fast, and it is what 7.1 and 7.8 already cover — colors, depth, and normals land in a G-buffer via deferred shading), then spend a strict, small ray budget only on the effects that genuinely need it — shadows from area lights, mirror-like reflections, or a bit of bounced ambient light for global illumination — and finally denoise (section 10) and combine that with the rasterized image. Rasterization draws almost the whole picture; ray tracing fills in the handful of things it cannot fake convincingly.
center = (0, 0, -10) with radius = 2. A ray starts at origin = (0, 1, 0) with direction dir = (0, 0, -1). Using the hitSphere formula from section 3, compute a, b, c, the discriminant, and (if it hits) the closest hit point's t and 3D position, either by hand or in code.int main()
{
Sphere s = { {0.0f, 0.0f, -10.0f}, 2.0f };
Ray r = { {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, -1.0f} };
Vec3 oc = r.origin - s.center;
float a = dot(r.dir, r.dir);
float b = 2.0f * dot(oc, r.dir);
float c = dot(oc, oc) - s.radius * s.radius;
float disc = b*b - 4*a*c;
printf("a = %.2f, b = %.2f, c = %.2f, disc = %.2f\n", a, b, c, disc);
float t;
if (hitSphere(s, r, t))
{
Vec3 p = r.origin + r.dir * t;
printf("hit at t = %.3f, point = (%.2f, %.2f, %.2f)\n", t, p.x, p.y, p.z);
}
}
Output:
a = 1.00, b = -20.00, c = 97.00, disc = 12.00
hit at t = 8.268, point = (0.00, 1.00, -8.27)
oc = origin - center = (0, 1, 10). a = dot(dir,dir) = 1 since dir is already unit length. b = 2*dot(oc,dir) = 2*(-10) = -20. c = dot(oc,oc) - radius^2 = (0+1+100) - 4 = 97. The discriminant b*b - 4ac = 400 - 388 = 12 is positive, so the ray hits at two points; the nearer one, t0 = (20 - sqrt(12)) / 2 ≈ 8.268, is in front of the ray, so that is the reported hit. The point sits at y = 1 the whole way (the ray never moves in y or x, only z), which matches a hit point of (0, 1, -8.27) exactly.
hitPlane for an infinite flat plane defined by a point planePoint on it and a unit normal planeNormal, using the same "solve for t" approach as hitSphere: a point p is on the plane when dot(p - planePoint, planeNormal) == 0. Substitute p = ray.origin + t * ray.dir and solve for t. Test it with a ground plane at y = 0 (so planePoint = (0,0,0), planeNormal = (0,1,0)) and a ray looking down and forward, origin = (0, 2, 0), dir = normalize((0, -1, -1)).bool hitPlane(Vec3 planePoint, Vec3 planeNormal, const Ray& r, float& t)
{
float denom = dot(planeNormal, r.dir);
if (fabsf(denom) < 1e-6f) return false; // ray is parallel to the plane
t = dot(planePoint - r.origin, planeNormal) / denom;
return t > 0.001f; // same epsilon idea as sections 3 and 7
}
int main()
{
Vec3 planePoint = { 0.0f, 0.0f, 0.0f };
Vec3 planeNormal = { 0.0f, 1.0f, 0.0f };
Ray r = { {0.0f, 2.0f, 0.0f}, normalize({0.0f, -1.0f, -1.0f}) };
float t;
if (hitPlane(planePoint, planeNormal, r, t))
{
Vec3 p = r.origin + r.dir * t;
printf("hit at t = %.3f, point = (%.2f, %.2f, %.2f)\n", t, p.x, p.y, p.z);
}
else
{
printf("miss (parallel or behind)\n");
}
}
Output:
hit at t = 2.828, point = (0.00, 0.00, -2.00)
Deriving t is the same substitution used for the sphere, just simpler because a plane's equation is linear instead of quadratic: dot(origin + t*dir - planePoint, normal) = 0 rearranges directly to t = dot(planePoint - origin, normal) / dot(dir, normal). With this ray's direction normalized to length 1, its y-component and z-component are both -1/sqrt(2) ≈ -0.707; the ray needs to drop 2 units in y to reach y = 0, which takes t = 2 / 0.707 ≈ 2.828, landing at z = 0 + (-0.707 * 2.828) ≈ -2.0 — exactly the printed point.
bool inShadow(Vec3 hitPoint, Vec3 lightPos, const std::vector<Sphere>& spheres)
{
Vec3 toLight = lightPos - hitPoint;
float lightDist = sqrtf(dot(toLight, toLight));
Vec3 shadowDir = normalize(toLight);
Ray shadowRay = { hitPoint, shadowDir };
for (size_t i = 0; i < spheres.size(); i++)
{
float t;
if (hitSphere(spheres[i], shadowRay, t) && t < lightDist)
return true;
}
return false;
}
The shadow ray's origin is set to hitPoint exactly, with no epsilon offset. Because hitPoint was itself computed from a floating-point intersection test, it sits a tiny, essentially random fraction inside or outside the true surface. hitSphere's own t > 0.001f guard usually catches this, but it is not a large enough margin to always save a ray that starts practically glued to the sphere it just left, so the shadow ray occasionally re-detects its own starting surface as a blocker — this is exactly the shadow acne pattern described in section 7, and it flickers frame to frame because the floating-point rounding error is not consistent in the same direction every time.
bool inShadow(Vec3 hitPoint, Vec3 lightPos, const std::vector<Sphere>& spheres)
{
Vec3 toLight = lightPos - hitPoint;
float lightDist = sqrtf(dot(toLight, toLight));
Vec3 shadowDir = normalize(toLight);
// nudge the origin forward along the shadow ray's own direction
Ray shadowRay = { hitPoint + shadowDir * 0.001f, shadowDir };
for (size_t i = 0; i < spheres.size(); i++)
{
float t;
if (hitSphere(spheres[i], shadowRay, t) && t < lightDist)
return true;
}
return false;
}
The fix, matching section 7's original code, offsets the new ray's starting point a small distance along the direction it is about to travel before testing it against the scene, so the ray never starts close enough to its own origin surface to accidentally re-hit it.