Sheet 3: Path Tracing
Exercise: From Ray Tracing to Path Tracing (25 points)
In the previous assignments you implemented a recursive raytracer with textured materials, reflection, refraction, explicit light sources, and shadow rays. In this exercise you will keep much of that ray traversal logic, but replace local Phong illumination with a simple path tracer.
The application already accumulates multiple paths per pixel over time. While you do
not interact with the scene, each frame adds more paths to the current image. When you
rotate the scene, the accumulation is reset. Your task is to complete the missing code
in Scene.cpp, specifically sampleDiffuseDirection and
Scene::traceLocalPath.
The important conceptual change is that a path tracer does not evaluate all lights at every visible surface point. Instead, it follows one randomly chosen path through the scene. If the path eventually reaches the environment or hits emissive geometry, that light contributes to the pixel. Averaging many such paths approximates the rendering integral.
The rendering equation presented in the lecture looks like this. The yellow term
L_O is the outgoing radiance that we want to estimate for a ray. The blue
term L_e is light emitted directly by the surface. The red part describes
incoming radiance L_i from direction ω' and the cosine
factor ω' · N. The remaining factor f_r is the
BRDF, which describes how the surface reflects incoming light.
In this exercise we simplify the equation in several ways. Each material plays one of
two roles in our code: it either emits light, or it scatters the path so that the path
may gather light later. If a material has a non-zero blue emission term, it is treated
as light-source geometry and the path stops there. Otherwise, the material is one of
three idealized scattering types: perfectly diffuse, perfect mirror, or transparent
refractive glass. A path can gather light by leaving the scene and sampling
environmentColor, or by hitting emissive geometry and collecting
material.getEmission(). The path contribution is the gathered light
multiplied by the path throughput accumulated at previous bounces.
Task 1: Diffuse Bounce Sampling (8 points)
Complete sampleDiffuseDirection. A perfectly diffuse surface scatters
light over the hemisphere above the surface. For this introductory path tracer, use
the simple sampling construction prepared in the comments:
- Generate a random unit vector with
Vec3::randomUnitVector(). - Add the surface normal to shift the vector into the hemisphere above the surface.
- If the result is almost zero, use the normal itself as a fallback.
- Normalize the resulting direction and return it.
This produces a cosine-weighted distribution: directions near the normal are sampled
more often than grazing directions. That matches the cosine term in Lambertian
reflection well enough for this exercise and keeps the implementation compact. In the
formula above, the letter ξ (xi) denotes the random unit vector returned
by Vec3::randomUnitVector().
Task 2: Trace One Path (17 points)
Complete the body of the loop in Scene::traceLocalPath. The comments in
the code describe the required steps. The setup is already present:
raystores the current ray of the path.throughputstores the accumulated material attenuation.- The loop limits the path length to
maxDepth. - The final return value after the loop represents a path that was terminated before it reached a light source.
Start by intersecting the current ray with the scene, exactly as in the previous raytracer. If there is no hit, the path has reached the environment light and should return:
throughput * environmentColor(transformDirection(model, ray.getDirection()))
If there is a hit, extract the intersection position, normal, and material. In this
simplified renderer, an emissive material is not also handled as a scattering
material. Therefore, before choosing a new bounce direction, check whether the
material emits light. If material.emits() is true, the path has reached
light-source geometry and should return:
throughput * material.getEmission()
Only if the material does not emit light do we choose a continuation direction:
- Glass: reuse the refraction code from the texturing raytracer. Compute Fresnel reflectivity and a potential refracted direction. Instead of tracing both reflection and refraction recursively, use the Fresnel value as a probability to choose one of them for this path.
-
Mirror: reuse
Vec3::reflect. This part is almost identical to the old recursive reflection ray. -
Diffuse: call
sampleDiffuseDirection. This replaces the old direct-light loop with a random bounce.
After selecting the direction, update throughput. Diffuse surfaces should
multiply by getAlbedo(inter). Mirror and glass surfaces should multiply by
material.getSpecular(). Finally, normalize the direction
and create the next ray from offsetRayOrigin so the ray does not intersect
the same surface again.
Task 3: Understand the Behavior
Use the reference solution to compare the two scenes. Press S to switch
between them and watch both the runtime behavior and the noise over multiple samples.
Answer the following questions in a few sentences:
- Why is the first scene faster per ray than the Cornell box scene? Think about the number of objects, the typical number of bounces, and how often a ray quickly leaves the scene.
- Why does the first scene converge faster? After about 200 samples per pixel the noise is almost gone, while the Cornell box still needs many more samples. Think about environment lighting, closed geometry, indirect bounces, and the probability that a random path actually reaches a light source.
Reference Solution
The reference solution below shows the expected behavior: a progressively refined path traced image with environment lighting or emissive light geometry, mirror/glass paths, diffuse bounces, texture sampling, and visible color bleeding.
Drag with the left mouse button to rotate the scene. Press S to switch
between the two scenes. The path accumulation restarts whenever the scene changes.