Project 2

Path Tracing

A CPU path tracer with implicit and explicit light paths, Russian Roulette termination and jittered sub-pixel sampling, compared side by side from 8 to 4096 passes.

Course
CS 500, Ray Tracing
Submitted
1 March 2026
Status
Complete

Project overview

A CPU path tracer that renders physically based lighting with both implicit and explicit path tracing. Global illumination comes from simulating light bounces, with Russian Roulette terminating paths and explicit light connections speeding up convergence.

The framework is modified to toggle between implicit and explicit path tracing, and writes each mode’s output images into its own folder for side by side comparison.


Natural anti-aliasing via jittered sampling

Both modes sample pixels stochastically, which improves output quality on its own. Instead of shooting a ray through the exact center of every pixel (x + 0.5f), the ray’s position inside the pixel is randomized with sub-pixel jitter on every pass:

float u = (float)(x + myrandom(RNGen)) / width;
float v = (float)(y + myrandom(RNGen)) / height;

Varying the primary ray’s origin within the pixel’s bounds on each pass makes the final pixel color an average of several slightly different scene samples. That smooths jagged edges (aliasing) on geometry boundaries and removes the stair-stepping artifacts, so no separate post-process anti-aliasing step is needed.


Implicit path tracing

Purpose

Compute global illumination by bouncing rays randomly through the scene according to the surface BRDF. A ray’s contribution is recorded only if it happens to intersect a light source along its traversal.

Implementation overview

The implicit path tracing logic lives in Scene::TracePath. At each intersection the path is extended by sampling a new direction from the surface’s BRDF.

// Extend path
vec3 N = P.normal;
vec3 inDir = SampleBRDF(N);  // ωi = SampleBrdf(N)

Ray Q(P.position + EPSILON * P.normal, inDir); 
Intersection nextHit = accelStruc->intersect(Q);

The rendering equation is evaluated by multiplying the accumulated weight W with the scattering and probability density function (PDF):

float eval = EvalScattering(N, inDir);  
vec3 f = P.shape->mat->Kd * eval; 
float p = pdfBrdf(N, inDir) * RUSSIAN_ROULETTE_PROB;  

W *= (f / p);  // Update accumulated weight

If the ray happens to hit a light source, the implicit light connection is added to the accumulated color:

// Implicit light connection
if (nextHit.shape->mat->isLight()) {
    C += W * nextHit.shape->mat->Kd * (TRACE_EXPLICIT_PATH ? 0.5f : 1.0f);
    break;
}

BRDF / ray sampling

At each bounce the implicit path picks a new traversal direction by cosine-weighted hemisphere sampling around the geometric surface normal:

glm::vec3 Scene::SampleBRDF(const glm::vec3& normal) {
    float e1 = sqrt(myrandom(RNGen)); // Cosine-weighted sampling
    float e2 = myrandom(RNGen) * 2.0f * PI; // Uniformly azinmuthal angle
    return SampleLobe(normal, e1, e2);
}

That distributes the scattered rays according to Lambert’s cosine law, so the probability is concentrated at angles perpendicular to the diffuse surface rather than at grazing ones.

Scattering evaluation and probability

When a path bounces, the light’s contribution has to be modulated by the material’s BRDF, which is evaluated in EvalScattering:

float Scene::EvalScattering(glm::vec3& normal, glm::vec3& inDir) {
    return abs(dot(normalize(normal), normalize(inDir))) / PI;
}

float Scene::pdfBrdf(const glm::vec3& normal, const glm::vec3& inDir) {
    return abs(dot(normalize(normal), normalize(inDir))) / PI;
}

The probability density function (pdfBrdf) follows the same Lambert cosine law as the diffuse reflection, so it exactly matches the scattering term and the two cancel out completely. The scattering evaluation is purely diffuse for now, but keeping EvalScattering and pdfBrdf as separate functions leaves room to extend the framework with other material components, through more complex BRDF calculations, later.

Russian Roulette path termination

The implicit path tracing loop has no hard-coded maximum bounce depth. Paths span as many bounces as the Russian Roulette statistical method lets them:

const float RUSSIAN_ROULETTE_PROB = 0.8f;
while (myrandom(RNGen) <= RUSSIAN_ROULETTE_PROB) {
    // ... Path tracing evaluation ...
    float p = pdfBrdf(N, inDir) * RUSSIAN_ROULETTE_PROB;  
    W *= (f / p);
}

At every bounce iteration the ray has an 80% chance of continuing and a 20% chance of being terminated artificially. To keep the result mathematically unbiased when a path is terminated early, the accumulated weight W is divided by the probability of continuation, so over a high number of passes the surviving paths scale out to the value the infinite-bouncing paths would have given in the exact global illumination equation.

Interpretation

Implicit path tracing samples light in a mathematically correct, unbiased way. Because light sources are only found by pure chance, though, it has high variance (noise), especially for small light sources. In the resulting renders that reliance on chance makes the computation far less efficient than an explicit connection. The path tracer does converge to the correct global illumination solution eventually, but a clean image takes a massive number of passes.

Test images

Rendered with implicit paths only, growing in powers of 2 from 8 to 4096 passes. Convergence is very slow, with significant noise remaining even at the higher pass counts.

8 Passes16 Passes
Click to zoom
Implicit 8
Click to zoom
Implicit 16
32 Passes64 Passes
Click to zoom
Implicit 32
Click to zoom
Implicit 64
128 Passes256 Passes
Click to zoom
Implicit 128
Click to zoom
Implicit 256
512 Passes1024 Passes
Click to zoom
Implicit 512
Click to zoom
Implicit 1024
2048 Passes4096 Passes
Click to zoom
Implicit 2048
Click to zoom
Implicit 4096

Explicit path tracing

Purpose

Accelerate convergence by explicitly tracing a shadow ray toward the light source at every bounce. Unless the ray is occluded, every step then evaluates a light contribution, which cuts variance sharply compared to implicit sampling.

Implementation overview

In Scene::TracePath, if TRACE_EXPLICIT_PATH is enabled, a specific point on the light source is sampled before extending the ray randomly.

First, we sample the light’s position and compute its PDF alongside the geometry factor relative to the current intersection:

Intersection L = SampleLight();  
float p = PdfLight(L) / GeometryFactor(P, L);  
glm::vec3 inDir = normalize(L.position - P.position);

Next, a shadow ray is explicitly cast toward the light to evaluate occlusion:

Ray shadowRay(P.position + EPSILON * P.normal, inDir);
Intersection hit = accelStruc->intersect(shadowRay);           

if (p > 0 && hit.t < INF && hit.shape->mat->isLight()) {
    float eval = EvalScattering(P.normal, inDir);  
    vec3 f = P.shape->mat->Kd * eval;
    C += 0.5f * W * (f / p) * hit.shape->mat->Kd;
}

Explicit light sampling

Where the implicit mode has to strike the light through raw, globally randomized bounces, the explicit mode queries the light bounds deterministically, on demand. Each path evaluation selects a uniformly distributed point on the sphere surrounding the light’s origin:

std::pair<vec3, vec3> Scene::SampleSpehere(glm::vec3 center, float radius) {
    float e1 = myrandom(RNGen);
    float e2 = myrandom(RNGen);
    
    float z = 2 * e1 - 1;
    float r = sqrt(1 - z * z);
	float a = 2 * PI * e2;
    
	glm::vec3 N = vec3(r * cos(a), r * sin(a), z);
	glm::vec3 P = center + radius * N; 
    
    return std::make_pair(P, N);
}

The routine converts a random spherical coordinate sample onto the actual bounds of a sphere in the scene, here the light. Because the sample lands on a fixed point of the spherical light surface, an unoccluded explicit shadow ray evaluates valid, physical direct lighting every time it is cast.

Convergence analysis

Explicit path tracing guarantees that every unoccluded surface immediately receives direct light, so the image converges in far fewer passes. Implicit paths waste evaluation time whenever a ray wanders into dark space or misses the light.

Comparing the output images shows the speedup. An explicit render at 16 passes exhibits a noise level visually comparable to an implicit render at 32 passes. Because shadow rays deterministically gather the light contribution at each vertex, the variance drops far faster per pass.

By 128 passes the explicit method produces a nearly noise-free image in the directly lit areas, while the implicit method still shows severe high-frequency noise. As the number of passes keeps increasing, towards 512 and beyond, both methods converge mathematically toward the exact same correct solution, and at those high sample counts the visual difference between strict implicit and explicit path tracing becomes almost negligible.

When both implicit and explicit paths are computed (a Multiple Importance Sampling-like approach), the weights are blended, here by multiplying by 0.5f, to keep the result unbiased.

Interpretation

Explicit calculation accounts for the light’s visible contribution directly, which gives smoother areas of direct illumination and sharper shadow definition at much lower pass counts. In the rendered outputs the geometry faintly casts soft shadows, specifically the rabbit, the dwarf and the sphere. Those shadows blend into the environment’s global illumination, and they are the occlusion the explicit shadow rays estimated during evaluation.

Test images

Rendered with explicit path tracing (Next Event Estimation). The noise clears up much faster than in the implicit renders.

8 Passes16 Passes
Click to zoom
Explicit 8
Click to zoom
Explicit 16
32 Passes64 Passes
Click to zoom
Explicit 32
Click to zoom
Explicit 64
128 Passes256 Passes
Click to zoom
Explicit 128
Click to zoom
Explicit 256
512 Passes1024 Passes
Click to zoom
Explicit 512
Click to zoom
Explicit 1024
2048 Passes4096 Passes
Click to zoom
Explicit 2048
Click to zoom
Explicit 4096

Side-by-side comparison

The implicit and explicit rendering methods at identical pass counts. The explicit method reaches a smoother, more converged image significantly earlier than the implicit one.

PassesImplicit Path TracingExplicit Path Tracing
8
Click to zoom
Implicit 8
Click to zoom
Explicit 8
16
Click to zoom
Implicit 16
Click to zoom
Explicit 16
32
Click to zoom
Implicit 32
Click to zoom
Explicit 32
64
Click to zoom
Implicit 64
Click to zoom
Explicit 64
128
Click to zoom
Implicit 128
Click to zoom
Explicit 128
256
Click to zoom
Implicit 256
Click to zoom
Explicit 256
512
Click to zoom
Implicit 512
Click to zoom
Explicit 512
1024
Click to zoom
Implicit 1024
Click to zoom
Explicit 1024
2048
Click to zoom
Implicit 2048
Click to zoom
Explicit 2048
4096
Click to zoom
Implicit 4096
Click to zoom
Explicit 4096

Summary

  • Implicit path tracing: relies purely on random chance to intersect light sources. It is mathematically sound and guaranteed to converge to the correct global illumination solution, but it suffers from severe variance (noise) at lower pass counts, and resolving intricate shapes and soft shadows takes a massive computational effort.
  • Explicit path tracing: incorporates next-event estimation, deterministically casting a shadow ray directly to the light source at every bounce. Visual convergence is drastically quicker, giving a much cleaner, nearly noise-free image at a fraction of the required passes.
  • Convergence parity: in the side-by-side comparison, explicit rendering at 16 passes has visual clarity comparable to an implicit render at 32 passes or more. As the pass count approaches high numbers (512 and beyond), both algorithms converge to the identical, true rendering of the lighted scene.
  • Framework extensibility: the scattering evaluation and probability density terms mathematically cancel out for now, but the framework deliberately maintains separate, generic calls for EvalScattering and pdfBrdf. That leaves the extensibility to implement and evaluate complex, non-diffuse materials (such as Specular or Microfacet BRDFs) later.