euphonictechnologies’s diary

Haskell超初心者の日記です。OCamlが好きです。

follow us in feedly

Rustで3Dのレンダリングエンジンを書く(1) - ベクトルの実装とマクロ

なぜレンダリングエンジンを?

この記事に感化されて、勉強をしてみたいと思いました。Rustは高速で安全な言語なので、スクラッチでこういうものを作るにはうってつけの言語です。 前から、3Dグラフィックスには興味があって、一時期DirectXでゲームを作っていたりしたので、むくむくと興味が湧き上がってきました。

gam0022.net

レンダリングエンジン?

3Dのレンダリングエンジン。ラスタライザともいいますが、3Dのモデリングソフトウェアなどで作った光景(シーン)データを実際にカメラから見たイメージとしてピクセル画像に変換するソフトウェアのことです。画像だけでなくアニメーション映像を扱うこともあります。

有名なレンダリングエンジンにはPixarのRenderManや、他にもMentalray、VRayなどがあります。3Dソフトに組み込まれていることも多いです(Blenderなど)。

どうやって進めるの?

最終的に欲しいものはシーンを記述した文章をもとに3Dのイメージを画像ファイル形式で出力するソフトウェアです。なので、シーンを記述する方法、シーンから画像を生み出す素になるデータを得る方法、最後に画像を出力する方法の3段階に分かれる予定です。

おおまかには

  1. ベクトルや行列などの3Dの基本的な部品を作ります
  2. それを元にシーンを表すデータ構造を定義します
  3. シーンをレンダリングする部分を実装します
  4. 最後に画像ファイルとして出力できるようにします

こんな流れでやっていこうと思います。最初にあげたブログの方がソースコードを公開されているのでがしがしパクって参考にしていこうと思います。

ベクトルを実装する

ベクトルをどうしても扱う必要があります。なぜかというと、シーンはこんな風に記述されるからです。

hanamaru-renderer/main.rs at 16c4ade782e17893ab6f0d8d646422677c2b210e · gam0022/hanamaru-renderer · GitHub

一部引用すると

fn init_scene_simple() -> (Camera, Scene) {
    let camera = Camera::new(
        Vector3::new(0.0, 2.0, 9.0), // eye
        Vector3::new(0.0, 1.0, 0.0), // target
        Vector3::new(0.0, 1.0, 0.0).normalize(), // y_up
        10.0, // fov

        LensShape::Circle, // lens shape
        0.2 * 0.0,// aperture
        8.8,// focus_distance
    );
...

最初にカメラが配置されているのですが、当然カメラは3D空間上で場所とカメラレンズの向きを指し示すのにベクトルが必要です。

二つ選択肢があります。

  1. 自分でベクトルモジュールを書く
  2. ライブラリを使う

ライブラリにはrust-ndarrayがあります。Pythonのと同じ感じで使えるもののようです。

github.com

ただ、少しオーバーキルっぽいこと、自分で実装して勉強したいこと、欲しいベクトルが3Dレンダリング向けの特殊なものになる気がすることから自分で書くことにしました。

Vector3を作る

基本的な関数を定義する

3次元ベクトルVector3を作ります。ここは参考先のコードを思いっきり参考にします。

基本的には

pub struct Vector3 {
    pub x: f64,
    pub y: f64,
    pub z: f64,
}

というデータとモジュール(シグネチャのみ)

impl Vector3 {
    pub fn new(x: f64, y: f64, z: f64) -> Vector3

    pub fn zero() -> Vector3

    pub fn one() -> Vector3

    pub fn norm(&self) -> f64

    pub fn length(&self) -> f64

    pub fn normalized(&self) -> Vector3

    pub fn dot(&self, other: &Vector3) -> f64

    pub fn cross(&self, other: &Vector3) -> Vector3
}

ぐらい。あとはreflectとretractという関数が便利そうなので参考先からもらってくることにします。

内積とかクロス積とか覚えてますか?内積はx同士、y同士、z同士かけて全部足すだけですが、クロス積ってなんでしたっけ。クロス積は二つのベクトルの法線方向に伸びるので、x座標には二つのベクトルのyとz成分をかけたり足したりしたものが、y座標にはxとz成分をかけたり足したりしたものが、z座標にも同じものが入っています。クロス積をそれぞれの基底成分の足し算で表してかけて項を整理してもいいですし、Wikipediaを見てもいいです。答えは

   x: self.y * other.z - self.z * other.y

です。y, zも同じです。

演算子オーバーロードをする

Rustで演算子オーバーロードは、できます!

演算子オーバーロードっていうのは、こんなことを可能にすることです。

let a = new Vector3 {x: 1, y: 0, z: 0};
let b = new Vector3 {x: 0, y: 1, z: 0};

a + b

いちいちJavaみたいにa.mult(b)とかやってられませんよね。読めないし。

Rustの演算子オーバーロードはトレイトで実装されています。具体的にはこんな感じ。

use std::ops::{Add, Sub, Mul, Div, Neg, AddAssign, MulAssign};

impl Add for Vector3 {
    type Output = Vector3;

    fn add(self, other: Vector3) -> Vector3 {
        Vector3 {
            x: self.x + other.x,
            y: self.y + other.y,
            z: self.z + other.z,
        }
    }
}

impl Add<f64> for Vector3 {
    type Output = Vector3;

    fn add(self, other: f64) -> Vector3 {
        Vector3 {
            x: self.x + other,
            y: self.y + other,
            z: self.z + other,
        }
    }
}

...

こんな感じでopsモジュールの中にあるAddトレイトで+を、以下同文で四則演算をオーバーロードできます。二種類必要で、

let a = new Vector3 {x: 1, y: 0, z: 0};
let b = new Vector3 {x: 0, y: 1, z: 0};

a + b;        // Vector3{ x: 1, y: 1, z: 0 }
a + 2.0;     // Vector3{ x: 3, y: 2, z: 0 }

どっちもできるようにしたいです。このa + bというのは実際にはAdd::add(a, b)のシンタックスシュガーになっています。なので、プラスの演算はAdd::add、マイナスの演算はSub::subが呼ばれているので、それのメソッドをオーバーロードすれば良いです。

なので早速Add, Sub, Mul, Divで2通りずつ、全部で8通りのimplを書きましょう!

嫌です!!!!!!

どう考えても4回コピペしてAddの部分をSub, Mul, Divへそれぞれ、+を-, *, /へそれぞれ置換する単純作業のお出ましです。人間はそういうことをするために生きているわけではありません。

当然怠惰を美徳とする模範的プログラマである我々はCtrl-Cをする前に関数にして繰り返しを防げないか考えます。

今回はできそうにありませんね、なんせtraitの型とかインターフェースにまつわるところなので。ただ、Rustにはマクロがあります。

マクロ?

もしあなたが老害C++プログラマの場合は「マクロはやめろ!!八つ墓村の祟りじゃあ!!!」といって聞かないかもしれませんが是非聞いて欲しいです。Rustには本物のマクロがります。

www.atmarkit.co.jp

本物のマクロといえばLispですね。Lispのマクロは本物です。Cのプリプロセサマクロは単なる文字置換機でクソみたいなものです。関係ないですが、最近C++のコードでマクロ関数をnamespaceで囲って名前空間を汚さないように気をつけている人を見かけました。驚愕です。

Rustのマクロも結構ホンモノです。今回の目的はそれぞれ4回ずつ計8回の繰り返しですコピペコードを抹殺することです。もう一度コピペ前のコードを引用しますと

use std::ops::{Add, Sub, Mul, Div, Neg, AddAssign, MulAssign};

impl Add for Vector3 {
    type Output = Vector3;

    fn add(self, other: Vector3) -> Vector3 {
        Vector3 {
            x: self.x + other.x,
            y: self.y + other.y,
            z: self.z + other.z,
        }
    }
}

impl Add<f64> for Vector3 {
    type Output = Vector3;

    fn add(self, other: f64) -> Vector3 {
        Vector3 {
            x: self.x + other,
            y: self.y + other,
            z: self.z + other,
        }
    }
}

...

です。これをマクロを使うと

macro_rules! impl_op_v2v_for {
    ($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {
        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $for_) -> $for_ {
                $for_ {
                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*
                }
            }
        }
    }
}

macro_rules! impl_op_v2f_for {
    ($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {
        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $templ_type_) -> $for_ {
                $for_ {
                    $(
                        $member_: $trait_::$op_(self.$member_, other),
                    )*
                }
            }
        }
    }
}

macro_rules! impl_ops_for_xyz {
    ($macro_name_: ident, $to_type_: ident, $with_type: ident, $(($trait_: ident, $op_fn_: ident)),*) => {
        $(
            $macro_name_!($trait_, $with_type, $to_type_, $op_fn_, x, y, z);
        )*
    }
}

//trace_macros!(true);

impl_ops_for_xyz!(impl_op_v2v_for, Vector3, Vector3, (Add, add), (Sub, sub), (Mul, mul), (Div, div));
impl_ops_for_xyz!(impl_op_v2f_for, Vector3, f64, (Add, add), (Sub, sub), (Mul, mul), (Div, div));

これで、4種類ずつ8個のimplが書けています。順番に見ていきます。

macro_rules! impl_op_v2v_for {
    ($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {
        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $for_) -> $for_ {
                $for_ {
                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*
                }
            }
        }
    }
}
// corresponds to
impl Add for Vector3 {
    type Output = Vector3;

    fn add(self, other: Vector3) -> Vector3 {
        Vector3 {
            x: Add::add( self.x, other.x ),
            y: Add::add( self.y, other.y ),
            z: Add::add( self.z, other.z ),
        }
    }
}

マクロはmacro_rules!から始まる行で定義できます。一般的な形は

macro_rules! <マクロ名>  {
    (マクロの引数の定義をつらつらと...) => {.   // この波括弧はマクロの定義内容の始まりを示す波括弧
        // ここにマクロの定義。つまりマクロ名!(...)という呼び出しがどういうコードに展開されるかを書く。
    }
}

こんな感じです。

macro_rules! impl_op_v2v_for {
    ($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {
        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $for_) -> $for_ {
                $for_ {
                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*
                }
            }
        }
    }
}
// corresponds to
impl Add for Vector3 {
    type Output = Vector3;

    fn add(self, other: Vector3) -> Vector3 {
        Vector3 {
            x: Add::add( self.x, other.x ),
            y: Add::add( self.y, other.y ),
            z: Add::add( self.z, other.z ),
        }
    }
}

まずimpl_op_v2v_forというのがマクロ名です。普通のRustの識別子ルールに従いアンダーバーで小文字名詞をつないでいきましょう。

次に引数です。$<マクロ変数名>: マクロの変数の型というのがマクロ変数のルールです。今回の場合、具体的には

impl_op_v2v_for ! ( Add, Vector3, Vector3 , add, x, y, z );

こういう呼び出し方ができるようにしたいです。4つの決まった数(Add(トレイト名), Vector3(トレイトを実装する型、a + bのaの方), Vector3(a + bのbの方の型), add(実装するトレイトのメンバ関数))の識別子と、いくつかの識別子(x, y, z, ...)を渡します。

($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {

ここで、最初の4つはわかりやすいです。全部ident(識別子型)にバインドされています。残りは$($member_: ident),*に当てはまります。これは可変長のパターンです。ここにx, yとかx, y, zとかを割り当てて最終的に

            x: Add::add( self.x, other.x ),
            y: Add::add( self.y, other.y ),
            z: Add::add( self.z, other.z ),

この繰り返しを作ります。$(...)*と書いてある部分が繰り返しパターンです。正規表現みたいですね。

        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $for_) -> $for_ {
                $for_ {
                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*
                }
            }
        }

この部分はルールさえわかれば読めるはずです。implの後に引数で与えられるトレイト名(Addとか)、でジェネリックスの型(<Vector3とかf64>)、で中身を定義します。

                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*

ここが引数の繰り返しパターンの展開場所です。お尻に*が付いている部分です。

implement_op_v2f_forも同じです。違いはx: self.x + other.x,のところがx: self.x + other,になるだけです。

このimpl_op_v2v_forマクロがあれば、退屈な繰り返しを

impl_op_v2v_for! ( Add, Vector3, Vector3 , add, x, y, z );
impl_op_v2v_for! ( Sub, Vector3, Vector3 , sub, x, y, z );
impl_op_v2v_for! ( Mul, Vector3, Vector3 , mul, x, y, z );
impl_op_v2v_for! ( Div, Vector3, Vector3 , div, x, y, z );

このように別の退屈な繰り返しに置き換えられます。アホか!

というわけで、まとめて定義できるようにしましょう。つまり

impl_ops_for_xyz!(impl_op_v2v_for, Vector3, Vector3, (Add, add), (Sub, sub), (Mul, mul), (Div, div));

これです。最初に呼び出したいマクロの名前、演算子の左の方、右の方、あとはimplしたいトレイト名とその関数名のタプルを渡していきます。勘のいい方はここで、繰り返しパターンが使えることに気づいたと思います。こんな感じです。

macro_rules! impl_ops_for_xyz {
    ($macro_name_: ident, $to_type_: ident, $with_type: ident, $(($trait_: ident, $op_fn_: ident)),*) => {
        $(
            $macro_name_!($trait_, $with_type, $to_type_, $op_fn_, x, y, z);
        )*
    }
}

$(($trait_: ident, $op_fn_: ident)),*の部分がタプルの繰り返しを受け取ります。マクロが展開された結果マクロが生まれます。なのでそのマクロもちゃんと展開されます。これはコンパイル時に解決されて、実際にRustのコンパイラがコンパイルする段階ではコード上からマクロは消え去っています。例えばprint!なんかもマクロですが、コンパイラはprint!マクロの展開された後のコードをコンパイルします。

なので、再帰的定義のマクロもかけます。自然数をマクロで表現して四則演算をマクロコンパイラにやらせることも当然できます。すごいですね。チューリング完全らしいです。

Vector2も作る

Vector3と同じように2次元ベクトルのVector2もつくります。でもってAdd/Sub/Mul/Divも同様にimplします。演算子の右と左の型や、演算子を適用するx, y, zの部分が可変なので、さっきのマクロがそのまま使えます。全部実装するとこんな感じです。

gist.github.com

まとめ

Rustで3Dのレンダリングエンジンを作り始めました。早速Rustのパワーを使ってみました。繰り返し根絶です。ただ、Lispの頃から言われていますが、マクロは毒にも薬にもなります。当然関数で同様のことができないかをまず初めに考えて、トレイトでもなんでもできないとき、最後の最後にマクロを検討すべきです。マクロは展開後の様子が基本的には見えないので、理解しづらくバグの元です。ただ、今回のように字句上の繰り返しパターンを抽象化することによって保守性を高めることもできます。

次は行列を作ると思います。