9. Volumes
Ray Tracing: The Next Week (v3.2.3): 9 Volumes / 2.9 Volumes
Everything I have rendered so far has been a surface. This chapter introduces volumes—participating media such as smoke, fog, and subsurface scattering. Following the approach in the original, I use a simple trick that represents a volume as a “probabilistically existing surface.” This lets me implement volumes with only a small addition on top of the existing Hittable infrastructure.
Constant-Density Media
As a ray travels through a medium, it may scatter at any point. The denser the medium, the more likely scattering becomes. The probability of scattering over an infinitesimal distance
where
If
ConstantMedium implements this behavior.
pub struct ConstantMedium {
pub boundary: Box<dyn Hittable>,
pub phase_function: Arc<dyn Material>,
pub neg_inv_density: f64,
}
impl ConstantMedium {
pub fn new(boundary: Box<dyn Hittable>, d: f64, color: Color) -> Self {
ConstantMedium {
boundary,
phase_function: Arc::new(Isotropic::new(color)),
neg_inv_density: -1.0 / d,
}
}
}Precomputing neg_inv_density = -1/d reduces the scattering-distance calculation to the straightforward one-liner neg_inv_density * random_double().ln().
Constructing hit
Treating a medium as a probabilistic surface works as follows:
- Find
rec1, where the ray enters the boundary, andrec2, where it exits. Search for the former afterand the latter after rec1.t + 0.0001. - Compute the distance the ray spends inside the medium:
. - Sample the distance to scattering:
. - If
, return Nonebecause the ray passes through. Otherwise, return aHitRecordrepresenting scattering at.
impl Hittable for ConstantMedium {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let mut rec1 = self.boundary.hit(r, f64::NEG_INFINITY, f64::INFINITY)?;
let mut rec2 = self.boundary.hit(r, rec1.t + 0.0001, f64::INFINITY)?;
if rec1.t < t_min { rec1.t = t_min; }
if rec2.t > t_max { rec2.t = t_max; }
if rec1.t >= rec2.t { return None; }
if rec1.t < 0.0 { rec1.t = 0.0; }
let ray_length = r.direction().length();
let distance_inside_boundary = (rec2.t - rec1.t) * ray_length;
let hit_distance = self.neg_inv_density * random_double().ln();
if hit_distance > distance_inside_boundary {
return None;
}
let t = rec1.t + hit_distance / ray_length;
let p = r.at(t);
Some(HitRecord {
t,
u: 0.0,
v: 0.0,
p,
normal: Vec3::new(1.0, 0.0, 0.0), // arbitrary
front_face: true, // arbitrary
mat: Some(self.phase_function.clone()),
})
}
fn bounding_box(&self, t0: f64, t1: f64) -> Option<Aabb> {
self.boundary.bounding_box(t0, t1)
}
}Points worth noting:
- Clamping
rec1.t < 0.0to0.0handles the case where the ray origin is already inside the medium. This commonly occurs when scattering chains through a cloud. Omitting it can make the ray appear to be in front of the boundary and darken the result. - The normal and
front_facemay take any values becauseIsotropic::scatterdoes not use them. I use(1, 0, 0)andtrueto match the comments in the original. - The
and coordinates are unused as well, although a textured medium might refer to them through its phase_functionin the future. Here both remain fixed at0.0. - This implementation assumes a convex boundary. Boxes and spheres work, while a torus or a shape with cavities does not.
Differences Between C++ and Rust
The original C++ boundary->hit(...) API returns a bool and writes the data through an argument. The Rust version returns Option<HitRecord>, allowing the ? operator to express the same logic concisely. The control flow of hit becomes a visually clear sequence of “continue on success, return on failure,” which is considerably easier to read than the nested if (!boundary->hit(...)) statements in the original.
Isotropic Scattering Material
When scattering occurs inside a medium, the ray scatters with equal probability in every direction. The Isotropic material models this isotropic scattering. I add it to material.rs alongside the existing materials.
pub struct Isotropic {
pub albedo: Arc<dyn Texture>,
}
impl Isotropic {
pub fn new(color: Color) -> Self {
Isotropic { albedo: Arc::new(SolidColor::new(color)) }
}
}
impl Material for Isotropic {
fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
let scattered = Ray::with_time(rec.p, random_in_unit_sphere(), r_in.time());
let attenuation = self.albedo.value(rec.u, rec.v, rec.p);
Some((attenuation, scattered))
}
}Whereas Lambertian chooses a scattering direction by adding the surface normal to a random vector inside the unit sphere, Isotropic uses the random vector itself. Not using a normal is the key to representing scattering that does not occur on a surface.
Differences Between C++ and Rust
The original C++ uses random_in_unit_sphere() directly as the scattering direction without normalizing it to a unit vector. Strictly speaking, this gives a slightly different distribution from uniform scattering within a sphere because it biases directions near the center, but the difference is not visible at the scale of this chapter. The Rust port follows the original expression to prioritize reproducibility. This is one place where random_unit_vector() could be substituted if desired.
Demo: A Smoky Cornell Box
I wrap the two white blocks in the classic Cornell box with ConstantMedium, replacing them with black smoke (color = (0, 0, 0)) and white fog (color = (1, 1, 1)). To accelerate convergence, the light is enlarged from 130×105 to 330×305 and its intensity reduced from
let light = Arc::new(DiffuseLight::new(Color::new(7.0, 7.0, 7.0)));
// ...
objects.add(Box::new(XzRect::new(113.0, 443.0, 127.0, 432.0, 554.0, light)));
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)));
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(Box::new(ConstantMedium::new(box1, 0.01, Color::new(0.0, 0.0, 0.0))));
objects.add(Box::new(ConstantMedium::new(box2, 0.01, Color::new(1.0, 1.0, 1.0))));The key is to add only the volume wrappers to the scene, not the blocks themselves. ConstantMedium retains box1 internally for boundary hit tests, effectively reusing the box shape as the boundary of the smoke. The exact RotateY / Translate chains from the standard Cornell box in Section 2.8 remain reusable, demonstrating the power of instances.
A density of
Summary
- I created
ConstantMediumincommon/src/constant_medium.rs. It stores a convex boundary inBox<dyn Hittable>and anIsotropicphase function inArc<dyn Material>. Itshitmethod finds the entry and exit points of the boundary, then samples a scattering distance from a random number. - I added the
Isotropicmaterial tomaterial.rs. Its scattering direction is a random vector inside the unit sphere, and it ignores the surface normal.Lambertian,Metal,Dielectric, andDiffuseLightremain unchanged. - In the
r209-cornell-smokedemo, I replaced the two blocks from the standard Cornell box in Section 2.8 with black smoke and white fog. Enlarging and dimming the light speeds convergence. - Because
Hittable::bounding_boxsimply delegates toboundary, integration with the BVH works automatically.
Section 2.10 combines every feature added so far—motion blur, BVHs, solid, noise, and image textures, rectangular lights, instances, and volumes—into the final scene for Book 2. While the final scene in Book 1 contained a large collection of spheres, this richer scene also serves as the cover image of the original book.