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

9. ボリューム

Ray Tracing: The Next Week (v3.2.3): 9 Volumes / 2.9 ボリューム

ここまではすべて「面」のレンダリングでした。本章では ボリューム(volume)—— 煙・霧・サブサーフェス散乱のような 媒質(participating media)—— を導入します。原典の流儀に倣い,ボリュームを「確率的に存在する面」として表現するシンプルなトリックを使います。これにより既存の Hittable インフラの上に最小限の追加で実装できます。

一様密度の媒質

光線が媒質中を進むと,任意の地点で散乱する可能性があります。媒質が濃いほど散乱しやすく,微小距離 ΔL の間に散乱する確率は

P(scatter)=CΔL

で,ここで C は媒質の光学密度に比例する定数です。微分方程式を解くと(あるいは逆関数法を使うと),[0,1) の一様乱数 ξ から 散乱までの距離

d=1Clnξ

がサンプリングできます。d が媒質の境界を越えるならば「ヒットしない」ことになります。

これを実装したのが ConstantMedium です。

common/src/constant_medium.rs
rust
pub struct ConstantMedium {
    pub boundary: Box<dyn Hittable>,
    pub phase_function: Arc<dyn Material>,
    pub neg_inv_density: f64,
}

impl ConstantMedium {
    pub fn new(boundary: Box<dyn Hittable>, d: f64, color: Color) -> Self {
        ConstantMedium {
            boundary,
            phase_function: Arc::new(Isotropic::new(color)),
            neg_inv_density: -1.0 / d,
        }
    }
}

neg_inv_density = -1/d をあらかじめ計算しておくと,散乱距離は素直に neg_inv_density * random_double().ln() の一行になります。

hit の組み立て

「媒質を確率的な面として扱う」とは具体的にはこういうことです:

  1. レイが境界に 入る点 rec1 と 出る点 rec2 をそれぞれ取る(前者は t= 以降、後者は rec1.t + 0.0001 以降)。
  2. レイが媒質内にいる距離 L=(rec2.trec1.t)d を計算。
  3. 散乱までの距離 dhit=1Clnξ をサンプリング。
  4. dhit>L なら「素通り」として None,そうでなければ「rec1.t+dhit/d の地点で散乱」として HitRecord を返す。
common/src/constant_medium.rs
rust
impl Hittable for ConstantMedium {
    fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
        let mut rec1 = self.boundary.hit(r, f64::NEG_INFINITY, f64::INFINITY)?;
        let mut rec2 = self.boundary.hit(r, rec1.t + 0.0001, f64::INFINITY)?;

        if rec1.t < t_min { rec1.t = t_min; }
        if rec2.t > t_max { rec2.t = t_max; }
        if rec1.t >= rec2.t { return None; }
        if rec1.t < 0.0 { rec1.t = 0.0; }

        let ray_length = r.direction().length();
        let distance_inside_boundary = (rec2.t - rec1.t) * ray_length;
        let hit_distance = self.neg_inv_density * random_double().ln();

        if hit_distance > distance_inside_boundary {
            return None;
        }

        let t = rec1.t + hit_distance / ray_length;
        let p = r.at(t);

        Some(HitRecord {
            t,
            u: 0.0,
            v: 0.0,
            p,
            normal: Vec3::new(1.0, 0.0, 0.0),  // arbitrary
            front_face: true,                  // arbitrary
            mat: Some(self.phase_function.clone()),
        })
    }

    fn bounding_box(&self, t0: f64, t1: f64) -> Option<Aabb> {
        self.boundary.bounding_box(t0, t1)
    }
}

注目点:

  • rec1.t < 0.00.0 に丸めるのは「レイの起点がすでに媒質の中にいる」場合への対応。雲の中で散乱が連鎖するときによく発生するケースで,省くとレイが境界の手前にいると誤判定して暗くなります。
  • 法線と front_face は どんな値でもよい(Isotropic::scatter がそれらを使わないため)。原典のコメントに合わせて (1, 0, 0)true にしました。
  • uv も使われませんが,将来テクスチャ付きの媒質を作るときには phase_function 側で参照されることがあります。ここでは 0.0 に固定。
  • この実装は 境界が凸であることを前提にしています(box や球は OK,トーラスや空洞のある形状は NG)。

C++ と Rust の違い

C++ 原典の boundary->hit(...)bool を返してデータを引数で渡す API ですが,Rust 版では Option<HitRecord> を返すので ? 演算子で短く書けます。hit 全体の制御フローが「成功なら続ける,失敗なら return」の連続であることが視覚的に分かりやすくなり,原典のネストした if (!boundary->hit(...)) よりかなり読みやすくなったと感じます。

等方散乱マテリアル

媒質中で散乱が起きたら,全方向に等確率で散乱させます。これが Isotropic(等方散乱)マテリアルです。material.rs に既存マテリアルと並べて追加しました。

common/src/material.rs
rust
pub struct Isotropic {
    pub albedo: Arc<dyn Texture>,
}

impl Isotropic {
    pub fn new(color: Color) -> Self {
        Isotropic { albedo: Arc::new(SolidColor::new(color)) }
    }
}

impl Material for Isotropic {
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
        let scattered = Ray::with_time(rec.p, random_in_unit_sphere(), r_in.time());
        let attenuation = self.albedo.value(rec.u, rec.v, rec.p);
        Some((attenuation, scattered))
    }
}

Lambertian の散乱方向が「法線 + 単位球内のランダムベクトル」だったのに対し,Isotropic は 単位球内のランダムベクトルそのもの。法線を使わないのが「面ではない散乱」を表現する核心です。

C++ と Rust の違い

C++ 原典は random_in_unit_sphere() を散乱方向にそのまま使い,単位ベクトルに正規化していません。理屈の上では均一な「球内散乱」とはやや異なる分布になりますが(中心付近の方向にバイアスがかかる),この章のスケールでは差は見えません。Rust 移植でも原典と同じ書き方を踏襲して再現性を優先しました。気になるなら random_unit_vector() に置き換えても良い箇所です。

デモ:煙の Cornell box

定番の Cornell box の 2 つの白いブロックを ConstantMedium でラップし,黒い煙(color = (0, 0, 0))と白い霧(color = (1, 1, 1))に置き換えます。光源は 130×105 から 330×305 に拡大し,輝度を (15,15,15) から (7,7,7) に下げて収束を早くしています。

r209-cornell-smoke/src/lib.rs
rust
let light = Arc::new(DiffuseLight::new(Color::new(7.0, 7.0, 7.0)));
// ...
objects.add(Box::new(XzRect::new(113.0, 443.0, 127.0, 432.0, 554.0, light)));

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.clone(),
));
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)));

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(Box::new(ConstantMedium::new(box1, 0.01, Color::new(0.0, 0.0, 0.0))));
objects.add(Box::new(ConstantMedium::new(box2, 0.01, Color::new(1.0, 1.0, 1.0))));

ポイントは「ブロックそのものはシーンに追加せず,ボリュームのラッパだけを追加する」ことです。ConstantMedium は内部で box1 の境界をヒットテスト用に保持しているので,箱の形を「煙の境界」として使い回している格好になります。同じ RotateY / Translate のチェーンが 2.8 の標準 Cornell box とそのまま再利用できているのが,インスタンスの威力です。

密度 0.01 は「単位距離あたり 1% で散乱する」程度の薄さで,箱を通り抜ける光線がそれなりの割合で抜けつつ,箱の内部でかなりの確率で散乱が起こります。これより大きくすると煙が濃く,小さくすると薄くなります。

まとめ

  • ConstantMediumcommon/src/constant_medium.rs に新設。境界は Box<dyn Hittable>(凸形状を前提),位相関数は IsotropicArc<dyn Material> で保持。hit は 境界の入射点と出射点を 2 回取り,乱数から散乱距離をサンプリングする。
  • Isotropic マテリアルを material.rs に追加(散乱方向は単位球内のランダムベクトル,法線は無視)。Lambertian / Metal / Dielectric / DiffuseLight のいずれにも変更なし。
  • デモ r209-cornell-smoke で,2.8 で組んだ標準 Cornell box の 2 ブロックを 黒い煙と白い霧に置き換えた。光源を拡大・暗くして収束を早めた。
  • Hittable::bounding_boxboundary に委譲するだけなので,BVH との統合も自動的に動く。

次章 2.10 では,これまで追加した機能(モーションブラー,BVH,ソリッド/ノイズ/イメージテクスチャ,矩形ライト,インスタンス,ボリューム)すべてを盛り込んだ 第 2 編の最終シーンを組み立てます。第 1 編の「最終シーン」が球をたくさん並べたものだったのに対し,第 2 編のそれは原典の表紙イラストにも使われている,もっとリッチなシーンです。