Skip to content

Path Tracing

Monte Carlo path tracing is a global illumination algorithm that solves the rendering equation to produce realistic lighting effects.

The Rendering Equation

The core of path tracing is solving the Kajiya rendering equation:

$$L_o(x, \omega_o) = L_e(x, \omega_o) + \int_{\Omega} f_r(x, \omega_i, \omega_o) L_i(x, \omega_i) (\omega_i \cdot n) d\omega_i$$

Where:

  • $L_o$ is outgoing radiance
  • $L_e$ is emitted radiance
  • $f_r$ is the BRDF
  • $L_i$ is incoming radiance
  • $\omega_i \cdot n$ is the cosine term

Monte Carlo Integration

Using Monte Carlo methods to estimate the integral:

$$\int f(x) dx \approx \frac{1}{N} \sum_{i=1}^{N} \frac{f(X_i)}{p(X_i)}$$

Applied to the rendering equation:

$$L_o \approx L_e + \frac{1}{N} \sum_{i=1}^{N} \frac{f_r L_i (\omega_i \cdot n)}{p(\omega_i)}$$

Cosine-Weighted Sampling

For Lambertian materials, use cosine-weighted hemisphere sampling:

$$p(\omega) = \frac{\cos\theta}{\pi}$$

Direction generation:

cpp
__device__ vec3 cosine_weighted_hemisphere(curandState* state) {
    float r1 = curand_uniform(state);
    float r2 = curand_uniform(state);

    float phi = 2.0f * M_PI * r1;
    float sqrt_r2 = sqrtf(r2);

    float x = cosf(phi) * sqrt_r2;
    float y = sinf(phi) * sqrt_r2;
    float z = sqrtf(1.0f - r2);

    return vec3(x, y, z);
}

Russian Roulette

To avoid infinite recursion, use Russian roulette termination:

$$L = \frac{L_{direct} + p_{rr} \cdot L_{indirect}}{p_{rr}}$$

When path length > 3, continue with probability $p_{rr} = 0.8$:

cpp
__device__ bool russian_roulette(curandState* state, int depth) {
    if (depth <= 3) return true;
    return curand_uniform(state) < 0.8f;
}

Complete Path Tracing Kernel

cpp
__device__ vec3 trace_path(
    const Ray& ray,
    const Scene& scene,
    curandState* state,
    int max_depth
) {
    vec3 radiance = vec3(0.0f);
    vec3 throughput = vec3(1.0f);
    Ray current_ray = ray;

    for (int depth = 0; depth < max_depth; ++depth) {
        HitRecord hit;
        if (!scene.intersect(current_ray, hit)) {
            radiance += throughput * scene.background(current_ray);
            break;
        }

        // Emissive materials
        radiance += throughput * hit.material->emitted();

        // Russian roulette
        if (depth > 3 && !russian_roulette(state, depth)) break;

        // Sample BSDF
        vec3 wi;
        float pdf;
        vec3 f = hit.material->sample(state, hit, wi, pdf);

        // Update throughput
        throughput *= f * fabsf(dot(wi, hit.normal)) / pdf;

        // Update ray
        current_ray = Ray(hit.p, wi);
    }

    return radiance;
}

Denoising

High sample counts converge slowly. Denoising techniques include:

  • Temporal accumulation
  • Spatial filtering
  • AI denoising (OptiX Denoiser)

Sampling Strategies

Uniform Hemisphere Sampling

$$p(\omega) = \frac{1}{2\pi}$$

Simple but inefficient for diffuse surfaces.

Cosine-Weighted Sampling

$$p(\omega) = \frac{\cos\theta}{\pi}$$

Importance sampling for Lambertian BRDF - reduces variance significantly.

Multiple Importance Sampling (MIS)

Combine BSDF sampling and light sampling:

$$L = w_1 \cdot \frac{f(X_1)}{p_1(X_1)} + w_2 \cdot \frac{f(X_2)}{p_2(X_2)}$$

References

  • [Kajiya 1986] "The Rendering Equation"
  • [Pharr 2016] "Physically Based Rendering"

Technical Whitepaper · Built with VitePress