8. インスタンス
Ray Tracing: The Next Week (v3.2.3): 8 Instances / 2.8 インスタンス
前章の Cornell box には壁と天井のライトしか置きませんでした。この章では中に 2 つの白い箱を置き,さらに壁に対して少しだけ回転させて Cornell box の定番構図に近づけます。そのために必要な部品は次の 3 つです。
- 6 枚の矩形をまとめた 箱(box)プリミティブ
- オブジェクトを世界座標で平行移動する
Translate - オブジェクトを Y 軸まわりに回転させる
RotateY
「インスタンス」とは,ジオメトリ自体を動かさず,入射レイの方を逆向きに変換することで「見かけ上動いた」ように見せる仕組みのことです。レイトレーシングの強力なテクニックで,1 つの形状をシーン中の任意の位置・向きに何度でも使い回せます。
軸並行な箱
まずは軸並行な直方体を作ります。前章で導入した 3 種類の矩形(XyRect/XzRect/YzRect)を 6 枚並べれば直方体になるので,これらを HittableList にまとめて Hittable トレイトを実装するだけです。
Rust では型名に Box を使うと std::boxed::Box と紛らわしいので,RectBox という名前にしています。
pub struct RectBox {
pub box_min: Point3,
pub box_max: Point3,
sides: HittableList,
}
impl RectBox {
pub fn new(p0: Point3, p1: Point3, material: Arc<dyn Material>) -> Self {
let mut sides = HittableList::new();
sides.add(Box::new(XyRect::new(p0.x(), p1.x(), p0.y(), p1.y(), p1.z(), material.clone())));
sides.add(Box::new(XyRect::new(p0.x(), p1.x(), p0.y(), p1.y(), p0.z(), material.clone())));
sides.add(Box::new(XzRect::new(p0.x(), p1.x(), p0.z(), p1.z(), p1.y(), material.clone())));
sides.add(Box::new(XzRect::new(p0.x(), p1.x(), p0.z(), p1.z(), p0.y(), material.clone())));
sides.add(Box::new(YzRect::new(p0.y(), p1.y(), p0.z(), p1.z(), p1.x(), material.clone())));
sides.add(Box::new(YzRect::new(p0.y(), p1.y(), p0.z(), p1.z(), p0.x(), material)));
RectBox { box_min: p0, box_max: p1, sides }
}
}
impl Hittable for RectBox {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
self.sides.hit(r, t_min, t_max)
}
fn bounding_box(&self, _t0: f64, _t1: f64) -> Option<Aabb> {
Some(Aabb::new(self.box_min, self.box_max))
}
}hit は内部の HittableList に丸投げ,bounding_box は 2 隅から作るだけ。原典の C++ クラスと完全に同じ構造で,違いは型名 RectBox(vs. box)と Rust の Arc<dyn Material>(vs. shared_ptr<material>)だけです。
C++ と Rust の違い
C++ では同名の box クラスを問題なく定義できますが,Rust の Box<T> は所有権の根幹を成すスマートポインタなので,もしユーザーが use common::Box; してしまうと Box::new(...) が大混乱します。RectBox のように接頭辞を変えてしまうのが Rust らしい安全策です。
回転していない 2 つの箱を Cornell box に追加すると,原典 7.34 の Image 19 に相当する絵が得られます。
objects.add(Box::new(RectBox::new(
Point3::new(130.0, 0.0, 65.0),
Point3::new(295.0, 165.0, 230.0),
white.clone(),
)));
objects.add(Box::new(RectBox::new(
Point3::new(265.0, 0.0, 295.0),
Point3::new(430.0, 330.0, 460.0),
white,
)));ここまではただの直方体の集合です。次に,これらを壁に対して斜めに置きます。
インスタンスの基本アイデア
Cornell box の定番構図では,箱は壁に対して 15° 程度傾いています。原典の指摘通り,レイトレーシングではジオメトリを動かさずに レイのほうを逆方向に変換するのが定石です:
「ピンクの箱の x 座標すべてに 2 を足す代わりに,箱はそこに置いたまま hit ルーチンの中で入射レイの x 起点から 2 を引く」
これは座標変換の標準的な扱いで,変換後のレイで「ローカル座標系」のオブジェクトに当ててヒットを取り,得られた交点や法線を逆変換して世界座標に戻します。利点は二つあります:
- 同じジオメトリを複数の位置・向きでインスタンス化できる(メモリは 1 個分)
- AABB は変換前のローカル AABB を変換するだけで作れる(BVH と組み合わせて速い)
Translate
平行移動が一番単純です。「世界座標で offset だけ動かす」ということは,逆に「ローカル座標系では入射レイの起点を -offset する」ということに相当します。
pub struct Translate {
pub object: Box<dyn Hittable>,
pub offset: Vec3,
}
impl Hittable for Translate {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let moved = Ray::with_time(r.origin() - self.offset, r.direction(), r.time());
let mut rec = self.object.hit(&moved, t_min, t_max)?;
rec.p = rec.p + self.offset;
let n = rec.normal;
rec.set_face_normal(&moved, n);
Some(rec)
}
fn bounding_box(&self, time0: f64, time1: f64) -> Option<Aabb> {
let bbox = self.object.bounding_box(time0, time1)?;
Some(Aabb::new(bbox.min() + self.offset, bbox.max() + self.offset))
}
}注目点:
- レイの方向は変えず,起点だけを
-offsetする。方向ベクトルの平行移動は意味を持たないので変換不要です。 - 当たった点
rec.pには逆に+offsetを足し戻して世界座標に持ち上げる。法線は平行移動で変わらないのでそのまま。 - AABB は両端をシフトするだけ。これにより
Translateを BVH に乗せても問題なく動きます(2.3 章でHittable::bounding_boxをOption<Aabb>で導入しておいた効果がここで効いてきます)。
RotateY
Y 軸まわりに角度
逆回転(
となります。RotateY::hit では「世界座標のレイ」を「ローカル座標のレイ」に逆回転で変換し,ヒット後の点・法線は順回転で世界座標に戻します。法線も方向ベクトルなので回転で変換が必要です(平行移動とはここが違います)。
impl Hittable for RotateY {
fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
let mut origin = r.origin();
let mut direction = r.direction();
// World -> local: rotate by -theta.
origin[0] = self.cos_theta * r.origin()[0] - self.sin_theta * r.origin()[2];
origin[2] = self.sin_theta * r.origin()[0] + self.cos_theta * r.origin()[2];
direction[0] = self.cos_theta * r.direction()[0] - self.sin_theta * r.direction()[2];
direction[2] = self.sin_theta * r.direction()[0] + self.cos_theta * r.direction()[2];
let rotated = Ray::with_time(origin, direction, r.time());
let mut rec = self.object.hit(&rotated, t_min, t_max)?;
// Local -> world: rotate by +theta.
let mut p = rec.p;
let mut normal = rec.normal;
p[0] = self.cos_theta * rec.p[0] + self.sin_theta * rec.p[2];
p[2] = -self.sin_theta * rec.p[0] + self.cos_theta * rec.p[2];
normal[0] = self.cos_theta * rec.normal[0] + self.sin_theta * rec.normal[2];
normal[2] = -self.sin_theta * rec.normal[0] + self.cos_theta * rec.normal[2];
rec.p = p;
rec.set_face_normal(&rotated, normal);
Some(rec)
}
fn bounding_box(&self, _t0: f64, _t1: f64) -> Option<Aabb> {
self.bbox
}
}hit のローカル変換に cos/sin の同じ係数が 2 回ずつ現れているのに違和感を持つかもしれませんが,順方向と逆方向で sin の符号が逆になっているところが要点です。
回転後の AABB
回転前の AABB をそのまま使うとボックスが回転後の形状を覆いきれないので,8 つの隅すべてを回転して新しい min/max を取り直します。コンストラクタで一度だけ計算してキャッシュします。
common/src/instance.rs(RotateY コンストラクタ)
pub fn new(object: Box<dyn Hittable>, angle_deg: f64) -> Self {
let radians = degrees_to_radians(angle_deg);
let sin_theta = radians.sin();
let cos_theta = radians.cos();
let inner_bbox = object.bounding_box(0.0, 1.0);
let bbox = inner_bbox.map(|bbox| {
let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut max = Point3::new(-f64::INFINITY, -f64::INFINITY, -f64::INFINITY);
for i in 0..2 {
for j in 0..2 {
for k in 0..2 {
let x = i as f64 * bbox.max().x() + (1 - i) as f64 * bbox.min().x();
let y = j as f64 * bbox.max().y() + (1 - j) as f64 * bbox.min().y();
let z = k as f64 * bbox.max().z() + (1 - k) as f64 * bbox.min().z();
let newx = cos_theta * x + sin_theta * z;
let newz = -sin_theta * x + cos_theta * z;
let tester = Vec3::new(newx, y, newz);
for c in 0..3 {
min[c] = min[c].min(tester[c]);
max[c] = max[c].max(tester[c]);
}
}
}
}
Aabb::new(min, max)
});
RotateY { object, sin_theta, cos_theta, bbox }
}C++ と Rust の違い
原典の C++ コードは bool hasbox フラグを別に持って「内部オブジェクトに有限な境界がない場合」を表現しています。Rust 版では Hittable::bounding_box がもともと Option<Aabb> を返すので,inner_bbox.map(...) で Option を持ち上げるだけで等価な動作が得られます。フラグ管理が消えて素直になるのが嬉しいところです。
定番 Cornell box
以上を組み合わせると,原典の標準 Cornell box(Image 20)が組めます。Box<dyn Hittable> でラップしながら Translate(RotateY(RectBox)) を作る部分が,C++ の make_shared チェーンに対応します。
// Tall block: 165 x 330 x 165, +15 deg, then translate to (265, 0, 295).
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)));
objects.add(box1);
// Short block: 165 x 165 x 165, -18 deg, then translate to (130, 0, 65).
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);順序が大事で,「原点で回転 → 平行移動」 とするのが正しい流儀です。逆順(先に平行移動してから回転)にすると箱が原点まわりにぐるっと回ってしまい,意図した位置から外れます。同じことを 2.7 で見た Arc::new(...) の連鎖と比べると,こちらは型注釈 Box<dyn Hittable> を毎回書く必要があってやや冗長ですが,型がはっきり追えるという読者向けの利点もあります。
C++ と Rust の違い
C++ 原典は shared_ptr<hittable> box1 = ...; のあと 同じ変数 box1 に再代入してインスタンスを積み上げています:
shared_ptr<hittable> box1 = make_shared<box>(...);
box1 = make_shared<rotate_y>(box1, 15);
box1 = make_shared<translate>(box1, vec3(265,0,295));Rust では let box1 = ...; let box1 = ...; という シャドーイングで同じ書き味を再現しました。mut を付けずに済み,各段階の box1 がそれぞれ独立した束縛である点が読み手の助けになります。
まとめ
- 6 枚の軸並行矩形を束ねた
RectBoxをcommon/src/rect_box.rsに追加。Rust ではBoxの名前衝突を避けるためRectBoxとした。 - レイ側を逆変換する形で「インスタンス」を実装。
common/src/instance.rsにTranslate(起点を-offset)とRotateY(/ を逆回転)を追加。AABB はそれぞれ「両端シフト」「8 隅を回転して min/max 取り直し」で更新。 - これらを使って Cornell box の定番構図
r208-cornell-standardと,比較用の回転なし版r208-cornell-blocksをデモ化した。 - 既存のパイプライン(
ray_colorのbackground引数化,Material::emittedのデフォルト実装,BVH のOption<Aabb>)はすべてそのまま動く。
次章 2.9 では,箱の中に煙や霧を入れるための Volumes(一様密度の媒質) を扱います。ConstantMedium という新種の Hittable と,等方散乱(isotropic)マテリアルを導入することで,おなじみの「煙の入った箱」が作れるようになります。