Skip to content
Copied!
published on 2026-04-05

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 P on a sphere with center C is the vector pointing from C to P:

n^=PCr

For a unit sphere the length is already 1, but for a general sphere explicit normalization by radius r is required.

Normal color map

Each component of the normal lies in [1,1]. Mapping it to [0,1] lets us visualize the normal as an RGB color:

color=n^+12=(nx+12, ny+12, nz+12)

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 t.

The C++ implementation:

cpp
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:

rust
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:

cpp
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:

rust
fn ray_color(r: &Ray) -> Color {
    let center = Point3::new(0.0, 0.0, -1.0);
    let t = hit_sphere(&center, 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 at2+bt+c=0 from the previous chapter, substituting b=2h gives:

b±b24ac2a=h±h2aca

The factor of 2 cancels, simplifying the computation. Using half_b (h):

rust
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:

cpp
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:

rust
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.

cpp
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.

rust
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.

cpp
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.

rust
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.

cpp
std::vector<shared_ptr<hittable>> objects;
objects.push_back(make_shared<sphere>(...));

In Rust, the equivalent is a trait object Box<dyn Hittable>.

rust
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.

rust
// 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:

cpp
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:

rust
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:

cpp
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.

rust
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.

cpp
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:

rust
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

common/src/hittable.rs
rust
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

common/src/sphere.rs
rust
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

common/src/hittable_list.rs
rust
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)

common/src/lib.rs
rust
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:

r106-sphere-normals/Cargo.toml
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
rust
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(&center, 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:

r106-hittable-list/Cargo.toml
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.

r106-hittable-list/src/lib.rs
rust
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:

raytracing-demos/src/lib.rs
rust
// 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()
}