Chair of Computer Graphics and Visualization

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.

Lecture notation of the rendering equation with outgoing radiance, emitted radiance, BRDF, incoming radiance, and cosine term.

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.

The next throughput is the current throughput multiplied by the albedo; the path radiance is throughput times environment radiance on a miss or throughput times emitted radiance on a light hit.

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:

  1. Generate a random unit vector with Vec3::randomUnitVector().
  2. Add the surface normal to shift the vector into the hemisphere above the surface.
  3. If the result is almost zero, use the normal itself as a fallback.
  4. Normalize the resulting direction and return it.

The diffuse direction is the normalized sum of the normal and a random unit vector.

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:

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:

The probability of reflection is the Fresnel term and the probability of refraction is one minus the Fresnel term.

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:

  1. 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.
  2. 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.

Loading

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.