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:
- 7.29 Emissive Materials
- 7.30 Adding Background Color to
ray_color - 7.31 Rectangular Objects in the XY Plane
- 7.32 Turning a Rectangle into a Light
- 7.33 The Remaining Axis-Aligned Rectangles in the XZ and YZ Planes
- 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.
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.
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.
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
backgrounddirectly instead of calculating a sky gradient. - After a hit, obtain
emittedfirst and add it regardless of whether scattering occurs. When a ray hits a light surface,scatterreturnsNone, leaving onlyemitted. - When scattering occurs, add the reflected component
attenuation * ray_color(...)toemittedas 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
The ray hits the rectangle when
common/src/aarect.rs (XyRect excerpt)
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 YzRect, where
- Passing an outward normal to
set_face_normalfor front-back classification follows the convention used consistently since the BVH was introduced in Chapter 3. Fixing the outward normal ofXyRectatsets front_facecorrectly regardless of which side the ray strikes. - A zero-thickness plane cannot provide a useful
bounding_box, so its AABB receives padding ofin 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 XzRect, for example, ImageTexture can therefore be mapped directly onto a rectangle.
Differences Between C++ and Rust
The original xy_rect::hit allows the case 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
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
YzRectat - The red left wall, a
YzRectat - A small ceiling light, an
XzRectwithand at - A white floor, an
XzRectat, and a white ceiling, an XzRectat - The white back wall, an
XyRectat
The front, on the camera side, remains open according to the usual Cornell box arrangement, allowing the camera to look inside.
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
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
emittedto theMaterialtrait. It returns black by default, allowing an emissive material to be introduced without changingLambertian,Metal, orDielectric. - Added
DiffuseLighttocommon/src/material.rs. ItsscatterreturnsNone, whileemittedsimply returns a texture value. - Added the axis-aligned rectangles
XyRect,XzRect, andYzRecttocommon/src/aarect.rs. Padded AABBs avoid zero thickness, and front-back classification throughset_face_normalkeeps the rectangles compatible with the BVH. - Demonstrated a rectangular light without ambient illumination in
r207-simple-lightand the five-sided Cornell box inr207-cornell-box.ray_colornow accepts a generalbackgroundargument; 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.