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

10. Mixture Densities

Ray Tracing: The Rest of Your Life (v3.2.3): 10 Mixture Densities / 3.10 Mixture Densities

The previous chapter introduced a PDF plight that samples the light directly. Relying on the light PDF alone, however, can increase variance where indirect illumination from other directions dominates. A mixture PDF combining it with the cosine-weighted PDF pcosine converges reliably in either extreme.

This chapter covers four topics:

  • The mathematics of mixture PDFs: pmix(d)=12p0(d)+12p1(d)
  • Designing the Pdf trait with CosinePdf, HittablePdf, and MixturePdf
  • The role of scattering_pdf() in distinguishing specular from diffuse scattering
  • Integration into the Cornell box through the r310-cornell-box crate

The Idea Behind Mixture PDFs

Define a mixture of two PDFs, p0 and p1, with equal 50:50 weights:

pmix(d)=12p0(d)+12p1(d)

To sample it, generate direction d from p0 with probability 12 and from p1 otherwise, then evaluate the probability density of the resulting direction with pmix(d). The important point is that evaluation always uses the mixture-PDF value, regardless of which component generated the direction.

The MC estimator for a Lambertian surface is

Loρcosθ/πpmix(d)Li(d)

Here ρ is the albedo, cosθ/π is the Lambertian BRDF multiplied by cosθ—the value returned by scattering_pdf() in the code—and pmix(d) is the mixture-PDF value.

Designing the Pdf Trait

Three types are implemented in common/src/pdf.rs.

common/src/pdf.rs
rust
pub trait Pdf: Send + Sync {
    fn value(&self, direction: Vec3) -> f64;
    fn generate(&self) -> Vec3;
}

CosinePdf samples cosine-weighted directions using the ONB (Onb) introduced in the previous chapter:

common/src/pdf.rs
rust
pub struct CosinePdf { uvw: Onb }

impl Pdf for CosinePdf {
    fn value(&self, direction: Vec3) -> f64 {
        let cosine = dot(unit_vector(direction), self.uvw.w);
        if cosine <= 0.0 { 0.0 } else { cosine / PI }
    }
    fn generate(&self) -> Vec3 {
        self.uvw.local_vec(random_cosine_direction())
    }
}

HittablePdf delegates to the pdf_value and random methods of Hittable. With these methods implemented for XzRect in common/src/aarect.rs, wrapping the rectangular light in HittablePdf is sufficient to obtain its light PDF:

common/src/pdf.rs
rust
pub struct HittablePdf {
    pub obj: Arc<dyn Hittable>,
    pub origin: Point3,
}

impl Pdf for HittablePdf {
    fn value(&self, direction: Vec3) -> f64 {
        self.obj.pdf_value(self.origin, direction)
    }
    fn generate(&self) -> Vec3 {
        self.obj.random(self.origin)
    }
}

MixturePdf stores two Box<dyn Pdf> values and implements an equally weighted mixture:

common/src/pdf.rs
rust
pub struct MixturePdf { p: [Box<dyn Pdf>; 2] }

impl Pdf for MixturePdf {
    fn value(&self, direction: Vec3) -> f64 {
        0.5 * self.p[0].value(direction) + 0.5 * self.p[1].value(direction)
    }
    fn generate(&self) -> Vec3 {
        if random_double() < 0.5 { self.p[0].generate() } else { self.p[1].generate() }
    }
}

Modifying ray_color

The ray_color function in the previous chapter's r309-cornell-box hard-coded the light direction directly. In this chapter, it accepts a lights parameter as Arc<dyn Hittable> and uses the return value of scattering_pdf() to distinguish specular from diffuse scattering:

r310-cornell-box/src/lib.rs
rust
fn ray_color(
    r: &Ray, background: Color,
    world: &dyn Hittable,
    lights: Arc<dyn Hittable>,
    depth: i32,
) -> Color {
    // ...
    let sp = mat.scattering_pdf(r, &rec, &specular_ray);
    if sp <= 0.0 {
        // Specular (Metal, Dielectric): use the scatter direction directly.
        emitted + attenuation * ray_color(&specular_ray, background, world, lights, depth - 1)
    } else {
        // Diffuse (Lambertian): mix light PDF and cosine PDF for importance sampling.
        let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
        let cp = Box::new(CosinePdf::new(rec.normal));
        let mixed = MixturePdf::new(lp, cp);
        let direction = mixed.generate();
        let scattered = Ray::with_time(rec.p, direction, r.time());
        let pdf_val = mixed.value(direction);
        if pdf_val < 1e-10 {
            return emitted;
        }
        let sp2 = mat.scattering_pdf(r, &rec, &scattered);
        emitted + attenuation * sp2 / pdf_val
            * ray_color(&scattered, background, world, lights, depth - 1)
    }
}

Materials whose scattering_pdf() returns the default 0.0, namely Metal and Dielectric, are treated as specular and use the direction returned by scatter() directly. Lambertian returns cosθ / π, so it follows the diffuse path and uses the mixture PDF.

The bare XzRect, without a FlipFace wrapper, is passed as lights. FlipFace does not implement pdf_value or random and therefore cannot be used for PDF sampling. The light added to the world remains wrapped in FlipFace, preserving one-sided emission from DiffuseLight::emitted.

Differences Between C++ and Rust

The C++ implementation in Section 3.10 adds a lights parameter of type shared_ptr<hittable> to the Cornell box's ray_color and derives mixture_pdf from the abstract pdf base class. It introduces a scatter_record structure to distinguish scattering paths, unifying storage of the specular ray with ownership of the PDF object:

cpp
struct scatter_record {
    ray specular_ray;
    bool is_specular;
    color attenuation;
    shared_ptr<pdf> pdf_ptr;
};

scatter outputs a scatter_record, and the is_specular flag selects the specular or diffuse path.

The Rust version does not introduce scatter_record. Instead, it branches on the return value of scattering_pdf(): 0.0 means specular and any other value means diffuse. This allows the transition to diffuse scattering without changing the scatter interface. Without a scatter_record, however, the direction generated by Lambertian::scatter is discarded and replaced by the mixture PDF. Consequently, scattering_pdf is called twice through sp and sp2: the former detects specular scattering, and the latter calculates the diffuse contribution.

Rust represents the abstract pdf base class as trait Pdf and uses Box<dyn Pdf> for dynamic dispatch:

cpp
// C++: pass shared_ptr values
auto mixed = mixture_pdf(
    make_shared<hittable_pdf>(lights, rec.p),
    make_shared<cosine_pdf>(rec.normal)
);
rust
// Rust: pass Box<dyn Pdf> values
let mixed = MixturePdf::new(
    Box::new(HittablePdf::new(lights.clone(), rec.p)),
    Box::new(CosinePdf::new(rec.normal)),
);

For passing the light object, C++ uses shared_ptr<hittable> and Rust uses the corresponding Arc<dyn Hittable>.

r310-cornell-box/src/lib.rs
rust
use std::sync::Arc;
use common::{
    Camera, CosinePdf, Color, DiffuseLight, FlipFace, Hittable, HittablePdf, HittableList,
    Lambertian, MixturePdf, Pdf, Point3, Ray, RectBox, RotateY, Translate, Vec3,
    XyRect, XzRect, YzRect, random_double, write_color_gamma,
};

fn ray_color(r: &Ray, background: Color, world: &dyn Hittable, lights: Arc<dyn Hittable>, depth: i32) -> Color {
    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }
    let rec = match world.hit(r, 0.001, f64::INFINITY) {
        Some(rec) => rec,
        None => return background,
    };
    let mat = match &rec.mat {
        Some(mat) => mat.clone(),
        None => return Color::new(0.0, 0.0, 0.0),
    };
    let emitted = mat.emitted(rec.u, rec.v, rec.p, rec.front_face);
    match mat.scatter(r, &rec) {
        None => emitted,
        Some((attenuation, specular_ray)) => {
            let sp = mat.scattering_pdf(r, &rec, &specular_ray);
            if sp <= 0.0 {
                // Specular (Metal, Dielectric): use the scatter direction directly.
                emitted + attenuation * ray_color(&specular_ray, background, world, lights, depth - 1)
            } else {
                // Diffuse (Lambertian): mix light PDF and cosine PDF for importance sampling.
                let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
                let cp = Box::new(CosinePdf::new(rec.normal));
                let mixed = MixturePdf::new(lp, cp);
                let direction = mixed.generate();
                let scattered = Ray::with_time(rec.p, direction, r.time());
                let pdf_val = mixed.value(direction);
                if pdf_val < 1e-10 {
                    return emitted;
                }
                let sp2 = mat.scattering_pdf(r, &rec, &scattered);
                emitted + attenuation * sp2 / pdf_val
                    * ray_color(&scattered, background, world, lights, depth - 1)
            }
        }
    }
}

/// Cornell box with two rotated blocks and a ceiling light.
///
/// Returns `(world, lights)` where `lights` is the bare `XzRect` light (without
/// `FlipFace`) so that `HittablePdf` can call `pdf_value` / `random` on it.
/// The world light is still wrapped in `FlipFace` for correct one-sided emission.
fn cornell_box() -> (HittableList, Arc<dyn Hittable>) {
    let mut objects = HittableList::new();

    let red   = Arc::new(Lambertian::new(Color::new(0.65, 0.05, 0.05)));
    let white = Arc::new(Lambertian::new(Color::new(0.73, 0.73, 0.73)));
    let green = Arc::new(Lambertian::new(Color::new(0.12, 0.45, 0.15)));
    let light = Arc::new(DiffuseLight::new(Color::new(15.0, 15.0, 15.0)));

    objects.add(Box::new(YzRect::new(0.0, 555.0, 0.0, 555.0, 555.0, green)));
    objects.add(Box::new(YzRect::new(0.0, 555.0, 0.0, 555.0, 0.0, red)));

    // Bare XzRect used by HittablePdf for geometric sampling.
    let light_rect: Arc<dyn Hittable> =
        Arc::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light.clone()));
    // FlipFace version in the world so DiffuseLight emits only downward.
    objects.add(Box::new(FlipFace::new(Box::new(
        XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light),
    ))));

    objects.add(Box::new(XzRect::new(0.0, 555.0, 0.0, 555.0, 0.0, white.clone())));
    objects.add(Box::new(XzRect::new(0.0, 555.0, 0.0, 555.0, 555.0, white.clone())));
    objects.add(Box::new(XyRect::new(0.0, 555.0, 0.0, 555.0, 555.0, white.clone())));

    let box1: Box<dyn Hittable> = Box::new(RectBox::new(
        Point3::new(0.0, 0.0, 0.0),
        Point3::new(165.0, 330.0, 165.0),
        white.clone(),
    ));
    let box1: Box<dyn Hittable> = Box::new(RotateY::new(box1, 15.0));
    let box1: Box<dyn Hittable> = Box::new(Translate::new(box1, Vec3::new(265.0, 0.0, 295.0)));
    objects.add(box1);

    let box2: Box<dyn Hittable> = Box::new(RectBox::new(
        Point3::new(0.0, 0.0, 0.0),
        Point3::new(165.0, 165.0, 165.0),
        white,
    ));
    let box2: Box<dyn Hittable> = Box::new(RotateY::new(box2, -18.0));
    let box2: Box<dyn Hittable> = Box::new(Translate::new(box2, Vec3::new(130.0, 0.0, 65.0)));
    objects.add(box2);

    (objects, light_rect)
}

pub fn render_image() -> String {
    let aspect_ratio = 1.0_f64;
    let image_width = 300_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;
    let samples_per_pixel = 200_i32;
    let max_depth = 50_i32;

    let (world, lights) = cornell_box();
    let background = Color::new(0.0, 0.0, 0.0);

    let lookfrom = Point3::new(278.0, 278.0, -800.0);
    let lookat   = Point3::new(278.0, 278.0, 0.0);
    let vup      = Point3::new(0.0, 1.0, 0.0);
    let dist_to_focus = 10.0_f64;
    let aperture      = 0.0_f64;

    let camera = Camera::new_with_shutter(
        lookfrom, lookat, vup, 40.0, aspect_ratio, aperture, dist_to_focus, 0.0, 1.0,
    );

    let mut output = String::new();
    output.push_str("P3\n");
    output.push_str(&format!("{} {}\n", image_width, image_height));
    output.push_str("255\n");

    for j in (0..image_height).rev() {
        for i in 0..image_width {
            let mut pixel_color = Color::new(0.0, 0.0, 0.0);
            for _ in 0..samples_per_pixel {
                let u = (i as f64 + random_double()) / (image_width - 1) as f64;
                let v = (j as f64 + random_double()) / (image_height - 1) as f64;
                let r = camera.get_ray(u, v);
                pixel_color += ray_color(&r, background, &world, lights.clone(), max_depth);
            }
            output.push_str(&format!(
                "{}\n",
                write_color_gamma(pixel_color, samples_per_pixel)
            ));
        }
    }

    output
}

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

    #[test]
    fn render_produces_valid_ppm() {
        let output = render_image();
        let mut lines = output.lines();
        assert_eq!(lines.next(), Some("P3"));
        let dims = lines.next().expect("missing dimensions line");
        let parts: Vec<&str> = dims.split_whitespace().collect();
        assert_eq!(parts.len(), 2);
        let w: i32 = parts[0].parse().expect("bad width");
        let h: i32 = parts[1].parse().expect("bad height");
        assert!(w > 0 && h > 0);
        assert_eq!(lines.next(), Some("255"));
    }
}

Summary

  • I introduced the equally weighted mixture pmix(d)=12p0(d)+12p1(d), providing importance sampling that efficiently covers both the light direction and the entire hemisphere.
  • I implemented CosinePdf, HittablePdf, and MixturePdf in common/ around the Pdf trait's value and generate methods. HittablePdf delegates to XzRect::pdf_value and XzRect::random.
  • I treat a 0.0 return from Material::scattering_pdf() as specular scattering, preserving the previous behavior, and any other value as diffuse scattering that uses the mixture PDF. This incrementally improves diffuse scattering without changing the scatter interface.
  • Because FlipFace has no pdf_value or random implementation, lights receives the bare XzRect without a FlipFace wrapper.