補講:第 3 編のネイティブ並列レンダラー
1/14 補講:ネイティブ並列レンダラー,2/11 補講:第 2 編のネイティブ並列レンダラー に続き,第 3 編の最終シーン(3/12 PDF 管理の整理)もネイティブ・マルチスレッドで高解像度レンダリングします。本稿では master/native/raytracing-book3-final-scene を作ります。
第 3 編の主題はモンテカルロ積分と重点サンプリングです。CosinePdf / HittablePdf / MixturePdf を駆使した ray_color は,WASM 版では 200×200 / 200 spp を上限としていましたが,ネイティブ並列では十分な時間をかければ 800×800 / 1000 spp 以上のノイズが少ない画像を生成できます。
第 2 編版との違い
Cargo.toml の構成は第 2 編版と同一です(common クレートを path 依存で参照,clap / image / 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" }第 2 編版とのray_color の違いが本稿の核心です。第 3 編では ray_color に lights: Arc<dyn Hittable> 引数が加わり,散乱分岐も ScatterRecord enum で整理されています。
fn ray_color(
r: &Ray,
background: Color,
world: &dyn Hittable,
lights: Arc<dyn Hittable>,
depth: i32,
) -> Color {
// ...
}第 2 編版の ray_color(&ray, background, &world, depth) から lights が増え,呼び出しは ray_color(&ray, background, &world, lights.clone(), max_depth) になります。
ScatterRecord による散乱の分岐
第 2 編版までの ray_color は mat.scatter(r, &rec) の Option<(attenuation, scattered)> を受け取って一本道で処理していました。第 3 編ではマテリアルが鏡面反射(Specular)か拡散(Diffuse)かをその場で決定し,ScatterRecord enum として返します。
match mat.scatter_record(r, &rec) {
None => emitted,
Some(ScatterRecord::Specular { attenuation, ray }) => {
// Metal / Dielectric:PDF 不要,そのまま再帰
emitted + attenuation * ray_color(&ray, background, world, lights, depth - 1)
}
Some(ScatterRecord::Diffuse { attenuation, pdf: mat_pdf }) => {
// Lambertian:HittablePdf と混合して重点サンプリング
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)
}
}アクネガード(if pdf_val < 1e-10 { return emitted; })は第 3 編で導入した重要な安定化措置です。コサイン重み付き方向の PDF が極めて 0 に近くなる状況(ほぼ接線方向の散乱)で除算が爆発するのを防ぎます。
シーン:コーネルボックス(白い箱+ガラス球)
第 3 編の最終シーンは以下の構成です。
- 壁:緑(右)/赤(左)/白(床・天井・奥壁)の Lambertian
- 天井照明:
DiffuseLight(輝度 15 の白色光)をFlipFaceでラップしたXzRect - 背の高い白い箱:
RectBox(165×330×165)を Y 軸まわり 15° 回転,(265, 0, 295)に移動 - ガラス球:
Sphere(中心(190, 90, 190),半径 90),屈折率 1.5 のDielectric
fn cornell_box() -> (HittableList, Arc<dyn Hittable>) {
// ...(壁・天井照明・床・天井・奥壁)
// 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,
)));
// 重点サンプリング対象:天井 XzRect + ガラス球
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)
}lights には天井矩形とガラス球の両方を登録しています。HittablePdf::new(lights.clone(), rec.p) はこのリストに向けて光線方向を重点サンプリングするため,第 3 編の Sphere::pdf_value / Sphere::random(球の立体角 PDF)と HittableList::pdf_value / HittableList::random(リスト平均 PDF,ランダム選択)が連携します。
並列ループと PNG 保存
並列化パターンは第 2 編版と同一です。(0..image_height).into_par_iter() で行単位に並列化し,AtomicUsize で進捗をカウントします。第 3 編版で注意が必要な点が一つあります。
pixel_color += ray_color(&ray, background, &world, lights.clone(), max_depth);lights は Arc<dyn Hittable> なので,.clone() はポインタの参照カウントを増やすだけでコストはほぼゼロです。world と同様,各スレッドが同じ Arc を読むだけで安全に共有できます。
PNG の保存には save_with_format を使っています。image::save() はファイル拡張子からフォーマットを推論するため,拡張子を省いたファイル名(final-scene-600px-1000spp 等)を渡すと保存に失敗します。
img.save_with_format(&cli.output, image::ImageFormat::Png)
.expect("Failed to save PNG");フォーマットを明示することでファイル名に .png が含まれなくても確実に保存できます。
コマンドラインオプション
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デフォルトは 600×600 / 1000 spp です。コーネルボックスは比較的単純なシーンのため,1000 spp でも数分以内に完了します。
品質比較:サンプル数の影響
以下はいずれも 400×400 で,サンプル数のみ変えてレンダリングしたものです。


Figure 14.1: 左:20 spp,右:50 spp。ガラス球周辺に粗いノイズが見える。


Figure 14.2: 左:100 spp,右:200 spp。ノイズは減るが,200 spp でもガラス球の透過部分にぼつぼつが残る。


Figure 14.3: 左:500 spp,右:1000 spp。1000 spp でようやく実用的な品質になる。

Figure 14.4: 800×800 / 1000 spp。
WASM 版では 200×200 / 200 spp が現実的な上限でしたが,ネイティブ並列レンダラーを使えば十分な品質の画像を現実的な時間で生成できます。
実行例
# デフォルト:600×600,1000 samples/px,全コア使用
./raytracing-book3-final-scene
# プレビュー:小さく少ないサンプル,4 スレッド
./raytracing-book3-final-scene --width 200 -s 50 -j 4 -o preview.png
# 高解像度:800×800 / 1000 spp
./raytracing-book3-final-scene --width 800 -s 1000 -o final-scene-800px-1000spp.png完全実装
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);
}まとめ
- 第 3 編の最終シーン(コーネルボックス,白い箱+ガラス球)をCPU マルチコア+PNG 出力でレンダリングする独立プログラム
raytracing-book3-final-sceneをmaster/native/に作成した。 - 第 2 編版と同じく
common/クレートをpath依存で参照する。Hittable: Send + Syncは第 2 編時点で対応済みのため追加変更なし。 - 第 3 編固有の変更点:
ray_colorにlights: Arc<dyn Hittable>が加わり,scatter_record()によるSpecular/Diffuse分岐と,アクネガードが入っている。 save_with_formatで拡張子によらず確実に PNG 保存できる。- 1000 spp でガラス球のノイズが許容範囲に収まり,実用的な品質の画像が得られる。