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

Supplement: Native Parallel Renderer for Book 2

In Supplement: Native Parallel Renderer, I created the standalone raytracing-book1-final-scene program to render the final scene from Book 1 in parallel on a native platform. The final scene from Book 2 (2.10 A Scene Testing All New Features) can practically reach only 300×300 at 50 samples/px in WASM, but a multi-threaded native program can render closer to the resolution and sample count of the original. In this supplement, I create the standalone raytracing-book2-final-scene program in master/native/.

Differences from the Book 1 Version

The Book 1 version, raytracing-book1-final-scene, copied all code corresponding to the common/ crate into src/rt/, making it completely independent of the WASM workspace. Book 2 presents a different situation. At the end of Book 1, common/ contained nine modules: vec3, ray, hittable, hittable_list, sphere, material, camera, utils, and lib.rs. Book 2 adds another nine—texture, perlin, image_texture, aarect, rect_box, instance, constant_medium, bvh, and moving_sphere—more than doubling its size. Copying all of them into src/rt/ again would be wasteful and difficult to maintain, so the Book 2 version instead references the common/ crate directly as a path dependency.

master/native/raytracing-book2-final-scene/Cargo.toml
toml
[package]
name = "raytracing-book2-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 single line common = { path = "../../wasm/raytracing/common" } reuses the shared library from the WASM workspace directly. The r210-final-scene crate inside that workspace returns PPM data as a String through wasm-bindgen, whereas this native version has no dependency on wasm-bindgen and builds as a plain CLI binary. The common/ crate itself also has no wasm-bindgen dependency, so it can be reused across both targets without difficulty.

A Small Change to common/: Hittable: Send + Sync

I apply the same change made in the Book 1 supplement, this time directly to common/. Specifically, I add Send + Sync as supertraits of Hittable in common/src/hittable.rs.

common/src/hittable.rs
rust
/// `Send + Sync` is required so that a fully built world (e.g. a `BvhNode`
/// holding `Arc<dyn Hittable>` children) can be shared across rayon threads
/// in the native multi-threaded renderer.
pub trait Hittable: Send + Sync {
    fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord>;
    fn bounding_box(&self, time0: f64, time1: f64) -> Option<Aabb> { None }
}

This change also affects the WASM build, but every implementation in common/ consists only of Arc values and primitive types and therefore satisfies Send + Sync automatically. All demos continue to build without additional changes. Material and Texture already declared Send + Sync when Book 2 was ported, so neither requires modification.

Handling the earthmap.jpg Asset

The native version also needs the Earth texture. As in the Book 1 program, I embed it in the binary, loading r206-earth/assets/earthmap.jpg from the WASM workspace with include_bytes!.

src/main.rs
rust
const EARTHMAP_JPG: &[u8] =
    include_bytes!("../../../wasm/raytracing/r206-earth/assets/earthmap.jpg");

// ...

let earth_image = image::load_from_memory(EARTHMAP_JPG)
    .expect("failed to decode embedded earthmap.jpg")
    .to_rgb8();
let (w, h) = earth_image.dimensions();
let earth_texture: Arc<dyn Texture> = Arc::new(ImageTexture::from_rgb_bytes(
    earth_image.into_raw(), w, h,
));

The WASM r210-final-scene uses build.rs to generate decoded RGB bytes and dimension constants, which it then loads with include_bytes!. The native version only needs to decode the image once at startup, so it omits build.rs and embeds the JPEG bytes as-is. The image crate is already included in the dependencies, so this adds no new dependency.

Scene Definition

The code that constructs the scene is identical to r210-final-scene::final_scene. The only change is to replace render_image(), which returns a String, with logic that returns each row as a Vec<[u8; 3]> and then stores the rows in an image::ImageBuffer as a PNG. The geometry, materials, and camera settings are all reused unchanged.

Parallel Loop and Progress Display

This uses the same pattern as the Book 1 supplement. Rows are processed in parallel with (0..image_height).into_par_iter().map(|j| ...), while an AtomicUsize counts completed rows and eprint! displays \rRows: x/y. Because world: HittableList satisfies Hittable: Send + Sync, each closure can simply borrow &world for concurrent access.

src/main.rs
rust
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, 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();

In Book 2, ray_color changed to accept an explicit background color, so this version calls the four-argument ray_color(&ray, background, &world, depth) rather than the Book 1 form ray_color(&ray, &world, depth). Setting background = Color::new(0.0, 0.0, 0.0) reproduces the original scene in which everything except the light source is completely dark.

Command-Line Options

The clap derive macro follows the same pattern as the Book 1 version. The default resolution and sample count are adjusted specifically for Book 2 to approach those of the original.

Usage: raytracing-book2-final-scene [OPTIONS]

Options:
  -o, --output <OUTPUT>        Output PNG file path [default: book2-final.png]
      --width <WIDTH>          Image width in pixels [default: 800]
      --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 original uses samples_per_pixel = 10000, but even a native parallel renderer can take tens of minutes or hours at 10,000 spp. A practical approach is to render an initial image at 1000 spp, where some fireflies remain, and increase the value to -s 10000 when necessary. Unlike the final scene from Book 1, this scene has no geometry parameter such as --grid; its contents are fixed.

Examples

bash
# Default: 800×800, 1000 samples/px, all cores
./raytracing-book2-final-scene

# Preview: smaller image and fewer samples, 4 threads
./raytracing-book2-final-scene --width 200 -s 50 -j 4 -o preview.png

# High quality: the original 800×800 / 10000 spp
./raytracing-book2-final-scene -s 10000 -o hq.png

Quality Comparison: The Effect of Sample Count

All of the following images are 400×400 and differ only in their sample counts.

20 spp
50 spp

Figure 11.1: Left: 20 spp. Right: 50 spp. Noise is conspicuous throughout, with many fireflies around the light source.

100 spp
200 spp

Figure 11.2: Left: 100 spp. Right: 200 spp. Noise is reduced, but mottling remains on the Earth-textured sphere and volume smoke.

500 spp
1000 spp

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

800px, 1000 spp

Figure 11.4: 800×800 / 1,000 spp.

Summary

  • I created the standalone raytracing-book2-final-scene program in master/native/ to render the final scene from Book 2 using multiple CPU cores and PNG output.
  • Whereas the Book 1 version copied all of common/ into src/rt/, the Book 2 version references the common/ crate directly as a path dependency because of its increased size.
  • Accordingly, I added Send + Sync to the supertraits of Hittable in common/src/hittable.rs. This does not affect the behavior of the WASM version.
  • The Earth texture is embedded directly as JPEG data with include_bytes! and decoded at startup by the image crate.
  • Parallelization, progress reporting, and random-number-generator behavior are identical to those of the Book 1 version.