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:
// 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:
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:
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:
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:
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:
// 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():
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:
| Aspect | C++ | Rust |
|---|---|---|
| Specular/diffuse distinction | is_specular Boolean | Enum variants (type-safe) |
| PDF storage | shared_ptr<pdf> | Box<dyn Pdf> |
| Effect on existing code | Breaking change to every material | Additive scatter_record() method |
| Existing crates | All require updates | No 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
CosinePdfinray_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. The0.0return value fromscattering_pdf()was being repurposed as a flag. - The Rust design introduces the
ScatterRecordenum withSpecularandDiffusevariants through a newscatter_record()method. The existingscatter()method and all existing crates remain unchanged. - The next chapter implements
ScatterRecordincommon/and builds a new Cornell-box scene combining an aluminum box with a glass sphere.