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

補講:第 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)。

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" }

第 2 編版とのray_color の違いが本稿の核心です。第 3 編では ray_colorlights: Arc<dyn Hittable> 引数が加わり,散乱分岐も ScatterRecord enum で整理されています。

src/main.rs
rust
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_colormat.scatter(r, &rec)Option<(attenuation, scattered)> を受け取って一本道で処理していました。第 3 編ではマテリアルが鏡面反射(Specular)か拡散(Diffuse)かをその場で決定し,ScatterRecord enum として返します。

src/main.rs
rust
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
src/main.rs
rust
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 編版で注意が必要な点が一つあります。

src/main.rs
rust
pixel_color += ray_color(&ray, background, &world, lights.clone(), max_depth);

lightsArc<dyn Hittable> なので,.clone() はポインタの参照カウントを増やすだけでコストはほぼゼロです。world と同様,各スレッドが同じ Arc を読むだけで安全に共有できます。

PNG の保存には save_with_format を使っています。image::save() はファイル拡張子からフォーマットを推論するため,拡張子を省いたファイル名(final-scene-600px-1000spp 等)を渡すと保存に失敗します。

src/main.rs
rust
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 で,サンプル数のみ変えてレンダリングしたものです。

20 spp
50 spp

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

100 spp
200 spp

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

500 spp
1000 spp

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

800px, 1000 spp

Figure 14.4: 800×800 / 1000 spp。

WASM 版では 200×200 / 200 spp が現実的な上限でしたが,ネイティブ並列レンダラーを使えば十分な品質の画像を現実的な時間で生成できます。

実行例

bash
# デフォルト: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
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);
}

まとめ

  • 第 3 編の最終シーン(コーネルボックス,白い箱+ガラス球)をCPU マルチコア+PNG 出力でレンダリングする独立プログラム raytracing-book3-final-scenemaster/native/ に作成した。
  • 第 2 編版と同じく common/ クレートを path 依存で参照する。Hittable: Send + Sync は第 2 編時点で対応済みのため追加変更なし。
  • 第 3 編固有の変更点:ray_colorlights: Arc<dyn Hittable> が加わり,scatter_record() による Specular / Diffuse 分岐と,アクネガードが入っている。
  • save_with_format で拡張子によらず確実に PNG 保存できる。
  • 1000 spp でガラス球のノイズが許容範囲に収まり,実用的な品質の画像が得られる。