これは ドリコム Advent Calendar 2016 13日目です。

はじめに

はじめまして、ドリコムでエンジニアをやっているイシカワと申します。
普段はネイティブアプリケーション向けに共通ライブラリの開発を担当していて、最近はUnity開発にも関わっています。

今年からUnityを日常的に使うようになり、これまで敷居が高かった3Dアプリ開発が自分にとってぐっと身近な存在になりました。
エディタを起動すれば、すぐさま開発に集中できる環境を用意できます。ゲーム開発の民主化、万歳。

しかし、カッコイイ視覚表現を追求しようといざグラフィックスまわりのコードを書き始めると、そこにはマルチプラットフォーム対応によって生まれた落とし穴が潜んでいます。

この記事では、3Dプログラミングの基礎である「座標変換」において、Unity開発者がつまずきやすい問題を紹介します。
数ヶ月前の自分をふりかえり「こんなまとめ記事が欲しかった・・・」という視点で書いてみました。

テーマの都合上、読者として予め以下の知識をお持ちの方を想定しています。

  • レンダリングパイプラインの基礎
  • OpenGLもしくはDirectXを使った3Dプログラミング

かなり偏った内容になってしまいましたが、マニアックなUnityの余話としてお楽しみください。

3Dプログラミングにおける規則

3Dプログラミングで座標変換を扱う時、真っ先に把握すべき規則があります。
最初にどんな規則があるか簡単におさらいします。

座標系の向き

XY座標にZ座標を加えて3次元にすると座標系の取りうる方向が2通りあります。
それぞれを左手系と右手系と呼び、システム全体で基準となる方向を選びます。

行列のメモリレイアウト

行列をメモリ上の配列として表現する時、一次元のアドレスにどうやって2次元の要素を並べるか予め決めないといけません。
通常、要素の並べ方は行優先と列優先の2種類あります。
またベクトルは、行優先ならば行ベクトル、列優先ならば列ベクトルで扱われる場合が多いです。

最低限この2つをきちんと把握してないとオブジェクトの位置や姿勢をまともに制御できないでしょう。
ささいな勘違いでオブジェクトが明後日の方向に移動したり反対方向に回転したりします。

参考までに、既存のグラフィックスAPIの規則を示しておきます。

OpenGL 右手系 列優先
DirectX 左手系 行優先

Unityの場合

さて、我らがUnityはどういう規則を採用しているのでしょうか?
それぞれ確認していきましょう。

座標系の向き

公式ドキュメントを探しても明記している箇所は見当たりませんが、エディタのUIを見ると左手系であることは明らか。
ちなみにこちらの外積の説明を読むと、結果が「左手の法則」に従うとあるのでこれが一つの裏付けといえるでしょう。

You can determine the direction of the result vector using the “left hand rule”.

行列のメモリレイアウト

リファレンスによると、スクリプト(C#)の世界はプラットフォームに関わらず列優先となっています。

Matrices in unity are column major. Data is accessed as: row + (column*4).

このため、ベクトルに行列をかける時、積の順番は 行列 x ベクトル となります。

では、シェーダーの世界もスクリプトと同じ列優先なのでしょうか?
調べるかぎりCg言語はデフォルトでどうも行優先のようにみえます。
しかし、両者を切り替える#pragmaオプションの存在がNvidiaサイトで見つかるので、ドキュメントだけでは一体どちらなのかいまいち確信がもてません。

確証を得るため、試しにスクリプトからプロパティで行列(Matrix4x4)を渡して先頭ベクトルをピクセルに出力してみましょう。
行と列どちらが出力されるでしょうか。

// スクリプト

Matrix4x4 matrix = new Matrix4x4 ();
matrix.SetRow (0, new Vector4 (0.0f, 1.0f, 0.0f, 1.0f)); // 緑
matrix.SetColumn (0, new Vector4 (0.0f, 0.0f, 1.0f, 1.0f));  // 青
Shader.SetGlobalMatrix ("_Matrix", matrix);
// シェーダー(頂点シェーダーは省略)

struct FragmentInput {
  float4 position : SV_POSITION;
};

float4x4 _Matrix;

fixed4 frag(FragmentInput input) : SV_Target {
  return _Matrix[0]; // 行ベクトル(緑)と列ベクトル(青)のどちらが返るだろうか?
}

結果は行ベクトルが返ってきます。これはMacとWindowsで異なるプラットフォームでも同じでした。

なるほど、Unityのシェーダーの世界は行優先であることが分かりました。
結果から推測すると、どうもスクリプトから渡した行列はUnity内部で列優先から行優先に要素の詰め直しがされるようです。
ただし、メモリレイアウトは切り替わっても転置されるわけではないのでご注意を。基本的に計算は列ベクトル前提で行います。
これは組み込み変数で提供される行列も同様です。

例えば、UNITY_MATRIX_MVPを使って頂点座標をオブジェクト空間からクリッピング空間に変換する時、
mul()関数の引数は列ベクトルに従って左に行列、右にベクトルとなります。

float4 v = float4(0, 0, 0, 1);
mul(UNITY_MATRIX_MVP, v); // OK
mul(v, UNITY_MATRIX_MVP); // NG

一方、float3x3()などのコンストラクタ関数を使って行列を組み立てる時、その引数の順番は行優先です。
例えば、バンプマッピングなどでタンジェント空間からオブジェクト空間に座標系を変換する場合、
その行列は3つのベクトルを基底にして次のような計算で求めます。すなわち、基底を行ベクトルで並べて最後に転置します。

// normalとtangentはfloat3型の単位ベクトルで、頂点データで渡したパラメータとする
float3 bitangent = cross(normal, tangent);
float3x3 bases = float3x3(tangent, bitangent, normal);
float3x3 tangentToObject = transpose(bases);

座標変換で気をつけること

UnityにはグラフィックスAPIに依存する特殊な仕様が存在します。
ここではカメラ空間〜クリッピング空間に注目します。

ワールド空間 -> カメラ空間

ワールド空間からカメラ空間へはCamera#worldToCameraMatrixを使って座標変換します。
単純にTransform#worldToLocalMatrixでないのは訳があります。

なんと Camera#worldToCameraMatrix はカメラのローカル空間への変換に加えてZ座標の軸方向を反転させます。つまり、座標系の向きが左手系から右手系へと切り替わります。突然カメラの前方方向が逆転するので直感に反して分かりにくい印象があります。

この不思議な変換はUnityがOpenGLの規約にしたがった結果によるものです。
OpenGLの場合、後段の正規化デバイス空間で右手系から左手系に切り替わるので、予めカメラ方向を反転させる決まりになってるんですね。

一方、DirectXは左手系なので常にZ軸とカメラが同じ方向を指します。
DirectXに慣れ親しんだ人にとってUnityの挙動は戸惑ってしまうかもしれません。

カメラ空間 -> クリッピング空間

カメラ空間からクリッピング空間へはCamera#projectionMatrixを使って座標変換します。いわゆる射影変換です。
その後、クリッピング空間の同次座標を再び直交座標に戻すため、XYZ座標をW座標で割って正規化します(正規化デバイス空間)。

ここで忘れてはいけない事実が一つ。正規化によってZ値を収める範囲が各プラットホームで異なることです。

最小 最大
OpenGL -1 1
DirectX 0 1

Unityではこの差異をGL.GetGPUProjectionMatrix()を使って射影変換行列を補正します。
通常、行列の補正はUnity内部で行われるので普段アプリケーションがメソッド呼び出しを意識する必要はありません。
しかし、自前で行列を計算する場合は別です。
直接シェーダーに行列を渡す時は必ず GL.GetGPUProjectionMatrix() で補正した値を使用します。

Matrix4x4 matrix = GL.GetGPUProjectionMatrix (Camera.main.projectionMatrix, false);

では GL.GetGPUProjectionMatrix() は具体的に何をやってるんでしょうか。
分かる範囲で掘り下げてみましょう。あいにくUnityはソースコードが非公開なので、以下、手元で調べた結果からの推測です。

例として、透視投影を取り上げます。この場合の射影変換 はこんな計算になります。

Vc と Ve はそれぞれクリッピング空間とカメラ空間における位置ベクトル、M は透視投影行列です。
Z座標にかかる係数はまだわからないので仮に A,B としておきます。

ここで変換後のZ座標に注目すると

さらに Wc(=-Ze) で除算すると(カメラがZ軸の負の方向にむいてるのを忘れないで!)

この式に対し、Ze=-Near Ze=-Far をそれぞれ代入してみます。
すると、OpenGLの場合は -1 <= Zc/Wc <= 1 なので

よって、この連立方程式を解くとめでたく係数 A,B の値が求まります。

一方、DirectXの場合は 0 <= Zc/Wc <= 1 なので以下の式になります。

つまるところ、GL.GetGPUProjectionMatrix() は、プラットフォームに合わせてこの係数 A,B の値を切り替えて補正を行っているわけですね。

まとめ

Unityで座標変換を扱う上で知っておくべき規則・仕様を解説しました。
大抵はドキュメントに書かれていることですが、情報が各所に散在しているため見つけにくいのが現状です。

  • 座標系の向きは、原則、左手系。ただし、カメラ空間は右手系に切り替わる。
  • 行列のメモリレイアウトは、スクリプトの世界は列優先、シェーダーの世界は行優先。ただし、計算は両者とも列ベクトルで行う。
  • シェーダーで参照する射影変換行列を自前で計算する時は Camera#projectionMatrix を GL.GetGPUProjectionMatrix() で補正した値を使用する。

ここで挙げた知識は、例えば、異なる環境で書かれたコードをUnityに移植する時に、どういう計算で置き換えればいいか判断する時に必要になるでしょう。

おわりに

エディタの世界からハードウェアに近いレンダリングパイプラインの領域に一歩踏み込むと、マルチプラットフォーム対応による設計の複雑さが途端に顔を出します。
今回の座標変換はまだ序の口。ビルトインシェーダーのコードには魔物がすんでいるとか・・・。

リファレンスを読んでいると、ある憶測が頭に浮かんできます。

「一貫性を重視して、いっそのこと規則を全てDirectX(HLSL)に合わせた方がアプリ開発者にとって驚きを最小におさえられたのではないか?」

「OpenGLに仕様を寄せたのは、分かりやすさを多少犠牲にしてもマルチプラットフォーム対応を優先させたからだろうか?」

そんなトレードオフの狭間で揺らぐゲームエンジン開発に思いを巡らせながら、このお話を締めくくります。