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

7. Rectangles and Lights ​

Ray Tracing: The Next Week (v3.2.3): 7 Rectangles and Lights / 2.7 Rectangles and Lights

Until now, the ray tracer has created implicit ambient light by returning a blue-to-white vertical gradient for the sky. This chapter introduces an emissive material and an axis-aligned rectangle, allowing objects in the scene to become light sources themselves. I ultimately assemble the well-known Cornell box.

Section 7 of the original contains six subsections:

  1. 7.29 Emissive Materials
  2. 7.30 Adding Background Color to ray_color
  3. 7.31 Rectangular Objects in the XY Plane
  4. 7.32 Turning a Rectangle into a Light
  5. 7.33 The Remaining Axis-Aligned Rectangles in the XZ and YZ Planes
  6. 7.34 An Empty Cornell Box

I build the Rust implementation in the same order.

Emissive Materials and Material::emitted ​

The Material trait previously contained only scatter, which describes how light scatters from a hit point. An emissive material must additionally return the light that it emits. The original adds emitted(u, v, p) -> color to the base class and makes it return black by default, extending the interface without changing existing non-emissive materials.

A Rust trait default method provides the same behavior.

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

    /// Light emitted by this material at surface coordinates `(u, v)` and
    /// world-space point `p`. Defaults to black so non-emissive materials
    /// (chapter 1) need not override it.
    fn emitted(&self, _u: f64, _v: f64, _p: Point3) -> Color {
        Color::new(0.0, 0.0, 0.0)
    }
}

Lambertian, Metal, and Dielectric do not need to define emitted; they automatically return black. This illustrates the utility of Rust default methods: the trait can gain new behavior without changing a single line in the existing impl Material for ... blocks.

Differences Between C++ and Rust ​

The original C++ achieves the same goal by placing virtual color emitted(...) const { return color(0,0,0); } in the base class. A Rust default method corresponds directly to this C++ pattern, including the inheritance of the base implementation unless it is overridden. Like C++ virtual, a Rust default method retains zero-cost abstraction; calling it through a vtable adds only the virtual dispatch itself.

DiffuseLight is a non-scattering material whose scatter returns None.

common/src/material.rs
rust
pub struct DiffuseLight {
    pub emit: Arc<dyn Texture>,
}

impl DiffuseLight {
    pub fn new(color: Color) -> Self {
        DiffuseLight {
            emit: Arc::new(SolidColor::new(color)),
        }
    }

    pub fn with_texture(emit: Arc<dyn Texture>) -> Self {
        DiffuseLight { emit }
    }
}

impl Material for DiffuseLight {
    fn scatter(&self, _r_in: &Ray, _rec: &HitRecord) -> Option<(Color, Ray)> {
        None
    }

    fn emitted(&self, u: f64, v: f64, p: Point3) -> Color {
        self.emit.value(u, v, p)
    }
}

As with Lambertian, two constructors accept either a color, wrapped in SolidColor, or an arbitrary texture.

Adding a Background Color to ray_color ​

The ray_color functions used by earlier demos returned the sky gradient whenever a ray hit nothing. I now distinguish between light from emission and the background color returned when nothing is hit.

r207-simple-light/src/lib.rs
rust
fn ray_color(r: &Ray, background: Color, world: &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);
    match mat.scatter(r, &rec) {
        Some((attenuation, scattered)) => {
            emitted + attenuation * ray_color(&scattered, background, world, depth - 1)
        }
        None => emitted,
    }
}

There are three key points:

  • If nothing is hit, return background directly instead of calculating a sky gradient.
  • After a hit, obtain emitted first and add it regardless of whether scattering occurs. When a ray hits a light surface, scatter returns None, leaving only emitted.
  • When scattering occurs, add the reflected component attenuation * ray_color(...) to emitted as before.

This general form of ray_color can be reused by every demo from this chapter onward. The earlier scenes from Books 1 and 2 produce their previous results when passed background = Color::new(0.7, 0.8, 1.0).

Axis-Aligned Rectangles ​

The original places xy_rect, xz_rect, and yz_rect together in aarect.h. I similarly place the three Rust structures XyRect, XzRect, and YzRect in common/src/aarect.rs.

For a rectangle covering [x0,x1]×[y0,y1] in the XY plane z=k, calculate the following values for the ray P(t)=A+tb:

t=k−Azbz,x=Ax+tbx,y=Ay+tby

The ray hits the rectangle when t∈[tmin,tmax], x0≤x≤x1, and y0≤y≤y1.

common/src/aarect.rs (XyRect excerpt)
rust
const PAD: f64 = 0.0001;

pub struct XyRect {
    pub x0: f64,
    pub x1: f64,
    pub y0: f64,
    pub y1: f64,
    pub k: f64,
    pub material: Arc<dyn Material>,
}

impl XyRect {
    pub fn new(x0: f64, x1: f64, y0: f64, y1: f64, k: f64, material: Arc<dyn Material>) -> Self {
        XyRect { x0, x1, y0, y1, k, material }
    }
}

impl Hittable for XyRect {
    fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
        let dz = r.direction().z();
        if dz == 0.0 {
            return None;
        }
        let t = (self.k - r.origin().z()) / dz;
        if t < t_min || t > t_max {
            return None;
        }
        let x = r.origin().x() + t * r.direction().x();
        let y = r.origin().y() + t * r.direction().y();
        if x < self.x0 || x > self.x1 || y < self.y0 || y > self.y1 {
            return None;
        }
        let outward_normal = Vec3::new(0.0, 0.0, 1.0);
        let mut rec = HitRecord {
            t,
            u: (x - self.x0) / (self.x1 - self.x0),
            v: (y - self.y0) / (self.y1 - self.y0),
            p: r.at(t),
            normal: outward_normal,
            front_face: false,
            mat: Some(self.material.clone()),
        };
        rec.set_face_normal(r, outward_normal);
        Some(rec)
    }

    fn bounding_box(&self, _time0: f64, _time1: f64) -> Option<Aabb> {
        Some(Aabb::new(
            Point3::new(self.x0, self.y0, self.k - PAD),
            Point3::new(self.x1, self.y1, self.k + PAD),
        ))
    }
}

XzRect, where y=k, and YzRect, where x=k, only exchange the corresponding axes and otherwise have the same structure. See the complete source. There are only two substantive differences from the original:

  • Passing an outward normal to set_face_normal for front-back classification follows the convention used consistently since the BVH was introduced in Chapter 3. Fixing the outward normal of XyRect at +z sets front_face correctly regardless of which side the ray strikes.
  • A zero-thickness plane cannot provide a useful bounding_box, so its AABB receives padding of ±0.0001 in the normal direction. This is the same defensive measure as the original and prevents division from failing on a box with zero width when the rectangle is placed in the Book 2 BVH.

The (u,v) values are normalized coordinates on the rectangle. For XzRect, for example, u=(x−x0)/(x1−x0) and v=(z−z0)/(z1−z0). An ImageTexture can therefore be mapped directly onto a rectangle.

Differences Between C++ and Rust ​

The original xy_rect::hit allows the case bz=0 to continue, whereas the Rust implementation explicitly returns early when dz == 0.0. Allowing the subsequent division by zero to produce inf would be harmless, but making the case explicit is easier to follow in a debugger. This is a defensive improvement that preserves the meaning of the original.

Demo: A Perlin Sphere with a Rectangular Light ​

The r207-simple-light crate ports the simple_light() scene from Section 7.32 of the original. It places a 2 m × 2 m rectangle carrying a DiffuseLight with color (4,4,4) in front of the Perlin-marble ground and sphere from Chapter 5. Allowing brightness above white, as in (4,4,4), is essential because it gives the source enough intensity to illuminate other surfaces.

r207-simple-light/src/lib.rs
rust
fn simple_light() -> HittableList {
    let mut objects = HittableList::new();

    let pertext: Arc<dyn Texture> = Arc::new(NoiseTexture::new(NoiseMode::Marble, 4.0));
    objects.add(Box::new(Sphere::with_material(
        Point3::new(0.0, -1000.0, 0.0),
        1000.0,
        Arc::new(Lambertian::with_texture(pertext.clone())),
    )));
    objects.add(Box::new(Sphere::with_material(
        Point3::new(0.0, 2.0, 0.0),
        2.0,
        Arc::new(Lambertian::with_texture(pertext)),
    )));

    let difflight = Arc::new(DiffuseLight::new(Color::new(4.0, 4.0, 4.0)));
    objects.add(Box::new(XyRect::new(3.0, 5.0, 1.0, 3.0, -2.0, difflight)));

    objects
}

The camera uses lookfrom = (26, 3, 6), lookat = (0, 2, 0), and vfov = 20°. Passing background = (0, 0, 0) makes the rectangle the only light source in the scene. To keep the WASM demo practical, I use 200 samples per pixel, fewer than the original. The pattern of illumination remains visible: the sphere is darker on top than near the ground, and only the side facing the light in front is bright.

Five Sides of the Cornell Box ​

The r207-cornell-box crate assembles the empty Cornell box from Section 7.34 of the original. The scene contains only six primitives:

  • The green right wall, a YzRect at x=555
  • The red left wall, a YzRect at x=0
  • A small ceiling light, an XzRect with 213≤x≤343 and 227≤z≤332 at y=554
  • A white floor, an XzRect at y=0, and a white ceiling, an XzRect at y=555
  • The white back wall, an XyRect at z=555

The front, on the camera side, remains open according to the usual Cornell box arrangement, allowing the camera to look inside.

r207-cornell-box/src/lib.rs
rust
fn cornell_box() -> 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(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)));

    objects
}

The camera uses lookfrom = (278, 278, -800), lookat = (278, 278, 0), vfov = 40°, and an aspect ratio of 1:1. The light surface has a very high intensity of (15,15,15), but its area is small at 130 × 105. Most rays therefore fail to find the light and return black. This produces the pronounced noise seen in Cornell box images, which will be improved by importance sampling in Book 3.

Differences Between C++ and Rust ​

C++ permits the concise expression make_shared<lambertian>(color(.73, .73, .73)), while the equivalent Rust expression, Arc::new(Lambertian::new(Color::new(0.73, 0.73, 0.73))), tends to be more deeply nested. Binding the four materials to variables and reusing them is particularly effective in scenes like this one because it limits that nesting. The original C++ code uses exactly the same structure.

Summary ​

  • Added the default method emitted to the Material trait. It returns black by default, allowing an emissive material to be introduced without changing Lambertian, Metal, or Dielectric.
  • Added DiffuseLight to common/src/material.rs. Its scatter returns None, while emitted simply returns a texture value.
  • Added the axis-aligned rectangles XyRect, XzRect, and YzRect to common/src/aarect.rs. Padded AABBs avoid zero thickness, and front-back classification through set_face_normal keeps the rectangles compatible with the BVH.
  • Demonstrated a rectangular light without ambient illumination in r207-simple-light and the five-sided Cornell box in r207-cornell-box. ray_color now accepts a general background argument; the Book 1 scenes can retain their previous appearance by passing a pale sky color.

Chapter 8 constructs a rectangular box from six rectangles and introduces instances that translate and rotate it. The goal is the classic Cornell box image with two tilted boxes inside.