Skip to content
Copied!
published on 2026-09-17

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.

master/native/raytracing-book3-final-scene/Cargo.toml
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" }

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.

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

src/main.rs
rust
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 XzRect wrapped in FlipFace, with a white DiffuseLight of 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 Sphere centered at (190, 90, 190) with radius 90 and a Dielectric refractive index of 1.5
src/main.rs
rust
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:

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

src/main.rs
rust
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 help

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

20 spp
50 spp

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

100 spp
200 spp

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.

500 spp
1000 spp

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

800px, 1000 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

bash
# 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.png

Complete Implementation

master/native/raytracing-book3-final-scene/Cargo.toml
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
rust
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-scene program in master/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 + Sync was already added in Book 2, so no further change is needed.
  • Book 3 adds lights: Arc<dyn Hittable> to ray_color, branches between Specular and Diffuse through scatter_record(), and includes the acne guard.
  • save_with_format reliably 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.