第9章 MultiPlane PerspectiveProjection

本章では直方体の形をした部屋の壁面や床面など複数の面にプロジェクターで映像を投影しCG世界の中にいるような体験ができる映像投影方法を紹介します。また、そのバックグラウンドとしてCGにおけるカメラの処理とその応用例について解説します。サンプルプロジェクトは、UnityGraphicsProgrammingのUnityプロジェクト*1内のAssets/RoomProjectionにありますのでよかったらご覧ください。また、本内容は「数学セミナー 2016年12月号」*2に 寄稿した内容を元に大幅に加筆修正を行ったものになります。

[*1] サンプルプロジェクト https://github.com/IndieVisualLab/UnityGraphicsProgramming

[*2] https://www.nippyo.co.jp/shop/magazine/7292.html

9.1 CGにおけるカメラの仕組み

一般的なCGにおけるカメラ処理とは、透視投影変換を用いて見えている範囲の3Dモデルを2次元画像へ射影する処理を行います。透視投影変換は、各モデルの中心を原点に持つローカル座標系、CG世界の一意に決めた場所を原点にするワールド座標系、カメラを中心としたビュー座標系、クリッピング用のクリップ座標系(これはwも意味を持つ4次元の座標系で、3次元化したものをNDC (Normalized Device Coordinates,正規化デバイス座標系)と呼びます)、出力画面の2次元の位置を表すスクリーン座標系、という順番で頂点の座標を射影していきます。

座標変換の流れ

図9.1: 座標変換の流れ

また、これらの変換はそれぞれ1つの行列で表すことができるのであらかじめ行列同士を乗算しておくことで、いくつかの座標変換を行列とベクトルの乗算1回で済ませる方法もよく行われています。

9.2 複数カメラでのパースの整合性

CGにおけるカメラでは、頭頂点をカメラの位置に、底面をカメラの向きに合わせた四角錐を視錐台と呼び、カメラの射影を表す3Dボリュームとして図示できます。

視錐台

図9.2: 視錐台

2つのカメラの視錐台が頭頂点を共有し側面が接していれば、投影面が別々の方向を向いていても映像的には繋がり、かつ、頭頂点から見たときのパースペクティブが一致します。

接する視錐台(わかりやすいように少し離して配置しています)

図9.3: 接する視錐台(わかりやすいように少し離して配置しています)

これは視錐台を無数の視線の集合とみなすことで「視線同士が連続している(=パースペクティブ上矛盾のない映像を投影することができる)」と考えることで理解できます。この考えを5つのカメラまで拡張し、5つの視錐台が頭頂点を共有しそれぞれ隣接する視錐台と接するような配置になるよう画角を調整することで、部屋の各面に対応した映像を生成することができます。理論上は天井も含めた6面も可能ですが今回はプロジェクタの設置スペースとして考え、天井を除く5面を想定しています。

部屋に対応した5つの視錐台

図9.4: 部屋に対応した5つの視錐台

この頭頂点、つまり全てのカメラの位置に相当する場所から鑑賞することで、部屋どの方向を見てもパースペクティブ上矛盾のない映像を鑑賞することができます。

9.3 プロジェクション行列の導出

プロジェクション行列(以下Proj)はビュー座標系からクリップ座標系へ変換する行列です。

として式で表すと以下のようになります。

C = Proj * V

さらにCの各要素をC_{w}で除算することでNDCでの位置座標となります。

NDC = (\frac{C_{x}}{C_{w}},\frac{C_{y}}{C_{w}},\frac{C_{z}}{C_{w}})

なお、C_{w}=-V_{z}とな(るようにProjを作)ります。ビュー座標系の正面方向がZマイナス方向なのでマイナスがかかっています。NDCでは表示範囲を-1\leq x,y,z\leq 1とし、この変換でV_{z}に応じてV_{x,y}が拡大縮小することにより遠近法の表現が得られます。

それでは、Projをどのように作ればよいか考えてみましょう。ビュー座標系におけるnearClipPlaneの右上の点の座標をN、farClipPlaneの右上の点の座標をFとしておきます。

N,F

図9.5: N,F

まずはxに注目してみると、

を考慮すると

Proj[0,0] = \frac{N_{z}}{N_{x}}

とすれば良さそうです。x,zの比率は変わらないのでProj[0][0] = \frac{F_{z}}{F_{x}}など視錐台の右端ならどのx,zでも構いません。

同様に

Proj[1,1] = \frac{N_{z}}{N_{y}}

も求まります。

zについては少し工夫が必要です。Proj * Vでzに関わる計算は以下ようになります。

C_{z} = Proj[2,2]*V_{z} + Proj[2,3]*V_{w} (ただし、V_{w} = 1)
NDC_{z} = \frac{C_{z}}{C_{w}}(ただし、C_{w} = -V_{z})

ここで、N_{z} → -1, F_{z} → 1と変換したいので、a = Proj[2,2], b = Proj[2,3] と置いて

-1 = \frac{1}{N_{z}} (aN_{z} +b), 
1 = \frac{1}{F_{z}} (aF_{z} +b)

この連立方程式から解が得られます。

Proj[2,2] = a = \frac{F_{z}+N_{z}}{F_{z}-N_{z}}, 
Proj[2,3] = b = \frac{-2F_{z}N_{z}}{F_{z}-N_{z}}

また、C_{w} = -V_{w}となるようにしたいので

Proj[3,2] = -1

とします。

したがって求めるProjは以下の形になります。

Proj = \left(
\begin{array}{cccc}
    \frac{N_{z}}{N_{x}} &   0 & 0 & 0\\
    0   & \frac{N_{z}}{N_{y}} & 0 & 0\\
    0   &   0 & \frac{F_{z}+N_{z}}{F_{z}-N_{z}} & \frac{-2F_{z}N_{z}}{F_{z}-N_{z}} \\
    0   &   0 & -1 & 0
\end{array}
\right)

9.3.1 Camera.projectionMatrix の罠

シェーダー内などでプロジェクション行列を扱ったことがある方の中にはもしかしたらここまでの内容に違和感を持つ方もいらっしゃるかもしれません。実はUnityのプロジェクション行列の扱いはややこしく、ここまでの内容はCamera.projectionMatrixについての解説になります。この値はプラットフォームによらずOpenGLに準拠しています*3-1\leq NDC_{z}\leq 1C_{w} = -V_{w}となるのもこのためです。

[*3] https://docs.unity3d.com/ScriptReference/GL.GetGPUProjectionMatrix.html

しかしUnity内でシェーダーに渡す際にプラットフォームに依存した形に変換するため、Camera.projectionMatrixをそのまま透視投影変換に使っているとは限りません。とくにNDC_{z}の範囲や向き(つまりZバッファの扱い)は多様でひっかかりやすいポイントになっています*4

[*4] https://docs.unity3d.com/Manual/SL-PlatformDifferences.html

9.4 視錐台の操作

9.4.1 投影面のサイズ合わせ

視錐台の底面つまり投影面の形はカメラのfov (fieldOfView,画角)aspect (アスペクト比)に依存しています。Unityのカメラでは画角はInspectorで公開されているものの、アスペクト比は公開されていないのでコードから編集する必要があります。faceSize (部屋の面のサイズ)distance (視点から面までの距離)から画角とアスペクト比を求めるコードは以下のようになります。

リスト9.1: 画角とアスペクト比を求める

camera.aspect = faceSize.x / faceSize.y;
camera.fieldOfView = 2f * Mathf.Atan2(faceSize.y * 0.5f, distance)
                     * Mathf.Rad2Deg;

Mathf.Atan2() でfovの半分の角度をradianで求め、2倍し、Camera.fieldOfViewに代入するためdegreeに直している点に注意して下さい。

9.4.2 投影面の移動(レンズシフト)

視点が部屋の中心にない場合も考慮してみましょう。視点に対して投影面が上下左右に平行移動することができれば、投影面に対して視点が移動したことと同じ効果が得られます。これは現実世界ではプロジェクターなどで映像の投影位置を調整するレンズシフトという機能に相当します。

レンズシフト

図9.6: レンズシフト

あらためてカメラが透視投影する仕組みに立ち返ってみるとレンズシフトはどの部分で行う処理になるでしょうか?プロジェクション行列でNDCに射影する際に、x,yをずらせば良さそうですもう一度Projection行列を見てみましょう。

Proj = \left(
\begin{array}{cccc}
    \frac{N_{z}}{N_{x}} &   0 & 0 & 0\\
    0   & \frac{N_{z}}{N_{y}} & 0 & 0\\
    0   &   0 & \frac{F_{z}+N_{z}}{F_{z}-N_{z}} & \frac{-2F_{z}N_{z}}{F_{z}-N_{z}} \\
    0   &   0 & -1 & 0
\end{array}
\right)

C_{x},C_{y}がずれればいいので、行列の平行移動成分であるProj[0,3],Pro[1,3]になにか入れたくなりますが、あとでC_{w}で除算されることを考慮すると、Proj[0,2],Pro[1,2]に入れるのが正解です。

Proj = \left(
\begin{array}{cccc}
    \frac{N_{z}}{N_{x}} &   0 & LensShift_{x} & 0\\
    0   & \frac{N_{z}}{N_{y}} & LensShift_{y} & 0\\
    0   &   0 & \frac{F_{z}+N_{z}}{F_{z}-N_{z}} & \frac{-2F_{z}N_{z}}{F_{z}-N_{z}} \\
    0   &   0 & -1 & 0
\end{array}
\right)

LensShiftの単位はNDCですので投影面のサイズを-1〜1に正規化したものになります。コードにすると以下のようになります。

リスト9.2: レンズシフトをプロジェクション行列に反映

var shift = new Vector2(
    positionOffset.x / faceSize.x,
    positionOffset.y / faceSize.y
) * 2f;
var projectionMatrix = camera.projectionMatrix;
projectionMatrix[0,2] = shift.x;
projectionMatrix[1,2] = shift.y;
camera.projectionMatrix = projectionMatrix;

一度Camera.projectionMatrixにsetするとCamera.ResetProjectionMatrix()を呼ばない限りその後のCamera.fieldOfViewの変更が反映されなくなる点に注意が必要です。*5

[*5] https://docs.unity3d.com/ScriptReference/Camera-projectionMatrix.html

9.5 部屋プロジェクション

直方体の部屋で、鑑賞者の視点位置をトラッキングできているものとします。前節の方法で視錐台の投影面のサイズと平行移動ができるので、視点位置を視錐台の頭頂点、壁面や床面を投影面としたときその形状に合うような視錐台を動的に求める事ができます。各カメラをこのような視錐台にすることによって各投影面用の映像を作ることができます。この映像を実際の部屋に投影すれは鑑賞者からはパースのあったCG世界が見えるようになります。

部屋のシミュレーション(俯瞰視点)

図9.7: 部屋のシミュレーション(俯瞰視点)

部屋のシミュレーション(一人称視点)

図9.8: 部屋のシミュレーション(一人称視点)

9.6 まとめ

本章ではプロジェクション行列を応用することで複数の投影面でパースを合わせる投影方法を紹介しました。目の前にディスプレイを置くのではなく視界の広い範囲を動的に反応する映像にしてしまう点で、昨今のHMD型と似て非なるアプローチのVRと言えるのではないかと思います。また、この方法では両眼視差や目のフォーカスを騙せるわけではないのでそのままでは立体視できずに「壁に投影された動く絵」に見えてしまう可能性があります。没入感を高めるためにはもう少し工夫する必要がありそうです。

なお、同様の手法を立体視と組み合わせる「CAVE」*6という仕組みが知られています。

[*6] https://en.wikipedia.org/wiki/Cave_automatic_virtual_environment