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

10. A Scene Testing All New Features

To conclude Book 2, I construct a scene that uses every feature added so far. The original renders at 800 × 800 with 10,000 samples per pixel and can take hours on a CPU. To keep the WASM version practical, this site uses a more modest 300 × 300 resolution, 50 samples, and a maximum depth of 20. Even so, all of the following features appear together in a single image:

  • BVH—400 ground boxes and a cluster of 1,000 small spheres
  • MovingSphere (motion blur)—a Lambertian sphere moving horizontally
  • Dielectric / Metal—a glass sphere and a fuzzy metal sphere
  • ConstantMedium—a colored medium inside glass and thin fog covering the entire scene
  • ImageTexture—an Earth-textured sphere
  • NoiseTexture (Marble mode)—a Perlin marble sphere
  • DiffuseLight—a large ceiling area light
  • RotateY + Translate—rotation and translation of the small-sphere cluster

In other words, the scene directly calls almost every feature added to the shared common/ library during Book 2.

Ground: Uneven Blocks in a BVH

Rather than using one flat rectangle for the ground, I arrange 20 × 20 = 400 boxes and randomize their heights. Besides making the scene more lively, this demonstrates the effect from Section 2.3 by placing 400 hittables in a BVH instead of a linear list.

r210-final-scene/src/lib.rs
rust
let ground = Arc::new(Lambertian::new(Color::new(0.48, 0.83, 0.53)));
let boxes_per_side = 20;
let mut boxes1: Vec<Arc<dyn Hittable>> = Vec::with_capacity(boxes_per_side * boxes_per_side);
for i in 0..boxes_per_side {
    for j in 0..boxes_per_side {
        let w = 100.0;
        let x0 = -1000.0 + i as f64 * w;
        let z0 = -1000.0 + j as f64 * w;
        let y1 = random_double_range(1.0, 101.0);
        boxes1.push(Arc::new(RectBox::new(
            Point3::new(x0, 0.0, z0),
            Point3::new(x0 + w, y1, z0 + w),
            ground.clone(),
        )));
    }
}
objects.add(Box::new(BvhNode::new(boxes1, 0.0, 1.0)));

BvhNode::new accepts Vec<Arc<dyn Hittable>>, while HittableList::add accepts Box<dyn Hittable>, so only this part uses Arc. The difference arises because child nodes within the BVH require shared ownership, as described in Section 2.3.

Simple Spheres: Motion Blur, Glass, and Metal

r210-final-scene/src/lib.rs
rust
// Moving Lambertian.
let center1 = Point3::new(400.0, 400.0, 200.0);
let center2 = center1 + Vec3::new(30.0, 0.0, 0.0);
objects.add(Box::new(MovingSphere::new(
    center1, center2, 0.0, 1.0, 50.0,
    Arc::new(Lambertian::new(Color::new(0.7, 0.3, 0.1))),
)));

// Glass.
objects.add(Box::new(Sphere::with_material(
    Point3::new(260.0, 150.0, 45.0), 50.0,
    Arc::new(Dielectric::new(1.5)),
)));

// Fuzzy metal.
objects.add(Box::new(Sphere::with_material(
    Point3::new(0.0, 150.0, 145.0), 50.0,
    Arc::new(Metal::new(Color::new(0.8, 0.8, 0.9), 1.0)),
)));

The camera shutter remains open over the interval [0,1], during which the moving sphere travels 30 units along the x axis. The horizontal blur visible on this sphere in the completed image is the effect introduced in Section 2.2.

A Colored Glass Volume

Here is a small technique for putting colored smoke inside a glass sphere. I create two Dielectric spheres: one serves as the visible glass surface, and the other as the boundary of ConstantMedium.

r210-final-scene/src/lib.rs
rust
// Visible glass surface.
objects.add(Box::new(Sphere::with_material(
    Point3::new(360.0, 150.0, 145.0), 70.0,
    Arc::new(Dielectric::new(1.5)),
)));
// Boundary of the colored medium (same sphere, used inside ConstantMedium).
let boundary = Sphere::with_material(
    Point3::new(360.0, 150.0, 145.0), 70.0,
    Arc::new(Dielectric::new(1.5)),
);
objects.add(Box::new(ConstantMedium::new(
    Box::new(boundary),
    0.2,
    Color::new(0.2, 0.4, 0.9),
)));

Differences Between C++ and Rust

The original C++ creates shared_ptr<hittable> boundary = make_shared<sphere>(...);, then passes the same pointer both to objects.add(boundary) and to make_shared<constant_medium>(boundary, ...). Because objects.add in the Rust version requires Box<dyn Hittable>, I reproduce the equivalent behavior by creating two identical Sphere values. Switching to one Arc would more closely resemble the original, but it would require changing the signature of HittableList::add and produce a much wider ripple of changes. Creating two instances is preferable because a Sphere is a value of only about 24 bytes, making the cost negligible.

A medium with density 0.2 and color (0.2,0.4,0.9) fills the sphere, producing the appearance of clear glass on the outside and cloudy blue material within.

Thin Fog over the Entire Scene

I enclose the entire scene in an enormous glass sphere with a radius of 5,000, then place an extremely low-density (d=0.0001) white ConstantMedium inside it. This makes distant objects appear hazy.

r210-final-scene/src/lib.rs
rust
let global_boundary = Sphere::with_material(
    Point3::new(0.0, 0.0, 0.0), 5000.0,
    Arc::new(Dielectric::new(1.5)),
);
objects.add(Box::new(ConstantMedium::new(
    Box::new(global_boundary),
    0.0001,
    Color::new(1.0, 1.0, 1.0),
)));

Although it may appear that directly visible objects unaffected by refraction through the glass sphere would remain unchanged, scattering actually occurs according to the path length through the medium. A value of d=0.0001 corresponds to roughly a 0.01% chance of scattering per unit distance, producing a subtle effect on rays that traverse the hundreds of units spanning the scene.

Textured Spheres: Earth and Marble

r210-final-scene/src/lib.rs
rust
let earth_texture: Arc<dyn Texture> = Arc::new(ImageTexture::from_rgb_bytes(
    EARTHMAP_RGB.to_vec(), EARTHMAP_WIDTH, EARTHMAP_HEIGHT,
));
objects.add(Box::new(Sphere::with_material(
    Point3::new(400.0, 200.0, 400.0), 100.0,
    Arc::new(Lambertian::with_texture(earth_texture)),
)));

let pertext: Arc<dyn Texture> = Arc::new(NoiseTexture::new(NoiseMode::Marble, 0.1));
objects.add(Box::new(Sphere::with_material(
    Point3::new(220.0, 280.0, 300.0), 80.0,
    Arc::new(Lambertian::with_texture(pertext)),
)));

The Earth texture uses the same assets/earthmap.jpg as r206-earth from Section 2.6. The build.rs script decodes the image and embeds its byte sequence. The asset is shared with r206 and referenced from build.rs using the relative path ../r206-earth/assets/earthmap.jpg, avoiding a second committed copy of the JPEG.

r210-final-scene/build.rs
rust
let asset = "../r206-earth/assets/earthmap.jpg";

A 1,000-Sphere Cluster with Instance Transforms

The final showpiece places 1,000 small white spheres randomly throughout the cubic region [0,165]3, packs them into a BVH, and then rotates and translates the result.

r210-final-scene/src/lib.rs
rust
let white = Arc::new(Lambertian::new(Color::new(0.73, 0.73, 0.73)));
let ns = 1000;
let mut boxes2: Vec<Arc<dyn Hittable>> = Vec::with_capacity(ns);
for _ in 0..ns {
    let p = Point3::new(
        random_double_range(0.0, 165.0),
        random_double_range(0.0, 165.0),
        random_double_range(0.0, 165.0),
    );
    boxes2.push(Arc::new(Sphere::with_material(p, 10.0, white.clone())));
}
let cluster: Box<dyn Hittable> = Box::new(BvhNode::new(boxes2, 0.0, 1.0));
let cluster: Box<dyn Hittable> = Box::new(RotateY::new(cluster, 15.0));
let cluster: Box<dyn Hittable> = Box::new(Translate::new(cluster, Vec3::new(-100.0, 270.0, 395.0)));
objects.add(cluster);

The shadowing pattern seen in Section 2.8 is again useful here. A RotateY wraps one BvhNode, and a Translate wraps that result. With these three layers, 1,001 Hittable objects—the 1,000 spheres and the BVH's internal nodes—are added to the scene as a single Box<dyn Hittable>. This illustrates the power of combining a BVH with instance transforms.

Rendering Settings

Here are the camera and final rendering settings.

r210-final-scene/src/lib.rs
rust
let aspect_ratio = 1.0_f64;
let image_width = 300_i32;
let samples_per_pixel = 50_i32;
let max_depth = 20_i32;

let lookfrom = Point3::new(478.0, 278.0, -600.0);
let lookat = Point3::new(278.0, 278.0, 0.0);
let camera = Camera::new_with_shutter(
    lookfrom, lookat, Vec3::new(0.0, 1.0, 0.0),
    40.0, aspect_ratio, 0.0, 10.0, 0.0, 1.0,
);
let background = Color::new(0.0, 0.0, 0.0);

The original uses samples_per_pixel = 10000, while the much more modest value of 50 used here leaves visible fireflies, especially excessively bright outlier pixels, as well as noise throughout the image. The importance sampling introduced in Book 3 dramatically improves both problems. Limiting the maximum depth to 20 is another compromise for WASM in a scene where repeated scattering can occur inside the colored glass. Increasing it to the original value of 50 makes rendering uncomfortably slow in WASM.

Summary

  • I implemented r210-final-scene, a crate that combines every feature added in Book 2—BVHs, motion blur, glass, metal, Lambertian materials, constant-density media, image and noise textures, rectangular lights, and rotated and translated instances—in a single image.
  • The Earth texture JPEG is referenced from r206-earth by relative path in build.rs, avoiding duplicate assets.
  • Resolution, sample count, and maximum depth are reduced for WASM execution, so the result is not as smooth as the original image, but it demonstrates all features working together.

This completes the Rust + WebAssembly port of every chapter in Ray Tracing: The Next Week. Book 3, Ray Tracing: The Rest of Your Life, confronts the remaining noise problem with importance sampling. In particular, it completely rebuilds the sampling pipeline by introducing:

  1. Explicit treatment of PDFs (probability density functions)
  2. A sampling strategy that aims rays toward light sources
  3. Cosine-weighted Lambertian sampling
  4. Mixtures of multiple PDFs

The next book is the most theory-oriented part of the series, exposing the sampling mechanics that have remained implicit until now.