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

12. Cleaning Up PDF Management

Ray Tracing: The Rest of Your Life (v3.2.3): 12 Cleaning Up PDF Management / 3.12 Cleaning Up PDF Management

This chapter resolves the two problems identified previously: the hard-coded CosinePdf and the incompatible handling of specular and diffuse scattering. I add the ScatterRecord enum and scatter_record() method to common, then implement four variations of the Cornell box while evolving the scene from an aluminum and white-box arrangement to one with a glass sphere.

ScatterRecord enum

Add the following enum to common/src/material.rs:

common/src/material.rs
rust
pub enum ScatterRecord {
    /// Specular scatter: the direction is fully determined (Metal, Dielectric).
    Specular { attenuation: Color, ray: Ray },
    /// Diffuse scatter: a PDF object determines the direction (Lambertian).
    Diffuse { attenuation: Color, pdf: Box<dyn Pdf> },
}

The Specular variant represents a fully determined scattering direction, as used by Metal and Dielectric. The Diffuse variant represents a direction determined by a PDF object, as used by Lambertian.

Extending the Material Trait

Leave scatter() unchanged and add a new scatter_record() method:

common/src/material.rs
rust
pub trait Material: Send + Sync {
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)>;

    // New method: the default implementation wraps scatter() as Specular
    fn scatter_record(&self, r_in: &Ray, rec: &HitRecord) -> Option<ScatterRecord> {
        self.scatter(r_in, rec).map(|(attenuation, ray)| {
            ScatterRecord::Specular { attenuation, ray }
        })
    }

    fn emitted(&self, _u: f64, _v: f64, _p: Point3, _front_face: bool) -> Color {
        Color::new(0.0, 0.0, 0.0)
    }

    fn scattering_pdf(&self, _r_in: &Ray, _rec: &HitRecord, _scattered: &Ray) -> f64 {
        0.0
    }
}

Because the default implementation wraps the existing scatter() result in Specular, Metal, Dielectric, and Isotropic continue to work without any awareness of the new method.

Overriding Lambertian::scatter_record()

Only Lambertian overrides the method, returning a Diffuse variant containing a CosinePdf:

common/src/material.rs
rust
impl Material for Lambertian {
    // Keep the existing scatter() implementation unchanged

    fn scatter_record(&self, _r_in: &Ray, rec: &HitRecord) -> Option<ScatterRecord> {
        let attenuation = self.albedo.value(rec.u, rec.v, rec.p);
        Some(ScatterRecord::Diffuse {
            attenuation,
            pdf: Box::new(CosinePdf::new(rec.normal)),
        })
    }
}

As a result, ray_color no longer needs direct knowledge of CosinePdf.

Updating ray_color

ray_color now matches on the result of scatter_record():

r311-cornell-box/src/lib.rs
rust
let emitted = mat.emitted(rec.u, rec.v, rec.p, rec.front_face);
match mat.scatter_record(r, &rec) {
    None => emitted,
    Some(ScatterRecord::Specular { attenuation, ray }) => {
        emitted + attenuation * ray_color(&ray, background, world, lights, depth - 1)
    }
    Some(ScatterRecord::Diffuse { attenuation, pdf: mat_pdf }) => {
        let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
        let mixed = MixturePdf::new(lp, mat_pdf);
        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 sp = mat.scattering_pdf(r, &rec, &scattered);
        emitted + attenuation * sp / pdf_val
            * ray_color(&scattered, background, world, lights, depth - 1)
    }
}

CosinePdf no longer appears in the imports for ray_color:

r311-cornell-box/src/lib.rs
rust
use common::{
    Camera, Color, DiffuseLight, FlipFace, Hittable, HittablePdf, HittableList,
    Lambertian, Metal, MixturePdf, Pdf, Point3, Ray, RectBox, RotateY, ScatterRecord,
    Translate, Vec3, XyRect, XzRect, YzRect, random_double, write_color_gamma,
};
// CosinePdf does not appear here

Figure 3.16: Aluminum and White Boxes

Place a tall aluminum (Metal) box beside a short white Lambertian box. This scene verifies that both ScatterRecord variants—specular and diffuse—work correctly:

r311-cornell-box/src/lib.rs
rust
// Tall aluminum box.
let aluminum = Arc::new(Metal::new(Color::new(0.8, 0.85, 0.88), 0.0));
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),
    aluminum,
));
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);

// Short white Lambertian box.
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);

// Only the ceiling light is sampled for importance sampling.
let lights: Arc<dyn Hittable> =
    Arc::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light));

The aluminum box with fuzz = 0.0 follows the perfectly specular Specular path, while the white box follows the Lambertian Diffuse path.

The Sphere PDF (Figure 3.17)

Next, replace the short white box with a glass sphere. Adding that sphere to lights for importance sampling requires implementations of Sphere::pdf_value() and Sphere::random().

Consider the cone subtended by a sphere of radius r at squared distance d2 from origin, as shown in Figure 3.17. Its maximum cosine is

cosθmax=1r2d2

The solid angle is Ω=2π(1cosθmax), so the PDF value of a uniform distribution over it is 1/Ω.

Implementing Sphere::pdf_value()

common/src/sphere.rs
rust
fn pdf_value(&self, origin: Point3, v: Vec3) -> f64 {
    if self.hit(&Ray::new(origin, v), 0.001, f64::INFINITY).is_none() {
        return 0.0;
    }
    let distance_squared = (self.center - origin).length_squared();
    let cos_theta_max = (1.0 - self.radius * self.radius / distance_squared).sqrt();
    let solid_angle = 2.0 * PI * (1.0 - cos_theta_max);
    1.0 / solid_angle
}

Implementing Sphere::random()

Generate a uniformly random direction inside the cone subtended by the sphere:

common/src/sphere.rs
rust
fn random(&self, origin: Point3) -> Vec3 {
    let direction = self.center - origin;
    let distance_squared = direction.length_squared();
    let uvw = Onb::build_from_w(direction);
    uvw.local_vec(random_to_sphere(self.radius, distance_squared))
}

The helper function random_to_sphere generates a direction within the cone in local coordinates:

common/src/sphere.rs
rust
fn random_to_sphere(radius: f64, distance_squared: f64) -> Vec3 {
    let r1 = random_double();
    let r2 = random_double();
    let cos_theta_max = (1.0 - radius * radius / distance_squared).sqrt();
    let z = 1.0 + r2 * (cos_theta_max - 1.0);
    let phi = 2.0 * PI * r1;
    let sin_theta = (1.0 - z * z).max(0.0).sqrt();
    Vec3::new(phi.cos() * sin_theta, phi.sin() * sin_theta, z)
}

The expression z = 1 + r2*(cos_theta_max - 1) samples [cosθmax,1] uniformly, and Onb::local_vec transforms the result into world coordinates.

Figure 3.18: Using Only the Glass Sphere as a Light Target

Replace the short white box with a glass sphere of refractive index 1.5 and specify only that sphere in lights. The scene combines a tall white Lambertian box with the glass sphere:

r312-cornell-box/src/lib.rs
rust
// Tall white Lambertian box.
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,
));
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);

// Glass sphere.
let glass = Arc::new(Dielectric::new(1.5));
objects.add(Box::new(Sphere::with_material(
    Point3::new(190.0, 90.0, 190.0),
    90.0,
    glass,
)));

// Sphere PDF only — no ceiling XzRect in lights yet.
let lights: Arc<dyn Hittable> = Arc::new(Sphere::with_material(
    Point3::new(190.0, 90.0, 190.0),
    90.0,
    Arc::new(Dielectric::new(1.5)),
));

HittableList::pdf_value and HittableList::random

To include both the glass sphere and the ceiling XzRect in lights, add HittableList::pdf_value() and HittableList::random() to common/src/hittable_list.rs:

common/src/hittable_list.rs
rust
fn pdf_value(&self, origin: Point3, v: Vec3) -> f64 {
    if self.objects.is_empty() {
        return 0.0;
    }
    let weight = 1.0 / self.objects.len() as f64;
    self.objects.iter().map(|o| weight * o.pdf_value(origin, v)).sum()
}

fn random(&self, origin: Point3) -> Vec3 {
    if self.objects.is_empty() {
        return Vec3::new(1.0, 0.0, 0.0);
    }
    let idx = random_int(0, self.objects.len() as i32 - 1) as usize;
    self.objects[idx].random(origin)
}

pdf_value() returns the average PDF value across all objects, while random() selects one object with equal probability and returns a direction sampled from it.

Figure 3.19: Adding the Glass Sphere to the Sampling Targets

To sample the glass sphere as well, store both the ceiling XzRect and the sphere in a HittableList and use it as lights:

r312-cornell-box/src/lib.rs
rust
// Lights: ceiling rect + glass sphere (both sampled for importance sampling).
let mut lights = HittableList::new();
lights.add(Box::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light)));
lights.add(Box::new(Sphere::with_material(
    Point3::new(190.0, 90.0, 190.0),
    90.0,
    Arc::new(Dielectric::new(1.5)),
)));
let lights: Arc<dyn Hittable> = Arc::new(lights);

Removing Acne (Figure 3.20)

In the original C++ progression, the Figure 3.19 render develops acne, or bright speckles. Because Sphere::pdf_value() is not yet implemented there, mixed.value(direction) becomes nearly zero and the following division makes the radiance explode:

emitted + attenuation * sp / pdf_val  // pdf_val ≈ 0 → ∞ → acne

The pdf_val guard in ray_color removes the acne in Figure 3.20:

r312-cornell-box/src/lib.rs
rust
let pdf_val = mixed.value(direction);
if pdf_val < 1e-10 {
    return emitted;
}

Differences Between C++ and Rust

The C++ version changes the signature of scatter() to accept a scatter_record structure as an output argument, requiring all materials to be rewritten at once:

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

virtual bool scatter(
    const ray& r_in, const hit_record& rec, scatter_record& srec) const;

The principal differences in the Rust version are:

AspectC++Rust
Specular/diffuse distinctionis_specular BooleanSpecular / Diffuse variants (type-safe)
PDF storageshared_ptr<pdf>Box<dyn Pdf>
Effect on existing codeBreaking scatter() change to every materialAdditive scatter_record() method
Exhaustiveness guaranteeNoneCompile-time exhaustive pattern checking
Acne in Figure 3.19Occurs because Sphere::pdf_value() is missingDoes not occur because it is already implemented

By adding scatter_record() without changing scatter(), every demo crate from Books 1 and 2 continues to build unchanged.

The Rust version implements Sphere::pdf_value() and Sphere::random() together at Figure 3.18, so the acne does not appear in Figure 3.19. Figures 3.19 and 3.20 therefore look almost identical, although the architectural difference—the absence or presence of the guard—remains.

Complete Implementations

r311-cornell-box/src/lib.rs
rust
use std::sync::Arc;
use common::{
    Camera, Color, DiffuseLight, FlipFace, Hittable, HittablePdf, HittableList,
    Lambertian, Metal, MixturePdf, Pdf, Point3, Ray, RectBox, RotateY, ScatterRecord,
    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_record(r, &rec) {
        None => emitted,
        Some(ScatterRecord::Specular { attenuation, ray }) => {
            emitted + attenuation * ray_color(&ray, background, world, lights, depth - 1)
        }
        Some(ScatterRecord::Diffuse { attenuation, pdf: mat_pdf }) => {
            let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
            let mixed = MixturePdf::new(lp, mat_pdf);
            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 sp = mat.scattering_pdf(r, &rec, &scattered);
            emitted + attenuation * sp / pdf_val
                * ray_color(&scattered, background, world, lights, depth - 1)
        }
    }
}

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)));

    objects.add(Box::new(FlipFace::new(Box::new(
        XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light.clone()),
    ))));

    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())));

    // Tall aluminum box.
    let aluminum = Arc::new(Metal::new(Color::new(0.8, 0.85, 0.88), 0.0));
    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),
        aluminum,
    ));
    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);

    // Short white Lambertian box.
    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);

    // Only the ceiling light is sampled for importance sampling.
    let lights: Arc<dyn Hittable> =
        Arc::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light));

    (objects, lights)
}

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 camera = Camera::new_with_shutter(
        lookfrom, lookat, vup, 40.0, aspect_ratio, 0.0, 10.0, 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
}
r312-cornell-box/src/lib.rs
rust
use std::sync::Arc;
use common::{
    Camera, Color, Dielectric, DiffuseLight, FlipFace, Hittable, HittablePdf, HittableList,
    Lambertian, MixturePdf, Pdf, Point3, Ray, ScatterRecord,
    Sphere, 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_record(r, &rec) {
        None => emitted,
        Some(ScatterRecord::Specular { attenuation, ray }) => {
            emitted + attenuation * ray_color(&ray, background, world, lights, depth - 1)
        }
        Some(ScatterRecord::Diffuse { attenuation, pdf: mat_pdf }) => {
            let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
            let mixed = MixturePdf::new(lp, mat_pdf);
            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 sp = mat.scattering_pdf(r, &rec, &scattered);
            emitted + attenuation * sp / pdf_val
                * ray_color(&scattered, background, world, lights, depth - 1)
        }
    }
}

// ray_color without the near-zero PDF guard (used for Figure 3.19).
fn ray_color_no_guard(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_record(r, &rec) {
        None => emitted,
        Some(ScatterRecord::Specular { attenuation, ray }) => {
            emitted + attenuation * ray_color_no_guard(&ray, background, world, lights, depth - 1)
        }
        Some(ScatterRecord::Diffuse { attenuation, pdf: mat_pdf }) => {
            let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
            let mixed = MixturePdf::new(lp, mat_pdf);
            let direction = mixed.generate();
            let scattered = Ray::with_time(rec.p, direction, r.time());
            let pdf_val = mixed.value(direction);
            let sp = mat.scattering_pdf(r, &rec, &scattered);
            emitted + attenuation * sp / pdf_val
                * ray_color_no_guard(&scattered, background, world, lights, depth - 1)
        }
    }
}

fn make_world() -> HittableList {
    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)));

    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())));

    // Tall white Lambertian box.
    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,
    ));
    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 glass = Arc::new(Dielectric::new(1.5));
    objects.add(Box::new(Sphere::with_material(
        Point3::new(190.0, 90.0, 190.0),
        90.0,
        glass,
    )));

    objects
}

fn make_camera(aspect_ratio: f64) -> Camera {
    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);
    Camera::new_with_shutter(lookfrom, lookat, vup, 40.0, aspect_ratio, 0.0, 10.0, 0.0, 1.0)
}

fn render(
    world: &HittableList,
    lights: Arc<dyn Hittable>,
    camera: &Camera,
    image_width: i32,
    image_height: i32,
    samples_per_pixel: i32,
    max_depth: i32,
    use_guard: bool,
) -> String {
    let background = Color::new(0.0, 0.0, 0.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);
                let c = if use_guard {
                    ray_color(&r, background, world, lights.clone(), max_depth)
                } else {
                    ray_color_no_guard(&r, background, world, lights.clone(), max_depth)
                };
                pixel_color += c;
            }
            output.push_str(&format!(
                "{}\n",
                write_color_gamma(pixel_color, samples_per_pixel)
            ));
        }
    }
    output
}

// Figure 3.18: glass sphere only scene; lights = glass sphere (sphere PDF only).
pub fn render_fig18() -> String {
    let aspect_ratio = 1.0_f64;
    let image_width = 300_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;

    let world = make_world();
    let lights: Arc<dyn Hittable> = Arc::new(Sphere::with_material(
        Point3::new(190.0, 90.0, 190.0),
        90.0,
        Arc::new(Dielectric::new(1.5)),
    ));
    let camera = make_camera(aspect_ratio);

    render(&world, lights, &camera, image_width, image_height, 200, 50, true)
}

// Figure 3.19: glass sphere scene; lights = ceiling XzRect + glass sphere, no acne guard.
pub fn render_fig19() -> String {
    let aspect_ratio = 1.0_f64;
    let image_width = 300_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;

    let world = make_world();
    let light = Arc::new(DiffuseLight::new(Color::new(15.0, 15.0, 15.0)));
    let mut lights_list = HittableList::new();
    lights_list.add(Box::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light)));
    lights_list.add(Box::new(Sphere::with_material(
        Point3::new(190.0, 90.0, 190.0),
        90.0,
        Arc::new(Dielectric::new(1.5)),
    )));
    let lights: Arc<dyn Hittable> = Arc::new(lights_list);
    let camera = make_camera(aspect_ratio);

    render(&world, lights, &camera, image_width, image_height, 200, 50, false)
}

// Figure 3.20: glass sphere scene; lights = ceiling XzRect + glass sphere, with acne guard.
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 world = make_world();
    let light = Arc::new(DiffuseLight::new(Color::new(15.0, 15.0, 15.0)));
    let mut lights_list = HittableList::new();
    lights_list.add(Box::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light)));
    lights_list.add(Box::new(Sphere::with_material(
        Point3::new(190.0, 90.0, 190.0),
        90.0,
        Arc::new(Dielectric::new(1.5)),
    )));
    let lights: Arc<dyn Hittable> = Arc::new(lights_list);
    let camera = make_camera(aspect_ratio);

    render(&world, lights, &camera, image_width, image_height, 200, 50, true)
}

Summary

  • I added the ScatterRecord enum with Specular and Diffuse variants to common, distinguishing specular from diffuse scattering at the type level.
  • I added scatter_record() as a new method while leaving the existing scatter() unchanged. Only Lambertian overrides it to return a Diffuse variant containing a CosinePdf; Metal, Dielectric, and Isotropic use the default implementation that wraps their results in Specular.
  • Direct construction of CosinePdf disappeared from ray_color, resolving the leakage of material knowledge into the renderer.
  • I implemented Sphere::pdf_value() and Sphere::random() in common/src/sphere.rs, allowing a sphere in lights to be importance-sampled with the solid-angle PDF 1/[2π(1cosθmax)].
  • I added HittableList::pdf_value() and HittableList::random() to common, allowing multiple sampling targets—the ceiling XzRect and glass sphere—to be managed together in one HittableList.
  • The pdf_val < 1e-10 guard removes bright-speckle acne caused by a near-zero PDF.
  • I implemented four Cornell-box variations in r311-cornell-box for Figure 3.16 and r312-cornell-box for Figures 3.18, 3.19, and 3.20.