Supplement: Native Parallel Renderer for Book 3
Following the native parallel renderer for Book 1 and the native parallel renderer for Book 2, I render the final scene from Book 3, introduced in 3.12 Cleaning Up PDF Management, at high resolution with native multithreading. This supplement creates master/native/raytracing-book3-final-scene.
Book 3 focuses on Monte Carlo integration and importance sampling. The WASM version of ray_color, using CosinePdf, HittablePdf, and MixturePdf, was limited to 200×200 at 200 spp. With enough time, the native parallel renderer can produce low-noise images at 800×800 and 1,000 spp or higher.
Differences from the Book 2 Version
The Cargo.toml has the same structure as the Book 2 version, referencing the common crate through a path dependency and using clap, image, and rayon.
[package]
name = "raytracing-book3-final-scene"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
image = "0.25"
rayon = "1"
common = { path = "../../wasm/raytracing/common" }The central difference from the Book 2 version lies in ray_color. Book 3 adds a lights: Arc<dyn Hittable> argument and organizes the scattering branches with the ScatterRecord enum.
fn ray_color(
r: &Ray,
background: Color,
world: &dyn Hittable,
lights: Arc<dyn Hittable>,
depth: i32,
) -> Color {
// ...
}Adding lights changes the Book 2 call ray_color(&ray, background, &world, depth) to ray_color(&ray, background, &world, lights.clone(), max_depth).
Branching on ScatterRecord
Through Book 2, ray_color received Option<(attenuation, scattered)> from mat.scatter(r, &rec) and followed a single path. In Book 3, the material determines whether scattering is specular or diffuse and returns the corresponding ScatterRecord variant.
match mat.scatter_record(r, &rec) {
None => emitted,
Some(ScatterRecord::Specular { attenuation, ray }) => {
// Metal / Dielectric: no PDF is needed; recurse directly
emitted + attenuation * ray_color(&ray, background, world, lights, depth - 1)
}
Some(ScatterRecord::Diffuse { attenuation, pdf: mat_pdf }) => {
// Lambertian: mix with HittablePdf for importance sampling
let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
let mixed = MixturePdf::new(lp, mat_pdf);
let direction = mixed.generate();
let scattered = Ray::with_time(rec.p, direction, r.time());
let pdf_val = mixed.value(direction);
if pdf_val < 1e-10 {
return emitted;
}
let sp = mat.scattering_pdf(r, &rec, &scattered);
emitted + attenuation * sp / pdf_val
* ray_color(&scattered, background, world, lights, depth - 1)
}
}The acne guard, if pdf_val < 1e-10 { return emitted; }, is an important stabilization measure introduced in Book 3. It prevents division from exploding when the PDF of a cosine-weighted direction is extremely close to zero, as occurs for scattering nearly tangent to the surface.
Scene: Cornell Box with a White Box and Glass Sphere
The final scene in Book 3 contains:
- Lambertian walls: green on the right, red on the left, and white on the floor, ceiling, and back wall
- A ceiling light: an
XzRectwrapped inFlipFace, with a whiteDiffuseLightof intensity 15 - A tall white box:
RectBox(165×330×165), rotated 15° around the Y axis and translated to(265, 0, 295) - A glass sphere: a
Spherecentered at(190, 90, 190)with radius 90 and aDielectricrefractive index of 1.5
fn cornell_box() -> (HittableList, Arc<dyn Hittable>) {
// ...walls, ceiling light, floor, ceiling, and back wall...
// Tall white Lambertian box.
let box1: Box<dyn Hittable> = Box::new(RectBox::new(
Point3::new(0.0, 0.0, 0.0),
Point3::new(165.0, 330.0, 165.0),
white,
));
let box1: Box<dyn Hittable> = Box::new(RotateY::new(box1, 15.0));
let box1: Box<dyn Hittable> = Box::new(Translate::new(box1, Vec3::new(265.0, 0.0, 295.0)));
objects.add(box1);
let glass = Arc::new(Dielectric::new(1.5));
objects.add(Box::new(Sphere::with_material(
Point3::new(190.0, 90.0, 190.0),
90.0,
glass,
)));
// Importance-sampling targets: ceiling XzRect + glass sphere
let mut lights_list = HittableList::new();
lights_list.add(Box::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light)));
lights_list.add(Box::new(Sphere::with_material(
Point3::new(190.0, 90.0, 190.0),
90.0,
Arc::new(Dielectric::new(1.5)),
)));
let lights: Arc<dyn Hittable> = Arc::new(lights_list);
(objects, lights)
}Both the ceiling rectangle and glass sphere are registered in lights. HittablePdf::new(lights.clone(), rec.p) importance-samples ray directions toward this list, coordinating the sphere solid-angle methods Sphere::pdf_value and Sphere::random with the list-average and random-selection methods HittableList::pdf_value and HittableList::random introduced in Book 3.
Parallel Loop and PNG Output
The parallelization pattern is identical to the Book 2 version. (0..image_height).into_par_iter() processes rows in parallel, while an AtomicUsize tracks progress. The Book 3 version adds one point to note:
pixel_color += ray_color(&ray, background, &world, lights.clone(), max_depth);Because lights is an Arc<dyn Hittable>, .clone() merely increments the pointer's reference count and has negligible cost. Like world, it is safely shared because each thread only reads the same Arc.
PNG output uses save_with_format. Because image::save() infers the format from the file extension, it fails when given a filename without one, such as final-scene-600px-1000spp.
img.save_with_format(&cli.output, image::ImageFormat::Png)
.expect("Failed to save PNG");Specifying the format explicitly ensures that the image is saved even when the filename does not include .png.
Command-Line Options
Usage: raytracing-book3-final-scene [OPTIONS]
Options:
-o, --output <OUTPUT> Output PNG file path [default: book3-final.png]
--width <WIDTH> Image width in pixels [default: 600]
--height <HEIGHT> Image height in pixels (default: equal to width)
-s, --samples <SAMPLES> Samples per pixel [default: 1000]
--max-depth <MAX_DEPTH> Maximum ray recursion depth [default: 50]
-j <THREADS> Number of worker threads (0 = all cores) [default: 0]
-h, --help Print helpThe default is 600×600 at 1,000 spp. Because the Cornell box is a relatively simple scene, even 1,000 spp completes within a few minutes.
Quality Comparison: The Effect of Sample Count
All the following images are 400×400 and differ only in their sample counts.


Figure 14.1: Left: 20 spp. Right: 50 spp. Coarse noise is visible around the glass sphere.


Figure 14.2: Left: 100 spp. Right: 200 spp. Noise is reduced, but speckles remain in the transmissive parts of the glass sphere even at 200 spp.


Figure 14.3: Left: 500 spp. Right: 1,000 spp. The image finally reaches practical quality at 1,000 spp.

Figure 14.4: 800×800 / 1,000 spp.
While 200×200 at 200 spp was a practical limit for the WASM version, the native parallel renderer can produce an image of sufficient quality in a practical amount of time.
Examples
# Default: 600×600, 1000 samples/px, all cores
./raytracing-book3-final-scene
# Preview: smaller image and fewer samples, 4 threads
./raytracing-book3-final-scene --width 200 -s 50 -j 4 -o preview.png
# High resolution: 800×800 / 1000 spp
./raytracing-book3-final-scene --width 800 -s 1000 -o final-scene-800px-1000spp.pngComplete Implementation
master/native/raytracing-book3-final-scene/Cargo.toml
[package]
name = "raytracing-book3-final-scene"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
image = "0.25"
rayon = "1"
common = { path = "../../wasm/raytracing/common" }master/native/raytracing-book3-final-scene/src/main.rs
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use clap::Parser;
use image::{ImageBuffer, Rgb};
use rayon::prelude::*;
use common::{
Camera, Color, Dielectric, DiffuseLight, FlipFace, Hittable, HittablePdf, HittableList,
Lambertian, MixturePdf, Pdf, Point3, Ray, RectBox, RotateY, ScatterRecord, Sphere,
Translate, Vec3, XyRect, XzRect, YzRect, random_double,
};
#[derive(Parser)]
#[command(
name = "raytracing-book3-final-scene",
about = "Ray Tracing: The Rest of Your Life — Cornell box (glass sphere, native parallel PNG output)"
)]
struct Cli {
/// Output PNG file path
#[arg(short, long, default_value = "book3-final.png")]
output: String,
/// Image width in pixels
#[arg(long, default_value_t = 600)]
width: u32,
/// Image height in pixels (default: equal to width; the original is square)
#[arg(long)]
height: Option<u32>,
/// Samples per pixel
#[arg(short, long, default_value_t = 1000)]
samples: u32,
/// Maximum ray recursion depth
#[arg(long, default_value_t = 50)]
max_depth: i32,
/// Number of worker threads (0 = use all available CPU cores)
#[arg(short = 'j', default_value_t = 0)]
threads: usize,
}
fn ray_color(
r: &Ray,
background: Color,
world: &dyn Hittable,
lights: Arc<dyn Hittable>,
depth: i32,
) -> Color {
if depth <= 0 {
return Color::new(0.0, 0.0, 0.0);
}
let rec = match world.hit(r, 0.001, f64::INFINITY) {
Some(rec) => rec,
None => return background,
};
let mat = match &rec.mat {
Some(mat) => mat.clone(),
None => return Color::new(0.0, 0.0, 0.0),
};
let emitted = mat.emitted(rec.u, rec.v, rec.p, rec.front_face);
match mat.scatter_record(r, &rec) {
None => emitted,
Some(ScatterRecord::Specular { attenuation, ray }) => {
emitted + attenuation * ray_color(&ray, background, world, lights, depth - 1)
}
Some(ScatterRecord::Diffuse { attenuation, pdf: mat_pdf }) => {
let lp = Box::new(HittablePdf::new(lights.clone(), rec.p));
let mixed = MixturePdf::new(lp, mat_pdf);
let direction = mixed.generate();
let scattered = Ray::with_time(rec.p, direction, r.time());
let pdf_val = mixed.value(direction);
if pdf_val < 1e-10 {
return emitted;
}
let sp = mat.scattering_pdf(r, &rec, &scattered);
emitted + attenuation * sp / pdf_val
* ray_color(&scattered, background, world, lights, depth - 1)
}
}
}
fn cornell_box() -> (HittableList, Arc<dyn Hittable>) {
let mut objects = HittableList::new();
let red = Arc::new(Lambertian::new(Color::new(0.65, 0.05, 0.05)));
let white = Arc::new(Lambertian::new(Color::new(0.73, 0.73, 0.73)));
let green = Arc::new(Lambertian::new(Color::new(0.12, 0.45, 0.15)));
let light = Arc::new(DiffuseLight::new(Color::new(15.0, 15.0, 15.0)));
objects.add(Box::new(YzRect::new(0.0, 555.0, 0.0, 555.0, 555.0, green)));
objects.add(Box::new(YzRect::new(0.0, 555.0, 0.0, 555.0, 0.0, red)));
objects.add(Box::new(FlipFace::new(Box::new(
XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light.clone()),
))));
objects.add(Box::new(XzRect::new(0.0, 555.0, 0.0, 555.0, 0.0, white.clone())));
objects.add(Box::new(XzRect::new(0.0, 555.0, 0.0, 555.0, 555.0, white.clone())));
objects.add(Box::new(XyRect::new(0.0, 555.0, 0.0, 555.0, 555.0, white.clone())));
// Tall white Lambertian box.
let box1: Box<dyn Hittable> = Box::new(RectBox::new(
Point3::new(0.0, 0.0, 0.0),
Point3::new(165.0, 330.0, 165.0),
white,
));
let box1: Box<dyn Hittable> = Box::new(RotateY::new(box1, 15.0));
let box1: Box<dyn Hittable> = Box::new(Translate::new(box1, Vec3::new(265.0, 0.0, 295.0)));
objects.add(box1);
let glass = Arc::new(Dielectric::new(1.5));
objects.add(Box::new(Sphere::with_material(
Point3::new(190.0, 90.0, 190.0),
90.0,
glass,
)));
// Lights: ceiling rect + glass sphere (both sampled for importance sampling).
let mut lights_list = HittableList::new();
lights_list.add(Box::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light)));
lights_list.add(Box::new(Sphere::with_material(
Point3::new(190.0, 90.0, 190.0),
90.0,
Arc::new(Dielectric::new(1.5)),
)));
let lights: Arc<dyn Hittable> = Arc::new(lights_list);
(objects, lights)
}
fn color_to_rgb(pixel_color: Color, samples_per_pixel: u32) -> [u8; 3] {
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);
[(256.0 * r) as u8, (256.0 * g) as u8, (256.0 * b) as u8]
}
fn main() {
let cli = Cli::parse();
let image_width = cli.width;
let image_height = cli.height.unwrap_or(image_width);
let aspect_ratio = image_width as f64 / image_height as f64;
let samples_per_pixel = cli.samples;
let max_depth = cli.max_depth;
let total_rows = image_height as usize;
if cli.threads > 0 {
rayon::ThreadPoolBuilder::new()
.num_threads(cli.threads)
.build_global()
.expect("Failed to build thread pool");
}
let thread_count = rayon::current_num_threads();
eprintln!(
"Rendering {}x{}, {} samples/px, depth={}, threads={}",
image_width, image_height, samples_per_pixel, max_depth, thread_count
);
eprintln!("Building scene...");
let (world, lights) = cornell_box();
let background = Color::new(0.0, 0.0, 0.0);
let lookfrom = Point3::new(278.0, 278.0, -800.0);
let lookat = Point3::new(278.0, 278.0, 0.0);
let vup = Point3::new(0.0, 1.0, 0.0);
let dist_to_focus = 10.0_f64;
let aperture = 0.0_f64;
let camera = Camera::new_with_shutter(
lookfrom, lookat, vup, 40.0, aspect_ratio, aperture, dist_to_focus, 0.0, 1.0,
);
let completed = AtomicUsize::new(0);
let rows: Vec<Vec<[u8; 3]>> = (0..image_height)
.into_par_iter()
.map(|j| {
let world_j = image_height - 1 - j;
let row = (0..image_width)
.map(|i| {
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 = (world_j as f64 + random_double()) / (image_height - 1) as f64;
let ray = camera.get_ray(u, v);
pixel_color +=
ray_color(&ray, background, &world, lights.clone(), max_depth);
}
color_to_rgb(pixel_color, samples_per_pixel)
})
.collect();
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
eprint!("\rRows: {}/{}", done, total_rows);
row
})
.collect();
eprintln!("\nWriting {}...", cli.output);
let mut img = ImageBuffer::new(image_width, image_height);
for (j, row) in rows.iter().enumerate() {
for (i, &[r, g, b]) in row.iter().enumerate() {
img.put_pixel(i as u32, j as u32, Rgb([r, g, b]));
}
}
img.save_with_format(&cli.output, image::ImageFormat::Png).expect("Failed to save PNG");
eprintln!("Done: {}", cli.output);
}Summary
- I created the standalone
raytracing-book3-final-sceneprogram inmaster/native/to render the final Book 3 scene—a Cornell box with a white box and glass sphere—using multiple CPU cores and PNG output. - As in the Book 2 version, it references the
common/crate through a path dependency.Hittable: Send + Syncwas already added in Book 2, so no further change is needed. - Book 3 adds
lights: Arc<dyn Hittable>toray_color, branches betweenSpecularandDiffusethroughscatter_record(), and includes the acne guard. save_with_formatreliably saves PNG data regardless of the filename extension.- At 1,000 spp, noise around the glass sphere falls within an acceptable range and the image reaches practical quality.