8. Instances
Ray Tracing: The Next Week (v3.2.3): 8 Instances / 2.8 Instances
The Cornell box in the previous chapter contained only walls and a ceiling light. I now add two white boxes and rotate them slightly relative to the walls, bringing the scene closer to the classic Cornell box composition. Three components are required:
- A box primitive made from six rectangles
Translate, which translates an object in world spaceRotateY, which rotates an object around the Y axis
An instance makes geometry appear to move without modifying the geometry itself by applying the inverse transformation to the incoming ray. This powerful ray-tracing technique allows one shape to be reused any number of times at arbitrary positions and orientations in a scene.
Axis-Aligned Boxes
Begin with an axis-aligned rectangular box. Six instances of the three rectangle types introduced in the previous chapter, XyRect, XzRect, and YzRect, form a rectangular box. I collect them in a HittableList and implement the Hittable trait.
Using the type name Box in Rust would be confusing because of std::boxed::Box, so I call the type RectBox.
pub struct RectBox {
pub box_min: Point3,
pub box_max: Point3,
sides: HittableList,
}
impl RectBox {
pub fn new(p0: Point3, p1: Point3, material: Arc<dyn Material>) -> Self {
let mut sides = HittableList::new();
sides.add(Box::new(XyRect::new(p0.x(), p1.x(), p0.y(), p1.y(), p1.z(), material.clone())));
sides.add(Box::new(XyRect::new(p0.x(), p1.x(), p0.y(), p1.y(), p0.z(), material.clone())));
sides.add(Box::new(XzRect::new(p0.x(), p1.x(), p0.z(), p1.z(), p1.y(), material.clone())));
sides.add(Box::new(XzRect::new(p0.x(), p1.x(), p0.z(), p1.z(), p0.y(), material.clone())));
sides.add(Box::new(YzRect::new(p0.y(), p1.y(), p0.z(), p1.z(), p1.x(), material.clone())));
sides.add(Box::new(YzRect::new(p0.y(), p1.y(), p0.z(), p1.z(), p0.x(), material)));
RectBox { box_min: p0, box_max: p1, sides }
}
}
impl Hittable for RectBox {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
self.sides.hit(r, t_min, t_max)
}
fn bounding_box(&self, _t0: f64, _t1: f64) -> Option<Aabb> {
Some(Aabb::new(self.box_min, self.box_max))
}
}hit delegates directly to the internal HittableList, while bounding_box simply uses the two corners. The structure is identical to the original C++ class; only the type names RectBox rather than box, and Rust's Arc<dyn Material> rather than shared_ptr<material>, differ.
Differences Between C++ and Rust
C++ can define a class named box without difficulty. Rust's Box<T>, however, is a fundamental smart pointer in its ownership system. If a user were to write use common::Box, every Box::new(...) expression would become confusing. Choosing a distinct name such as RectBox is the safer Rust approach.
Adding two unrotated boxes to the Cornell box produces an image corresponding to Image 19 in Section 7.34 of the original.
objects.add(Box::new(RectBox::new(
Point3::new(130.0, 0.0, 65.0),
Point3::new(295.0, 165.0, 230.0),
white.clone(),
)));
objects.add(Box::new(RectBox::new(
Point3::new(265.0, 0.0, 295.0),
Point3::new(430.0, 330.0, 460.0),
white,
)));So far, the scene contains only a collection of rectangular boxes. I next place them at angles to the walls.
Basic Idea Behind Instances
In the classic Cornell box composition, each box is tilted about 15° relative to the walls. As the original notes, the standard ray-tracing approach transforms the ray in the opposite direction instead of moving the geometry:
Instead of adding 2 to every x coordinate of the pink box, leave the box where it is and subtract 2 from the x origin of the incoming ray inside the hit routine.
This is the standard treatment of coordinate transformations. Test a transformed ray against an object in its local coordinate system, then apply the forward transformation to the resulting intersection point and normal to return them to world space. There are two advantages:
- The same geometry can be instanced at several positions and orientations while storing only one copy
- An AABB can be constructed by transforming the original local AABB, retaining efficient use with a BVH
Translate
Translation is the simplest case. Moving an object by offset in world space is equivalent to moving the incoming ray's origin by -offset in the local coordinate system.
pub struct Translate {
pub object: Box<dyn Hittable>,
pub offset: Vec3,
}
impl Hittable for Translate {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let moved = Ray::with_time(r.origin() - self.offset, r.direction(), r.time());
let mut rec = self.object.hit(&moved, t_min, t_max)?;
rec.p = rec.p + self.offset;
let n = rec.normal;
rec.set_face_normal(&moved, n);
Some(rec)
}
fn bounding_box(&self, time0: f64, time1: f64) -> Option<Aabb> {
let bbox = self.object.bounding_box(time0, time1)?;
Some(Aabb::new(bbox.min() + self.offset, bbox.max() + self.offset))
}
}The key points are:
- Leave the ray's direction unchanged and translate only its origin by
-offset. Translating a direction vector has no meaning, so no transformation is required. - Add
+offsetback to the hit pointrec.pto lift it into world space. Translation does not affect the normal, so it remains unchanged. - Shift both ends of the AABB.
Translatecan consequently be placed in a BVH without difficulty, benefiting from theOption<Aabb>form ofHittable::bounding_boxintroduced in Chapter 3.
RotateY
A rotation by angle
For the inverse rotation by
RotateY::hit applies the inverse rotation to transform a ray from world coordinates into local coordinates. After the hit, it applies the forward rotation to return the point and normal to world coordinates. A normal is also a direction vector and must be rotated, unlike the translation case.
impl Hittable for RotateY {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let mut origin = r.origin();
let mut direction = r.direction();
// World -> local: rotate by -theta.
origin[0] = self.cos_theta * r.origin()[0] - self.sin_theta * r.origin()[2];
origin[2] = self.sin_theta * r.origin()[0] + self.cos_theta * r.origin()[2];
direction[0] = self.cos_theta * r.direction()[0] - self.sin_theta * r.direction()[2];
direction[2] = self.sin_theta * r.direction()[0] + self.cos_theta * r.direction()[2];
let rotated = Ray::with_time(origin, direction, r.time());
let mut rec = self.object.hit(&rotated, t_min, t_max)?;
// Local -> world: rotate by +theta.
let mut p = rec.p;
let mut normal = rec.normal;
p[0] = self.cos_theta * rec.p[0] + self.sin_theta * rec.p[2];
p[2] = -self.sin_theta * rec.p[0] + self.cos_theta * rec.p[2];
normal[0] = self.cos_theta * rec.normal[0] + self.sin_theta * rec.normal[2];
normal[2] = -self.sin_theta * rec.normal[0] + self.cos_theta * rec.normal[2];
rec.p = p;
rec.set_face_normal(&rotated, normal);
Some(rec)
}
fn bounding_box(&self, _t0: f64, _t1: f64) -> Option<Aabb> {
self.bbox
}
}The repeated use of the same cos and sin coefficients in the local transformation may look unusual at first. The essential point is that the sign of sin differs between the forward and inverse transformations.
AABB After Rotation
The original AABB no longer encloses the rotated shape. Rotate all eight corners and calculate new minimum and maximum values. This result is calculated once in the constructor and cached.
common/src/instance.rs (RotateY constructor)
pub fn new(object: Box<dyn Hittable>, angle_deg: f64) -> Self {
let radians = degrees_to_radians(angle_deg);
let sin_theta = radians.sin();
let cos_theta = radians.cos();
let inner_bbox = object.bounding_box(0.0, 1.0);
let bbox = inner_bbox.map(|bbox| {
let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut max = Point3::new(-f64::INFINITY, -f64::INFINITY, -f64::INFINITY);
for i in 0..2 {
for j in 0..2 {
for k in 0..2 {
let x = i as f64 * bbox.max().x() + (1 - i) as f64 * bbox.min().x();
let y = j as f64 * bbox.max().y() + (1 - j) as f64 * bbox.min().y();
let z = k as f64 * bbox.max().z() + (1 - k) as f64 * bbox.min().z();
let newx = cos_theta * x + sin_theta * z;
let newz = -sin_theta * x + cos_theta * z;
let tester = Vec3::new(newx, y, newz);
for c in 0..3 {
min[c] = min[c].min(tester[c]);
max[c] = max[c].max(tester[c]);
}
}
}
}
Aabb::new(min, max)
});
RotateY { object, sin_theta, cos_theta, bbox }
}Differences Between C++ and Rust
The original C++ stores a separate bool hasbox flag to represent the case in which the inner object has no finite bounds. Because the Rust Hittable::bounding_box already returns Option<Aabb>, inner_bbox.map(...) lifts the operation over the Option and provides equivalent behavior. Eliminating explicit flag management makes the implementation more direct.
The Classic Cornell Box
Combining these components produces the standard Cornell box from Image 20 in the original. The nested construction of Translate(RotateY(RectBox)), wrapped in Box<dyn Hittable>, corresponds to a chain of make_shared calls in C++.
// Tall block: 165 x 330 x 165, +15 deg, then translate to (265, 0, 295).
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)));
objects.add(box1);
// Short block: 165 x 165 x 165, -18 deg, then translate to (130, 0, 65).
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(box2);Order matters: rotate at the origin, then translate. Reversing the order would swing the box around the origin and away from its intended position. Compared with the chain of Arc::new(...) calls in Chapter 7, this form is somewhat verbose because each stage needs the type annotation Box<dyn Hittable>. It also has the pedagogical advantage of making every intermediate type explicit.
Differences Between C++ and Rust
The original C++ builds the instance stack by assigning each wrapper back to the same variable after shared_ptr<hittable> box1 = ...:
shared_ptr<hittable> box1 = make_shared<box>(...);
box1 = make_shared<rotate_y>(box1, 15);
box1 = make_shared<translate>(box1, vec3(265,0,295));The Rust implementation recreates this style through shadowing with let box1 = ...; let box1 = ...;. No mut is required, and each stage of box1 remains an independent binding, making the transformation sequence easier to follow.
Summary
- Added
RectBox, which groups six axis-aligned rectangles, tocommon/src/rect_box.rs. The Rust type is namedRectBoxto avoid a collision withBox. - Implemented instances by applying inverse transformations to rays.
common/src/instance.rsaddsTranslate, which moves the origin by-offset, andRotateY, which applies an inverse rotation toand . Their AABBs are updated by shifting both endpoints and by rotating all eight corners before recalculating the minima and maxima, respectively. - Used these components to create the classic Cornell box demo
r208-cornell-standardand the unrotated comparisonr208-cornell-blocks. - The existing pipeline continues to work unchanged, including the
backgroundargument toray_color, the default implementation ofMaterial::emitted, and the BVH'sOption<Aabb>.
Chapter 9 covers volumes, or constant-density media, for placing smoke and fog inside the boxes. A new Hittable called ConstantMedium and an isotropic scattering material create the familiar image of smoke-filled boxes.