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

12. PDF の管理方法の整理

Ray Tracing: The Rest of Your Life (v3.2.3): 12 Cleaning Up PDF Management / 3.12 PDF の管理方法の整理

前章で特定した 2 つの問題,「CosinePdf のハードコード」と「鏡面/拡散の非互換な分岐」を,この章で解消します。ScatterRecord enum と scatter_record() メソッドを common に追加し,シーンをアルミ箱+白い小箱 → ガラス球へと段階的に変化させながら,計 4 パターンのコーネルボックスを実装します。

ScatterRecord enum

common/src/material.rs に以下の enum を追加します:

common/src/material.rs
rust
pub enum ScatterRecord {
    /// Specular scatter: the direction is fully determined (Metal, Dielectric).
    Specular { attenuation: Color, ray: Ray },
    /// Diffuse scatter: a PDF object determines the direction (Lambertian).
    Diffuse { attenuation: Color, pdf: Box<dyn Pdf> },
}

Specular バリアントは散乱方向が確定している場合(Metal・Dielectric),Diffuse バリアントは PDF オブジェクトで方向を決定する場合(Lambertian)です。

Material トレイトへの追加

scatter() は変更せず,新メソッド scatter_record() を追加します:

common/src/material.rs
rust
pub trait Material: Send + Sync {
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)>;

    // 新メソッド: デフォルト実装は scatter() 結果を Specular として包む
    fn scatter_record(&self, r_in: &Ray, rec: &HitRecord) -> Option<ScatterRecord> {
        self.scatter(r_in, rec).map(|(attenuation, ray)| {
            ScatterRecord::Specular { attenuation, ray }
        })
    }

    fn emitted(&self, _u: f64, _v: f64, _p: Point3, _front_face: bool) -> Color {
        Color::new(0.0, 0.0, 0.0)
    }

    fn scattering_pdf(&self, _r_in: &Ray, _rec: &HitRecord, _scattered: &Ray) -> f64 {
        0.0
    }
}

デフォルト実装は既存の scatter()Specular として包むため,MetalDielectricIsotropic はこの新メソッドを意識せず引き続き動作します。

Lambertian::scatter_record() のオーバーライド

Lambertian のみオーバーライドし,CosinePdf を内包した Diffuse バリアントを返します:

common/src/material.rs
rust
impl Material for Lambertian {
    // scatter() は既存実装を維持(変更なし)

    fn scatter_record(&self, _r_in: &Ray, rec: &HitRecord) -> Option<ScatterRecord> {
        let attenuation = self.albedo.value(rec.u, rec.v, rec.p);
        Some(ScatterRecord::Diffuse {
            attenuation,
            pdf: Box::new(CosinePdf::new(rec.normal)),
        })
    }
}

これにより,ray_colorCosinePdf を直接知る必要がなくなります。

ray_color の更新

ray_color では scatter_record() にマッチします:

r311-cornell-box/src/lib.rs
rust
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)
    }
}

CosinePdf はもはや ray_color の import リストには現れません:

r311-cornell-box/src/lib.rs
rust
use common::{
    Camera, Color, DiffuseLight, FlipFace, Hittable, HittablePdf, HittableList,
    Lambertian, Metal, MixturePdf, Pdf, Point3, Ray, RectBox, RotateY, ScatterRecord,
    Translate, Vec3, XyRect, XzRect, YzRect, random_double, write_color_gamma,
};
// CosinePdf はここに現れない

図 3.16: アルミ箱+白い小箱

アルミ(Metal)製の背の高い箱と白い Lambertian の短い箱を配置します。ScatterRecord の両バリアント(鏡面と拡散)が正しく動作することを確認するシーンです:

r311-cornell-box/src/lib.rs
rust
// Tall aluminum box.
let aluminum = Arc::new(Metal::new(Color::new(0.8, 0.85, 0.88), 0.0));
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),
    aluminum,
));
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);

// Short white Lambertian box.
let box2: Box<dyn Hittable> = Box::new(RectBox::new(
    Point3::new(0.0, 0.0, 0.0),
    Point3::new(165.0, 165.0, 165.0),
    white,
));
let box2: Box<dyn Hittable> = Box::new(RotateY::new(box2, -18.0));
let box2: Box<dyn Hittable> = Box::new(Translate::new(box2, Vec3::new(130.0, 0.0, 65.0)));
objects.add(box2);

// Only the ceiling light is sampled for importance sampling.
let lights: Arc<dyn Hittable> =
    Arc::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light));

アルミ箱(fuzz = 0.0)は完全鏡面反射(Specular パス),白い箱は Lambertian 拡散(Diffuse パス)で処理されます。

球の PDF(図 3.17)

次の変更として,白い小箱の代わりにガラス球を置きます。ガラス球を lights に追加して重点サンプリングするには,Spherepdf_value()random() を実装する必要があります。

origin から半径 r,距離の二乗 d2 の球が subtend する円錐を考えます(図 3.17)。最大余弦は:

cosθmax=1r2d2

立体角は Ω=2π(1cosθmax) であり,均一分布の PDF 値は 1/Ω です。

Sphere::pdf_value() の実装

common/src/sphere.rs
rust
fn pdf_value(&self, origin: Point3, v: Vec3) -> f64 {
    if self.hit(&Ray::new(origin, v), 0.001, f64::INFINITY).is_none() {
        return 0.0;
    }
    let distance_squared = (self.center - origin).length_squared();
    let cos_theta_max = (1.0 - self.radius * self.radius / distance_squared).sqrt();
    let solid_angle = 2.0 * PI * (1.0 - cos_theta_max);
    1.0 / solid_angle
}

Sphere::random() の実装

球が subtend する円錐内に均一にランダムな方向を生成します:

common/src/sphere.rs
rust
fn random(&self, origin: Point3) -> Vec3 {
    let direction = self.center - origin;
    let distance_squared = direction.length_squared();
    let uvw = Onb::build_from_w(direction);
    uvw.local_vec(random_to_sphere(self.radius, distance_squared))
}

補助関数 random_to_sphere は,ローカル座標系で円錐内の方向を生成します:

common/src/sphere.rs
rust
fn random_to_sphere(radius: f64, distance_squared: f64) -> Vec3 {
    let r1 = random_double();
    let r2 = random_double();
    let cos_theta_max = (1.0 - radius * radius / distance_squared).sqrt();
    let z = 1.0 + r2 * (cos_theta_max - 1.0);
    let phi = 2.0 * PI * r1;
    let sin_theta = (1.0 - z * z).max(0.0).sqrt();
    Vec3::new(phi.cos() * sin_theta, phi.sin() * sin_theta, z)
}

z = 1 + r2*(cos_theta_max - 1)[cosθmax,1] の均一なサンプルを生成し,Onb::local_vec で世界座標に変換します。

図 3.18: ガラス球のみをライトにしたコーネルボックス

白い小箱の代わりにガラス球(屈折率 1.5)を置き,lights にはガラス球のみを指定します。シーンは白い背の高い Lambertian ボックスとガラス球の組み合わせです:

r312-cornell-box/src/lib.rs
rust
// 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);

// Glass sphere.
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,
)));

// Sphere PDF only — no ceiling XzRect in lights yet.
let lights: Arc<dyn Hittable> = Arc::new(Sphere::with_material(
    Point3::new(190.0, 90.0, 190.0),
    90.0,
    Arc::new(Dielectric::new(1.5)),
));

HittableListpdf_value / random

ガラス球と天井 XzRect の両方を lights に含めるため,HittableList::pdf_value() / random()common/src/hittable_list.rs に追加します:

common/src/hittable_list.rs
rust
fn pdf_value(&self, origin: Point3, v: Vec3) -> f64 {
    if self.objects.is_empty() {
        return 0.0;
    }
    let weight = 1.0 / self.objects.len() as f64;
    self.objects.iter().map(|o| weight * o.pdf_value(origin, v)).sum()
}

fn random(&self, origin: Point3) -> Vec3 {
    if self.objects.is_empty() {
        return Vec3::new(1.0, 0.0, 0.0);
    }
    let idx = random_int(0, self.objects.len() as i32 - 1) as usize;
    self.objects[idx].random(origin)
}

pdf_value() は全オブジェクトの平均 PDF 値,random() は均等確率で 1 オブジェクトを選んでその方向を返します。

図 3.19: ガラス球をライトに追加

ガラス球も PDF サンプリングの対象にするため,天井 XzRect とガラス球の両方を HittableList に格納して lights とします:

r312-cornell-box/src/lib.rs
rust
// Lights: ceiling rect + glass sphere (both sampled for importance sampling).
let mut lights = HittableList::new();
lights.add(Box::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light)));
lights.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);

アクネの除去(図 3.20)

原典(C++)では図 3.19 のレンダリングにアクネ(輝点)が発生します。Sphere::pdf_value() が未実装のため,mixed.value(direction) がほぼゼロになり,次の除算で輝度が爆発するためです:

emitted + attenuation * sp / pdf_val  // pdf_val ≈ 0 → ∞ → アクネ

ray_colorpdf_val ガード節がアクネを除去します(図 3.20):

r312-cornell-box/src/lib.rs
rust
let pdf_val = mixed.value(direction);
if pdf_val < 1e-10 {
    return emitted;
}

C++ と Rust の違い

C++ 版では scatter_record 構造体を出力引数として取る形式に scatter() のシグネチャーを変更し,全マテリアルを一斉に書き換えます:

cpp
struct scatter_record {
    ray specular_ray;
    bool is_specular;
    color attenuation;
    shared_ptr<pdf> pdf_ptr;
};

virtual bool scatter(
    const ray& r_in, const hit_record& rec, scatter_record& srec) const;

Rust 版の主な違い:

項目C++Rust
鏡面/拡散の区別is_specular ブール値Specular / Diffuse バリアント(型安全)
PDF の保持shared_ptr<pdf>Box<dyn Pdf>
既存コードへの影響scatter() 破壊的変更(全マテリアル更新)scatter_record() を追加(後方互換)
網羅性の保証なしコンパイル時パターン網羅チェック
図 3.19 のアクネ発生(Sphere::pdf_value() 未実装)発生しない(本実装では既実装済み)

scatter() を変更せず scatter_record() を追加したことで,第1・第2編の全デモクレートは変更なしでそのままビルドできます。

Rust 版では Sphere::pdf_value()Sphere::random() を図 3.18 の時点で同時に実装するため,図 3.19 でアクネが発生しません。図 3.19 と図 3.20 は視覚的にほぼ同じ見た目になりますが,アーキテクチャ上の違い(ガード節の有無)は変わりません。

完全実装

r311-cornell-box/src/lib.rs
rust
use std::sync::Arc;
use common::{
    Camera, Color, DiffuseLight, FlipFace, Hittable, HittablePdf, HittableList,
    Lambertian, Metal, MixturePdf, Pdf, Point3, Ray, RectBox, RotateY, ScatterRecord,
    Translate, Vec3, XyRect, XzRect, YzRect, random_double, write_color_gamma,
};

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 aluminum box.
    let aluminum = Arc::new(Metal::new(Color::new(0.8, 0.85, 0.88), 0.0));
    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),
        aluminum,
    ));
    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);

    // Short white Lambertian box.
    let box2: Box<dyn Hittable> = Box::new(RectBox::new(
        Point3::new(0.0, 0.0, 0.0),
        Point3::new(165.0, 165.0, 165.0),
        white,
    ));
    let box2: Box<dyn Hittable> = Box::new(RotateY::new(box2, -18.0));
    let box2: Box<dyn Hittable> = Box::new(Translate::new(box2, Vec3::new(130.0, 0.0, 65.0)));
    objects.add(box2);

    // Only the ceiling light is sampled for importance sampling.
    let lights: Arc<dyn Hittable> =
        Arc::new(XzRect::new(213.0, 343.0, 227.0, 332.0, 554.0, light));

    (objects, lights)
}

pub fn render_image() -> String {
    let aspect_ratio = 1.0_f64;
    let image_width = 300_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;
    let samples_per_pixel = 200_i32;
    let max_depth = 50_i32;

    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 camera = Camera::new_with_shutter(
        lookfrom, lookat, vup, 40.0, aspect_ratio, 0.0, 10.0, 0.0, 1.0,
    );

    let mut output = String::new();
    output.push_str("P3\n");
    output.push_str(&format!("{} {}\n", image_width, image_height));
    output.push_str("255\n");

    for j in (0..image_height).rev() {
        for i in 0..image_width {
            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 = (j as f64 + random_double()) / (image_height - 1) as f64;
                let r = camera.get_ray(u, v);
                pixel_color += ray_color(&r, background, &world, lights.clone(), max_depth);
            }
            output.push_str(&format!(
                "{}\n",
                write_color_gamma(pixel_color, samples_per_pixel)
            ));
        }
    }

    output
}
r312-cornell-box/src/lib.rs
rust
use std::sync::Arc;
use common::{
    Camera, Color, Dielectric, DiffuseLight, FlipFace, Hittable, HittablePdf, HittableList,
    Lambertian, MixturePdf, Pdf, Point3, Ray, ScatterRecord,
    Sphere, XyRect, XzRect, YzRect, random_double, write_color_gamma,
};

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

// ray_color without the near-zero PDF guard (used for Figure 3.19).
fn ray_color_no_guard(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_no_guard(&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);
            let sp = mat.scattering_pdf(r, &rec, &scattered);
            emitted + attenuation * sp / pdf_val
                * ray_color_no_guard(&scattered, background, world, lights, depth - 1)
        }
    }
}

fn make_world() -> HittableList {
    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),
    ))));

    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,
    )));

    objects
}

fn make_camera(aspect_ratio: f64) -> Camera {
    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);
    Camera::new_with_shutter(lookfrom, lookat, vup, 40.0, aspect_ratio, 0.0, 10.0, 0.0, 1.0)
}

fn render(
    world: &HittableList,
    lights: Arc<dyn Hittable>,
    camera: &Camera,
    image_width: i32,
    image_height: i32,
    samples_per_pixel: i32,
    max_depth: i32,
    use_guard: bool,
) -> String {
    let background = Color::new(0.0, 0.0, 0.0);
    let mut output = String::new();
    output.push_str("P3\n");
    output.push_str(&format!("{} {}\n", image_width, image_height));
    output.push_str("255\n");

    for j in (0..image_height).rev() {
        for i in 0..image_width {
            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 = (j as f64 + random_double()) / (image_height - 1) as f64;
                let r = camera.get_ray(u, v);
                let c = if use_guard {
                    ray_color(&r, background, world, lights.clone(), max_depth)
                } else {
                    ray_color_no_guard(&r, background, world, lights.clone(), max_depth)
                };
                pixel_color += c;
            }
            output.push_str(&format!(
                "{}\n",
                write_color_gamma(pixel_color, samples_per_pixel)
            ));
        }
    }
    output
}

// Figure 3.18: glass sphere only scene; lights = glass sphere (sphere PDF only).
pub fn render_fig18() -> String {
    let aspect_ratio = 1.0_f64;
    let image_width = 300_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;

    let world = make_world();
    let lights: Arc<dyn Hittable> = Arc::new(Sphere::with_material(
        Point3::new(190.0, 90.0, 190.0),
        90.0,
        Arc::new(Dielectric::new(1.5)),
    ));
    let camera = make_camera(aspect_ratio);

    render(&world, lights, &camera, image_width, image_height, 200, 50, true)
}

// Figure 3.19: glass sphere scene; lights = ceiling XzRect + glass sphere, no acne guard.
pub fn render_fig19() -> String {
    let aspect_ratio = 1.0_f64;
    let image_width = 300_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;

    let world = make_world();
    let light = Arc::new(DiffuseLight::new(Color::new(15.0, 15.0, 15.0)));
    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);
    let camera = make_camera(aspect_ratio);

    render(&world, lights, &camera, image_width, image_height, 200, 50, false)
}

// Figure 3.20: glass sphere scene; lights = ceiling XzRect + glass sphere, with acne guard.
pub fn render_image() -> String {
    let aspect_ratio = 1.0_f64;
    let image_width = 300_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;

    let world = make_world();
    let light = Arc::new(DiffuseLight::new(Color::new(15.0, 15.0, 15.0)));
    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);
    let camera = make_camera(aspect_ratio);

    render(&world, lights, &camera, image_width, image_height, 200, 50, true)
}

まとめ

  • ScatterRecord enum(Specular / Diffuse)を common に追加し,鏡面散乱と拡散散乱を型レベルで区別した。
  • scatter_record() を新メソッドとして追加(既存の scatter() は変更なし)。Lambertian のみオーバーライドして CosinePdf を内包した Diffuse バリアントを返す。Metal・Dielectric・Isotropic はデフォルト実装(Specular として包む)で動作。
  • ray_color から CosinePdf の直接構築が消え,マテリアルの知識がレンダラーに漏れる問題を解決した。
  • Sphere::pdf_value() / random()common/src/sphere.rs に実装し,球を lights として重点サンプリングできるようにした(立体角 PDF 1/[2π(1cosθmax)])。
  • HittableList::pdf_value() / random()common に追加し,複数光源を HittableList として一括管理できるようにした(天井 XzRect +ガラス球)。
  • pdf_val < 1e-10 ガードで near-zero PDF によるアクネ(輝点)を除去した。
  • 4 つのコーネルボックスを r311-cornell-box(図 3.16)と r312-cornell-box(図 3.18 / 3.19 / 3.20)として実装した。