嗚呼、帰りたい

プログラミングのことから日常のことまで。いわゆるごった煮というものだな。

レイトレーシングを実装した話

この記事はあくあたん工房 Advent Calendar 2023の9日目の記事です。8日目はトカゲ教授こと水野先生によるヒョウモントカゲモドキの孵卵器を自作するでした。

はじめに

こんにちは、茶葉亭です。アドカレとしてちょうどよいネタを探していたところ、しばらく積んでたRay Tracing in One Weekendを思い出したので、実際に手を動かして実装してみることにしました。参考元では実装にC++を用いていますが、個人的にRustで本格的にコードを書いてみたかったこともあり、今回はRustで実装します。

コードはこちらのリポジトリに置いていますのでよろしければご覧ください。

github.com

実装

参考元ではものすごい大雑把には

  1. ベクトル
  2. シーン
  3. オブジェクト
  4. マテリアル

の順で少しずつ実装が進み、最終的にこの記事のアイキャッチのような画像が出来上がる作りになっています。この記事では、詳細な手順については参考元を見てもらうことにして、私がRust初心者として疑問に思ったことに触れながら、実装の流れをなんとなくつかめる程度に大雑把に話を進めることにします。

ベクトル

レイトレーシングを実装するにあたり、もっとも重用する三次元ベクトルから実装します。実装方法はいくつかあると思いますが、私は次のようにシンプルにおきました。

pub struct Vec3([f64; 3]);

ベクトルを作ったからには四則演算や内積外積が欲しくなります。ナイーブに実装するなら、例えば足し算なら次のようになるでしょう。

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

impl Add for Vec3 {
    type Output = Vec3;

    fn add(self, rhs: Self) -> Self::Output {
        Vec3([self[0] + rhs[0], self[1] + rhs[1], self[2] + rhs[2]])
    }
}

ここで私が悩んだのはこの引数の取り方についてです。上のような実装方法では a + b みたいに足し算を行うたびに所有権が移動します。これでは複数の式で同じベクトルの変数を使いまわしたいときに不便です。

そこで次の三つの方法を考えました。

  1. Add トレイトを &Vec3 に対して実装する
  2. Clone トレイトを derive する
  3. 2に加え Copy トレイトも derive する

1の方法では a + b + c とは書けずに &(&a + &b) + &c とする必要があり書くのがめんどくさくなるので却下。また Vec3浮動小数点数三つからなる小さくて単純な構造体であったため、コピーコストなどを気にする必要はないだろうと考え 3 の方法を取りました1

ということで Vec3

#[derive(Clone, Copy)]
pub struct Vec3([f64; 3]);

としました。

シーン

ベクトルが出来上がったら次はシーンの実装です。空間を描画するのに使用する「光線」と「カメラ」を作っていきます。この部分ではプログラムの知識よりかは高校数学の幾何の知識が重要になります。

光線

位置ベクトル  \textbf{A} と方向ベクトル  \textbf{b} を用いることで三次元空間上の直線は  \textbf{P} = \textbf{A} + t \textbf{b}表すことができるのでコード上でも同様に表現します。

pub struct Ray {
    origin: Vec3,
    direction: Vec3,
}

impl Ray {
    /* ... */
    
    pub fn at(&self, t: f64) -> Vec3 {
        self.origin + t * self.direction
    }
}

カメラ

ここ説明がめっちゃめんどくさいのでかいつまんで書くのですが、レイトレーシングでの描画では「ワールド座標系」と「カメラ座標系」の二つの座標系が関わってきます。

描画対象となるオブジェクトはワールド座標系に従って空間に配置される一方で、描画時にシミュレーションする光線は視点位置を原点とするカメラ座標系から算出するため、各ピクセルの色を計算するためには座標系の変換処理をめちゃくちゃ頑張らないといけません。そのため、カメラには座標系の変換処理をまとめてやってもらいます。

参考元ではこの座標系の変換処理の実装をはじめとして、カメラにアンチエイリアスやボケなどを段階的に追加していきます。

オブジェクト

この部分もプログラムの知識よりかは高校数学の幾何の知識が重要になります。

レイトレーシングをする上では光線とオブジェクトの衝突判定を行う必要があります。参考元では簡単のために球体をオブジェクトとして使用します。この場合、球体の中心を  \textbf{C} 、球面上の点を  \textbf{P} 、半径を  r とすることで球は  |\textbf{P} - \textbf{C}| = r表されることから、先ほどの光線の式と連立させることで衝突するか否かの判定と、衝突した場合の位置について計算することができます。

マテリアル

オブジェクトの表面の質感を表現するためにマテリアルを設定します。分かりやすい例でいうとオブジェクトの表面に色を付けたり、その他にもガラスや金属などの光沢を表現したりします。ここでは高校数学の幾何の他にも高校物理で習う光の反射が重要になります。

例えばガラスのマテリアルで言うと高校物理で出てくる、入射角と屈折率についての懐かしい法則

 n \sin \theta = n' \sin \theta'

を元に光の反射する方向を決定します。

実装してみて

Ray Tracing in One Weekend を最後までやり切ると得られる画像。自分の書いたコードでこんなに綺麗な画像が得られるのは感動もの

簡易的な球体の描写から光の拡散のシミュレーションに至るまで少しずつステップアップしながら本格的なレイトレーシングに近づいていく流れは実装しててとても楽しかったです。基本的には自分が知っている基礎的な数学と物理の知識で話が進んでいって高度な知識はさほど要しないので、こんなに簡単に実装できてしまうのかという感動もありました。もちろん、より本格的な実装にはより高度な知識が必要なのでしょうが、入り口としてはもってこいな内容だったと思います。

コードを自分で改造して真ん中の球体を光らせてみたり

あとRustは書いてて楽しい言語ですね。f64to_radians() という関数が生えているのに途中で気づいて (angle / 2.0).to_radians().tan() みたいに書けることに気が付いたときには感動さえ覚えました。ロボコンC++を書いてた時はラジアンに変換する箇所で毎回180で割ってπをかけて…としていたのが懐かしい。

モチベーションが湧いてきたので続けて Ray Tracing: The Next Week にも挑戦してみたいですね。

まとめ

Ray Tracing in One WeekendをもとにRustでレイトレーシングを実装しました。一日もあれば実装できますので、みなさんもぜひ挑戦してみてはいかがでしょうか。

10日の記事はソフトウェアエンジニア4年目です。私が遅刻したこともあり先に投稿されてしまったのですが、ためになる記事でしたのでぜひ読んでみてください。


  1. 今回は十分に小さいのが事前に分かっているからこの方法が取れたものの、線形代数のライブラリみたいにベクトルのサイズがユーザーの書くプログラムに大きく依存するような場合に関してはどのような方針を取っているのが個人的に気になる