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.
[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.
/// `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!.
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.
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 helpThe 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
# 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.pngQuality Comparison: The Effect of Sample Count
All of the following images are 400×400 and differ only in their sample counts.


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


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


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

Figure 11.4: 800×800 / 1,000 spp.
Summary
- I created the standalone
raytracing-book2-final-sceneprogram inmaster/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/intosrc/rt/, the Book 2 version references thecommon/crate directly as a path dependency because of its increased size. - Accordingly, I added
Send + Syncto the supertraits ofHittableincommon/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 theimagecrate. - Parallelization, progress reporting, and random-number-generator behavior are identical to those of the Book 1 version.