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

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 volumesparticipating 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 ΔL is

P(scatter)=CΔL

where C is a constant proportional to the optical density of the medium. Solving the differential equation—or using inverse transform sampling—allows the distance to scattering to be sampled from a uniform random number ξ in [0,1):

d=1Clnξ

If d extends beyond the boundary of the medium, the ray does not hit the volume.

ConstantMedium implements this behavior.

common/src/constant_medium.rs
rust
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:

  1. Find rec1, where the ray enters the boundary, and rec2, where it exits. Search for the former after t= and the latter after rec1.t + 0.0001.
  2. Compute the distance the ray spends inside the medium: L=(rec2.trec1.t)d.
  3. Sample the distance to scattering: dhit=1Clnξ.
  4. If dhit>L, return None because the ray passes through. Otherwise, return a HitRecord representing scattering at rec1.t+dhit/d.
common/src/constant_medium.rs
rust
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.0 to 0.0 handles 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_face may take any values because Isotropic::scatter does not use them. I use (1, 0, 0) and true to match the comments in the original.
  • The u and v coordinates are unused as well, although a textured medium might refer to them through its phase_function in the future. Here both remain fixed at 0.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.

common/src/material.rs
rust
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 (15,15,15) to (7,7,7).

r209-cornell-smoke/src/lib.rs
rust
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 0.01 corresponds roughly to a 1% chance of scattering per unit distance. A reasonable proportion of rays still pass through the box, while scattering occurs quite often inside it. Increasing the value makes the smoke denser; decreasing it makes the smoke thinner.

Summary

  • I created ConstantMedium in common/src/constant_medium.rs. It stores a convex boundary in Box<dyn Hittable> and an Isotropic phase function in Arc<dyn Material>. Its hit method finds the entry and exit points of the boundary, then samples a scattering distance from a random number.
  • I added the Isotropic material to material.rs. Its scattering direction is a random vector inside the unit sphere, and it ignores the surface normal. Lambertian, Metal, Dielectric, and DiffuseLight remain unchanged.
  • In the r209-cornell-smoke demo, 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_box simply delegates to boundary, 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.