Ray Tracing in One Weekend: Part 8 Transformations

Matthew MacFarquhar
4 min readNov 8, 2024

--

Introduction

I have been reading through this book series on Raytracing which walks the reader through the creation of a Raytracer using C++. In this series of articles, I will be going through this book and implementing the lessons in Rust instead and diving deep into the pieces.

In this article we will be adding transformations to our tool chest (specifically translation and y rotation). We will do this by essentially making every transformation operation a hittable object that wraps a sub hittable (e.x. a sphere or another transformation object).

The following link is the commit in my Github repo that matches the code we will go over.

Something to Transform

First, let’s craft something cooler to transform (as rotating a sphere is pretty much pointless). We will construct a Box using a collection of quads.

This is an associated function within the Quad implementation.

pub fn get_box(min: Point3, max: Point3, mat: Arc<dyn Material>) -> HittableList {
let mut sides = HittableList::new();

let dx = Vec3::new(max.x() - min.x(), 0.0, 0.0);
let dy = Vec3::new(0.0, max.y() - min.y(), 0.0);
let dz = Vec3::new(0.0, 0.0, max.z() - min.z());

sides.add(Box::new(Quad::new(Point3::new(min.x(), min.y(), max.z()), dx, dy, mat.clone()))); // front
sides.add(Box::new(Quad::new(Point3::new(max.x(), min.y(), max.z()), -dz, dy, mat.clone()))); // right
sides.add(Box::new(Quad::new(Point3::new(max.x(), min.y(), min.z()), -dx, dy, mat.clone()))); // back
sides.add(Box::new(Quad::new(Point3::new(min.x(), min.y(), min.z()), dz, dy, mat.clone()))); // left
sides.add(Box::new(Quad::new(Point3::new(min.x(), max.y(), max.z()), dx, -dz, mat.clone()))); // top
sides.add(Box::new(Quad::new(Point3::new(min.x(), min.y(), min.z()), dx, dz, mat.clone()))); // bottom
sides
}

We build a box as a HittableList of six quads. This idea that we can build more complex 3D shapes using combinations of 2D shapes is pretty neat! Just imagine building complex glTF models by just adding a bunch of triangles to our scene.

Transformation

Now we will be creating two implementers of the hittable trait — Translate and RotateY.

Translate

A Translate object will reference an inner Hittable object and an offset in world space to move it by.

pub struct Translate {
object: Box<dyn Hittable>,
offset: Vec3
}

impl Translate {
pub fn new(object: Box<dyn Hittable>, offset: Vec3) -> Self {
Translate {
object,
offset
}
}
}

Implementing hit for Translation involves moving the ray instead of the actual object (this is like saying if the box moves 1 unit further away from us, it is as if we kept the box in the same place and just moved our camera back 1 unit instead).

impl Hittable for Translate {
fn hit(&self, ray: &crate::ray::Ray, t_min: f64, t_max: f64) -> Option<crate::hittable::HitRecord> {
let offset_ray = Ray::new(ray.origin() - self.offset, ray.direction(), ray.time());
match self.object.hit(&offset_ray, t_min, t_max) {
None => return None,
Some(mut hit_record) => {
hit_record.p += self.offset;
return Some(hit_record);
}
}
}
}

If our moved ray gets a hit, we will apply our transformation to the point in the hit record as well before returning the record back.

RotateY

Rotation follows the same approach as translation (although the math for applying rotation for the hit calculation is a little more complex).

When we construct a Y rotation, we calculate the cosine and sine values of the amount to rotate and save our hittable object reference.

pub struct RotateY {
object: Box<dyn Hittable>,
sin_theta: f64,
cos_theta: f64
}
impl RotateY {
pub fn new(object: Box<dyn Hittable>, angle: f64) -> Self {
let radians = degrees_to_radians(angle);
let sin_theta = f64::sin(radians);
let cos_theta = f64::cos(radians);
RotateY {
object,
sin_theta,
cos_theta
}
}
}

There is some complex math to rotate the ray’s origin and direction and to un-rotate the hit point and normal direction, but we are really doing exactly the same thing as translation: move ray to transformed space, determine hit and then apply transform to turn hit point back into old space.

impl Hittable for RotateY {
fn hit(&self, ray: &Ray, t_min: f64, t_max: f64) -> Option<crate::hittable::HitRecord> {
let origin = Point3::new((self.cos_theta * ray.origin().x()) - (self.sin_theta * ray.origin().z()), ray.origin().y(), (self.sin_theta * ray.origin().x()) + (self.cos_theta * ray.origin().z()));
let direction = Vec3::new((self.cos_theta * ray.direction().x()) - (self.sin_theta * ray.direction().z()), ray.direction().y(), (self.sin_theta * ray.direction().x()) + (self.cos_theta * ray.direction().z()));
let rotated_ray = Ray::new(origin, direction, ray.time());
if let Some(mut hit_record) = self.object.hit(&rotated_ray, t_min, t_max) {
hit_record.p = Point3::new((self.cos_theta * hit_record.p.x()) + (self.sin_theta * hit_record.p.z()), hit_record.p.y(), (-self.sin_theta * hit_record.p.x()) + (self.cos_theta * hit_record.p.z()));
hit_record.normal= Point3::new((self.cos_theta * hit_record.normal.x()) + (self.sin_theta * hit_record.normal.z()), hit_record.normal.y(), (-self.sin_theta * hit_record.normal.x()) + (self.cos_theta * hit_record.normal.z()));
return Some(hit_record);
} else {
return None;
}
}
}

Render

We can then update our Cornell Box scene to use these translations and rotations with some boxes.

fn cornell_box() {
let mut world = HittableList::new();

let red = Lambertian::from_color(Color::new(0.65, 0.05, 0.05));
let white = Arc::new(Lambertian::from_color(Color::new(0.73, 0.73, 0.73)));
let green = Lambertian::from_color(Color::new(0.12, 0.45, 0.15));
let light = DiffuseLight::from_color(Color::new(15.0, 15.0, 15.0));


world.add(Box::new(Quad::new(Point3::new(555.0,0.0,0.0), Vec3::new(0.0, 555.0, 0.0), Vec3::new(0.0, 0.0, 555.0), Arc::new(green))));
world.add(Box::new(Quad::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 555.0, 0.0), Vec3::new(0.0, 0.0, 555.0), Arc::new(red))));
world.add(Box::new(Quad::new(Point3::new(343.0, 554.0, 332.0), Vec3::new(-130.0, 0.0, 0.0), Vec3::new(0.0, 0.0, -105.0), Arc::new(light))));
world.add(Box::new(Quad::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(555.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 555.0), white.clone())));
world.add(Box::new(Quad::new(Point3::new(555.0, 555.0, 555.0), Vec3::new(-555.0, 0.0, 0.0), Vec3::new(0.0, 0.0, -555.0), white.clone())));
world.add(Box::new(Quad::new(Point3::new(0.0, 0.0, 555.0), Vec3::new(555.0, 0.0, 0.0), Vec3::new(0.0, 555.0, 0.0), white.clone())));

let box1 = Box::new(Quad::get_box(Point3::new(0.0, 0.0, 0.0), Point3::new(165.0, 330.0, 165.0), white.clone()));
let box1 = Box::new(RotateY::new(box1, 15.0));
let box1 = Box::new(Translate::new(box1, Vec3::new(265.0, 0.0, 295.0)));
world.add(box1);

let box2 = Box::new(Quad::get_box(Point3::new(0.0, 0.0, 0.0), Point3::new(165.0, 165.0, 165.0), white.clone()));
let box2 = Box::new(RotateY::new(box2, -18.0));
let box2 = Box::new(Translate::new(box2, Vec3::new(130.0, 0.0, 65.0)));
world.add(box2);

let eye = Point3::new(278.0, 278.0, -800.0);
let lookat = Point3::new(278.0, 278.0, 0.0);
let up = Point3::new(0.0, 1.0, 0.0);
let dist_to_focus = (eye - lookat).length();
let aperture = 0.0;
let camera = Camera::new(600, 600, SAMPLES_PER_PIXEL, MAX_DEPTH, eye, lookat, up, 40.0, 1.0, aperture, dist_to_focus, Color::new(0.0, 0.0, 0.0));

camera.render(&world);
}

Giving us this.

Conclusion

In this article, we built more complex 3D shapes using our 2D Quad primitives with ease. We then learned how to represent transformations as wrapper hittables and how to apply hits to those transforms by manipulating the incoming rays and outgoing hit records. With all of these tools we have pretty much everything you need to build a basic Raytracer for simplistic shapes and lights.

--

--

Matthew MacFarquhar
Matthew MacFarquhar

Written by Matthew MacFarquhar

I am a software engineer working for Amazon living in SF/NYC.

No responses yet