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

8. Diffuse Materials

Ray Tracing in One Weekend (v3.2.3): 8 Diffuse Materials

This chapter implements diffuse materials (matte materials). A diffuse material absorbs light while scattering it in random directions. Three implementations are presented in sequence. Each uses a different probability distribution for the scatter direction, resulting in differences in overall brightness and how shadows look.

How Diffuse Materials Work

When light hits a diffuse material, it simultaneously:

  1. Absorbs: some fraction of the light is absorbed by the surface and lost
  2. Scatters: the remainder bounces off in a random direction

To simulate this, we generate a new scattered ray at the hit point P, recursively call ray_color to shade it, and multiply the result by 0.5 (50% absorption).

Limiting Recursion Depth

Each scattered ray may hit something else and scatter again — recursion that could go on forever. A depth parameter caps the maximum number of bounces; when depth <= 0, black (0, 0, 0) is returned.

The C++ implementation:

cpp
color ray_color(const ray& r, const hittable& world, int depth) {
    if (depth <= 0)
        return color(0,0,0);
    // ...
    return 0.5 * ray_color(scattered, world, depth-1);
}

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);
    }
    // ...
    return 0.5 * ray_color(&scattered, world, depth - 1);
}

Fixing Shadow Acne

Floating-point rounding errors can cause a scattered ray originating at a hit point to intersect the same surface again at t0. This is called shadow acne.

The fix is simple: change t_min in the hit call from 0 to 0.001 to ignore self-intersections at very small t.

rust
// Causes shadow acne.
if let Some(rec) = world.hit(r, 0.0, f64::INFINITY) { ... }

// Fixed.
if let Some(rec) = world.hit(r, 0.001, f64::INFINITY) { ... }

Method 1: Simple Diffuse (Rejection Sampling)

The first implementation picks a random point S inside the unit sphere tangent to the surface on the outside (centered at P+n^), then uses SP as the scatter direction.

The random point is found via the rejection method: repeatedly sample a random point in the unit cube [1,1]3 until one falls inside the unit sphere.

The C++ implementation:

cpp
vec3 random_in_unit_sphere() {
    while (true) {
        auto p = vec3::random(-1, 1);
        if (p.length_squared() >= 1) continue;
        return p;
    }
}

The Rust implementation:

rust
/// Returns a random point **inside** the unit sphere (rejection method).
pub fn random_in_unit_sphere() -> Vec3 {
    use crate::utils::random_double_range;
    loop {
        let p = Vec3::new(
            random_double_range(-1.0, 1.0),
            random_double_range(-1.0, 1.0),
            random_double_range(-1.0, 1.0),
        );
        if p.length_squared() < 1.0 {
            return p;
        }
    }
}

loop is Rust's looping keyword, equivalent to while true. The loop repeats until a point inside the unit sphere is found. On average this takes about 1.91 attempts.

The code to generate the scattered ray:

rust
if let Some(rec) = world.hit(r, 0.001, f64::INFINITY) {
    let target = rec.p + rec.normal + random_in_unit_sphere();
    let scattered = Ray::new(rec.p, target - rec.p);
    return 0.5 * ray_color(&scattered, world, depth - 1);
}

Gamma Correction

The rendered result looks quite dark, because image viewers expect gamma-corrected values. Here we apply γ=2, i.e., taking the square root of each component.

colorout=colorlinear

In C++, this is done inside write_color():

cpp
auto scale = 1.0 / samples_per_pixel;
r = sqrt(scale * r);  // Gamma=2 correction
g = sqrt(scale * g);
b = sqrt(scale * b);

In Rust, we add a write_color_gamma() function. The only difference from write_color is a .sqrt() call after scaling.

rust
/// Averages `pixel_color` over `samples_per_pixel` samples, applies gamma=2 correction (square root), and converts to an "R G B" string.
pub fn write_color_gamma(pixel_color: Color, samples_per_pixel: i32) -> String {
    let scale = 1.0 / samples_per_pixel as f64;
    let r = (pixel_color.x() * scale).sqrt().clamp(0.0, 0.999);
    let g = (pixel_color.y() * scale).sqrt().clamp(0.0, 0.999);
    let b = (pixel_color.z() * scale).sqrt().clamp(0.0, 0.999);
    let ir = (256.0 * r) as i32;
    let ig = (256.0 * g) as i32;
    let ib = (256.0 * b) as i32;
    format!("{} {} {}", ir, ig, ib)
}

From chapter 1.8 onwards we use write_color_gamma. The non-gamma write_color is kept for compatibility with the existing crates from chapters 1.2–1.7.

Method 2: True Lambertian Reflection

The rejection-method random_in_unit_sphere() yields a probability density proportional to cos3ϕ (where ϕ is the angle from the normal). The ideal Lambertian distribution has density proportional to cosϕ.

Picking a random point on the unit sphere surface with random_unit_vector() gives the correct cosϕ distribution.

The C++ implementation:

cpp
vec3 random_unit_vector() {
    return unit_vector(random_in_unit_sphere());
}

The Rust implementation:

rust
/// Returns a random point **on** the unit sphere surface (true Lambertian reflection).
pub fn random_unit_vector() -> Vec3 {
    unit_vector(random_in_unit_sphere())
}

The only change in ray_color is replacing random_in_unit_sphere() with random_unit_vector() in the scatter target:

rust
let target = rec.p + rec.normal + random_unit_vector();

The visual difference is that shadows appear lighter and the sphere looks brighter, because the cosϕ distribution draws more samples toward the normal direction.

Method 3: Uniform Hemisphere Sampling

Early research papers used uniform sampling over the hemisphere oriented around the normal.

The C++ implementation:

cpp
vec3 random_in_hemisphere(const vec3& normal) {
    vec3 in_unit_sphere = random_in_unit_sphere();
    if (dot(in_unit_sphere, normal) > 0.0)
        return in_unit_sphere;
    else
        return -in_unit_sphere;
}

The Rust implementation:

rust
/// Returns a random point in the hemisphere around the normal (uniform hemisphere sampling).
pub fn random_in_hemisphere(normal: Vec3) -> Vec3 {
    let in_unit_sphere = random_in_unit_sphere();
    if dot(in_unit_sphere, normal) > 0.0 {
        in_unit_sphere
    } else {
        -in_unit_sphere
    }
}

The scatter target uses rec.p + random_in_hemisphere(rec.normal) — note that rec.normal is not added separately:

rust
let target = rec.p + random_in_hemisphere(rec.normal);

Visual comparison of all three methods (each with gamma correction and shadow acne fix):

MethodScatter functionProbability densityAppearance
Simple diffuserandom_in_unit_sphere()cos3ϕSomewhat dark, heavy shadows
True Lambertianrandom_unit_vector()cosϕBrighter, lighter shadows
Uniform hemisphererandom_in_hemisphere()UniformBetween the two extremes

Differences Between C++ and Rust

loop vs while(true)

C++'s infinite loop typically uses while(true), but Rust's loop keyword is idiomatic.

rust
// C++
while (true) { ... }

// Rust
loop { ... }

The loop construct also tells the compiler that there is definitely a break or return somewhere inside, which helps with return-type inference.

Calling sqrt

cpp
// C++
double r = sqrt(scale * pixel_color.x());
rust
// Rust
let r = (pixel_color.x() * scale).sqrt();

In Rust, f64::sqrt() is called as a method. There is no free-function sqrt() as in C.

Adding to the common Crate

common/src/vec3.rs
rust
impl Vec3 {
    // ... add to existing methods ...

    /// Returns `true` if all components are very close to zero (zero-vector check).
    pub fn near_zero(self) -> bool {
        const S: f64 = 1e-8;
        self.e[0].abs() < S && self.e[1].abs() < S && self.e[2].abs() < S
    }
}

/// Returns a random point **inside** the unit sphere (rejection method).
pub fn random_in_unit_sphere() -> Vec3 {
    use crate::utils::random_double_range;
    loop {
        let p = Vec3::new(
            random_double_range(-1.0, 1.0),
            random_double_range(-1.0, 1.0),
            random_double_range(-1.0, 1.0),
        );
        if p.length_squared() < 1.0 {
            return p;
        }
    }
}

/// Returns a random point **on** the unit sphere surface (true Lambertian reflection).
pub fn random_unit_vector() -> Vec3 {
    unit_vector(random_in_unit_sphere())
}

/// Returns a random point in the hemisphere around the normal (uniform hemisphere sampling).
pub fn random_in_hemisphere(normal: Vec3) -> Vec3 {
    let in_unit_sphere = random_in_unit_sphere();
    if dot(in_unit_sphere, normal) > 0.0 {
        in_unit_sphere
    } else {
        -in_unit_sphere
    }
}

/// Averages `pixel_color` over `samples_per_pixel` samples, applies gamma=2 correction (square root), and converts to an "R G B" string.
pub fn write_color_gamma(pixel_color: Color, samples_per_pixel: i32) -> String {
    let scale = 1.0 / samples_per_pixel as f64;
    let r = (pixel_color.x() * scale).sqrt().clamp(0.0, 0.999);
    let g = (pixel_color.y() * scale).sqrt().clamp(0.0, 0.999);
    let b = (pixel_color.z() * scale).sqrt().clamp(0.0, 0.999);
    let ir = (256.0 * r) as i32;
    let ig = (256.0 * g) as i32;
    let ib = (256.0 * b) as i32;
    format!("{} {} {}", ir, ig, ib)
}

Implementing the r108-diffuse Crate (Simple Diffuse)

Set up r108-diffuse/Cargo.toml:

r108-diffuse/Cargo.toml
toml
[package]
name = "r108-diffuse"
version = "0.1.0"
edition = "2024"

[dependencies]
common = { workspace = true }
r108-diffuse/src/lib.rs
rust
use common::{
    Camera, Color, Hittable, HittableList, Point3, Sphere,
    unit_vector, write_color_gamma, Ray, random_double, random_in_unit_sphere,
};

fn ray_color(r: &Ray, world: &dyn Hittable, depth: i32) -> Color {
    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }
    // t_min = 0.001 prevents shadow acne.
    if let Some(rec) = world.hit(r, 0.001, f64::INFINITY) {
        let target = rec.p + rec.normal + random_in_unit_sphere();
        let scattered = Ray::new(rec.p, target - rec.p);
        return 0.5 * ray_color(&scattered, world, depth - 1);
    }
    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::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 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 r108-lambertian Crate (True Lambertian)

r108-lambertian/src/lib.rs
rust
use common::{
    Camera, Color, Hittable, HittableList, Point3, Sphere, Vec3,
    unit_vector, write_color_gamma, Ray, random_double, random_unit_vector,
};

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) {
        let target = rec.p + rec.normal + random_unit_vector();
        let scattered = Ray::new(rec.p, target - rec.p);
        return 0.5 * ray_color(&scattered, world, depth - 1);
    }
    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::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 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 r108-hemisphere Crate (Uniform Hemisphere Sampling)

r108-hemisphere/src/lib.rs
rust
use common::{
    Camera, Color, Hittable, HittableList, Point3, Sphere, Vec3,
    unit_vector, write_color_gamma, Ray, random_double, random_in_hemisphere,
};

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) {
        // Uniform sampling from the hemisphere around the normal (no need to add rec.normal)
        let target = rec.p + random_in_hemisphere(rec.normal);
        let scattered = Ray::new(rec.p, target - rec.p);
        return 0.5 * ray_color(&scattered, world, depth - 1);
    }
    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::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 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
}

WASM Export

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

raytracing-demos/src/lib.rs
rust
// Chapter 1.8: Diffuse Materials
#[wasm_bindgen]
pub fn render_diffuse() -> String {
    r108_diffuse::render_image()
}

#[wasm_bindgen]
pub fn render_lambertian() -> String {
    r108_lambertian::render_image()
}

#[wasm_bindgen]
pub fn render_hemisphere() -> String {
    r108_hemisphere::render_image()
}