フリーランもオービット追従もできるレイマーチングカメラの作り方

レイマーチング用のカメラを実装したので、作り方をまとめます。

既に Web 上に数多に存在するレイマーチング作品には、いずれにもカメラの実装が含まれているのですが、今回どうしても譲れない下記のこだわりポイントを全て満たすカメラを作りたかったので、数学知識 0 の状態から逐一調べつつ実装してみました。

また、下記は実際に今回実装したカメラの動作デモになります。

こだわった! ポイント

1. マウスでぐりぐり回すタイプのカメラ (= オービット追従) だけでなく、カメラ本体やカメラの注視する場所を自由に移動させられるようにしたい。

2. Unity の Physical Camela のように、物理的に存在するカメラのプロパティ (センサーサイズなど) をもとに画角を決定できるようにしたい。

3. カメラの視線軸でカメラを回転できるようにしたい。(またその際の回転量は角度で指定できるようにしたい)

今回これらは全て対応しました。ある程度納得のいく出来になっています。(今後の課題もあるのですが、最後に書きます)

それでは早速、以下より作り方について順を追って記載していきたいと思います。

カメラ処理の全体の流れ

まず始めに、今回実装したカメラが最終的に canvas へ像を成すに至るまでの、全体の流れを記載しておきたいと思います。その後、各ステップについて詳しく概念や具体的な実装を説明していきます。

1. ワールド座標系に形状とカメラを配置する

2. ビュー座標変換

3. レイマーチング

4. マテリアル適用

5. 描画

こだわりポイントを満たすため模索した結果、最もベターと思われた上記の流れに落ち着いたのですが、上記が唯一解というわけではないため、ご注意ください。

例えば以前、ビュー座標変換すら不要なレイマーチング用カメラを実装したことがあり、これは実際に動作します。

ビュー座標変換が不要なため、レイマーチングの動作モデルとしてはこの上なくシンプルで理解しやすい (と思う) のですが、反面ビュー座標変換を行わないゆえに、画角のコントロールが不可という機能的な欠点を、構造上の根本的な問題として抱えています。

どのような実装方法を取るかによって、パフォーマンスや機能性に差が出てくると思われます。

今回は上記の流れで示した動作を行うカメラの実装について記載していきたいと思います。

[余談] 3D 空間を映すカメラは一種類だけじゃない

「3D 空間を映すカメラ」としては、レイマーチング用のカメラより、ポリゴンを映すカメラの方が一般的と思われます。

レイマーチング用のカメラは、ポリゴンではなく距離関数で表された形状を映すという点が異なります。

しかし裏を返すと、その他の点については変わらないということでもあります。

そのため、レイマーチング用のカメラの実装について理解を深めるため、まずはポリゴンを映すカメラの実装について理解してみることは、スタート地点としてとても有用でした。

1. ワールド座標系に形状とカメラを配置する (形状編)

レイマーチング用カメラの処理の第一歩として、まずは形状とカメラをワールドに配置します。

まず形状ですが、レイマーチング用カメラが映すのは、距離関数で表された形状です。

おそらくこの記事の読者の大半の方は、かの有名なサイトなどから、距離関数の実装やその仕組みについては、既にある程度把握されているものと思われます。

距離関数については、当初疑問に思っていたことが一つありました。

それは、 それらの全ての距離関数は、その形状が原点に配置されていることを前提とした記述になっている => 原点とは別の場所に配置することは出来ないのか? ということです。

結論としては、原点とは別の場所に配置することは可能でした。距離関数は形状が原点にある想定で記述された状態のままで、です。

距離関数が形状を原点においた形で記述されているのは、計算を簡単化するためだと思われます。

形状を移動させる方法ですが、形状自体の座標をずらすのではなく、形状と衝突判定するレイの方をずらします。

例えば、下記は一般的な球の距離関数とレイ先端との距離を測る記述です。

float distance = sdSphere(p, r); // p はレイの先端で、r は球の半径。

この場合、球の位置は原点になります。(距離関数の処理がそのようになっているため)

この時、もし球の座標を X 軸の正の方向に 1 ずらしたい場合、その記述は下記のようになります。

float distance = sdSphere(p - vec3(-1., 0., 0.), r);

レイの先端を X 軸の負の方向に 1 ずらしています。

形状が右にずれることと、レイ先端が左にずれることは、形状とレイ先端の位置関係としてはどちらも同一の意味となるため、このようにすることで、距離関数は原点基準の記述そのままで、形状を任意の位置に移動する (= 原点位置から相対的な位置へとずらす) ことができるようになります。

この要領で形状の移動が可能となるため、ワールド座標上に好きなだけ形状を配置していきます。

※ビュー座標変換の実装方法に酔っては、上記のようにならない可能性があるかもしれません。そのため、今回実装するカメラで行っているビュー座標変換の場合は、上記のようにすれば移動ができるよ、ぐらいに捉えておいたほうがいいかもしれません。

float sdScene(vec3 p) {
	float d = sdSphere(p, 1.);
	d = min(d, sdSphere(p-vec3(-1., 0., 0.), 1.));
	d = min(d, sdSphere(p-vec3(0., 3., 0.), 1.));
	return d;
}

のような感じで、シーン全体を一つの距離関数として定義し、min 関数で個別の形状に対して順番に距離をチェックしていくことにより、複数の形状のうちいずれの形状が最短に位置するか? を特定することが出来ます。

このとき、最短の形状をどこかの変数にメモしておくと、最短形状によって処理を分岐することが出来、それを利用して、個々の形状ごとに異なるマテリアルを適用できるようになります。

1. ワールド座標系に形状とカメラを配置する (カメラの場所編)

形状を配置できたら、次はカメラを配置します。

カメラを配置する際は、カメラ本体の位置とカメラの注視する位置を指定します。

カメラの注視点をワールド座標で指定できると、カメラの視線を任意のオブジェクトのワールド座標に追従させることができるため、便利です。

また、あるオブジェクトのワールド座標を中心に、オービット軌道でカメラを移動させたい場合は、カメラの注視点を中心として、三角関数でカメラ本体の座標を回してあげれば OK です。(sin と cos で 円軌道の座標を取得することができる)

1. ワールド座標系に形状とカメラを配置する (カメラの画角編)

カメラの場所が決まったら、今度はカメラのセンサーサイズと焦点距離を設定します。これによって、カメラの画角が決定されます。

センサーサイズと焦点距離によって画角が決まる仕組みについては、分かりやすい記事がありました。

中学生の理科の授業でうっすら見覚えのあるような感じの図が掲載されています。

現実のカメラが図で示されているような動作原理で画角を決定しているため、これを模倣すれば、センサーサイズと焦点距離の数値を通して、画角をコントロールすることが可能となります。

冒頭の動作でもでは、下記のように、カメラ情報を格納する用の構造体を作成し、そのプロパティとしてセンサーサイズと焦点距離を定義しました。

/**
* カメラ情報の構造体。
* センサーサイズは実在カメラのスペック表に書いてある数値をそのまま入れることができる。
* 焦点距離は、撮影しながら変更するものらしい。(普段 iPhone のカメラしか使わないのでよく知らない・・)
* 各センサーサイズに対して適正な焦点距離を計算するサイトがありました。-> https://keisan.casio.jp/exec/system/1209002710
*/
struct Camera {
   float focalLength; // mm
   vec2 sensorSize; // mm
   vec3 position;
   vec3 lookAt;
   float roll; // degree
};

// カメラ構造体のインスタンスを作成
Camera camera;
camera.focalLength = 59.8333;
camera.sensorSize = vec2(35.9, 24.);
camera.position = vec3(1000., 3000.*sin(iTime*2.), 5000.*sin(iTime));
camera.lookAt = vec3(sin(iTime*3.)*1., 0., 0.);
camera.roll = sin(iTime) * 360.;

さて、上記参考記事の図でいうと、センサーサイズは h に、焦点距離は f に該当します。画角を求めるにあたり、その計算式ではこれらを利用していきます。

画角は縦の画角と横の画角それぞれ計算して求める形になります。様々なセンサーサイズのカメラが存在するため、センサーサイズは縦と横の情報を持った vec2 としています。

また、ゲーム界隈においては、Hor+ という FOV (= 画角) の決定手法も有名です (らしい)。これは、縦の FOV だけ任意の角度で設定し、横は画面幅によって成り行きとするシステムです。これにより、様々な画面比率に対応することが出来ます。

冒頭の動作デモでは、画面サイズは気にせず、センサーサイズのみを考慮して計算しています。そのため、画面にフィットさせていません。

画面にフィットさせることは可能で、おそらく上記の Hor+ の仕組みを模倣し、横のセンサーサイズを画面幅に併せて拡大縮小するようにすれば、いけるはず。。

さて画角の計算式ですが、これも上記記事内で紹介されています。ここではその仕組みについてもう少し詳しく解説しておきたいと思います。

先に画角計算のソースコードを掲載しておきたいと思います。上記記事内の式と見比べると、そのままコードに変換されているだけのため、分かりやすいかと思います。

/**
* カメラ設定をもとに、FOV を計算し返します。
* @param {vec2} sensorSize カメラのセンサーサイズ (縦横)
* @param {float} focalLength カメラの焦点距離
* @return {vec2} FOV (単位:角度 (ラジアンじゃなく))
*/
vec2 getFieldOfView(vec2 sensorSize, float focalLength) {
   float verticalFov = degrees(2. * atan(sensorSize.y / 2. / focalLength));
   float horizontalFov = degrees(2. *atan(sensorSize.y / 2. / focalLength));
   return vec2(horizontalFov, verticalFov);
}

なお、ソースコード上では画角のことを FOV と表記しています。FOV = 画角です。

改めて、上記参考記事の図を見て頂くと、画角の角度は、センサーを底辺としてレンズをてっぺんとした二等辺三角形の頭の角の対角であることがわかります。

そしてその二等辺三角形を水平に二分割すると、直角三角形が 2 つ出来上がります。そして、直角三角形の成す角は、逆三角関数の atan で求めることが出来ます。この仕組で、画角を計算していきます。

直角三角形には、角が 2 つありますが、そのうち atan で求めることのできる角は、底辺に接している方になります。そのため、上記で出来上がった直角三角形は、f を底辺、h を対辺とみなして計算します。

1 点だけ計算上の注意点があり、h を 2 で割っている点にご注意ください。さもないと、h はそのままだと図の直角三角形の対辺 2 つぶんの長さになっているため、計算が狂います。図の直角三角形の対辺 1 つぶんの長さは h/2 になります。

ここまで分かれば、あとは計算するだけになります。

最終的に、atan で得られた戻り値はラジアンのため、GLSL 組み込み関数の degrees を使って、角度へと変換すれば、画角の計算は完了です。

1. ワールド座標系に形状とカメラを配置する (カメラを任意の注視点に向ける方法編)

ベクトル A から ベクトル B を減算すると、ベクトルB から ベクトルA の方向のベクトルが得られます。そのため、

vec3 cameraPosition = vec3(10., 10., 10.);
vec3 cameraLookAt = vec3(0., 0., 0.);
vec3 cameraDirection = normalize(cameraLookAt - cameraPosition);

というような記述で、カメラの向いている方向を示す単位ベクトル (= ノルムが 1 のベクトル) を得ることが出来ます。

レイマーチングの場合、カメラの方向とは別にレイの方向の概念があるのですが、レイの方向 = カメラの向きを軸とした四角錐上に存在するベクトルの 1 つ、として表現します。

つまり、上記のコードで求められたベクトルは、uv.x が 0.5、uv.y が 0.5 の、画面の真正面を示すベクトルになります。あとは画角の限り、レイが上下左右に広がりながら発射される形になります。

画角を広く (狭く) すると、四角錐がより広く (狭く) なるので、画面はより広角 (望遠) な見た目となります。また、画角をアニメーションすることで、特定の演出表現を行うことができ、重要なテクニックと思われます。

なお、さらにカメラを up ベクトルを基準として任意の角度だけ回転させることができるのですが、この処理はビュー座標変換に組み込まれているため、次項で詳述いたします。

2. ビュー座標変換

ワールドに形状とカメラを配置し終わったら、次はワールド上に存在する各形状がカメラから相対的にどのような位置関係にあるかを求めていきます。

そうすることにより、その形状がカメラからどれぐらい遠いのか、右にあるのか左にあるのか、を認識できるようになるため、実際にカメラに像をなすことができるようになります。(ワールド座標をカメラからの相対座標に置き換えることを、ビュー座標変換と呼びます)

ビュー座標変換についても分かりやすい記事がありました。

今回は、上記の記事でも表示されているような、カメラ本体→カメラ注視点 の方向を Z 軸とした座標系の座標として、ワールド座標系で表現された任意の座標を表現し直す行列を作成します。(後述しますが、X 軸と Y 軸については、"up ベクトル" によって指定します)

この行列をレイに適用すれば、レイもワールド座標系で表現できるため、ワールド座標系同士、レイと距離関数で衝突判定を行うことができるようになります。(FOV から直接求めた直後のレイは、-1〜1 の uv を基準としたカメラ相対的なベクトル成分の値になっているため、ワールドに配置した形状と衝突判定を行うには、ワールド座標系におけるベクトルの表現へと表現し直す必要がある。LookAt ベクトルはワールド座標系で表現されている (ワールドに配置したカメラ本体座標とカメラ注視点座標からベクトル減算で計算しているため) ため、これを基準とする行列を作成することで、レイをワールド座標で表現することができるようになる (のはず・・))

さて、ある座標系の座標を別の座標系の場合の座標に表現し直すということは、すなわち基底 (その座標系を表す軸となる方向を示すベクトル) をすり替えるということになります。これを基底ベクトル変換と呼びます。

[分かりやすい基底ベクトル変換の解説記事がありました。この記事の計算方法のところに記載されている式を解くことによって、ワールド座標系の基底をカメラローカル座標系の基底に変換した場合の座標を求めることが出来ます。

上記の式を解くには、その式の最終形に出てくる逆行列を求める必要があります。

ここで、ワールド座標系の基底もカメラローカル座標系の基底も、共に直交基底であることに注目します。直交基底とは、X 軸も Y 軸も Z 軸も、それぞれどの軸同士も 90 度の角度関係になっている基底、という意味になります。よほど特別な理由がない限り、ワールド座標系の基底もカメラローカル座標系の基底も、直交基底で OK かと思います。(そうではない歪な座標系のゲームを作りたい! というケースもあるかもしれないですが・・今回は一旦そのケースは考えないこととします)

さて、直交基底で作った行列は、結果として直交行列になります。(直交行列の定義のページをご覧下さい)

そして直交行列には、その直交行列を転置した行列が、その直交行列の逆行列に等しい、という性質があります。

これにより、先ほどの式の逆行列は、単に行列を転置することで求めることが出来ます。

これでビュー座標変換を行う行列を得ることが出来ます。ソースコードは下記のようになります。

/**
* ビュー座標変換を行う3次元行列を返します。
* @param {vec3} position カメラの位置のワールド座標
* @param {vec3} lookAt カメラの注視点のワールド座標
* @param {vec3} up カメラの ”up” ベクトル
* @return {mat3} ビュー座標変換を行う3次元行列
*/
mat3 getViewingTransformMat3(vec3 position, vec3 lookAt, vec3 up) {
   vec3 z = normalize(lookAt-position);
   vec3 x = normalize(cross(z, up));
   vec3 y = normalize(cross(x, z));
	return mat3(
       vec3(x.x, y.x, z.x),
       vec3(x.y, y.y, z.y),
       vec3(x.z, y.z, z.z)
   );
}

getViewingTransformMat3 関数の中では、変数 x y z それぞれに対し、カメラローカル座標系の正規基底ベクトルを計算し格納しています。

これは外積を使って求める (外積を使うと、2 つのベクトルに対して垂直なベクトルを求めることができる) のですが、このとき、既に明らかになっている「カメラ→注視点」のベクトルに加え、新たに「up」ベクトルを指定する必要があります。これは関数の引数に手動で設定します。

up ベクトルはカメラの上面ではなく、空の方向を示すベクトルです。(と解釈しています)

各基底を求める計算を順に見ていきたいのですが、まず Z は既に説明した「カメラ→注視点」のベクトルです。これは camera.position と camera.lookAt から得られます。(ベクトルの減算)

次に X はカメラの横方向のベクトルです。これを求める時、具体的には、先ほど求めた Z と up ベクトルの外積で求めます。そのために、up ベクトルが必要になります。

up ベクトルを変更すると、得られる X も変化します。これを利用することでカメラを回転させることが出来ます。実際に計算するとそのようになるのですが、どうやら計算の結果としては、カメラの上面がなるべく up ベクトルの方向へ寄るように X が算出されるようなのです。

このとき、カメラを Z 軸を軸に右回りに傾けるか左回りに傾けるかは、コントロールすることが出来ます。また、傾ける角度を数値で指定することも出来ます。

下記が、そのソースコードです。

/*
 * up ベクトルを求めます。
 * @param {float} roll カメラを回転させたい角度
 */
vec3 getUpVector(float roll) {
	return vec3(sin(radians(roll)), cos(radians(roll)), 0.);
}

ちゃんと調べていないのですが、上記で動作確認してみたところ上記で成立しているっぽいため、これで大丈夫なものと思われます。(笑) (lookAt ベクトルに併せて向き調整しなきゃいけないんじゃ・・とか思っていたんですが、それすら不要でした)

得られた単位ベクトルの向きが、2次元的にカメラの Z 軸に対して上下左右どっち向きか、でいけるみたいです。三角関数の sin と cos を x と y に入れることでこれを実現しています。

結果として、up ベクトルを天に向ければ、カメラは天を上に、地を下に状態となり、up ベクトルを真横 (X=1, y=0)に傾ければ、カメラは右回り 90 度に回転します。

最後に Y は、計算して得られた Z と X の外積で求めます。これでカメラ座標系の基底が揃いました。その後、それを転置したものを return しています。これがビュー変換行列そのものになります。

※なお、どっちが転置する前でどっちが転置した後なんだっけ、と頭がこんがらがった場合、分かりやすい基底ベクトル変換の解説記事を読み直すと分かりやすいかと思います。式を変換していった最終形の式に出てくる逆行列が転置後行列です。

lookAt 基準の座標系に変換する行列ができたら、あとはレイにその行列を掛けるだけです。

[補足] ポリゴンカメラとレイマーチングカメラの違い

ポリゴンを映すタイプのカメラの場合、MVP 変換という変換を行うことによって、各頂点やポリゴンがカメラからの相対的に位置としてどこに位置するか、最終的にそれらを画面平面に射影するとどのような配置になるか、ということを計算して求めていきます。

しかし、レイマーチング用カメラの場合、レイを飛ばして衝突したらそのままそのフラグメント (= fragCoord) に色を塗るので、MVP の P、プロジェクション変換は原理的に不要となります。

また、レイマーチングの場合そもそもポリゴンを使わず、距離関数によって形状を表現するため、MVP の M、モデル変換も不要となります。

よって、レイマーチングのカメラでは、MVP の V、ビュー変換のみ行えば OK となります。

3. レイマーチング

これについては解説資料が山のようにあるため、ここでは分かりやすい資料の紹介に留めたいと思います。

シェーダだけで世界を創る!three.jsによるレイマーチング

また、冒頭の動作デモのレイマーチング処理部分のソースコードも下記に掲載しておきたいと思います。基本的に、色んな所でよく見る何の変哲もないレイマーチングのソースコードになります。

// Ray Marching
vec3 color = vec3(false);
float depth = 1.;
vec3 direction = getRayDirection(getFieldOfView(camera.sensorSize, camera.focalLength), camera.focalLength, uv) * getViewingTransformMat3(camera.position, camera.lookAt, getUpVector(camera.roll));
for(int i=0; i<MAX_MARCHING_STEPS; i++) {
    float distance = sdScene(camera.position + direction * depth);
    if(distance < EPSILON) {
        color = getNormalMaterial(getNormal(camera.position + direction * depth)); // プリミティブ毎にマテリアルを切り替える場合、マーチ毎に、その時最も近いプリミティブを総当たりで調べ、最も近いものに衝突しているので、それに対応するマテリアルを選択すればOK。
        break;
    }
    depth += distance;
}

direction には、先ほど説明したビュー座標変換行列を用いて、ワールド座標に置き直したレイのベクトルを格納しています。各形状は元からワールド座標で表現されているため、特に何もせずそのままレイと衝突判定してしまって OK です。

なお注意点としては、行列の場合、掛け算の順番が逆になると意味が異なってしまい、今回のビュー座標変換の意味としては、「レイのベクトル * ビュー座標変換行列」の順番にしないと計算結果が狂ってしまうため、注意が必要です。(これが分からなくて一日ハマりました・・)

また、レイマーチングでよく見かける EPS ですが、EPSILON のことのようです。非常に小さい数字、という意味で、つまるところ、距離関数で得られた数字が厳密に 0 にはならないので、距離いくつ以下を衝突とみなすかを、EPSILON という名前で表現した定数で定義しているようです。

4. マテリアル適用

こちらも下記に分かりやすい記事を紹介するにとどめておきます。自分もまだ実装できておらず・・ただ、形状の法線については 3.レイマーチング の項に掲載したソースコードのように、取得し画面に反映することが確認できたため、あとは実装するのみ、というステータスです。

法線の取得方法も、3.レイマーチング の項に掲載した資料内に分かりやすい解説が載っています。

偏微分で勾配を求めるのですが、レイを EPS 分 XYZ それぞれずらした距離を計測し、それと実際のレイとの差分をとって、それすなわち勾配とみなしている、というイメージだと思います。

さてシェーディングに関する資料ですが、下記がかなりよさそうでした。

メガデモ勉強会!2018で発表しました

また、同ブログのレイマーチングタグにも、非常に参考になる記事がたくさん投稿されているようです・・感謝とともに拝見していきたいと思います。

5. 描画

ここまで来ると、もう何も言うことはありません。

fragColor = vec4(color, 1.);

今後の課題

・マテリアルを適用する (AO や Phong マテリアル、反射など)
・最適な MAX_MARCHING_STEPS 値の検討 (シーンによるかもしれないが、なんらか判断基準が欲しい)
・最適な EPSILON 値の検討 (シーンによるかもしれないが、なんらか判断基準が欲しい)
Perlin Noise (fBm) を使ったカメラ揺れエフェクトの適用
パーティクルやグローなどの適用 (もうこの辺はカメラの仕事じゃない?)
Lerp減衰の実装

その他、参考になった記事など

下記の記事を見て、ちょっとしっかりカメラやろう! となり、実際に取り組むことが出来ました。ありがとうございました・・

シェーダー芸人になりたかった6か月前の自分に教えてあげたいリンク集

以上です。

間違っているところや改善点あれば、お待ちしております・・・・

この記事が気に入ったらサポートをしてみませんか?