前回、こちらの記事(新しいタブで開く)では、カメラを定義する要素の1つとして、Projection Matrix(射影行列)を求めました。
今回は、もう1つの要素である View Matrix(ビュー変換行列)を扱いたいと思います。
pos_clip = projectionMatrix * viewMatrix * modelMatrix * vec4f(position, 1);
ビュー変換行列は、カメラの位置と向きに基づいて、ワールド座標をカメラから見た座標系(ビュー座標系)へ変換する行列です。
前回求めた射影行列では、カメラは原点に固定され、視線の方向も一方向(OpenGL系では −Z 方向)に決まっていました。
そのため、オブジェクトを画面に映すには、Near クリップ面から Far クリップ面の間で、かつ視野角の範囲内(視錐台の内側)にオブジェクトを配置する必要がありました。
この状態では、「もう少し右から見たい」「上から見下ろしたい」と思っても、カメラは動かせません。
見え方を変えるたびに、各オブジェクトの配置を一つひとつ調整し直すことになります。
ビュー変換行列を使うと、カメラをワールド空間内の好きな位置に置き、好きな方向へ向けられるようになります。
各オブジェクトのワールド空間での配置はそのままに、カメラの位置と向きを指定するだけで、シーン全体の見え方を変えられます。
ただし、ビュー変換行列の計算の内部では、実はカメラではなくオブジェクトのほうを動かしています。ビュー変換行列は、すべてのオブジェクトをまとめて同じように動かすことで、あたかもカメラが動いたかのように見せる行列です。
この仕組みは次の章から詳しく見ていきます。
カメラの考え方
現実世界と 3DCG の世界のカメラの違い
現実世界でオブジェクトを撮影するときは、撮りたいものに合わせてカメラを動かします。
一方、3DCG では、カメラは原点に固定したまま(−Z 方向を向いたまま)にし、代わりにシーン内のすべてのオブジェクトをカメラとは逆向きに動かします。
たとえば、カメラを右に 1 動かしたいなら、すべてのオブジェクトを左に 1 動かします。カメラを右に 30° 回したいなら、シーン全体を左に 30° 回します。
こうしてもカメラとオブジェクトの相対的な位置関係は変わらないため、画面上の見え方も同じになります。つまり、あたかもカメラが移動したかのように見ることができます。
現実世界では、撮りたいもの(球)に合わせてカメラ自身を動かす。
3DCG ではカメラを原点に固定したまま、撮りたいもの(球)をカメラとは逆向きに動かす。
この考え方の利点は、前回求めた射影行列をそのまま使えることです。
射影行列は「カメラが原点にあり、−Z 方向を向いている」ことを前提としていました。シーンのほうをこの前提に合わせて動かせば、射影行列には手を加えずに済みます。
相対的な移動と逆行列
では、「オブジェクトをカメラとは逆向きに動かす」変換は、どのように表せばよいでしょうか。ここで登場するのが 逆行列 です。
この記事では、次の 2 つの行列を区別して扱います。
cameraAim行列:カメラをワールド空間内の好きな位置・向きに実際に移動させる行列
ビュー変換行列:カメラから見た座標系(ビュー座標系)へ、オブジェクトを相対的に移動させる行列
cameraAim 行列は、原点で −Z 方向を向いているカメラを、好きな位置・向きへ動かす行列です。
一方、ビュー変換行列はその逆で、好きな位置・向きにあるカメラを、原点で −Z 方向を向いた状態へ戻したときに、オブジェクトがどこに来るかを求める行列です。
そのため、ビュー変換行列は cameraAim 行列の逆行列として求められます。
viewMatrix=cameraAim−1
cameraAim 行列を求める
cameraAim 行列は、原点で −Z 方向を向いているカメラを、ワールド空間内の指定した位置へ移動させ、指定した方向へ向ける行列です。
求めるために、次の2つの情報を使います。
eye:カメラの位置
target:カメラで見たいもの・狙いたいものの位置
カメラの3軸を求める
カメラの向きを決めるには、カメラ自身の X 軸・Y 軸・Z 軸が、ワールド空間でそれぞれどの方向を向いているかを求めます。
以降、ワールド座標で表したカメラの X 軸・Y 軸・Z 軸を、それぞれ xcam、ycam、zcam と書きます。
Z 軸
前回の射影行列は、カメラが −Z 方向を向いていることを前提に作りました。
つまり、カメラ(eye)から見たいもの(target)へ向かう方向が、カメラの −Z 軸になります。
したがって、+Z 軸はその逆向き、target から eye へ向かう方向です。
zcam=normalize(eye−target)
この軸は、後で座標系の基底として使います。大きさが 1 でないと、回転だけでなく拡大・縮小まで入ってしまうので、正規化しておきます。
これで Z 軸は求まりましたが、まだカメラの姿勢は決まりません。
同じ方向を向いたままでも、カメラを Z 軸まわりに傾ける余地が残っているからです。
そこで、X 軸と Y 軸も求めます。
青矢印は、ビュー座標系の Z 軸。原点の球(target)からカメラ(eye)へ向かう方向が正。
X 軸
X 軸を求めるために、カメラの「おおよその上方向」を表すベクトル up を用意します。
今回はカメラを真上や真下に向けることは考えないものとして、ワールド空間の上方向 up=(0,1,0) を使います。
外積は、2つのベクトルの両方に直交するベクトルを返します。
up と zcam の外積をとることで、両方に直交する X 軸が得られます。
xcam=normalize(up×zcam)
外積は順序を入れ替えると向きが反転するので注意します。
たとえば zcam=(0,0,1) のとき、up×zcam=(1,0,0) となり、正しく +X 方向を向きます。
なお、カメラを真上や真下に向けると zcam と up が平行になり、外積がゼロベクトルになって X 軸が求まりません。今回これらの向きを考えないのはこのためです。
up(白矢印)と Z 軸(青矢印)の外積で、両方に直交する X 軸(赤矢印)が得られる。
Y 軸
最後に、Z 軸と X 軸の外積から Y 軸を求めます。
ycam=zcam×xcam
zcam と xcam は互いに直交する大きさ 1 のベクトルなので、その外積も大きさ 1 になり、正規化は不要です。
ここで up をそのまま Y 軸に使わないのは、カメラが上や下に傾いているとき、up が zcam と直交しないためです。up はあくまで X 軸を求めるための「おおよその上方向」です。
Z 軸(青矢印)と X 軸(赤矢印)の外積で Y 軸(緑矢印)が定まる。
具体例で 3 軸を求める
たとえば eye=(0,0,5)、target=(0,0,0) とすると、
zcam=(0,0,1),xcam=(1,0,0),ycam=(0,1,0)となります。カメラを +Z 方向に下げて原点を見ているだけなので、軸の向きは元のカメラと同じになり、直感とも一致します。
eye = (0, 0, 5)、target = (0, 0, 0) のとき、カメラの 3 軸(赤・緑・青)はワールド座標軸と同じ向きになる。
ビュー座標系からワールド座標系へ変換する
求めた 3 軸と、カメラの位置 eye を使って、次の行列を作ります。
C≡(xcam0ycam0zcam0eye1)
各列に X 軸・Y 軸・Z 軸・カメラの位置を並べただけの行列です。
成分で書くと、次のようになります。
C=xcam,xxcam,yxcam,z0ycam,xycam,yycam,z0zcam,xzcam,yzcam,z0eyexeyeyeyez1
ここで xcam,y は、カメラの X 軸 xcam の y 成分です。
これが cameraAim 行列で、カメラから見た座標(ビュー座標)をワールド座標に変換します。
具体例で変換の向きを確かめる
3 軸を求めたときと同じく、 eye=(0,0,5)、target=(0,0,0) の場合で、実際に変換の向きを確かめてみます。
このとき 3 軸は元の座標軸と同じになるので、cameraAim 行列は Z 方向に 5 だけ平行移動する行列になります。
C=1000010000100051まず、target を考えます。target はカメラから見ると正面に 5 離れた位置にあるので、ビュー座標では (0,0,−5) です。
これに C を掛けると、次のようになります。
C00−51=0001結果の (0,0,0) は、target のワールド座標そのものです。
次に、カメラ自身を考えます。ビュー座標の原点 (0,0,0) がカメラの位置です。
C0001=0051結果の (0,0,5) は、カメラを置いた位置 eye のワールド座標です。
最後に、カメラから見て右に 2、正面に 5 の位置にある点を考えてみます。ビュー座標では (2,0,−5) です。
C20−51=2001このカメラは原点を正面から見ていて、カメラの右はワールドの +X 方向にあたるので、ワールド座標 (2,0,0) は正しい位置です。
3 つとも、ビュー座標を入れるとワールド座標が得られました。原点で −Z 方向を向いていたカメラが、eye の位置に移動し、target の方向を向いたことが確認できます。
なぜこのように並べると座標変換になるのか
各列に X 軸・Y 軸・Z 軸を並べるとなぜ座標変換になるのか、回転行列を例に見ていきます。
Z 軸まわりに θ 回転させる回転行列は、次のとおりです。
R=cosθsinθ0−sinθcosθ0001元の座標系の基底ベクトル ex=(1,0,0)、ey=(0,1,0)、ez=(0,0,1) に、この回転行列を作用させてみます。
Rex=cosθsinθ0,Rey=−sinθcosθ0,Rez=001これらは、回転後の座標系の基底ベクトル ex′、ey′、ez′ を元の座標系で表したものです。見比べると、回転後の基底ベクトルが、そのまま回転行列 R の各列になっていることがわかります。
では、回転後の座標系で座標が (o,p,q) の点は、元の座標系ではどこにあるでしょうか。
「回転後の座標系で (o,p,q)」とは、回転後の基底ベクトルをそれぞれ o、p、q 倍して足した位置のことです。
同じ 1 点でも、回転後の座標系では (o, p, q)、元の座標系では (l, m, n) と表され、両者は等しい。この点の元の座標系での座標を (l,m,n) とすると、次の等式が成り立ちます。
oex′+pey′+qez′=lex+mey+nez左辺は、回転後の基底ベクトルを列に並べた行列、つまり R を使って書くことができます。
oex′+pey′+qez′=(ex′ey′ ez′)opq=Ropq右辺は、ex、ey、ez が (1,0,0) などの単位ベクトルなので、そのまま成分を並べたものになります。
lex+mey+nez=lmn両辺をつなげると、
lmn=Ropqつまり、回転後の基底ベクトルを各列に並べた行列 R を作用させると、回転後の座標系での座標 (o,p,q) を、元の座標系での座標 (l,m,n) に変換できるということです。
この考え方は、ビュー座標系とワールド座標系の関係性においても成り立ちます。
先ほど求めたカメラの 3 軸 xcam、ycam、zcam は、いずれもワールド座標で表したベクトルです。
これらを各列に並べた行列 C′ を作用させれば、ビュー座標系での座標 (o,p,q) をワールド座標 (l,m,n) に変換できます。
C′≡(xcamycamzcam)lmn=C′opqただし、これだけでは原点を中心に向きを変えただけです。
カメラは eye の位置にあるので、最後にカメラの位置 eye を足します。
oxcam+pycam+qzcam+eyeこの平行移動まで 1 つの行列で表すために、4 成分目を 1 とした同次座標を使います。
4 列目に eye を置くと、4 成分目の 1 が掛かって eye がそのまま足されます。
(xcam0ycam0zcam0eye1)opq1=(oxcam+pycam+qzcam+eye1)この左辺における行列が cameraAim 行列 C となります。
C≡(xcam0ycam0zcam0eye1)
ビュー変換行列を求める
カメラを動かすか、オブジェクトを動かすか
カメラの考え方の章で、3DCG ではカメラを動かす代わりに、オブジェクトをカメラとは逆向きに動かすと説明しました。
前の章の例 eye=(0,0,5)、target=(0,0,0) で、2 つの方法を比べてみます。
カメラを動かす場合:原点にあるカメラに cameraAim 行列 C を掛けると、カメラは (0,0,5) に移動します。オブジェクトは原点にあるので、カメラの正面 5 の位置に見えます。
オブジェクトを動かす場合:カメラは原点に置いたまま、原点にあるオブジェクトをカメラとは逆向きに 5 動かすと、オブジェクトは (0,0,−5) に移動します。こちらもオブジェクトはカメラの正面 5 の位置に見えます。
どちらの方法でも、カメラとオブジェクトの相対的な位置関係は同じなので、画面上の見え方も同じになります。
そして、後者の「オブジェクトをカメラとは逆向きに動かす」行列こそが、ビュー変換行列です。
ビュー変換行列は cameraAim 行列の逆行列
これまで見てきたとおり、ビュー変換行列は cameraAim 行列の逆行列で与えられます。
cameraAim 行列を C、ビュー変換行列を V とすると、次のように表せます。
V=C−1
座標の変換として見ると、cameraAim 行列はビュー座標をワールド座標に変換する行列でした。
その逆行列であるビュー変換行列は、ワールド座標をビュー座標に変換します。
射影行列は「カメラが原点にあり、−Z 方向を向いている」ことを前提にしていたので、ビュー変換行列で頂点をビュー座標に変換しておけば、前回求めた射影行列をそのまま使えます。
具体例で確かめる
cameraAim 行列 C は Z 方向に 5 平行移動する行列だったので、その逆行列は Z 方向に −5 平行移動する行列です。
V=10000100001000−51target のワールド座標 (0,0,0) に V を掛けると、次のようになります。
V0001=00−51前の章で確かめた target のビュー座標 (0,0,−5) が得られました。
逆行列を具体的に計算する
逆行列は汎用の計算方法で求めることもできますが、cameraAim 行列は形が決まっているので、もっと簡単に求められます。
cameraAim 行列は、「3 軸による回転」の後に「eye への平行移動」を行う行列です。
それぞれを Crot、Ctrans とすると、次のように分解できます。
C=CtransCrotCtrans=100001000010eyexeyeyeyez1,Crot=xcam,xxcam,yxcam,z0ycam,xycam,yycam,z0zcam,xzcam,yzcam,z00001積の逆行列は、それぞれの逆行列を逆の順番で掛けたものになります。
「回転してから移動する」を打ち消すには、「移動を戻してから回転を戻す」必要があるからです。
C−1=Crot−1Ctrans−1平行移動の逆行列は、逆向きに平行移動する行列です。
Ctrans−1=100001000010−eyex−eyey−eyez1回転の逆行列は、転置行列(行と列を入れ替えた行列)になります。
Crot の各列は互いに直交する大きさ 1 のベクトルなので、転置行列を掛けると、同じ軸どうしの内積は 1、異なる軸どうしの内積は 0 になり、単位行列が得られるからです。
Crot−1=CrotT=xcam,xycam,xzcam,x0xcam,yycam,yzcam,y0xcam,zycam,zzcam,z00001この 2 つを掛け合わせると、ビュー変換行列が得られます(⋅ は内積)。
V=xcam,xycam,xzcam,x0xcam,yycam,yzcam,y0xcam,zycam,zzcam,z0−xcam⋅eye−ycam⋅eye−zcam⋅eye1cameraAim 行列では 3 軸が「列」に並んでいたのに対し、ビュー変換行列では「行」に並びます。
4 列目は、eye を各軸の方向に分解した大きさを、逆向きにしたものです。
先ほどの例に当てはめると、zcam⋅eye=(0,0,1)⋅(0,0,5)=5 なので、4 列目の Z 成分は −5 となり、先ほどの V と一致します。
lookAt 関数
ここまで、cameraAim 行列を求めてから逆行列をとる、という手順でビュー変換行列を求めてきました。
しかし、多くの数学ライブラリには cameraAim 関数はなく、代わりに lookAt 関数が用意されています。
lookAt は、cameraAim 行列の逆行列、つまりビュー変換行列を直接返す関数です。
lookAt(eye,target,up)=C−1=V
WebGPU 向けの数学ライブラリ wgpu-matrix(新しいタブで開く) には cameraAim(新しいタブで開く) と lookAt(新しいタブで開く) の両方が用意されていて、次の 2 つは同じ結果になります。
import { mat4 } from 'wgpu-matrix';
const eye = [0, 0, 5];
const target = [0, 0, 0];
const up = [0, 1, 0];
// lookAt でビュー変換行列を直接求める
const viewMatrix = mat4.lookAt(eye, target, up);
// cameraAim 行列の逆行列をとる(上と同じ結果になる)
const viewMatrix2 = mat4.inverse(mat4.cameraAim(eye, target, up));
実装では、lookAt は逆行列の計算を経由せず、前の節で求めた式で直接計算されています。
補足:aim 関数
cameraAim 行列の考え方は、カメラ以外にも使えます。
「ある位置から、目標の方向を向かせる」行列なので、キャラクターの頭を目標に追従させたり、砲塔を狙う相手に向けたりするのにも使えます。
ただし、カメラは −Z 方向が正面でしたが、物体を向かせる場合は +Z 方向を正面とするのが一般的です。
そのため、Z 軸の求め方だけを逆にします。
// cameraAim:target → eye の向きを +Z にする
const zAxis = vec3.normalize(vec3.subtract(eye, target));
// aim:position → target の向きを +Z にする
const zAxis = vec3.normalize(vec3.subtract(target, position));
wgpu-matrix では mat4.aim(新しいタブで開く) として用意されており、物体の位置と向きを決めるモデル行列として使えます。