9. ボリューム
ここまではすべて「面」のレンダリングでした。本章では ボリューム(volume)—— 煙・霧・サブサーフェス散乱のような 媒質(participating media)—— を導入します。原典の流儀に倣い,ボリュームを「確率的に存在する面」として表現するシンプルなトリックを使います。これにより既存の Hittable インフラの上に最小限の追加で実装できます。
一様密度の媒質
光線が媒質中を進むと,任意の地点で散乱する可能性があります。媒質が濃いほど散乱しやすく,微小距離
で,ここで
がサンプリングできます。
これを実装したのが ConstantMedium です。
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 の組み立て
「媒質を確率的な面として扱う」とは具体的にはこういうことです:
- レイが境界に 入る点
rec1と 出る点rec2をそれぞれ取る(前者は以降、後者は rec1.t + 0.0001以降)。 - レイが媒質内にいる距離
を計算。 - 散乱までの距離
をサンプリング。 なら「素通り」として None,そうでなければ「の地点で散乱」として HitRecordを返す。
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.0を0.0に丸めるのは「レイの起点がすでに媒質の中にいる」場合への対応。雲の中で散乱が連鎖するときによく発生するケースで,省くとレイが境界の手前にいると誤判定して暗くなります。- 法線と
front_faceは どんな値でもよい(Isotropic::scatterがそれらを使わないため)。原典のコメントに合わせて(1, 0, 0)とtrueにしました。 と も使われませんが,将来テクスチャ付きの媒質を作るときには 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 に既存マテリアルと並べて追加しました。
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 に拡大し,輝度を
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 とそのまま再利用できているのが,インスタンスの威力です。
密度
まとめ
ConstantMediumをcommon/src/constant_medium.rsに新設。境界はBox<dyn Hittable>(凸形状を前提),位相関数はIsotropicをArc<dyn Material>で保持。hitは 境界の入射点と出射点を 2 回取り,乱数から散乱距離をサンプリングする。Isotropicマテリアルをmaterial.rsに追加(散乱方向は単位球内のランダムベクトル,法線は無視)。Lambertian/Metal/Dielectric/DiffuseLightのいずれにも変更なし。- デモ
r209-cornell-smokeで,2.8 で組んだ標準 Cornell box の 2 ブロックを 黒い煙と白い霧に置き換えた。光源を拡大・暗くして収束を早めた。 Hittable::bounding_boxをboundaryに委譲するだけなので,BVH との統合も自動的に動く。
次章 2.10 では,これまで追加した機能(モーションブラー,BVH,ソリッド/ノイズ/イメージテクスチャ,矩形ライト,インスタンス,ボリューム)すべてを盛り込んだ 第 2 編の最終シーンを組み立てます。第 1 編の「最終シーン」が球をたくさん並べたものだったのに対し,第 2 編のそれは原典の表紙イラストにも使われている,もっとリッチなシーンです。