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

9. Metal

Ray Tracing in One Weekend (v3.2.3): 9 Metal

This chapter implements metal materials (specular reflection). To do that, we first introduce an abstract interface for materials and encapsulate the scattering logic in each material class. This also lets us reorganize the Lambertian diffuse from chapter 1.8 as a proper material class.

Abstracting the Material Interface

To handle different materials without branching, we introduce an abstract material interface with the following responsibilities:

  1. Decide whether scattering occurs
  2. If scattering occurs, compute and return the scattered ray and the attenuation color (albedo)

The C++ implementation (material.h):

cpp
class material {
public:
  virtual ~material() {}
  virtual bool scatter(
    const ray& r_in, const hit_record& rec,
    color& attenuation, ray& scattered
  ) const = 0;
};

C++ defines interfaces with pure virtual functions (= 0). Rust uses a trait for the same purpose.

rust
/// Material trait: determines whether scattering occurs and the scattered ray and attenuation color.
pub trait Material: Send + Sync {
    /// Returns `Some((attenuation, scattered_ray))` if the ray scatters, or `None` if it is absorbed.
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)>;
}

The C++ scatter returns results through output parameters (mutable references color& attenuation and ray& scattered), with a bool return value indicating whether scattering occurred. In Rust, returning Option<(Color, Ray)> is more natural.

  • Some((attenuation, scattered)) — scattering occurred
  • None — absorbed (no scatter)

Adding a Material to HitRecord

To pass the material of the hit object to subsequent processing, we add a reference to the material in HitRecord.

The C++ implementation (adding shared_ptr<material> to hit_record):

cpp
struct hit_record {
  point3 p;
  vec3 normal;
  shared_ptr<material> mat_ptr;
  double t;
  bool front_face;
};

The Rust implementation:

rust
use std::sync::Arc;
use crate::material::Material;

pub struct HitRecord {
    pub p: Point3,
    pub normal: Vec3,
    pub t: f64,
    pub front_face: bool,
    pub mat: Option<Arc<dyn Material>>,   // ← added
}

The Rust equivalent of C++'s shared_ptr<material> is Arc<dyn Material>.

C++RustMeaning
shared_ptr<T> (single-thread)Rc<T>Reference counted, single-thread only
shared_ptr<T> (multi-thread)Arc<T>Atomic reference counting, thread-safe

Arc stands for Atomically Reference Counted. The WASM runtime is single-threaded, but using dyn Material through Arc requires the trait to implement Send + Sync. Since the trait definition requires : Send + Sync, we use Arc.

Circular Dependency Between Material and HitRecord

material.rs references HitRecord, and hittable.rs references Material — a circular dependency between the two modules.

C++ resolves this with a forward declaration (class material;):

cpp
// hittable.h
class material;  // Forward declaration

struct hit_record {
    shared_ptr<material> mat_ptr;
};

In Rust, modules within the same crate can reference each other freely. The Rust compiler processes the entire crate at once, so forward declarations are not needed.

rust
// material.rs references hittable.rs
use crate::hittable::HitRecord;

// hittable.rs references material.rs
use crate::material::Material;

Reimplementing Lambertian as a Material

In chapter 1.8, the scatter direction was computed directly inside ray_color(). In chapter 1.9, it is encapsulated in the scatter() method of the Lambertian struct.

rust
pub struct Lambertian {
    pub albedo: Color,
}

impl Material for Lambertian {
    fn scatter(&self, _r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
        let mut scatter_direction = rec.normal + random_unit_vector();
        // Fall back to the normal direction if the scatter direction degenerates to a zero vector.
        if scatter_direction.near_zero() {
            scatter_direction = rec.normal;
        }
        let scattered = Ray::new(rec.p, scatter_direction);
        Some((self.albedo, scattered))
    }
}

The near_zero() check prevents the scatter direction from degenerating into a zero vector when the normal and the random vector are nearly opposite. The leading _ on _r_in means "unused argument" and suppresses the compiler's unused-variable warning.

The Mathematics of Specular Reflection

Metal reflects rays as specular reflection. Given an incident ray v and a unit surface normal n^, we want the reflected ray r.

Let b be the projection of v onto the normal (with reversed sign):

b=(vn^)n^

The reflected ray is:

r=v+2b=v2(vn^)n^

The C++ implementation (free function added to vec3.h):

cpp
vec3 reflect(const vec3& v, const vec3& n) {
    return v - 2 * dot(v, n) * n;
}

The Rust implementation (added to vec3.rs):

rust
/// Computes the reflection vector (v: incident direction, n: unit surface normal).
///
/// **r** = **v** - 2(**v**·**n**)**n**
pub fn reflect(v: Vec3, n: Vec3) -> Vec3 {
    v - 2.0 * dot(v, n) * n
}

The Metal Material

We implement the metal material using the reflection vector. The incident ray direction is normalized before computing the reflection, and if the reflected ray goes below the surface it is absorbed (None).

The C++ implementation:

cpp
class metal : public material {
public:
  metal(const color& a) : albedo(a) {}

  virtual bool scatter(
    const ray& r_in, const hit_record& rec,
    color& attenuation, ray& scattered
  ) const {
    vec3 reflected = reflect(unit_vector(r_in.direction()), rec.normal);
    scattered = ray(rec.p, reflected);
    attenuation = albedo;
    return (dot(scattered.direction(), rec.normal) > 0);
  }

  color albedo;
};

The Rust implementation (final version including fuzzy reflection):

rust
impl Material for Metal {
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
        let reflected = reflect(unit_vector(r_in.direction()), rec.normal);
        let scattered = Ray::new(
            rec.p,
            reflected + self.fuzz * random_in_unit_sphere(),
        );
        // Absorb the ray if the scattered direction goes below the surface.
        if dot(scattered.direction(), rec.normal) > 0.0 {
            Some((self.albedo, scattered))
        } else {
            None
        }
    }
}

Updating ray_color

We update ray_color to delegate scattering to the material.

The C++ implementation:

cpp
color ray_color(const ray& r, const hittable& world, int depth) {
    if (depth <= 0) return color(0,0,0);
    hit_record rec;
    if (world.hit(r, 0.001, infinity, rec)) {
        ray scattered;
        color attenuation;
        if (rec.mat_ptr->scatter(r, rec, attenuation, scattered))
            return attenuation * ray_color(scattered, world, depth-1);
        return color(0,0,0);
    }
    // ... background color ...
}

The Rust implementation:

rust
fn ray_color(r: &Ray, world: &dyn Hittable, depth: i32) -> Color {
    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }
    if let Some(rec) = world.hit(r, 0.001, f64::INFINITY) {
        if let Some(mat) = &rec.mat {
            if let Some((attenuation, scattered)) = mat.scatter(r, &rec) {
                return attenuation * ray_color(&scattered, world, depth - 1);
            }
        }
        return Color::new(0.0, 0.0, 0.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)
}

if let Some(mat) = &rec.mat safely skips the case where mat is None (no material assigned). Because mat.scatter(r, &rec) returns Option<(Color, Ray)>, the nested if let Some((attenuation, scattered)) = ... unpacks the tuple in one go. The two C++ output parameters and a bool return value become a single Option type in Rust.

Fuzzy Reflection

With perfect specular reflection, mirror-like objects look unrealistically sharp. Perturbing the endpoint of the reflected ray by a random point inside a sphere of radius fuzz blurs the reflection.

scattered=reflected+fuzz×random_in_unit_sphere()
  • fuzz = 0.0 → perfect mirror
  • fuzz = 1.0 → maximum blur
rust
pub struct Metal {
    pub albedo: Color,
    /// Fuzz factor (0.0 = perfect mirror, 1.0 = maximum blur).
    pub fuzz: f64,
}

impl Metal {
    pub fn new(albedo: Color, fuzz: f64) -> Self {
        Metal {
            albedo,
            fuzz: fuzz.min(1.0),
        }
    }
}

fuzz.min(1.0) is the Rust equivalent of the C++ ternary f < 1 ? f : 1, called as a method on f64.

Differences Between C++ and Rust

FeatureC++Rust
Abstract interfacePure virtual virtual ... = 0trait Material { ... }
Dynamic dispatchvirtual (vtable)dyn Material (vtable)
Reference-counted sharingshared_ptr<material>Arc<dyn Material>
Circular dependencyForward declaration class material;Not needed (modules can reference each other)
scatter return valuebool (with output parameters)Option<(Color, Ray)>
Clamping to upper boundf < 1 ? f : 1fuzz.min(1.0)
Unused argumentOmit the argument name_ prefix (e.g., _r_in)

Adding to the common Crate

common/src/material.rs (new file)

common/src/material.rs
rust
use crate::hittable::HitRecord;
use crate::ray::Ray;
use crate::vec3::{Color, dot, random_in_unit_sphere, random_unit_vector, reflect, unit_vector};

/// Material trait: determines whether scattering occurs and the scattered ray and attenuation color.
pub trait Material: Send + Sync {
    /// Returns `Some((attenuation, scattered_ray))` if the ray scatters, or `None` if it is absorbed.
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)>;
}

/// Lambertian (diffuse) material.
pub struct Lambertian {
    /// Albedo (diffuse color).
    pub albedo: Color,
}

impl Lambertian {
    /// Creates a `Lambertian` material with the given `albedo`.
    pub fn new(albedo: Color) -> Self {
        Lambertian { albedo }
    }
}

impl Material for Lambertian {
    fn scatter(&self, _r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
        let mut scatter_direction = rec.normal + random_unit_vector();
        if scatter_direction.near_zero() {
            scatter_direction = rec.normal;
        }
        let scattered = Ray::new(rec.p, scatter_direction);
        Some((self.albedo, scattered))
    }
}

/// Metal (specular reflection) material.
pub struct Metal {
    /// Albedo (specular color).
    pub albedo: Color,
    /// Fuzz factor (0.0 = perfect mirror, 1.0 = maximum blur).
    pub fuzz: f64,
}

impl Metal {
    /// Creates a `Metal` material. `fuzz` is clamped to [0, 1].
    pub fn new(albedo: Color, fuzz: f64) -> Self {
        Metal { albedo, fuzz: fuzz.min(1.0) }
    }
}

impl Material for Metal {
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
        let reflected = reflect(unit_vector(r_in.direction()), rec.normal);
        let scattered = Ray::new(
            rec.p,
            reflected + self.fuzz * random_in_unit_sphere(),
        );
        if dot(scattered.direction(), rec.normal) > 0.0 {
            Some((self.albedo, scattered))
        } else {
            None
        }
    }
}

common/src/vec3.rs (addition)

common/src/vec3.rs
rust
/// Computes the reflection vector (v: incident direction, n: unit surface normal).
///
/// **r** = **v** - 2(**v**·**n**)**n**
pub fn reflect(v: Vec3, n: Vec3) -> Vec3 {
    v - 2.0 * dot(v, n) * n
}

common/src/sphere.rs (changes)

common/src/sphere.rs
rust
use std::sync::Arc;
use crate::material::Material;

/// A sphere defined by a center, a radius, and an optional material.
pub struct Sphere {
    /// Center of the sphere.
    pub center: Point3,
    /// Radius of the sphere (negative radius creates a hollow sphere).
    pub radius: f64,
    /// Material of the sphere (`None` means no material is assigned).
    pub material: Option<Arc<dyn Material>>,
}

impl Sphere {
    /// Creates a sphere without a material (backward-compatible with earlier chapters).
    pub fn new(center: Point3, radius: f64) -> Self {
        Sphere { center, radius, material: None }
    }

    /// Creates a sphere with a material (used from chapter 1.9 onward).
    pub fn with_material(center: Point3, radius: f64, material: Arc<dyn Material>) -> Self {
        Sphere { center, radius, material: Some(material) }
    }
}

Inside hit(), add mat: self.material.clone() when constructing the HitRecord. Arc::clone only increments the reference count; it does not copy the data.

Implementing the r109-metal Crate

Set up r109-metal/Cargo.toml:

r109-metal/Cargo.toml
toml
[package]
name = "r109-metal"
version = "0.1.0"
edition = "2024"

[dependencies]
common = { workspace = true }
r109-metal/src/lib.rs
rust
use std::sync::Arc;
use common::{
    Camera, Color, Hittable, HittableList, Lambertian, Metal, Point3, Sphere, Vec3,
    unit_vector, write_color_gamma, Ray, random_double,
};

fn ray_color(r: &Ray, world: &dyn Hittable, depth: i32) -> Color {
    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }
    if let Some(rec) = world.hit(r, 0.001, f64::INFINITY) {
        if let Some(mat) = &rec.mat {
            if let Some((attenuation, scattered)) = mat.scatter(r, &rec) {
                return attenuation * ray_color(&scattered, world, depth - 1);
            }
        }
        return Color::new(0.0, 0.0, 0.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 samples_per_pixel = 50_i32;
    let max_depth = 20_i32;

    let mut world = HittableList::new();
    world.add(Box::new(Sphere::with_material(
        Point3::new(0.0, 0.0, -1.0), 0.5,
        Arc::new(Lambertian::new(Color::new(0.7, 0.3, 0.3))),
    )));
    world.add(Box::new(Sphere::with_material(
        Point3::new(0.0, -100.5, -1.0), 100.0,
        Arc::new(Lambertian::new(Color::new(0.8, 0.8, 0.0))),
    )));
    world.add(Box::new(Sphere::with_material(
        Point3::new(1.0, 0.0, -1.0), 0.5,
        Arc::new(Metal::new(Color::new(0.8, 0.6, 0.2), 0.0)),
    )));
    world.add(Box::new(Sphere::with_material(
        Point3::new(-1.0, 0.0, -1.0), 0.5,
        Arc::new(Metal::new(Color::new(0.8, 0.8, 0.8), 0.0)),
    )));

    let camera = Camera::new(
        Point3::new(0.0, 0.0, 0.0),
        Point3::new(0.0, 0.0, -1.0),
        Vec3::new(0.0, 1.0, 0.0),
        90.0,
        aspect_ratio,
        0.0,
        1.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 mut pixel_color = Color::new(0.0, 0.0, 0.0);
            for _ in 0..samples_per_pixel {
                let u = (i as f64 + random_double()) / (image_width - 1) as f64;
                let v = (j as f64 + random_double()) / (image_height - 1) as f64;
                let r = camera.get_ray(u, v);
                pixel_color += ray_color(&r, &world, max_depth);
            }
            output.push_str(&format!("{}\n", write_color_gamma(pixel_color, samples_per_pixel)));
        }
    }

    output
}

Implementing the r109-metal-fuzz Crate (Fuzzy Reflection)

r109-metal-fuzz has the same structure as r109-metal, with the right sphere changed to fuzz=0.3 and the left sphere to fuzz=1.0.

rust
// Right: Metal (gold, fuzz=0.3)
world.add(Box::new(Sphere::with_material(
    Point3::new(1.0, 0.0, -1.0), 0.5,
    Arc::new(Metal::new(Color::new(0.8, 0.6, 0.2), 0.3)),
)));
// Left: Metal (silver, fuzz=1.0)
world.add(Box::new(Sphere::with_material(
    Point3::new(-1.0, 0.0, -1.0), 0.5,
    Arc::new(Metal::new(Color::new(0.8, 0.8, 0.8), 1.0)),
)));

WASM Export

Add to raytracing-demos/src/lib.rs:

raytracing-demos/src/lib.rs
rust
// Chapter 1.9: Metal Materials
#[wasm_bindgen]
pub fn render_metal() -> String {
    r109_metal::render_image()
}

#[wasm_bindgen]
pub fn render_metal_fuzz() -> String {
    r109_metal_fuzz::render_image()
}