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:
- Absorbs: some fraction of the light is absorbed by the surface and lost
- Scatters: the remainder bounces off in a random direction
To simulate this, we generate a new scattered ray at the hit point 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:
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:
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
The fix is simple: change t_min in the hit call from 0 to 0.001 to ignore self-intersections at very small
// 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
The random point is found via the rejection method: repeatedly sample a random point in the unit cube
The C++ implementation:
vec3 random_in_unit_sphere() {
while (true) {
auto p = vec3::random(-1, 1);
if (p.length_squared() >= 1) continue;
return p;
}
}The Rust implementation:
/// 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:
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
In C++, this is done inside write_color():
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.
/// 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
Picking a random point on the unit sphere surface with random_unit_vector() gives the correct
The C++ implementation:
vec3 random_unit_vector() {
return unit_vector(random_in_unit_sphere());
}The Rust implementation:
/// 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:
let target = rec.p + rec.normal + random_unit_vector();The visual difference is that shadows appear lighter and the sphere looks brighter, because the
Method 3: Uniform Hemisphere Sampling
Early research papers used uniform sampling over the hemisphere oriented around the normal.
The C++ implementation:
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:
/// 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:
let target = rec.p + random_in_hemisphere(rec.normal);Visual comparison of all three methods (each with gamma correction and shadow acne fix):
| Method | Scatter function | Probability density | Appearance |
|---|---|---|---|
| Simple diffuse | random_in_unit_sphere() | Somewhat dark, heavy shadows | |
| True Lambertian | random_unit_vector() | Brighter, lighter shadows | |
| Uniform hemisphere | random_in_hemisphere() | Uniform | Between 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.
// 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
// C++
double r = sqrt(scale * pixel_color.x());// 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
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:
[package]
name = "r108-diffuse"
version = "0.1.0"
edition = "2024"
[dependencies]
common = { workspace = true }r108-diffuse/src/lib.rs
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
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
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:
// 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()
}