🌞

Rustを学びつつ週末レイトレーシング(Ray Tracing in One Weekend)をやった記録

2024/02/12に公開

1. はじめに

Rustの勉強がてら、今の自分でできるかぎりやってみることにした。
基本的にはこちらの邦訳を参考にし、適宜他の方のC++による実装原文とそのリポジトリを参照することにする。
章の名前は最初に挙げた邦訳から借用する。

数学の理解度が足りないので、基本的にはコードを翻訳していくだけに留める。
なお、コード中では、基本的に整数はi32を、小数はf64を使うものとする(考えることが減るので)。
また、コードの変更の表示にdiffを用いているが、追加した・書き換えた行のみを記述するものとする。

ソースコードは以下まで。
https://github.com/Qman11010101/weekend_raytracing_rust/tree/master

もしこの記事をもとに書いてみようと思っている場合

今回、手元では章ごとにディレクトリを分けている。その際ディレクトリ名が変わることによってコードも一部が変化している。具体的には以下の2箇所が必ず変化する。

  • main.rsuse

    7章の記述
  • 画像出力の際の画像名

    4章の記述
  • Cargo.toml(7章以降)
    特にuseとCargo.tomlはきちんと変えないとエラーが出るので注意。
    章が変わったらディレクトリを新しく作ってcargo initして、srcディレクトリを置き換えて以上の3箇所を書き換えるように。

2. 画像の出力

Windows上でPPM画像を開く方法がわからなかったので、インターネット上のものを利用する。
既存のものがあったが、画面が白くて眩しいし必要最低限の機能しかなかったので、ちょっとマシなものを自作した

スクリーンショットに写っているのは以前Pythonで実装したときの画像。
地味にダウンロード機能があったり、レスポンシブに対応していたりする。

PPM画像の出力

週末レイトレーシングは、画像出力の方式として、一貫して標準出力にPPM形式で出力し、それをリダイレクトでファイルに書き込むという方式を取っている。
ひとまず、愚直に従ってみる。

main.rs
const IMAGE_WIDTH: i32 = 256;
const IMAGE_HEIGHT: i32 = 256;

fn main() {
    println!("P3\n{} {}\n255", IMAGE_WIDTH, IMAGE_HEIGHT);

    for j in (0..IMAGE_HEIGHT).rev() {
        for i in 0..IMAGE_WIDTH {
            let r = i as f64 / (IMAGE_WIDTH - 1) as f64;
            let g = j as f64 / (IMAGE_HEIGHT - 1) as f64;
            let b = 0.25;

            let ir = (255.999 * r) as u8;
            let ig = (255.999 * g) as u8;
            let ib = (255.999 * b) as u8;

            println!("{} {} {}", ir, ig, ib);
        }
    }
}

外側のforループのイテレータで若干苦労した。当初は元のC++のコードに近い表現であるIMAGE_HEIGHT-1..=0を記述していたのだが、動かなかった。Bing AIやChatGPTに訊いたところ上記のコードをお出ししてきたが、説明は釈然としなかった。仕方ないが、見た感じ正しそうだし実際正しく動作したのでそのまま採用した。

実行コマンドは以下の通り。

cargo run > 2-output-an-image.ppm

雑にリダイレクトを指定したが、コンパイルの進捗などはどうも標準エラー出力に出ているようで、正常にファイルが生成できた。


元記事に合わせて標準エラー出力にインジケータを出力するバージョンも置いておく。
標準出力がバッファされるされないというところがよくわからないので、正しいのかは分からないが……

main.rs
const IMAGE_WIDTH: i32 = 256;
const IMAGE_HEIGHT: i32 = 256;

fn main() {
    println!("P3\n{} {}\n255", IMAGE_WIDTH, IMAGE_HEIGHT);

    for j in (0..IMAGE_HEIGHT).rev() {
+       eprint!("Remaining: {} lines    \r", j);
        for i in 0..IMAGE_WIDTH {
            let r = i as f64 / (IMAGE_WIDTH - 1) as f64;
            let g = j as f64 / (IMAGE_HEIGHT - 1) as f64;
            let b = 0.25;

            let ir = (255.999 * r) as u8;
            let ig = (255.999 * g) as u8;
            let ib = (255.999 * b) as u8;

            println!("{} {} {}", ir, ig, ib);
        }
    }

+   eprintln!("\nDone!");
}

しかし、気持ちとしてはバイナリを実行したら勝手にファイルまで作られてほしいので、変更する。
出力をStringのベクタとしてメモリ上に保持し、最後にまとめてファイルに書き出すようにする。
パフォーマンス上の影響についてはとりあえず考えないものとする。標準出力よりはマシな気がする。

main.rs
+ use std::fs::File;
+ use std::io::{self, stdout, Write};

const IMAGE_WIDTH: i32 = 256;
const IMAGE_HEIGHT: i32 = 256;
+ const COUNT_MAX: usize = IMAGE_HEIGHT as usize * IMAGE_WIDTH as usize;

fn main() -> io::Result<()> {
+   let mut out_str = format!("P3\n{} {}\n255\n", IMAGE_WIDTH, IMAGE_HEIGHT);

+   let mut data_vector: Vec<String> = vec![String::from(""); COUNT_MAX];
+   let mut index: usize = 0;

    for j in (0..IMAGE_HEIGHT).rev() {
+       print!("Progress: {} / {}    \r", j, IMAGE_HEIGHT);
+       stdout().flush().unwrap();
        for i in 0..IMAGE_WIDTH {
            let r = i as f64 / (IMAGE_WIDTH - 1) as f64;
            let g = j as f64 / (IMAGE_HEIGHT - 1) as f64;
            let b = 0.25;

            let ir = (255.999 * r) as u8;
            let ig = (255.999 * g) as u8;
            let ib = (255.999 * b) as u8;

+           data_vector[index] = format!("{} {} {}", ir, ig, ib);
+           index += 1;
        }
    }

+   print!("\nWriting to file...");

+   // Finalize
+   out_str += &data_vector.join("\n");
    
+   let mut file = File::create("2-output-an-image.ppm").unwrap();
+   file.write_fmt(format_args!("{}", out_str))?;
+   println!("Done!");
+   Ok(())
}

main関数がResultを返しているのが良いのかはよくわからないが、とりあえずここでは動くので良しとする。

3. Vec3クラス

Rustにはクラスが存在しないので、Structとimplとtraitを使用して似たものを作る。
配列添字でアクセスできるようにするところや演算子オーバーロードの型パラメータに関しては、Bing AIにお伺いを立てた。
途中で検索力がリアルタイムで落ちていくのを感じて、自力での検索に切り替えた。

vec3.rs
use std::ops::{Add, AddAssign, Div, DivAssign, Index, IndexMut, Mul, MulAssign, Neg, Sub};

#[derive(Debug, Clone, Copy)]
pub struct Vec3 {
    e: [f64; 4],
}

impl Default for Vec3 {
    fn default() -> Self {
        Vec3 { e: [0.0; 4] }
    }
}

impl Vec3 {
    pub fn new(e0: f64, e1: f64, e2: f64) -> Self {
        Self {
            e: [e0, e1, e2, 0.0],
        }
    }

    pub fn x(&self) -> f64 {
        self.e[0]
    }

    pub fn y(&self) -> f64 {
        self.e[1]
    }

    pub fn z(&self) -> f64 {
        self.e[2]
    }

    pub fn length_squared(&self) -> f64 {
        self.e[0] * self.e[0] + self.e[1] * self.e[1] + self.e[2] * self.e[2]
    }

    pub fn length(&self) -> f64 {
        self.length_squared().sqrt()
    }
}

impl Neg for Vec3 {
    type Output = Vec3;
    fn neg(self) -> Vec3 {
        Vec3::new(-self.e[0], -self.e[1], -self.e[2])
    }
}

impl AddAssign for Vec3 {
    fn add_assign(&mut self, v: Vec3) {
        self.e[0] += v.e[0];
        self.e[1] += v.e[1];
        self.e[2] += v.e[2];
    }
}

impl MulAssign<f64> for Vec3 {
    fn mul_assign(&mut self, t: f64) {
        self.e[0] *= t;
        self.e[1] *= t;
        self.e[2] *= t;
    }
}

impl DivAssign<f64> for Vec3 {
    fn div_assign(&mut self, t: f64) {
        self.e[0] /= t;
        self.e[1] /= t;
        self.e[2] /= t;
    }
}

impl Index<usize> for Vec3 {
    type Output = f64;
    fn index(&self, i: usize) -> &Self::Output {
        &self.e[i]
    }
}

impl IndexMut<usize> for Vec3 {
    fn index_mut(&mut self, i: usize) -> &mut Self::Output {
        &mut self.e[i]
    }
}

pub type Point3 = Vec3;
pub type Color = Vec3;

ユーティリティ関数

pub fn println_vec(v: Vec3) {
    println!("{} {} {}", v.e[0], v.e[1], v.e[2])
}

impl Add<Vec3> for Vec3 {
    type Output = Vec3;
    fn add(self, v: Vec3) -> Vec3 {
        Vec3::new(self.e[0] + v.e[0], self.e[1] + v.e[1], self.e[2] + v.e[2])
    }
}

impl Sub<Vec3> for Vec3 {
    type Output = Vec3;
    fn sub(self, v: Vec3) -> Vec3 {
        Vec3::new(self.e[0] - v.e[0], self.e[1] - v.e[1], self.e[2] - v.e[2])
    }
}

impl Mul<Vec3> for Vec3 {
    type Output = Vec3;
    fn mul(self, v: Vec3) -> Vec3 {
        Vec3::new(self.e[0] * v.e[0], self.e[1] * v.e[1], self.e[2] * v.e[2])
    }
}

impl Mul<f64> for Vec3 {
    type Output = Vec3;
    fn mul(self, t: f64) -> Vec3 {
        Vec3::new(t * self.e[0], t * self.e[1], t * self.e[2])
    }
}

impl Mul<Vec3> for f64 {
    type Output = Vec3;
    fn mul(self, v: Vec3) -> Vec3 {
        Vec3::new(self * v.e[0], self * v.e[1], self * v.e[2])
    }
}

impl Div<f64> for Vec3 {
    type Output = Vec3;
    fn div(self, t: f64) -> Vec3 {
        (1.0 / t) * self
    }
}

pub fn dot(u: Vec3, v: Vec3) -> f64 {
    u.e[0] * v.e[0] + u.e[1] * v.e[1] + u.e[2] * v.e[2]
}

pub fn cross(u: Vec3, v: Vec3) -> Vec3 {
    Vec3::new(
        u.e[1] * v.e[2] - u.e[2] * v.e[1],
        u.e[2] * v.e[0] - u.e[0] * v.e[2],
        u.e[0] * v.e[1] - u.e[1] * v.e[0],
    )
}

pub fn unit_vector(v: Vec3) -> Vec3 {
    v / v.length()
}

最初のderiveの部分でわかるように、Vec3にはCopyトレイトを実装してある。
最初はprintln_vec()(元のoperator<<に該当する)のみ引数を&Vec3にしていたが、最後のunit_vector()の実装で所有権の問題に引っかかり(vを2回使用しているため)、面倒になってCopyトレイトを実装した。
今後問題があれば実装を再考するが、今のところ特に警告もなく動くのでこのままにしておく。
演算子オーバーロード周りは、C++ほどではないがかなり直観的に書けた気がする。型パラメータなどは完全には理解してはいないが、書き方さえわかれば書けるというのはありがたい。

write_color()は以下のようになる。元は標準出力に書き込む関数だが、ファイルに書き込む方針にしたため返り値がStringになっている。

color.rs
use crate::vec3::Color;

pub fn write_color(pixel_color: Color) -> String {
    format!(
        "{} {} {}",
        (255.999 as f64 * pixel_color.x()) as u8,
        (255.999 as f64 * pixel_color.y()) as u8,
        (255.999 as f64 * pixel_color.z()) as u8
    )
}

この関数を使い、main.rsを書き換える……前に、lib.rsを書いておく。

lib.rs
pub mod color;
pub mod vec3;

これでモジュールとしてvec3やcolorを使えるようになる。Pythonの__init__.pyっぽい。
今後もファイルが増えるたびにここに追記していく。わざわざ言及しないが、エラーが出たら多分気づけると思う。

最終的に、以下の通りmain.rsを書き換えた。

main.rs
+ use s3_vec3::{color::write_color, vec3::Color};
use std::fs::File;
use std::io::{self, stdout, Write};

const IMAGE_WIDTH: i32 = 256;
const IMAGE_HEIGHT: i32 = 256;
const COUNT_MAX: usize = IMAGE_HEIGHT as usize * IMAGE_WIDTH as usize;

fn main() -> io::Result<()> {
    let mut out_str = format!("P3\n{} {}\n255\n", IMAGE_WIDTH, IMAGE_HEIGHT);

    let mut data_vector: Vec<String> = vec![String::from(""); COUNT_MAX];
    let mut index: usize = 0;

    for j in (0..IMAGE_HEIGHT).rev() {
        print!("Progress: {} / {}    \r", j, IMAGE_HEIGHT);
        stdout().flush().unwrap();
        for i in 0..IMAGE_WIDTH {
+           let pixel_color: Color = Color::new(
+               i as f64 / (IMAGE_WIDTH - 1) as f64,
+               j as f64 / (IMAGE_HEIGHT - 1) as f64,
+               0.25 as f64,
+           );
+           data_vector[index] = write_color(pixel_color);
            index += 1;
        }
    }

    print!("\nWriting to file...");

    // Finalize
    out_str += &data_vector.join("\n");

    let mut file = File::create("3-vec3.ppm").unwrap();
    file.write_fmt(format_args!("{}", out_str))?;
    println!("Done!");
    Ok(())
}

これは前章のプログラムと同じ画像を生成する。

4. レイ・簡単なカメラ・背景

まずはRayのstructとimplを実装する。

ray.rs
use crate::vec3::{Point3, Vec3};

#[derive(Debug, Clone, Copy)]
pub struct Ray {
    orig: Point3,
    dir: Vec3,
}

impl Ray {
    pub fn new(origin: Point3, direction: Vec3) -> Self {
        Self {
            orig: origin,
            dir: direction,
        }
    }

    pub fn origin(self) -> Point3 {
        self.orig
    }

    pub fn direction(self) -> Vec3 {
        self.dir
    }

    pub fn at(self, t: f64) -> Point3 {
        self.orig + t * self.dir
    }
}

レイの射出

main.rsは以下のように書き換えられる。

main.rs
use s4_ray_camera_bg::{
    color::write_color,
    ray::Ray,
+   vec3::{unit_vector, Color, Point3, Vec3},
};
use std::fs::File;
use std::io::{self, stdout, Write};

// Image Settings
+ const ASPECT_RATIO: f64 = 16.0 / 9.0;
+ const IMAGE_WIDTH: i32 = 384;
+ const IMAGE_HEIGHT: i32 = (IMAGE_WIDTH as f64 / ASPECT_RATIO) as i32;
+ const VIEWPORT_HEIGHT: f64 = 2.0;
+ const VIEWPORT_WIDTH: f64 = ASPECT_RATIO * VIEWPORT_HEIGHT;
+ const FOCAL_LENGTH: f64 = 1.0;

const COUNT_MAX: usize = IMAGE_HEIGHT as usize * IMAGE_WIDTH as usize;

+ fn ray_color(r: Ray) -> Color {
+     let unit_direction: Vec3 = unit_vector(r.direction());
+     let t = 0.5 * (unit_direction.y() + 1.0);
+     (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
+ }

fn main() -> io::Result<()> {
    let mut out_str = format!("P3\n{} {}\n255\n", IMAGE_WIDTH, IMAGE_HEIGHT);

+   let origin = Point3::new(0.0, 0.0, 0.0);
+   let horizontal = Vec3::new(VIEWPORT_WIDTH, 0.0, 0.0);
+   let vertical = Vec3::new(0.0, VIEWPORT_HEIGHT, 0.0);
+   let lower_left_corner =
+       origin - horizontal / 2.0 - vertical / 2.0 - Vec3::new(0.0, 0.0, FOCAL_LENGTH);

    let mut data_vector: Vec<String> = vec![String::from(""); COUNT_MAX];
    let mut index: usize = 0;

    for j in (0..IMAGE_HEIGHT).rev() {
        print!("Progress: {} / {}    \r", j, IMAGE_HEIGHT);
        stdout().flush().unwrap();
        for i in 0..IMAGE_WIDTH {
+           let u = i as f64 / (IMAGE_WIDTH - 1) as f64;
+           let v = j as f64 / (IMAGE_HEIGHT - 1) as f64;
+           let r = Ray::new(
+               origin,
+               lower_left_corner + u * horizontal + v * vertical - origin,
+           );
+           let pixel_color = ray_color(r);
            data_vector[index] = write_color(pixel_color);
            index += 1;
        }
    }

    print!("\nWriting to file...");

    // Finalize
    out_str += &data_vector.join("\n");

    let mut file = File::create("4-ray-camera-bg.ppm").unwrap();
    file.write_fmt(format_args!("{}", out_str))?;
    println!("Done!");
    Ok(())
}

事前に定数として計算できそうな値は全て定数にしている。
無事、画像の生成に成功した。

5. 球のレンダリング

main.rshit_sphiere()関数を追加し、ray_color()関数を書き換える。

main.rs
+ fn hit_sphere(center: Point3, radius: f64, r: Ray) -> bool {
+     let oc = r.origin() - center;
+     let a = dot(r.direction(), r.direction());
+     let b = 2.0 * dot(oc, r.direction());
+     let c = dot(oc, oc) - radius * radius;
+     let discriminant = b * b - (4.0 * a * c);
+     return discriminant > 0.0;
+ }

fn ray_color(r: Ray) -> Color {
+   if hit_sphere(Point3::new(0.0, 0.0, -1.0), 0.5, r) {
+       return Color::new(1.0, 0.0, 0.0);
+   };
    let unit_direction: Vec3 = unit_vector(r.direction());
    let t = 0.5 * (unit_direction.y() + 1.0);
    (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}

Rustは基本返り値をreturnで書かずとも最後に評価された式が勝手に返り値になるが、早期リターンの場合はちゃんとreturnを書かなければいけない。

6. 法線と複数オブジェクト

5で登場した2つの関数をまた書き換える。

main.rs
+ fn hit_sphere(center: Point3, radius: f64, r: Ray) -> f64 {
    let oc = r.origin() - center;
    let a = dot(r.direction(), r.direction());
    let b = 2.0 * dot(oc, r.direction());
    let c = dot(oc, oc) - radius * radius;
    let discriminant = b * b - (4.0 * a * c);
+   if discriminant < 0.0 {
+       -1.0
+   } else {
+       (-b - discriminant.sqrt()) / (2.0 * a)
+   }
}

fn ray_color(r: Ray) -> Color {
+   let t = hit_sphere(Point3::new(0.0, 0.0, -1.0), 0.5, r);
+   if t > 0.0 {
+       let n = unit_vector(r.at(t) - Vec3::new(0.0,0.0,-1.0));
+       return 0.5 * Color::new(n.x() + 1.0, n.y() + 1.0, n.z() + 1.0)
+   };
    let unit_direction: Vec3 = unit_vector(r.direction());
    let t = 0.5 * (unit_direction.y() + 1.0);
    (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}


また割れてる……

レイと球の衝突判定の簡略化

hit_sphere()関数の簡略化バージョンはこちら。

main.rs
fn hit_sphere(center: Point3, radius: f64, r: Ray) -> f64 {
    let oc = r.origin() - center;
    let a = dot(r.direction(), r.direction());
+   let half_b = dot(oc, r.direction());
    let c = dot(oc, oc) - radius * radius;
+   let discriminant = half_b * half_b - a * c;
    if discriminant < 0.0 {
        -1.0
    } else {
+       (-half_b - discriminant.sqrt()) / a
    }
}

まだ6章の途中ではあるが、順を追ってディレクトリを分けながら書いている人はここで一度ディレクトリを新しく作ることをおすすめする。書いている体感的には6章は6-1と6-2に分けるべきだと思った。

数学定数とユーティリティ関数

ここではrtweekend.rsは作らないものとする。(あとでユーティリティ集みたいなのが必要にはなってくる)
そもそも無限大はstd::f64::INFINITYが、円周率はstd::f64::consts::PIがデフォルトで用意されているし、ユーティリティ関数の度数法→ラジアンの変換に関してはそもそもf64に実装されている。すごい。

共通ヘッダー云々に関してはそもそもC++とRustで仕組みが違うので作っても仕方ないだろう。
このあたりを見ると、やはりRustはモダンな言語なんだと再認識できる。

レイが衝突するオブジェクトの抽象化

Hittableクラスを、例によってstructやimplを使って模倣する……といきたいところだが、この言語はRustであり、継承よりも合成を是としているらしい。
合成に関しては正直良くわかっていないが、オブジェクト指向の継承が「元ある機械のパーツを置き換えたり追加したりする」ものだとすれば、合成は「機械の箱に機能のあるパーツを詰めていく」作業に近い印象を受ける。

というわけで、hittable.rsを追加し、以下のような書き方をしてみた。

hittable.rs
use crate::ray::Ray;
use crate::vec3::{Point3, Vec3};

pub struct HitRecord {
    pub p: Point3,
    pub normal: Vec3,
    pub t: f64,
}

pub struct Hittable<T: Shape> {
    pub shape: T,
}

pub trait Shape {
    fn hit(&self, r: Ray, t_min: f64, t_max: f64, rec: &mut HitRecord) -> bool;
}

Ray Tracing in One Weekendのシリーズの次(The Next Week)にhittableクラスを継承したquadというものが出てきていたので、すべての物体(Hittable)は形(Shape)を持つと考えるようにしてみた。
元のC++のコードを見るとhittableがBoolean型を返す関数hit()を実装しているので、ひとまずShapehit()を持つものとしてtraitを書いた。
継承に比べると最初から考えておかないことが多く感じてなかなかしんどい気がするが、まあその分きちっと設計できるというメリットもあるのだろう。
Hittableのコンストラクタは、実装の都合上あとで書き足す。

次に、sphere.rs並びに構造体Sphereを書いていく。
SpherePoint3radius(これはただのf64)、そしてMaterialを持つ……のだが、今の時点では法線まわりを触っているだけでまだMaterialを持っていないので、一旦Materialの実装を見送ることにする。

sphere.rs
use crate::{vec3::Point3, ray::Ray, hittable::Shape, hittable::HitRecord};

pub struct Sphere {
    center: Point3,
    radius: f64,
}

impl Sphere {
    pub fn new(center: Point3, radius: f64) -> Self {
        Self { center, radius }
    }
}

new()がなんか変な書き方になっているのは、フィールド初期化省略記法というやつのようだ。いちいちcenter: centerとか書かずとも名前が同じだから片方は省略して単にcenterでいいだろ、という理屈らしい。

構造体SphereHittableに実装するShapeとして受け入れられるためには、Spherehit()を持っていなければならない。ということで、上記のコードにhit()を書き足す。

sphere.rs
impl Shape for Sphere {
    fn hit(&self, r: Ray, t_min: f64, t_max: f64, rec: &mut HitRecord) -> bool {
        let oc = r.origin() - self.center;
        let a = r.direction().length_squared();
        let half_b = dot(oc, r.direction());
        let c = oc.length_squared() - self.radius * self.radius;
        let discriminant = half_b * half_b - a * c;

        if discriminant > 0.0 {
            let root = discriminant.sqrt();
            let temp = (-half_b - root) / a;
            if temp < t_max && temp > t_min {
                rec.t = temp;
                rec.p = r.at(rec.t);
                rec.normal = (rec.p - self.center) / self.radius;
                return true
            }
            let temp = (-half_b + root) / a;
            if temp < t_max && temp > t_min {
                rec.t = temp;
                rec.p = r.at(rec.t);
                rec.normal = (rec.p - self.center) / self.radius;
                return true
            }
        }
        false
    }
}

これでようやくShapeの要件を満たした。hittable.rsに先延ばしにしていたコンストラクタを書きに行こう。useも忘れずに。

hittable.rs
use crate::sphere::Sphere;

// ...省略...

impl Hittable<Sphere> {
    pub fn new(shape: Sphere) -> Self {
        Self { shape }
    }
}

法線の向き

構造体HitRecordに対して書き足しが発生した。hittable.rsを以下の通り書き換える。

hittable.rs
use crate::ray::Ray;
use crate::sphere::Sphere;
- use crate::vec3::{Point3, Vec3};
+ use crate::vec3::{dot, Point3, Vec3};

pub struct HitRecord {
    pub p: Point3,
    pub normal: Vec3,
    pub t: f64,
+   pub front_face: bool,
}

+ impl HitRecord {
+     pub fn set_face_normal(&mut self, r: Ray, outward_normal: Vec3) {
+         self.front_face = dot(r.direction(), outward_normal) < 0.0;
+         self.normal = if self.front_face {
+             outward_normal
+         } else {
+             -outward_normal
+         };
+     }
+ }

sphere.rsに関しても以下の通り法線の向きの計算が追加される。

sphere.rs
impl Shape for Sphere {
    fn hit(&self, r: Ray, t_min: f64, t_max: f64, rec: &mut HitRecord) -> bool {
        let oc = r.origin() - self.center;
        let a = r.direction().length_squared();
        let half_b = dot(oc, r.direction());
        let c = oc.length_squared() - self.radius * self.radius;
        let discriminant = half_b * half_b - a * c;

        if discriminant > 0.0 {
            let root = discriminant.sqrt();
            let temp = (-half_b - root) / a;
            if temp < t_max && temp > t_min {
                rec.t = temp;
                rec.p = r.at(rec.t);
+               let outward_normal = (rec.p - self.center) / self.radius;
+               rec.set_face_normal(r, outward_normal);
-               rec.normal = (rec.p - self.center) / self.radius;
                return true
            }
            let temp = (-half_b + root) / a;
            if temp < t_max && temp > t_min {
                rec.t = temp;
                rec.p = r.at(rec.t);
+               let outward_normal = (rec.p - self.center) / self.radius;
+               rec.set_face_normal(r, outward_normal);
-               rec.normal = (rec.p - self.center) / self.radius;
                return true
            }
        }
        false
    }
}

オブジェクトのリスト / C++に特有の機能

ここは、元のコードがそもそもhittable_listhittableを継承するという親子関係がぐちゃぐちゃしたよくわからないコードになっているなどの問題もあって混沌の様相を呈していて、理解が難しい。さらに、shared_ptrというC++に特有の機能なんてものも出てきて、混乱してしまう。(vectorは別にあるので問題ないと思う)

この際、ひとまず先にmain.rsを書き換えて、何が必要なのかを見極めることにしよう。
hit_sphere()関数はShapeに実装されているhit()が全部受け持ったのでもう消して良い。
useまわりを丸々省いているが、エディタの出すエラーに従って書き足してほしい。

main.rs
fn ray_color(r: Ray, world: &Vec<Hittable<Sphere>>) -> Color {
    let mut rec = HitRecord {
        p: Point3::new(0.0, 0.0, 0.0),
        normal: Vec3::new(0.0, 0.0, 0.0),
        t: 0.0,
        front_face: false,
    };

    if world.iter().any(|h| h.shape.hit(r, 0.0, f64::INFINITY, &mut rec)) {
        return 0.5 * (rec.normal + Color::new(1.0, 1.0, 1.0));
    }

    let unit_direction: Vec3 = unit_vector(r.direction());
    let t = 0.5 * (unit_direction.y() + 1.0);
    (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}

fn main() -> io::Result<()> {
    let mut out_str = format!("P3\n{} {}\n255\n", IMAGE_WIDTH, IMAGE_HEIGHT);

    let origin = Point3::new(0.0, 0.0, 0.0);
    let horizontal = Vec3::new(VIEWPORT_WIDTH, 0.0, 0.0);
    let vertical = Vec3::new(0.0, VIEWPORT_HEIGHT, 0.0);
    let lower_left_corner =
        origin - horizontal / 2.0 - vertical / 2.0 - Vec3::new(0.0, 0.0, FOCAL_LENGTH);
    
    let world: Vec<Hittable<Sphere>> = vec![
        Hittable::new(Sphere::new(Point3::new(0.0, 0.0, -1.0), 0.5)),
        Hittable::new(Sphere::new(Point3::new(0.0, -100.5, -1.0), 100.0)),
    ];

    let mut data_vector: Vec<String> = vec![String::from(""); COUNT_MAX];
    let mut index: usize = 0;

    for j in (0..IMAGE_HEIGHT).rev() {
        print!("Progress: {} / {}    \r", j, IMAGE_HEIGHT);
        stdout().flush().unwrap();
        for i in 0..IMAGE_WIDTH {
            let u = i as f64 / (IMAGE_WIDTH - 1) as f64;
            let v = j as f64 / (IMAGE_HEIGHT - 1) as f64;
            let r = Ray::new(
                origin,
                lower_left_corner + u * horizontal + v * vertical - origin,
            );
            let pixel_color = ray_color(r, &world);
            data_vector[index] = write_color(pixel_color);
            index += 1;
        }
    }

    print!("\nWriting to file...");

    // Finalize
    out_str += &data_vector.join("\n");

    let mut file = File::create("6-2-multipul-objects.ppm").unwrap();
    file.write_fmt(format_args!("{}", out_str))?;
    println!("Done!");
    Ok(())
}

ポイントは以下の通り。

  • mutable(変更可能)なworldを定義してSphereを一つずつpushしていっても良かったのだが、Rustにはvec![]マクロがデフォルトで用意されている。Pythonのリストくらい簡単に書ける上mutableである必要もなくなるので積極的に利用すべき。
  • main()関数内のレンダリングを行っている部分では、world&world(参照)になる。参照でないとループの最初で所有権が移動してしまい2回目以降のループができなくなるため。Rust的には初歩の初歩だが、自分はすっかり忘れていたので注意。

元々のコードではhittable_listだったworldの型は、ここではVec<Hittable<Sphere>>である。要するにただのベクタだ。world.hit()で済まされていた部分が多少面倒にはなっているが、結局のところ元のコードもworld.hit()の中身は「オブジェクトのリスト(ベクタ)をループしてレイが当たっていたらtrueを返す」というものなので、そのループの部分がray_color()側に出てきただけと考えれば良い。

use関係のエラーを適当に解消すると、無事画像が出てくる。
shared_ptrだなんだと言っていたが、ぶっちゃけここでは&mutを使っておけば何の問題もなかった。結局のところ、recの参照を渡してきちんと変更してもらうことが重要だったので、そう考えると確かに「変更可能な参照」である&mutは適任だ。

せっかくなので左にも球を置いてみた

7. アンチエイリアシング

複数のサンプルを使ったピクセルの描画 (カメラの実装)

章の名前とは違うが、ここでサラッとcameraクラスが実装されている。そんなに複雑でもないので、サクッと実装する。

camera.rs
use crate::ray::Ray;
use crate::vec3::{Point3, Vec3};

pub struct Camera {
    origin: Point3,
    lower_left_corner: Point3,
    horizontal: Vec3,
    vertical: Vec3,
}

impl Camera {
    pub fn new() -> Camera {
        let aspect_ratio = 16.0 / 9.0;
        let viewport_height = 2.0;
        let viewport_width = aspect_ratio * viewport_height;
        let focal_length = 1.0;

        let origin = Point3::new(0.0, 0.0, 0.0);
        let horizontal = Vec3::new(viewport_width, 0.0, 0.0);
        let vertical = Vec3::new(0.0, viewport_height, 0.0);
        let lower_left_corner =
            origin - horizontal / 2.0 - vertical / 2.0 - Vec3::new(0.0, 0.0, focal_length);

        Camera {
            origin,
            lower_left_corner,
            horizontal,
            vertical,
        }
    }

    pub fn get_ray(&self, u: f64, v: f64) -> Ray {
        Ray::new(
            self.origin,
            self.lower_left_corner + u * self.horizontal + v * self.vertical - self.origin,
        )
    }
}

乱数関数

ここに来て、なんと初めてcrate(外部ライブラリ)を使う。その名もrand。乱数を扱うcrateである。
Pythonなど他の多くの言語から来るとびっくりしてしまうが、Rustは標準ライブラリで乱数生成機能を提供していない。(が、C++もどうやら長い間そうであったらしい)
外部ライブラリに依存すると言うと聞こえは悪いが、crates.ioのオーナー欄を見てみると、どう見てもRust本体に関わってそうな人たちがいる感じになっているので、これが実質標準ライブラリということで良いだろう。……ダメ?

上記ページにある通り、ディレクトリ上でcargo add randをするかCargo.tomlのdependenciesにrand = "0.8.5"を追記する。(0.8.5は執筆時点(2024/02/03)での最新ver。なんとまだv1に到達していない)

複数のサンプルを使ったピクセルの描画

ここでrtweekend.hもといutils.rsを作る。(名前は気に入らなかったので勝手に変えた)
clamp()と乱数の関数を2つ、ここに追加する。
乱数の方は元々はrandom_double()だったが、ここではRustらしくrandom_f64とした。関数のオーバーロードもデフォルト引数もないので、片方は_rangeを付け足しておく。
randの使い方はいろいろあるが、一定範囲内の乱数を作れるというUniformを使ってみた。

utils.rs
use rand::distributions::{Uniform, Distribution};

pub fn clamp(x: f64, min: f64, max: f64) -> f64 {
    if x < min {
        return min;
    }
    if x > max {
        return max;
    }
    x
}

pub fn random_f64() -> f64 {
    let mut rng = rand::thread_rng();
    let uniform = Uniform::from(0.0..1.0);
    uniform.sample(&mut rng)
}

pub fn random_f64_range(min: f64, max: f64) -> f64 {
    let mut rng = rand::thread_rng();
    let uniform = Uniform::from(min..max);
    uniform.sample(&mut rng)
}

clamp()関数はcolor.rsで使われる。関数(というかファイル全体)を以下のように書き換える。

color.rs
use crate::{util::clamp, vec3::Color};

pub fn write_color(pixel_color: Color, samples_per_pixel: i32) -> String {
    let mut r = pixel_color.x();
    let mut g = pixel_color.y();
    let mut b = pixel_color.z();

    let scale = 1.0 / samples_per_pixel as f64;
    r *= scale;
    g *= scale;
    b *= scale;

    format!(
        "{} {} {}",
        (256.0 * clamp(r, 0.0, 0.999)) as u8,
        (256.0 * clamp(g, 0.0, 0.999)) as u8,
        (256.0 * clamp(b, 0.0, 0.999)) as u8
    )
}

main.rsも書き換える。書き換わった部分が結構多いので、まるごと載せている。
具体的には、Cameraの導入により必要なくなったコードが結構ある。

main.rs
use s7_antialiasing::{
    camera::Camera,
    color::write_color,
    hittable::{HitRecord, Hittable, Shape},
    ray::Ray,
    sphere::Sphere,
    util::random_f64,
    vec3::{unit_vector, Color, Point3, Vec3},
};
use std::fs::File;
use std::io::{self, stdout, Write};

// Image Settings
const ASPECT_RATIO: f64 = 16.0 / 9.0;
const IMAGE_WIDTH: i32 = 384;
const IMAGE_HEIGHT: i32 = (IMAGE_WIDTH as f64 / ASPECT_RATIO) as i32;
const SAMPLES_PER_PIXEL: i32 = 100;

const COUNT_MAX: usize = IMAGE_HEIGHT as usize * IMAGE_WIDTH as usize;

fn ray_color(r: Ray, world: &Vec<Hittable<Sphere>>) -> Color {
    // ...省略...
}

fn main() -> io::Result<()> {
    let mut out_str = format!("P3\n{} {}\n255\n", IMAGE_WIDTH, IMAGE_HEIGHT);

    let world: Vec<Hittable<Sphere>> = vec![
        Hittable::new(Sphere::new(Point3::new(0.0, 0.0, -1.0), 0.5)),
        Hittable::new(Sphere::new(Point3::new(0.0, -100.5, -1.0), 100.0)),
    ];

    let cam = Camera::new();

    let mut data_vector: Vec<String> = vec![String::from(""); COUNT_MAX];
    let mut index: usize = 0;

    for j in (0..IMAGE_HEIGHT).rev() {
        print!("Progress: {} / {}    \r", j, IMAGE_HEIGHT);
        stdout().flush().unwrap();
        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_f64()) / (IMAGE_WIDTH - 1) as f64;
                let v = (j as f64 + random_f64()) / (IMAGE_HEIGHT - 1) as f64;
                let r = cam.get_ray(u, v);
                pixel_color += ray_color(r, &world);
            }
            data_vector[index] = write_color(pixel_color, SAMPLES_PER_PIXEL);
            index += 1;
        }
    }
    // ...省略...
}


シンプルに計算量が100倍になった上、乱数も関わっているので露骨に時間がかかるようになってきた。
cargo runで実行しているなら、リリースビルドを行うcargo run --releaseをかわりに用いたほうが良い。

8. 拡散マテリアル

単純な拡散マテリアル

vec3.rsVec3に対するimplementを追加する。useも忘れずに。

vec3.rs
impl Vec3 {
    pub fn new(e0: f64, e1: f64, e2: f64) -> Self {
        Self {
            e: [e0, e1, e2, 0.0],
        }
    }

    pub fn x(&self) -> f64 {
        self.e[0]
    }

    pub fn y(&self) -> f64 {
        self.e[1]
    }

    pub fn z(&self) -> f64 {
        self.e[2]
    }

    pub fn length_squared(&self) -> f64 {
        self.e[0] * self.e[0] + self.e[1] * self.e[1] + self.e[2] * self.e[2]
    }

    pub fn length(&self) -> f64 {
        self.length_squared().sqrt()
    }

+   pub fn random() -> Vec3 {
+       Vec3::new(random_f64(), random_f64(), random_f64())
+   }
+
+   pub fn random_range(min: f64, max: f64) -> Vec3 {
+       Vec3::new(
+           random_f64_range(min, max),
+           random_f64_range(min, max),
+           random_f64_range(min, max),
+       )
+   }
+   pub fn random_in_unit_sphere() -> Vec3 {
+       loop {
+           let p = Vec3::random_range(-1.0, 1.0);
+           if p.length_squared() < 1.0 {
+               return p;
+           }
+       }
+   }
}

random_in_unit_sphere()の中の不等号が逆だったりするがまあ特に問題はない。

子レイの数を制限する

ray_color()関数は以下のようになる。

main.rs
- fn ray_color(r: Ray, world: &Vec<Hittable<Sphere>>) -> Color {
+ fn ray_color(r: Ray, world: &Vec<Hittable<Sphere>>, depth: i32) -> Color {
    let mut rec = HitRecord {
        p: Point3::new(0.0, 0.0, 0.0),
        normal: Vec3::new(0.0, 0.0, 0.0),
        t: 0.0,
        front_face: false,
    };

+   if depth <= 0 {
+       return Color::new(0.0, 0.0, 0.0);
+   }

    if world
        .iter()
        .any(|h| h.shape.hit(r, 0.0, f64::INFINITY, &mut rec))
    {
        let target = rec.p + rec.normal + Vec3::random_in_unit_sphere();
-       return 0.5 * ray_color(Ray::new(rec.p, target - rec.p), world);
+       return 0.5 * ray_color(Ray::new(rec.p, target - rec.p), world, depth - 1);
    }

    let unit_direction: Vec3 = unit_vector(r.direction());
    let t = 0.5 * (unit_direction.y() + 1.0);
    (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}

あとは定数MAX_DEPTHを定義してやればよい。

レイの反射を最大50回追いかけるようになったのでさらに時間がかかるようになった。画像の下側に来るにつれ反射が増えるのでそのあたりが一番時間がかかっているように思える。

ガンマ補正による明るさの調整

color.rsを以下のように書き換える。

color.rs
use crate::{util::clamp, vec3::Color};

pub fn write_color(pixel_color: Color, samples_per_pixel: i32) -> String {
    let mut r = pixel_color.x();
    let mut g = pixel_color.y();
    let mut b = pixel_color.z();

    let scale = 1.0 / samples_per_pixel as f64;
-   r *= scale;
-   g *= scale;
-   b *= scale;
+   r = (scale * r).sqrt();
+   g = (scale * g).sqrt();
+   b = (scale * b).sqrt();

    format!(
        "{} {} {}",
        (256.0 * clamp(r, 0.0, 0.999)) as u8,
        (256.0 * clamp(g, 0.0, 0.999)) as u8,
        (256.0 * clamp(b, 0.0, 0.999)) as u8
    )
}


無事完成した。
ちなみに、記号が*=から=に変化している点に注意。記号を変え忘れると以下のようになる。

うぉっまぶしっ

シャドウアクネを消す

main.rs
- if world.iter().any(|h| h.shape.hit(r, 0.0, f64::INFINITY, &mut rec)) {
+ if world.iter().any(|h| h.shape.hit(r, 0.001, f64::INFINITY, &mut rec)) {

完全なランバート反射

Vec3に対するimplement。

vec3.rs
    pub fn random_unit_vector() -> Vec3 {
        let a = random_f64_range(0.0, 2.0 * std::f64::consts::PI);
        let z = random_f64_range(-1.0, 1.0);
        let r = (1.0 - z * z).sqrt();
        Vec3::new(r * a.cos(), r * a.sin(), z)
    }

main.rsray_color()関数も書き換える。

main.rs
fn ray_color(r: Ray, world: &Vec<Hittable<Sphere>>, depth: i32) -> Color {
    let mut rec = HitRecord {
        p: Point3::new(0.0, 0.0, 0.0),
        normal: Vec3::new(0.0, 0.0, 0.0),
        t: 0.0,
        front_face: false,
    };

    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }

    if world
        .iter()
        .any(|h| h.shape.hit(r, 0.001, f64::INFINITY, &mut rec))
    {
-       let target = rec.p + rec.normal + Vec3::random_in_unit_sphere();
+       let target = rec.p + rec.normal + Vec3::random_in_unit_vector();
        return 0.5 * ray_color(Ray::new(rec.p, target - rec.p), world, depth - 1);
    }

    let unit_direction: Vec3 = unit_vector(r.direction());
    let t = 0.5 * (unit_direction.y() + 1.0);
    (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}


ちょっと明るい感じになっていたら成功。

拡散マテリアルの異なる定式化

Vec3に対するimplement。

vec3.rs
    pub fn random_in_hemisphere(normal: Vec3) -> Vec3 {
        let in_unit_sphere = Vec3::random_in_unit_sphere();
        if dot(in_unit_sphere, normal) > 0.0 {
            in_unit_sphere
        } else {
            -in_unit_sphere
        }
    }

main.rsray_color()関数も書き換える。

main.rs
fn ray_color(r: Ray, world: &Vec<Hittable<Sphere>>, depth: i32) -> Color {
    let mut rec = HitRecord {
        p: Point3::new(0.0, 0.0, 0.0),
        normal: Vec3::new(0.0, 0.0, 0.0),
        t: 0.0,
        front_face: false,
    };

    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }

    if world
        .iter()
        .any(|h| h.shape.hit(r, 0.001, f64::INFINITY, &mut rec))
    {
-       let target = rec.p + rec.normal + Vec3::random_in_unit_vector();
+       let target = rec.p + Vec3::random_in_hemisphere(rec.normal);
        return 0.5 * ray_color(Ray::new(rec.p, target - rec.p), world, depth - 1);
    }

    let unit_direction: Vec3 = unit_vector(r.direction());
    let t = 0.5 * (unit_direction.y() + 1.0);
    (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}

9. 金属マテリアル

マテリアルを表す抽象クラス

先程まで少しずつ出ていたが、ざらざらした雰囲気の材質(=ランバート反射しているもの)をランバーティアンと呼ぶ。
ここでは金属が登場し、その後に誘電体というものが出てくる。
マテリアル、材質を持っているのは明らかにSphereというよりはHittableの方であるため、HittableShapeMaterialを持っている、というのが自然だろう。今後はその方針で実装を進める。

……と思っていたのだが、ここで今まで適当にやってきたことへのツケを払わされることになってしまった。

レイとオブジェクトの衝突を表すデータ構造

以下に、hittable.rsの変容っぷりを示す。

hittable.rs
+ use std::rc::Rc;

use crate::ray::Ray;
use crate::sphere::Sphere;
use crate::vec3::{dot, Point3, Vec3};

pub struct HitRecord {
    pub p: Point3,
    pub normal: Vec3,
+   pub material: Rc<dyn Material>,
    pub t: f64,
    pub front_face: bool,
}

impl HitRecord {
    pub fn set_face_normal(&mut self, r: Ray, outward_normal: Vec3) {
        self.front_face = dot(r.direction(), outward_normal) < 0.0;
        self.normal = if self.front_face {
            outward_normal
        } else {
            -outward_normal
        };
    }
}

- pub struct Hittable<T: Shape> {
-     pub shape: T,
- }
+ pub struct Hittable {
+     pub shape: Rc<dyn Shape>,
+     pub material: Rc<dyn Material>,
+ }

pub trait Shape {
    fn hit(&self, r: Ray, t_min: f64, t_max: f64, rec: &mut HitRecord) -> bool;
}

- impl Hittable<Sphere> {
-     pub fn new(shape: Sphere) -> Self {
-         Self { shape }
-     }
- }
+ pub trait Material {
+     fn scatter(&self, r_in: Ray, rec: &HitRecord, attenuation: &mut Vec3, scattered: &mut Ray) -> bool;
+ }

+ impl Hittable {
+     pub fn new<T: 'static + Shape, U: 'static + Material>(shape: T, material: U) -> Self {
+         Self {
+             shape: Rc::new(shape),
+             material: Rc::new(material),
+         }
+     }
+ }

先程の宣言通りHittableShapeMaterialを持つという構造にしたいため、このようになった。
impl Hittable<Sphere>をこのままMaterialを受け取れる形に拡張しようと考えると、少なくともHittable<Sphere, Lambertian>Hittable<Sphere, Metal>Hittable<Sphere, Dielectric>の3つが生まれてしまう。これではあまりにも冗長がすぎるので、これはジェネリクスを利用した形にする必要がある。
ということで、AIに訊いたりコンパイラの言いなりになったりしながらHittableまわりを修正した結果がこれである。(ShapeおよびMaterialトレイトは省略済み)

pub struct Hittable {
    pub shape: Rc<dyn Shape>,
    pub material: Rc<dyn Material>,
}

impl Hittable {
    pub fn new<T: 'static + Shape, U: 'static + Material>(shape: T, material: U) -> Self {
        Self {
            shape: Rc::new(shape),
            material: Rc::new(material),
        }
    }
}

まず、全体的にShapeMaterialといったトレイトがRcで囲まれている。これは参照カウンタ方式のスマートポインタというもので、今までに出てきたものの中では最もC++のshared_ptrに近しい存在である。複数の所有権が共有されて云々、ということなのだが、公式ドキュメントにはこれ以上ないくらい簡潔にこれがどういうものか説明されていたので、それを引用するに留める。

Rc<T>を家族部屋のテレビと想像してください。1人がテレビを見に部屋に入ったら、テレビをつけます。 他の人も部屋に入ってテレビを観ることができます。最後の人が部屋を離れる時、 もう使用されていないので、テレビを消します。他の人がまだ観ているのに誰かがテレビを消したら、 残りのテレビ視聴者が騒ぐでしょう!

Rc<dyn Shape>などはトレイトオブジェクトの型である。操作の形だけ見ると、抽象クラスをインスタンス化することに似ている。構造体であればそれがそのまま型として入るのだが、トレイトだけでは構造体と違ってそのオブジェクト(Hittableで言えばshapematerialがそれにあたる)のサイズがコンパイル時に決定できないため、このように少し回りくどい方法になる。
ちなみに当初はBox<dyn trait>という型(これもトレイトオブジェクトの型)を使っていたのだが、途中でこれをcloneしなければいけない場面に遭遇した。調べたところ、Rcでもほぼ同じように書けたのでこれを採用したが、より良い選択肢が他にあったのかどうかまでは調べられていない。

'staticライフタイムパラメータのうち、プログラムが終了するまでという指定……らしいのだが、正直自分では何もわかっていないので解説は見送る。コンパイラが出したエラーのヒントに「'staticをつけろ」と書いてあったのでつけたところ問題なく動作した。

エラー内容
Rustのコンパイラが優秀だということはよくわかった。ここまで親切に教えてくれるものはなかなかないと思う。

ray_color()関数は以下のように書き換わった。主にマテリアル周りの追加と、Hittableまわりの型が変わっていることに注目。

fn ray_color(r: Ray, world: &Vec<Hittable>, depth: i32) -> Color {
    let mut rec = HitRecord {
        p: Point3::new(0.0, 0.0, 0.0),
        normal: Vec3::new(0.0, 0.0, 0.0),
        material: Rc::new(Lambertian::new(Vec3::new(0.0, 0.0, 0.0))),
        t: 0.0,
        front_face: false,
    };

    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }

    let mut closest_so_far = f64::INFINITY;
    let mut hit_anything = false;
    let mut temp_rec = HitRecord {
        p: Point3::new(0.0, 0.0, 0.0),
        normal: Vec3::new(0.0, 0.0, 0.0),
        material: Rc::new(Lambertian::new(Vec3::new(0.0, 0.0, 0.0))),
        t: 0.0,
        front_face: false,
    };

    for hittable in world.iter() {
        temp_rec.material = Rc::clone(&hittable.material);
        if hittable.shape.hit(r, 0.001, closest_so_far, &mut temp_rec) {
            hit_anything = true;
            closest_so_far = temp_rec.t.clone();
            rec.p = temp_rec.p.clone();
            rec.normal = temp_rec.normal.clone();
            rec.material = Rc::clone(&temp_rec.material);
            rec.t = temp_rec.t.clone();
            rec.front_face = temp_rec.front_face.clone();
        }
    }

    if hit_anything {
        let mut scattered = Ray::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0));
        let mut attenuation = Color::new(0.0, 0.0, 0.0);

        if Rc::clone(&rec.material).scatter(r, &rec, &mut attenuation, &mut scattered) {
            return attenuation * ray_color(scattered, world, depth - 1);
        }
        return Color::new(0.0, 0.0, 0.0);
    }

    let unit_direction: Vec3 = unit_vector(r.direction());
    let t = 0.5 * (unit_direction.y() + 1.0);
    (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}

変数worldの型はVec<Hittable<Sphere>>だったが、ここではVec<Hittable>と多少シンプルになった。個人的にはVec<Hittable<T: Shape, U: Material>>のように書くものだと思っていたので、少し驚いた。(とはいえhittable.rsでも書き換わっていたので、当然ではある)

main.rs
let world: Vec<Hittable> = vec![
    Hittable::new(
        Sphere::new(Point3::new(0.0, 0.0, -1.0), 0.5),
        Lambertian::new(Vec3::new(0.8, 0.3, 0.3)),
    ),
    Hittable::new(
        Sphere::new(Point3::new(0.0, -100.5, -1.0), 100.0),
        Lambertian::new(Vec3::new(0.8, 0.8, 0.0)),
    ),
    Hittable::new(
        Sphere::new(Point3::new(1.0, 0.0, -1.0), 0.5),
        Metal::new(Vec3::new(0.8, 0.6, 0.2), 1.0),
    ),
    Hittable::new(
        Sphere::new(Point3::new(-1.0, 0.0, -1.0), 0.5),
        Metal::new(Vec3::new(0.8, 0.8, 0.8), 0.2),
    ),
];

10. 誘電体マテリアル

スネルの法則

vec3.rsに以下の関数を書き加える。

vec3.rs
pub fn refract(uv: Vec3, n: Vec3, etai_over_etat: f64) -> Vec3 {
    let cos_theta = dot(-uv, n).min(1.0);
    let r_out_perp = etai_over_etat * (uv + cos_theta * n);
    let r_out_parallel = -(1.0 - r_out_perp.length_squared()).abs().sqrt() * n;
    r_out_perp + r_out_parallel
}

ここのコードは、ずっと参考にしている邦訳ではなく、原文のコードを引用し、翻訳した。

というのも、以下の記事にこんな言及があったからである。
https://waregawa-log.hatenablog.com/entry/2020/08/09/170019

また、日本語版はコードや記述に間違いがあったりするので(翻訳が悪いわけではなく、海外版は更新が速いため修正されている)、原著の方も参考にしたほうがよいと思われる。 日本語版の、屈折したレイの導出には間違いがあって、私はここでかなり時間がもっていかれた。

実際、以前PythonでRay Tracing in One Weekendをやったときはいくらコードを書き換えても正しい誘電体が描画されず、結局誘電体の実装を諦めたという苦い思い出がある。日本語版にどのような間違いがあるのか気にはなるところではあるが、ひとまずここは原文のコードを参照することにした。

material.rsDielectricを実装する。

material.rs
pub struct Dielectric {
    pub ir: f64,
}

impl Dielectric {
    pub fn new(ir: f64) -> Self {
        Self { ir }
    }
}

impl Material for Dielectric {
    fn scatter(&self, r_in: Ray, rec: &HitRecord, attenuation: &mut Vec3, scattered: &mut Ray) -> bool {
        *attenuation = Vec3::new(1.0, 1.0, 1.0);
        let refraction_ratio = if rec.front_face { 1.0 / self.ir } else { self.ir };

        let unit_direction = unit_vector(r_in.direction());
        let refracted = refract(unit_direction, rec.normal, refraction_ratio);

        *scattered = Ray::new(rec.p, refracted);
        true
    }
}

そしてmain.rsも書き換える……のだが、今のうちに少しわかりやすく整理しておこう。しばらく地面・中央・左・右の4つのSphereを使うので、Hittableを変数に入れておく。

main.rs
// Hittables
let material_ground = Hittable::new(
    Sphere::new(Point3::new(0.0, -100.5, -1.0), 100.0),
    Lambertian::new(Vec3::new(0.8, 0.8, 0.0)),
);
let material_center = Hittable::new(
    Sphere::new(Point3::new(0.0, 0.0, -1.0), 0.5),
    Dielectric::new(1.5),
);
let material_left = Hittable::new(
    Sphere::new(Point3::new(-1.0, 0.0, -1.0), 0.5),
    Dielectric::new(1.5),
);
let material_right = Hittable::new(
    Sphere::new(Point3::new(1.0, 0.0, -1.0), 0.5),
    Metal::new(Vec3::new(0.8, 0.6, 0.2), 1.0),
);

let world: Vec<Hittable> = vec![
    material_ground,
    material_center,
    material_left,
    material_right,
];

この時点で、「常に屈折するガラス球」の画像が得られるようになる。

全反射

誘電体マテリアルのscatter()関数を書き換える。

material.rs
impl Material for Dielectric {
    fn scatter(&self, r_in: Ray, rec: &HitRecord, attenuation: &mut Vec3, scattered: &mut Ray) -> bool {
        *attenuation = Vec3::new(1.0, 1.0, 1.0);
        let refraction_ratio = if rec.front_face { 1.0 / self.ir } else { self.ir };

        let unit_direction = unit_vector(r_in.direction());
-       let refracted = refract(unit_direction, rec.normal, refraction_ratio);
+       let cos_theta = dot(-unit_direction, rec.normal).min(1.0);
+       let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();

+       let cannot_refract = refraction_ratio * sin_theta > 1.0;
+       let direction = if cannot_refract {
+           reflect(unit_direction, rec.normal)
+       } else {
+           refract(unit_direction, rec.normal, refraction_ratio)
+       };

-       *scattered = Ray::new(rec.p, refracted);
+       *scattered = Ray::new(rec.p, direction);
        true
    }
}

これで、「可能な限りは常に屈折するガラス球」の画像を得られる。

シュリックの近似

material.rsに以下の関数を書き加える。

material.rs
fn reflectance(cosine: f64, ref_idx: f64) -> f64 {
    let r0 = ((1.0 - ref_idx) / (1.0 + ref_idx)).powi(2);
    r0 + (1.0 - r0) * (1.0 - cosine).powi(5)
}

そして、scatter()関数を書き換える。

material.rs
- let direction = if cannot_refract {
+  let direction = if cannot_refract || reflectance(cos_theta, refraction_ratio) > random_f64()
+ {
    reflect(unit_direction, rec.normal)
} else {
    refract(unit_direction, rec.normal, refraction_ratio)
};

これで誘電体マテリアルは完成。

完成後(左)と完成前(右)。完成後は微妙に反射するなどの違いがある

中空ガラス球のモデリング

誘電体の内側に半径がマイナスになった球を追加する。

main.rs
// Hittables
let material_ground = Hittable::new(
    Sphere::new(Point3::new(0.0, -100.5, -1.0), 100.0),
    Lambertian::new(Vec3::new(0.8, 0.8, 0.0)),
);
let material_center = Hittable::new(
    Sphere::new(Point3::new(0.0, 0.0, -1.0), 0.5),
    Lambertian::new(Vec3::new(0.1, 0.2, 0.5)),
);
let material_left = Hittable::new(
    Sphere::new(Point3::new(-1.0, 0.0, -1.0), 0.5),
    Dielectric::new(1.5),
);
let material_left_inside = Hittable::new(
    Sphere::new(Point3::new(-1.0, 0.0, -1.0), -0.4),
    Dielectric::new(1.5),
);
let material_right = Hittable::new(
    Sphere::new(Point3::new(1.0, 0.0, -1.0), 0.5),
    Metal::new(Vec3::new(0.8, 0.6, 0.2), 1.0),
);

let world: Vec<Hittable> = vec![
    material_ground,
    material_center,
    material_left,
    material_left_inside,
    material_right,
];

11. カメラの移動

カメラの視野

原語版はかなりカメラの時点で変わっていたのだが、今回は件の邦訳をもとに進めてしまったので、そちらに沿うこととする。バグも存在していない(と思う)。

camera.rs
- pub fn new() -> Camera {
+ pub fn new(vfov: f64, aspect_ratio: f64) -> Camera {
-   let aspect_ratio = 16.0 / 9.0;
+   let theta = vfov.to_radians();
+   let h = (theta / 2.0).tan();]
-   let viewport_height = 2.0;
+   let viewport_height = 2.0 * h;
    let viewport_width = aspect_ratio * viewport_height;
    let focal_length = 1.0;

    let origin = Point3::new(0.0, 0.0, 0.0);
    let horizontal = Vec3::new(viewport_width, 0.0, 0.0);
    let vertical = Vec3::new(0.0, viewport_height, 0.0);
    let lower_left_corner =
        origin - horizontal / 2.0 - vertical / 2.0 - Vec3::new(0.0, 0.0, focal_length);

    Camera {
        origin,
        lower_left_corner,
        horizontal,
        vertical,
    }
}

main.rsworld周りを一旦書き換える。

main.rs
let R = (std::f64::consts::PI / 4.0).cos();

let mut world = vec![
    Hittable::new(
        Sphere::new(Point3::new(-R, 0.0, -1.0), R),
        Lambertian::new(Vec3::new(0.0, 0.0, 1.0)),
    ),
    Hittable::new(
        Sphere::new(Point3::new(R, 0.0, -1.0), R),
        Lambertian::new(Vec3::new(1.0, 0.0, 0.0)),
    ),
];

let cam = Camera::new(90.0, ASPECT_RATIO);


得られる画像

カメラの移動と回転

camera.rsnew()関数を書き換える。

camera.rs
-   pub fn new() -> Camera {
+   pub fn new(lookfrom: Point3, lookat: Point3, vup: Vec3, vfov: f64, aspect_ratio: f64) -> Camera {
+       let theta = vfov.to_radians();
+       let h = (theta / 2.0).tan();
-       let viewport_height = 2.0;
+       let viewport_height = 2.0 * h;
        let viewport_width = aspect_ratio * viewport_height;

-       let focal_length = 1.0;
-       let origin = Point3::new(0.0, 0.0, 0.0);
-       let horizontal = Vec3::new(viewport_width, 0.0, 0.0);
-       let vertical = Vec3::new(0.0, viewport_height, 0.0);
-       let lower_left_corner = origin - horizontal / 2.0 - vertical / 2.0 - Vec3::new(0.0, 0.0, focal_length);
+       let w = unit_vector(lookfrom - lookat);
+       let u = unit_vector(cross(vup, w));
+       let v = cross(w, u);

+       let origin = lookfrom;
+       let horizontal = viewport_width * u;
+       let vertical = viewport_height * v;
+       let lower_left_corner = origin - horizontal / 2.0 - vertical / 2.0 - w;

        Camera {
            origin,
            lower_left_corner,
            horizontal,
            vertical,
        }
    }

main.rsのカメラ初期化コードを書き換え、画像が得られるようになった。

let cam = Camera::new(
    Point3::new(-2.0, 2.0, 1.0),
    Point3::new(0.0, 0.0, -1.0),
    Vec3::new(0.0, 1.0, 0.0),
    90.0,
    ASPECT_RATIO,
);

let cam = Camera::new(
    Point3::new(-2.0, 2.0, 1.0),
    Point3::new(0.0, 0.0, -1.0),
    Vec3::new(0.0, 1.0, 0.0),
-   90.0,
+   20.0,
    ASPECT_RATIO,
);

12. 焦点ボケ

サンプルレイの生成

vec3.rsに以下の関数を追記する。

pub fn random_in_unit_disk() -> Vec3 {
    loop {
        let p = Vec3::new(random_f64_range(-1.0, 1.0), random_f64_range(-1.0, 1.0), 0.0);
        if p.length_squared() < 1.0 {
            return p;
        }
    }
}

camera.rsを書き換える。

camera.rs
use crate::ray::Ray;
- use crate::vec3::{cross, unit_vector, Point3, Vec3};
+ use crate::vec3::{cross, random_in_unit_disk, unit_vector, Point3, Vec3};

pub struct Camera {
    origin: Point3,
    lower_left_corner: Point3,
    horizontal: Vec3,
    vertical: Vec3,
+   lens_radius: f64,
+   u: Vec3,
+   v: Vec3,
+   w: Vec3,
}

impl Camera {
-   pub fn new(lookfrom: Point3, lookat: Point3, vup: Vec3, vfov: f64, aspect_ratio: f64) -> Camera {
+   pub fn new(lookfrom: Point3, lookat: Point3, vup: Vec3, vfov: f64, aspect_ratio: f64, aperture: f64, focus_dist: f64) -> Camera {
        let theta = vfov.to_radians();
        let h = (theta / 2.0).tan();
        let viewport_height = 2.0 * h;
        let viewport_width = aspect_ratio * viewport_height;
        
        let w = unit_vector(lookfrom - lookat);
        let u = unit_vector(cross(vup, w));
        let v = cross(w, u);

        let origin = lookfrom;
-       let horizontal = viewport_width * u;
-       let vertical = viewport_height * v;
-       let lower_left_corner = origin - horizontal / 2.0 - vertical / 2.0 - w;
+       let horizontal = focus_dist * viewport_width * u;
+       let vertical = focus_dist * viewport_height * v;
+       let lower_left_corner = origin - horizontal / 2.0 - vertical / 2.0 - focus_dist * w;
+       let lens_radius = aperture / 2.0;

        Camera {
            origin,
            lower_left_corner,
            horizontal,
            vertical,
+           lens_radius,
+           u,
+           v,
+           w,
        }
    }

-   pub fn get_ray(&self, u: f64, v: f64) -> Ray {
+   pub fn get_ray(&self, s: f64, t: f64) -> Ray {
+       let rd = self.lens_radius * random_in_unit_disk();
+       let offset = self.u * rd.x() + self.v * rd.y(); 

        Ray::new(
-           self.origin,
-           self.lower_left_corner + u * self.horizontal + v * self.vertical - self.origin,
+           self.origin + offset,
+           self.lower_left_corner + s * self.horizontal + t * self.vertical - self.origin - offset,
        )
    }
}

main.rsのカメラ周りを書き換える。引数が多く分かりづらくなってきたので、一度変数に代入する。ついでにコメントも付けた。

main.rs
// Camera

let lookfrom = Point3::new(3.0, 3.0, 2.0);
let lookat = Point3::new(0.0, 0.0, -1.0);
let vup = Vec3::new(0.0, 1.0, 0.0);
let dist_to_focus = (lookfrom - lookat).length();
let aperture = 2.0;

let cam = Camera::new(
    lookfrom,
    lookat,
    vup,
    20.0,
    ASPECT_RATIO,
    aperture,
    dist_to_focus,
);


ところで、Camera構造体のwが読まれないという警告が出ているが、害もないのでとりあえず抑制しておこうと思う。
camera.rsにアトリビュートを付与する。

camera.rs
#[allow(dead_code)]
pub struct Camera {
    origin: Point3,
    lower_left_corner: Point3,
    horizontal: Vec3,
    vertical: Vec3,
    lens_radius: f64,
    u: Vec3,
    v: Vec3,
    w: Vec3,
}

13. 次は?

最後のシーン

main.rsmain()関数の上の方を全部関数に分けて、コードを追加する。

main.rs
fn random_scene() -> Vec<Hittable> {
    let mut world: Vec<Hittable> = Vec::new();
    
    let ground_material = Lambertian::new(Vec3::new(0.5, 0.5, 0.5));
    world.push(Hittable::new(Sphere::new(Point3::new(0.0, -1000.0, 0.0), 1000.0), ground_material));

    for a in -11..11 {
        for b in -11..11 {
            let choose_mat = random_f64();
            let center = Point3::new(a as f64 + 0.9 * random_f64(), 0.2, b as f64 + 0.9 * random_f64());

            if (center - Point3::new(4.0, 0.2, 0.0)).length() > 0.9 {
                if choose_mat < 0.8 {
                    // diffuse
                    let albedo = Vec3::random() * Vec3::random();
                    let sphere_material = Lambertian::new(albedo);
                    world.push(Hittable::new(Sphere::new(center, 0.2), sphere_material));
                } else if choose_mat < 0.95 {
                    // metal
                    let albedo = Vec3::random_range(0.5, 1.0);
                    let fuzz = random_f64_range(0.0, 0.5);
                    let sphere_material = Metal::new(albedo, fuzz);
                    world.push(Hittable::new(Sphere::new(center, 0.2), sphere_material));
                } else {
                    // glass
                    let sphere_material = Dielectric::new(1.5);
                    world.push(Hittable::new(Sphere::new(center, 0.2), sphere_material));
                }
            }
        }
    }

    world.push(Hittable::new(Sphere::new(Point3::new(0.0, 1.0, 0.0), 1.0), Dielectric::new(1.5)));
    world.push(Hittable::new(Sphere::new(Point3::new(-4.0, 1.0, 0.0), 1.0), Lambertian::new(Vec3::new(0.4, 0.2, 0.1))));
    world.push(Hittable::new(Sphere::new(Point3::new(4.0, 1.0, 0.0), 1.0), Metal::new(Vec3::new(0.7, 0.6, 0.5), 0.0)));

    world
}

fn main() -> io::Result<()> {
    let mut out_str = format!("P3\n{} {}\n255\n", IMAGE_WIDTH, IMAGE_HEIGHT);

    let world = random_scene();
// ...省略...


感動も一入
お疲れ様でした。

完走してみて

非常にRustの勉強になった。C++にしかない機能まわりが本当にしんどかったが、コンパイラとAIの力のおかげでなんとか完成にこぎつけることができた。
動的な言語を主に書いていた自分に足りていなかったトレイトや所有権、参照やポインタなどへの理解度・解像度も高まった。
幅広い言語機能に触れられて、なおかつわかりやすい成果物が出てくる良い題材だと思う。
ただの写経と侮れない魅力があった。
https://github.com/Qman11010101/weekend_raytracing_rust/tree/master

また、これを書いている間に競技プログラミング的な問題を解く機会があったのだが、Rustで書いてみたところ驚くほどスムーズに書くことができた。簡単な問題であれば、Ray Tracing in One Weekendよりは使う言語機能が少ないので楽かもしれない。
Pythonメインでやってきた自分にとって、今後の競技プログラミングの言語の択に十分高速な言語ができたことが嬉しい。

Discussion