6. Surface Normals and Multiple Objects
Ray Tracing in One Weekend (v3.2.3): 6 Surface Normals and Multiple Objects
In the previous chapter we painted the sphere silhouette a flat red. This chapter uses surface normal vectors to visualize the sphere's surface in color. We also introduce the abstractions needed for multiple objects — the Hittable trait, Sphere, and HittableList — and complete a two-object scene with a ground plane.
Shading with Normals
Surface normal of a sphere
The outward normal at a point
For a unit sphere the length is already 1, but for a general sphere explicit normalization by radius
Normal color map
Each component of the normal lies in
This is a widely used debug visualization technique. Regions where the normal faces forward appear bluish, upward-facing regions are greenish, and sideward-facing regions are reddish.
Updating hit_sphere to return f64
The previous chapter's hit_sphere returned bool. To compute the intersection point, we need the value of the parameter
The C++ implementation:
double hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = r.origin() - center;
auto a = dot(r.direction(), r.direction());
auto b = 2.0 * dot(oc, r.direction());
auto c = dot(oc, oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
if (discriminant < 0) {
return -1.0;
} else {
return (-b - sqrt(discriminant)) / (2.0*a);
}
}The Rust implementation:
fn hit_sphere(center: &Point3, radius: f64, r: &Ray) -> f64 {
let oc = r.origin() - *center;
let a = dot(r.direction(), r.direction());
let b = 2.0 * dot(oc, r.direction());
let c = dot(oc, oc) - radius * radius;
let discriminant = b * b - 4.0 * a * c;
if discriminant < 0.0 {
-1.0
} else {
(-b - discriminant.sqrt()) / (2.0 * a)
}
}Testing t > 0.0 accepts only forward intersections. This also fixes the previous chapter's issue where a sphere behind the camera was still visible.
ray_color converting normals to color
The C++ implementation:
color ray_color(const ray& r) {
auto t = hit_sphere(point3(0,0,-1), 0.5, r);
if (t > 0.0) {
vec3 N = unit_vector(r.at(t) - vec3(0,0,-1));
return 0.5*color(N.x()+1, N.y()+1, N.z()+1);
}
vec3 unit_direction = unit_vector(r.direction());
t = 0.5*(unit_direction.y() + 1.0);
return (1.0-t)*color(1.0, 1.0, 1.0) + t*color(0.5, 0.7, 1.0);
}The Rust implementation:
fn ray_color(r: &Ray) -> Color {
let center = Point3::new(0.0, 0.0, -1.0);
let t = hit_sphere(¢er, 0.5, r);
if t > 0.0 {
let n = unit_vector(r.at(t) - center);
return 0.5 * Color::new(n.x() + 1.0, n.y() + 1.0, n.z() + 1.0);
}
let unit_direction = unit_vector(r.direction());
let t = 0.5 * (unit_direction.y() + 1.0);
(1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}r.at(t) computes the hit point, and subtracting the center then normalizing yields the surface normal.
Simplifying the Coefficients
In the quadratic equation
The factor of 2 cancels, simplifying the computation. Using half_b (
fn hit_sphere(center: &Point3, radius: f64, r: &Ray) -> f64 {
let oc = r.origin() - *center;
let a = r.direction().length_squared();
let half_b = dot(oc, r.direction());
let c = oc.length_squared() - radius * radius;
let discriminant = half_b * half_b - a * c;
if discriminant < 0.0 {
-1.0
} else {
(-half_b - discriminant.sqrt()) / a
}
}Using length_squared() and half_b reduces the number of multiplications.
Abstraction with the Hittable Trait
A single hit_sphere function was sufficient for one sphere, but adding more objects requires a unified interface. The original book introduces an abstract hittable class.
HitRecord
A struct that holds the result of an intersection.
The C++ implementation:
struct hit_record {
point3 p;
vec3 normal;
double t;
bool front_face;
void set_face_normal(const ray& r, const vec3& outward_normal) {
front_face = dot(r.direction(), outward_normal) < 0;
normal = front_face ? outward_normal : -outward_normal;
}
};The Rust implementation:
pub struct HitRecord {
pub p: Point3,
pub normal: Vec3,
pub t: f64,
pub front_face: bool,
}
impl HitRecord {
pub fn set_face_normal(&mut self, r: &Ray, outward_normal: Vec3) {
self.front_face = dot(r.direction(), outward_normal) < 0.0;
self.normal = if self.front_face { outward_normal } else { -outward_normal };
}
}The front_face field records whether the ray hit the front (outside) or back (inside) of the surface. The normal is always set to point against the incident ray, which simplifies material calculations later.
The Hittable trait
In C++, this is defined as an abstract class with a pure virtual function.
class hittable {
public:
virtual ~hittable() = default;
virtual bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const = 0;
};In Rust, the same concept is expressed with a trait.
pub trait Hittable {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord>;
}Differences Between C++ and Rust
Output parameters vs Option<T>
C++'s hit method returns bool and writes the intersection result into an output parameter hit_record& rec.
virtual bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const = 0;In Rust, passing a mutable reference as a "write" argument is possible, but returning Option<T> — which bundles "success or failure" with the value — is more idiomatic.
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord>;Some(rec) means an intersection was found; None means a miss. The caller processes the result with if let Some(rec) = ....
shared_ptr vs Box<dyn Trait>
C++ uses shared_ptr<hittable> to achieve polymorphism.
std::vector<shared_ptr<hittable>> objects;
objects.push_back(make_shared<sphere>(...));In Rust, the equivalent is a trait object Box<dyn Hittable>.
Vec<Box<dyn Hittable>>
world.add(Box::new(Sphere::new(...)));Box<T> is a smart pointer that owns a heap-allocated value. dyn Hittable is the syntax for dynamic dispatch — "some type that implements Hittable" — and the correct method is dispatched at runtime.
virtual vs no default implementation
In Rust, the concept corresponding to a C++ pure virtual function (= 0) is a trait method with no default implementation. Any type that impls the trait is required to provide that method.
// A type that impls Hittable is guaranteed to have a hit() method
impl Hittable for Sphere {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
// Must be implemented
}
}The Sphere Struct
The C++ implementation:
class sphere : public hittable {
public:
sphere(const point3& center, double radius) : center(center), radius(fmax(0,radius)) {}
bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const override {
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(), oc);
auto c = oc.length_squared() - radius*radius;
auto discriminant = h*h - a*c;
if (discriminant < 0) return false;
auto sqrtd = sqrt(discriminant);
auto root = (h - sqrtd) / a;
if (root <= ray_tmin || ray_tmax <= root) {
root = (h + sqrtd) / a;
if (root <= ray_tmin || ray_tmax <= root)
return false;
}
rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r, outward_normal);
return true;
}
private:
point3 center;
double radius;
};The Rust implementation:
pub struct Sphere {
pub center: Point3,
pub radius: f64,
}
impl Sphere {
pub fn new(center: Point3, radius: f64) -> Self {
Sphere { center, radius }
}
}
impl Hittable for Sphere {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let oc = r.origin() - self.center;
let a = r.direction().length_squared();
let half_b = dot(oc, r.direction());
let c = oc.length_squared() - self.radius * self.radius;
let discriminant = half_b * half_b - a * c;
if discriminant < 0.0 {
return None;
}
let sqrtd = discriminant.sqrt();
let mut root = (-half_b - sqrtd) / a;
if root < t_min || root > t_max {
root = (-half_b + sqrtd) / a;
if root < t_min || root > t_max {
return None;
}
}
let p = r.at(root);
let outward_normal = (p - self.center) / self.radius;
let mut rec = HitRecord {
t: root,
p,
normal: outward_normal,
front_face: false,
};
rec.set_face_normal(r, outward_normal);
Some(rec)
}
}Note: the C++ implementation uses oc = center - r.origin(), making the sign of h opposite to this implementation which uses oc = r.origin() - center. Only the sign of half_b differs; the result is equivalent.
HittableList
The C++ implementation:
class hittable_list : public hittable {
public:
std::vector<shared_ptr<hittable>> objects;
void add(shared_ptr<hittable> object) { objects.push_back(object); }
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_t.max;
for (const auto& object : objects) {
if (object->hit(r, interval(ray_t.min, closest_so_far), temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}
return hit_anything;
}
};The Rust implementation. closest_so_far is updated during the loop so that only the nearest intersection is ultimately returned.
pub struct HittableList {
pub objects: Vec<Box<dyn Hittable>>,
}
impl HittableList {
pub fn new() -> Self {
HittableList { objects: Vec::new() }
}
pub fn add(&mut self, object: Box<dyn Hittable>) {
self.objects.push(object);
}
}
impl Hittable for HittableList {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let mut closest_so_far = t_max;
let mut result = None;
for object in &self.objects {
if let Some(rec) = object.hit(r, t_min, closest_so_far) {
closest_so_far = rec.t;
result = Some(rec);
}
}
result
}
}Utility Constants (rtweekend.h)
The original book defines common constants and functions in rtweekend.h.
const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;
inline double degrees_to_radians(double degrees) {
return degrees * pi / 180.0;
}Rust's standard library provides equivalent functionality out of the box. When needed, use the following:
f64::INFINITY // Positive infinity
std::f64::consts::PI // Pi
fn degrees_to_radians(degrees: f64) -> f64 {
degrees * std::f64::consts::PI / 180.0
}In this chapter, f64::INFINITY is used as the initial t_max in HittableList::hit.
Adding to the common Crate
HitRecord, Hittable, Sphere, and HittableList will be reused in subsequent chapters, so they are added to the common crate.
common/src/hittable.rs
use crate::vec3::{Point3, Vec3, dot};
use crate::ray::Ray;
/// Stores the result of a ray–object intersection.
pub struct HitRecord {
/// Intersection point.
pub p: Point3,
/// Surface normal at the intersection (always points against the incident ray).
pub normal: Vec3,
/// Ray parameter at the intersection.
pub t: f64,
/// `true` if the ray hit the front (outside) face.
pub front_face: bool,
}
impl HitRecord {
/// Sets `front_face` and `normal` so the normal always points against the incident ray.
pub fn set_face_normal(&mut self, r: &Ray, outward_normal: Vec3) {
self.front_face = dot(r.direction(), outward_normal) < 0.0;
self.normal = if self.front_face { outward_normal } else { -outward_normal };
}
}
/// Trait for objects that can be intersected by a ray.
pub trait Hittable {
/// Returns `Some(HitRecord)` if the ray intersects in the interval `[t_min, t_max]`, or `None`.
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord>;
}common/src/sphere.rs
use crate::vec3::{Point3, dot};
use crate::ray::Ray;
use crate::hittable::{HitRecord, Hittable};
/// A sphere defined by a center and a radius.
pub struct Sphere {
/// Center of the sphere.
pub center: Point3,
/// Radius of the sphere.
pub radius: f64,
}
impl Sphere {
/// Creates a new sphere.
pub fn new(center: Point3, radius: f64) -> Self {
Sphere { center, radius }
}
}
impl Hittable for Sphere {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let oc = r.origin() - self.center;
let a = r.direction().length_squared();
let half_b = dot(oc, r.direction());
let c = oc.length_squared() - self.radius * self.radius;
let discriminant = half_b * half_b - a * c;
if discriminant < 0.0 {
return None;
}
let sqrtd = discriminant.sqrt();
let mut root = (-half_b - sqrtd) / a;
if root < t_min || root > t_max {
root = (-half_b + sqrtd) / a;
if root < t_min || root > t_max {
return None;
}
}
let p = r.at(root);
let outward_normal = (p - self.center) / self.radius;
let mut rec = HitRecord {
t: root,
p,
normal: outward_normal,
front_face: false,
};
rec.set_face_normal(r, outward_normal);
Some(rec)
}
}common/src/hittable_list.rs
use crate::ray::Ray;
use crate::hittable::{HitRecord, Hittable};
/// A collection of `Hittable` objects tested together.
pub struct HittableList {
/// The list of hittable objects.
pub objects: Vec<Box<dyn Hittable>>,
}
impl HittableList {
/// Creates an empty `HittableList`.
pub fn new() -> Self {
HittableList { objects: Vec::new() }
}
/// Appends `object` to the list.
pub fn add(&mut self, object: Box<dyn Hittable>) {
self.objects.push(object);
}
}
impl Hittable for HittableList {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let mut closest_so_far = t_max;
let mut result = None;
for object in &self.objects {
if let Some(rec) = object.hit(r, t_min, closest_so_far) {
closest_so_far = rec.t;
result = Some(rec);
}
}
result
}
}common/src/lib.rs (updated)
pub mod vec3;
pub use vec3::{Color, Point3, Vec3, cross, dot, unit_vector, write_color};
pub mod ray;
pub use ray::Ray;
pub mod hittable;
pub use hittable::{HitRecord, Hittable};
pub mod sphere;
pub use sphere::Sphere;
pub mod hittable_list;
pub use hittable_list::HittableList;Implementing the r106-sphere-normals Crate
Set up r106-sphere-normals/Cargo.toml:
[package]
name = "r106-sphere-normals"
version = "0.1.0"
edition = "2024"
[dependencies]
common = { workspace = true }Complete implementation of r106-sphere-normals/src/lib.rs. hit_sphere returns f64 and normals are mapped to colors.
r106-sphere-normals/src/lib.rs
use common::{Color, Point3, Vec3, dot, unit_vector, write_color, Ray};
fn hit_sphere(center: &Point3, radius: f64, r: &Ray) -> f64 {
let oc = r.origin() - *center;
let a = r.direction().length_squared();
let half_b = dot(oc, r.direction());
let c = oc.length_squared() - radius * radius;
let discriminant = half_b * half_b - a * c;
if discriminant < 0.0 {
-1.0
} else {
(-half_b - discriminant.sqrt()) / a
}
}
fn ray_color(r: &Ray) -> Color {
let center = Point3::new(0.0, 0.0, -1.0);
let t = hit_sphere(¢er, 0.5, r);
if t > 0.0 {
let n = unit_vector(r.at(t) - center);
return 0.5 * Color::new(n.x() + 1.0, n.y() + 1.0, n.z() + 1.0);
}
let unit_direction = unit_vector(r.direction());
let t = 0.5 * (unit_direction.y() + 1.0);
(1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}
pub fn render_image() -> String {
let aspect_ratio = 16.0_f64 / 9.0;
let image_width = 384_i32;
let image_height = (image_width as f64 / aspect_ratio) as i32;
let viewport_height = 2.0_f64;
let viewport_width = aspect_ratio * viewport_height;
let focal_length = 1.0_f64;
let origin = Point3::new(0.0, 0.0, 0.0);
let horizontal = Vec3::new(viewport_width, 0.0, 0.0);
let vertical = Vec3::new(0.0, viewport_height, 0.0);
let lower_left_corner =
origin - horizontal / 2.0 - vertical / 2.0 - Vec3::new(0.0, 0.0, focal_length);
let mut output = String::new();
output.push_str("P3\n");
output.push_str(&format!("{} {}\n", image_width, image_height));
output.push_str("255\n");
for j in (0..image_height).rev() {
for i in 0..image_width {
let u = i as f64 / (image_width - 1) as f64;
let v = j as f64 / (image_height - 1) as f64;
let r = Ray::new(
origin,
lower_left_corner + u * horizontal + v * vertical - origin,
);
let pixel_color = ray_color(&r);
output.push_str(&format!("{}\n", write_color(pixel_color)));
}
}
output
}Implementing the r106-hittable-list Crate
Set up r106-hittable-list/Cargo.toml:
[package]
name = "r106-hittable-list"
version = "0.1.0"
edition = "2024"
[dependencies]
common = { workspace = true }Complete implementation of r106-hittable-list/src/lib.rs. Uses the Hittable trait to place two spheres — a small floating sphere and a large ground sphere.
use common::{Color, Point3, Vec3, unit_vector, write_color, Ray, Hittable, HittableList, Sphere};
fn ray_color(r: &Ray, world: &dyn Hittable) -> Color {
if let Some(rec) = world.hit(r, 0.0, f64::INFINITY) {
return 0.5 * (rec.normal + Color::new(1.0, 1.0, 1.0));
}
let unit_direction = unit_vector(r.direction());
let t = 0.5 * (unit_direction.y() + 1.0);
(1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}
pub fn render_image() -> String {
let aspect_ratio = 16.0_f64 / 9.0;
let image_width = 384_i32;
let image_height = (image_width as f64 / aspect_ratio) as i32;
let viewport_height = 2.0_f64;
let viewport_width = aspect_ratio * viewport_height;
let focal_length = 1.0_f64;
let origin = Point3::new(0.0, 0.0, 0.0);
let horizontal = Vec3::new(viewport_width, 0.0, 0.0);
let vertical = Vec3::new(0.0, viewport_height, 0.0);
let lower_left_corner =
origin - horizontal / 2.0 - vertical / 2.0 - Vec3::new(0.0, 0.0, focal_length);
let mut world = HittableList::new();
world.add(Box::new(Sphere::new(Point3::new(0.0, 0.0, -1.0), 0.5)));
world.add(Box::new(Sphere::new(Point3::new(0.0, -100.5, -1.0), 100.0)));
let mut output = String::new();
output.push_str("P3\n");
output.push_str(&format!("{} {}\n", image_width, image_height));
output.push_str("255\n");
for j in (0..image_height).rev() {
for i in 0..image_width {
let u = i as f64 / (image_width - 1) as f64;
let v = j as f64 / (image_height - 1) as f64;
let r = Ray::new(
origin,
lower_left_corner + u * horizontal + v * vertical - origin,
);
let pixel_color = ray_color(&r, &world);
output.push_str(&format!("{}\n", write_color(pixel_color)));
}
}
output
}The small sphere (center (0, 0, -1), radius 0.5) and the large sphere (center (0, -100.5, -1), radius 100) are placed in the scene. The large sphere acts as a ground plane — with a gently curved surface visible in the lower portion of the image.
WASM Export
Add to raytracing-demos/src/lib.rs:
// Chapter 1.6: Surface Normals and Multiple Objects
#[wasm_bindgen]
pub fn render_sphere_normals() -> String {
r106_sphere_normals::render_image()
}
#[wasm_bindgen]
pub fn render_hittable_list() -> String {
r106_hittable_list::render_image()
}