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

11. Some Architectural Decisions

Ray Tracing: The Rest of Your Life (v3.2.3): 11 Some Architectural Decisions / 3.11 Some Architectural Decisions

The previous chapter completed the Cornell-box renderer, but closer inspection reveals two remaining structural problems. Both arise from the same fundamental mixing of responsibilities: the renderer holds information that should belong to the material. This chapter identifies those problems and develops an implementation plan for the next chapter.

Problem 1: Hard-Coded PDFs in ray_color

Consider the diffuse-scattering path in ray_color from the previous chapter's r310-cornell-box:

r310-cornell-box/src/lib.rs
rust
// 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));  // Knowledge about the material
let mixed = MixturePdf::new(lp, cp);

Constructing CosinePdf::new(rec.normal) relies on the knowledge that a Lambertian material uses a cosine-weighted PDF. This knowledge belongs to Lambertian itself, not to the renderer's ray_color function. Adding another diffuse material, such as one with a more complex BRDF, would otherwise require modifying ray_color again.

PDF construction should be fully encapsulated by the material's scatter() operation.

Problem 2: Incompatibility Between Specular Scattering and PDFs

In the current design, scatter() always returns (attenuation, ray), while a 0.0 return value from scattering_pdf() distinguishes specular from diffuse scattering:

r310-cornell-box/src/lib.rs
rust
Some((attenuation, specular_ray)) => {
    let sp = mat.scattering_pdf(r, &rec, &specular_ray);
    if sp <= 0.0 {
        // Specular: use the direction returned by scatter directly
        emitted + attenuation * ray_color(&specular_ray, ...)
    } else {
        // Diffuse: discard the scatter direction and replace it with MixturePdf
        let cp = Box::new(CosinePdf::new(rec.normal));
        ...
    }
}

The diffuse path unconditionally discards the direction generated by Lambertian::scatter() and replaces it with the result of MixturePdf::generate(). Furthermore, reusing the 0.0 return value of scattering_pdf() as a specular flag conflates that role with the method's actual purpose of returning a PDF value.

Specular and diffuse scattering are fundamentally different and should be distinguished at the type level.

The Solution: ScatterRecord

The C++ version introduces a scatter_record structure and changes scatter() to output it. Rust can express the distinction more clearly with an enum:

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> },
}

Because each enum variant represents specular or diffuse scattering in the type itself, neither a Boolean flag nor a scattering_pdf() test is necessary.

The Rust Design

I leave the existing scatter() signature unchanged. To preserve backward compatibility with every existing crate, I add a new scatter_record() method:

common/src/material.rs
rust
pub trait Material: Send + Sync {
    // Existing method (unchanged)
    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 }
        })
    }
    // ...
}

Only Lambertian overrides scatter_record(), returning the Diffuse variant with a CosinePdf:

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

    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)),
        })
    }
}

Metal, Dielectric, and Isotropic continue to work through the default implementation, which wraps the old scatter() result in Specular.

This removes the construction of CosinePdf from ray_color in r312-cornell-box:

rust
// r310: ray_color knew about CosinePdf
let cp = Box::new(CosinePdf::new(rec.normal));  // Removed

// r312: the material supplies its PDF
match mat.scatter_record(r, &rec) {
    Some(ScatterRecord::Specular { attenuation, ray }) => {
        emitted + attenuation * ray_color(&ray, ...)
    }
    Some(ScatterRecord::Diffuse { attenuation, pdf: mat_pdf }) => {
        let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
        let mixed = MixturePdf::new(lp, mat_pdf);  // CosinePdf arrives here
        ...
    }
    None => emitted,
}

Differences Between C++ and Rust

The C++ version designs scatter_record as an output argument with fields and changes the signature of scatter():

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

// The scatter() signature changes (a breaking change)
virtual bool scatter(
    const ray& r_in, const hit_record& rec, scatter_record& srec) const;

Every material's scatter() implementation must be rewritten at once.

The Rust version differs as follows:

AspectC++Rust
Specular/diffuse distinctionis_specular BooleanEnum variants (type-safe)
PDF storageshared_ptr<pdf>Box<dyn Pdf>
Effect on existing codeBreaking change to every materialAdditive scatter_record() method
Existing cratesAll require updatesNo changes required

Rust's enum pattern matching guarantees exhaustive handling at compile time, eliminating the Boolean flag. Adding scatter_record() without changing scatter() also allows every demo crate from Books 1 and 2 to continue working unchanged.

Summary

  • I identified the hard-coded construction of CosinePdf in ray_color, a design problem in which material knowledge leaks into the renderer.
  • I identified that the return value of scatter() cannot distinguish specular from diffuse scattering at the type level. The 0.0 return value from scattering_pdf() was being repurposed as a flag.
  • The Rust design introduces the ScatterRecord enum with Specular and Diffuse variants through a new scatter_record() method. The existing scatter() method and all existing crates remain unchanged.
  • The next chapter implements ScatterRecord in common/ and builds a new Cornell-box scene combining an aluminum box with a glass sphere.