Skip to content
Copied!
published on 2026-09-17

9. Sampling Lights Directly

Ray Tracing: The Rest of Your Life (v3.2.3): 9 Sampling Lights Directly / 3.9 Sampling Lights Directly

Until now, scattering directions have been sampled from a cosine-weighted distribution. This gives no special weight to the direction of a light source, however, so as the light becomes smaller, more rays miss it and convergence slows. A sampling PDF based directly on the area of the light solves this problem.

This chapter covers three topics:

  • Converting an area PDF on a light into a directional PDF: p(direction)=d2/(cosαA)
  • One-sided emission with flip_face, preventing noise through gaps in the ceiling
  • Designing the cosine_pdf class as an ONB-based PDF object in preparation for the mixture PDF in the next chapter

Deriving the Light PDF

When a point q is sampled uniformly on a light with area A, its area PDF is pq(q)=1/A. The following derivation converts it into a directional PDF p(direction).

The relationship between an area element dA and a solid-angle element dω, using the angle α between the light's normal and the direction, is

dω=dAcosαd2(p,q)

Equating the sampling probabilities, p(direction)dω=pq(q)dA, gives

p(direction)dAcosαd2=dAAp(direction)=d2(p,q)cosαA

The contribution of one sample in an MC estimator using this p is

f(direction)p(direction)=f(direction)cosαAd2

One-Sided Emission with flip_face

The Cornell-box light is embedded in the ceiling and emits only downward. DiffuseLight::emitted() checks rec.front_face and returns black for the back face, while the flip_face wrapper reverses the light's normal to orient it correctly. This prevents illumination from leaking through gaps in the ceiling.

The cosine_pdf Class

The cosine_pdf class encapsulates the ONB from Section 3.8 as a PDF object. Its value(direction) method returns a PDF value, while generate() returns a sampled direction. The abstract pdf base class has this simple two-method interface and will be combined with hittable_pdf in the mixture PDF of Section 3.10.

Rust Implementation

The r309-light-sampling crate numerically estimates the solid angle Ω subtended by the light from the center of the Cornell-box floor, comparing convergence between uniform-hemisphere sampling and light-PDF sampling.

The solid angle follows from the area-integral transformation:

Ω=lightcosαd2dA

Uniform-hemisphere sampling estimates it from how often a random direction happens to hit the light:

Ω^hemi=2πNi=1N1[ray hits light]

Light-PDF sampling directly selects points on the light and calculates their contributions:

Ω^light=1Ni=1NcosαiAdi2
r309-light-sampling/src/lib.rs
rust
/// Returns true if a ray from P in direction (dx, dy, dz) hits the light.
fn ray_hits_light(dx: f64, dy: f64, dz: f64) -> bool {
    if dy < 1e-10 { return false; }
    let t = (LIGHT_Y - P[1]) / dy;
    let x = P[0] + dx * t;
    let z = P[2] + dz * t;
    x >= LIGHT_X0 && x <= LIGHT_X1 && z >= LIGHT_Z0 && z <= LIGHT_Z1
}

To calculate one sample's contribution under the light PDF, choose a random point q=(qx,ylight,qz) on the light:

r309-light-sampling/src/lib.rs
rust
let qx = LIGHT_X0 + rng_l.next_f64() * (LIGHT_X1 - LIGHT_X0);
let qz = LIGHT_Z0 + rng_l.next_f64() * (LIGHT_Z1 - LIGHT_Z0);
let tx = qx - P[0];
let ty = LIGHT_Y - P[1]; // always positive
let tz = qz - P[2];
let d2 = tx * tx + ty * ty + tz * tz;
let cos_alpha = ty / d2.sqrt();
if cos_alpha > 1e-10 {
    // contribution = cos(α) · A / d²  (= 1 / p_dir)
    sum_l += cos_alpha * LIGHT_AREA / d2;
}

generate_report displays both estimates side by side at six checkpoints, N=10,100,,106. Because the light covers only about 0.7% of the hemisphere's area, the uniform-hemisphere estimate remains nearly zero through approximately N=104, while the light PDF converges steadily even at small N.

Differences Between C++ and Rust

The C++ version first verifies the behavior by hard-coding light sampling directly into ray_color():

cpp
auto on_light = point3(random_double(213,343), 554, random_double(227,332));
auto to_light = on_light - rec.p;
auto distance_squared = to_light.length_squared();
to_light = unit_vector(to_light);

if (dot(to_light, rec.normal) < 0)
    return emitted;

double light_area = (343-213)*(332-227);
auto light_cosine = fabs(to_light.y());
if (light_cosine < 0.000001)
    return emitted;

pdf = distance_squared / (light_cosine * light_area);
scattered = ray(rec.p, to_light, r.time());

The C++ version defines flip_face and cosine_pdf as follows:

cpp
// flip_face: emit only on the front face
if (rec.front_face)
    return emit->value(u, v, p);
else
    return color(0,0,0);

// cosine_pdf: PDF object using an ONB
class cosine_pdf : public pdf {
  public:
    cosine_pdf(const vec3& w) { uvw.build_from_w(w); }
    double value(const vec3& direction) const override {
        auto cosine = dot(unit_vector(direction), uvw.w());
        return (cosine <= 0) ? 0 : cosine/pi;
    }
    vec3 generate() const override {
        return uvw.local(random_cosine_direction());
    }
    onb uvw;
};

The Rust version keeps this chapter to a numerical demonstration; the PDF trait and flip_face are implemented with the Cornell-box renderer. A Rust trait represents the interface corresponding to the abstract C++ pdf base class.

r309-light-sampling/src/lib.rs
rust
use std::f64::consts::PI;

struct XorShift64 {
    state: u64,
}

impl XorShift64 {
    fn new(seed: u64) -> Self {
        let state = if seed == 0 { 0x9e3779b97f4a7c15 } else { seed };
        Self { state }
    }

    fn next_u64(&mut self) -> u64 {
        let mut x = self.state;
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        self.state = x;
        x
    }

    fn next_f64(&mut self) -> f64 {
        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
    }
}

// Rectangular light: x ∈ [213, 343], z ∈ [227, 332], y = 554
const LIGHT_X0: f64 = 213.0;
const LIGHT_X1: f64 = 343.0;
const LIGHT_Z0: f64 = 227.0;
const LIGHT_Z1: f64 = 332.0;
const LIGHT_Y: f64 = 554.0;
const LIGHT_AREA: f64 = (LIGHT_X1 - LIGHT_X0) * (LIGHT_Z1 - LIGHT_Z0); // = 13650.0

// Hit point: center of Cornell box floor
const P: [f64; 3] = [278.0, 0.0, 279.5];

fn random_uniform_hemi(rng: &mut XorShift64) -> (f64, f64, f64) {
    let r1 = rng.next_f64();
    let r2 = rng.next_f64();
    let y = 1.0 - r2; // cos(θ) = 1 - r2, so y ∈ (0, 1]
    let r = (1.0 - y * y).sqrt();
    let phi = 2.0 * PI * r1;
    (phi.cos() * r, y, phi.sin() * r)
}

/// Returns true if a ray from P in direction (dx, dy, dz) hits the light.
fn ray_hits_light(dx: f64, dy: f64, dz: f64) -> bool {
    if dy < 1e-10 {
        return false;
    }
    let t = (LIGHT_Y - P[1]) / dy;
    let x = P[0] + dx * t;
    let z = P[2] + dz * t;
    x >= LIGHT_X0 && x <= LIGHT_X1 && z >= LIGHT_Z0 && z <= LIGHT_Z1
}

pub fn generate_report() -> String {
    let mut out = String::new();

    out.push_str("=== Estimate the Solid Angle of the Cornell-Box Light ===\n");
    out.push_str(&format!("  hit point p = ({}, {}, {})\n", P[0], P[1], P[2]));
    out.push_str(&format!(
        "  light: x ∈ [{}, {}], z ∈ [{}, {}], y = {}\n",
        LIGHT_X0, LIGHT_X1, LIGHT_Z0, LIGHT_Z1, LIGHT_Y
    ));
    out.push_str(&format!("  light area A = {:.1}\n\n", LIGHT_AREA));

    let checkpoints: [usize; 6] = [10, 100, 1_000, 10_000, 100_000, 1_000_000];
    let n_total = *checkpoints.last().unwrap();

    let mut hemi_est = Vec::with_capacity(checkpoints.len());
    let mut light_est = Vec::with_capacity(checkpoints.len());

    let mut rng_h = XorShift64::new(0x1a2b_3c4d_0001);
    let mut rng_l = XorShift64::new(0x5e6f_7a8b_0001);
    let mut count_h = 0usize;
    let mut sum_l = 0.0f64;
    let mut cp_idx = 0usize;

    for i in 0..n_total {
        let (dx, dy, dz) = random_uniform_hemi(&mut rng_h);
        if ray_hits_light(dx, dy, dz) {
            count_h += 1;
        }

        let qx = LIGHT_X0 + rng_l.next_f64() * (LIGHT_X1 - LIGHT_X0);
        let qz = LIGHT_Z0 + rng_l.next_f64() * (LIGHT_Z1 - LIGHT_Z0);
        let tx = qx - P[0];
        let ty = LIGHT_Y - P[1]; // always positive
        let tz = qz - P[2];
        let d2 = tx * tx + ty * ty + tz * tz;
        let d = d2.sqrt();
        let cos_alpha = ty / d; // angle at light face
        if cos_alpha > 1e-10 {
            // contribution = cos(α) · A / d²  (= 1 / p_dir)
            sum_l += cos_alpha * LIGHT_AREA / d2;
        }

        let n = i + 1;
        if cp_idx < checkpoints.len() && n == checkpoints[cp_idx] {
            hemi_est.push(2.0 * PI * count_h as f64 / n as f64);
            light_est.push(sum_l / n as f64);
            cp_idx += 1;
        }
    }

    out.push_str("=== Convergence Comparison: Estimated Solid Angle Ω (sr) ===\n");
    out.push_str(&format!(
        "{:>10}  {:>22}  {:>22}\n",
        "N", "uniform hemisphere", "light PDF sampling"
    ));
    out.push_str(&format!("{:->10}  {:->22}  {:->22}\n", "", "", ""));
    for (i, &n) in checkpoints.iter().enumerate() {
        out.push_str(&format!(
            "{:>10}  {:>22.8}  {:>22.8}\n",
            n, hemi_est[i], light_est[i]
        ));
    }

    out
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Light PDF estimator converges to the correct solid angle.
    #[test]
    fn light_pdf_converges() {
        let n = 500_000;
        let mut rng = XorShift64::new(0xdead_beef_0001);
        let mut sum = 0.0f64;
        for _ in 0..n {
            let qx = LIGHT_X0 + rng.next_f64() * (LIGHT_X1 - LIGHT_X0);
            let qz = LIGHT_Z0 + rng.next_f64() * (LIGHT_Z1 - LIGHT_Z0);
            let tx = qx - P[0];
            let ty = LIGHT_Y - P[1];
            let tz = qz - P[2];
            let d2 = tx * tx + ty * ty + tz * tz;
            let d = d2.sqrt();
            let cos_alpha = ty / d;
            if cos_alpha > 1e-10 {
                sum += cos_alpha * LIGHT_AREA / d2;
            }
        }
        let omega = sum / n as f64;
        // Solid angle should be roughly 0.04–0.06 sr (small light far away)
        assert!(
            omega > 0.03 && omega < 0.07,
            "light PDF solid angle estimate {omega:.6} out of expected range"
        );
    }

    /// Hemisphere estimator converges to same solid angle (needs many samples).
    #[test]
    fn hemi_converges_same_as_light_pdf() {
        let n = 1_000_000;
        let mut rng_l = XorShift64::new(0xdead_beef_0002);
        let mut sum_l = 0.0f64;
        for _ in 0..n {
            let qx = LIGHT_X0 + rng_l.next_f64() * (LIGHT_X1 - LIGHT_X0);
            let qz = LIGHT_Z0 + rng_l.next_f64() * (LIGHT_Z1 - LIGHT_Z0);
            let tx = qx - P[0];
            let ty = LIGHT_Y - P[1];
            let tz = qz - P[2];
            let d2 = tx * tx + ty * ty + tz * tz;
            let d = d2.sqrt();
            let cos_alpha = ty / d;
            if cos_alpha > 1e-10 {
                sum_l += cos_alpha * LIGHT_AREA / d2;
            }
        }
        let omega_light = sum_l / n as f64;

        let mut rng_h = XorShift64::new(0xdead_beef_0003);
        let count_h: usize = (0..n)
            .filter(|_| {
                let (dx, dy, dz) = random_uniform_hemi(&mut rng_h);
                ray_hits_light(dx, dy, dz)
            })
            .count();
        let omega_hemi = 2.0 * PI * count_h as f64 / n as f64;

        // Both should agree within 5%
        let rel_err = (omega_hemi - omega_light).abs() / omega_light;
        assert!(
            rel_err < 0.05,
            "hemi={omega_hemi:.6} vs light={omega_light:.6}, rel_err={rel_err:.4}"
        );
    }
}

Results in the Browser

Because the light occupies only about 0.7% of the upper hemisphere's area 2π, the uniform-hemisphere estimate remains nearly zero through approximately N=104. Light-PDF sampling is already stable at N=10, demonstrating that it requires far fewer samples to reach the same accuracy.

Rendering the Cornell Box

The r309-cornell-box crate renders a Cornell box with one-sided emission implemented by FlipFace. Because DiffuseLight::emitted checks rec.front_face, light does not leak through gaps in the ceiling.

Summary

  • I derived the transformation from the light's area PDF pq=1/A to the directional PDF p(direction)=d2/(cosαA).
  • Uniform-hemisphere sampling becomes increasingly inefficient as the light shrinks, while sampling the light directly dramatically reduces variance.
  • I implemented one-sided emission with flip_face and examined how the cosine_pdf class encapsulates ONB-based sampling as a PDF object.

The next chapter introduces a mixture PDF that combines cosine_pdf with the light PDF and integrates it into the Cornell-box renderer.