🎥

camera.worldToCameraMatrix と projectionMatrix の挙動を確認しながらVP変換を理解する

2022/02/21に公開

最近MVP行列を理解し直そうと思いぐぐっていたところ、以下の記事を見つけました。
https://light11.hatenadiary.com/entry/2018/06/10/233954
視錐台と同じ形のメッシュを自作して、MVP変換をしてみるという内容ですが、一から理解したかった自分にはぴったりな内容で、とても参考になりました。

サンプルコードを動かしている中で、カメラが原点にあるときは正しく立方体になるのですが、カメラが原点以外にあるとき、変換後の立方体がスキューされる(歪む)ことがわかりました。
(元記事の想定していない使い方をしているためであって、元記事内のコードの不具合ではありません)

そこで、今回はスキューされるのがなぜなのかを検証しながら、camera.worldToCameraMatrix (ビュー変換)と camera.projectionMatrix (プロジェクション変換)の挙動も確認していきたいと思います。

(個人的な見解も含まれているため、正確な情報ではない可能性があります)

前提

  • 今回、カメラの Clipping Planes の設定は Near が 5 、 Far が 10 に設定されています
  • Field of View は 30 で設定されています
    • この値によって、視錐台の幅や高さが変わります

検証

ビュー変換と投影変換後の値を確かめてみる

まず、どのステップの処理が原因なのかを調べるために、以下のようにロギングを仕込んでみました。

        if (_applyMatrix) {
            // VP行列を適用する
            for (int i = 0; i < _vertices.Length; i++) {
                // 検証のため頂点情報を4次元に
                var vertex = new Vector4(_vertices[i].x, _vertices[i].y, _vertices[i].z, 1);
                Debug.Log("変換前: " + vertex.x + ", " + vertex.y + ", " + vertex.z + ", " + vertex.w);
                // VP行列を作成
                // var mat =   _camera.projectionMatrix * _camera.worldToCameraMatrix;
                // VP行列を適用
                // vertex = mat * vertex;
                vertex = _camera.worldToCameraMatrix * vertex;
                Debug.Log("ビュー変換後: " + vertex.x + ", " + vertex.y + ", " + vertex.z + ", " + vertex.w);
                
                vertex = _camera.projectionMatrix * vertex;
                // W除算
                Debug.Log("プロジェクション変換後: " + vertex.x + ", " + vertex.y + ", " + vertex.z + ", " + vertex.w);
                vertex /= vertex.w;
                Debug.Log("w除算後: " + vertex.x + ", " + vertex.y + ", " + vertex.z + ", " + vertex.w);

                _vertices[i] = vertex;
            }
        }

すると、以下のような出力が得られます。(カメラが原点にある状態での出力です)
near 側の2頂点に関する出力

far 側の2頂点に関する出力

ここから以下のことがわかります。

  • カメラが原点にある状態では、ビュー変換後の座標値はz座標以外は変わっていない
  • プロジェクション変換後に near と far それぞれのz座標値の2倍の大きさの四角形に変換されている
    • nearの場合片方が5でもう片方が-5なので、辺の長さとしては10になっている
      • あとでw (z座標値と同じ)で除算した際に大きさ2にするため
  • wで除算されることでnearもfarも大きさ2の四角形になっている
    • 片方が1でもう片方が-1のため

カメラの座標を原点から動かしてみる

では、カメラを原点から動かしたらどうなるかを試してみます。
今回は (0,0,0) → (10,0,0)へ動かしてみます。

near 側の2頂点に関する出力

far 側の2頂点に関する出力

先ほどとx座標が少し変わっていることがわかると思います。

  • カメラを原点から動かすと、各変換後のx座標が変わる
    • ビュー変換では、x座標が -10 されている
    • プロジェクション変換でも、なんらかの比率で座標が変換されていそう

ビュー変換時にx座標が-10されているのは、カメラが右に動く(x軸方向に+10される)ということは、カメラに映っている物体は相対的に左に動くことになるので、ビュー変換後のカメラ座標としては各頂点のx座標は-10されるのが正しいということだと思います。(個人の見解なので不確かかもしれません)

プロジェクション変換では、座標値の差を見れば、カメラが原点にある時と変わらず、nearとfarそれぞれをz座標値の2倍の大きさの四角形に変換していそうです。

nearを例に、変換前後の比率を求めると、

(nearのz座標値の2倍の大きさ)/(もともとのnearの四角形の幅) = (5*2)/(3.371449-(-3.371449)) ≒ 1.48304

となります。
実際、6.628 * 1.48304 = 9.82958912 となり、nearのプロジェクション変換後の座標とほぼ一致します。(小数部分を丸めて計算しているので、正確に一致はしていないです)

farに関しても同様で、計算してみると比率も同じになります。

ここまで見てきましたが、nearとfarで

  • ビュー変換時の座標の変化量は同じ
  • プロジェクション変換時の拡大される比率も同じ

となり、スキューされる要素が見つかりません。
どうやらwでの除算部分が今回の疑問に関係していそうですね。

実際に、wでの除算をしない状態であれば、カメラを動かしてもスキューされずに一緒に動いていることを確認できます。
(ちょっと歪んでいるように見えるかもしれませんが、実際は歪んでいません)

wでの除算

wでの除算時になぜスキューされるのかは簡単です。
wの値は投影行列よりzの値となり、zの値は near < far なので、単純に各座標値を割る数字がfarの方が大きく、割った結果に差が出るからです。

投影行列について

投影行列は以下の記事にあるような行列のことです。計算してみるとwはzになります
https://qiita.com/kajitaj63b3/items/3bf0e041f6be4fad164b#direct3d11の場合

イメージ図として以下を用意しました。
カメラを(10,0,0)に置いた状態で、左側がプロジェクション変換後(wでの除算なし)、右側がwでの除算後です。
wの値が near と far でそれぞれ 5, 10 のとき、以下の画像のように変換後の座標がずれてしまいます。
カメラが原点にあれば、wでの除算後も座標がともに x=1 になるはずです。

以上から、スキューが発生する原因は、wで除算した際に変換後の座標がずれてしまうことで、さらに元をたどれば、ビュー変換時に座標値が動いてしまっていることがこの座標のずれの原因と言えそうです。

もしも指定した座標値が固定ではなかったら

今回、サンプルコードの中では _vertices[0]~[7] を固定の座標(カメラの位置に関わらず固定)として指定しています。
固定であるために、カメラが+10動いた時にビュー変換時に座標値が-10されてしまい、最後のwでの除算で near と far の結果がずれてしまうのが原因と考えられそうです。

では、 _vertices にカメラの位置情報を加えたらどうなるでしょうか。該当部分を以下のコードに書き換えて試してみます。

        _vertices[0] = new Vector3(nearFrustumWidth * -0.5f, nearFrustumHeight * -0.5f, near) + _camera.transform.position;
        _vertices[1] = new Vector3(nearFrustumWidth * 0.5f, nearFrustumHeight * -0.5f, near)+ _camera.transform.position;
        _vertices[2] = new Vector3(nearFrustumWidth * -0.5f, nearFrustumHeight * 0.5f, near)+ _camera.transform.position;
        _vertices[3] = new Vector3(nearFrustumWidth * 0.5f, nearFrustumHeight * 0.5f, near)+ _camera.transform.position;
        _vertices[4] = new Vector3(farFrustomWidth * -0.5f, farFrustumHeight * -0.5f, far)+ _camera.transform.position;
        _vertices[5] = new Vector3(farFrustomWidth * 0.5f, farFrustumHeight * -0.5f, far)+ _camera.transform.position;
        _vertices[6] = new Vector3(farFrustomWidth * -0.5f, farFrustumHeight * 0.5f, far)+ _camera.transform.position;
        _vertices[7] = new Vector3(farFrustomWidth * 0.5f, farFrustumHeight * 0.5f, far)+ _camera.transform.position;

すると、カメラのポジションに関わらず立方体が表示されることがわかります。

まとめ

以上より、スキューされる原因は以下のようにまとめられます。

  • 座標を固定値で指定しているので、ビュー変換後の座標値がカメラの動きに合わせて変わってしまう
    • カメラが+10動けば、ビュー変換後の座標は-10動く
  • 変化後の座標値をプロジェクション変換し、その後wで除算する際、nearとfarで除算する数字が異なるため、除算前の数字が意図したものでないと立方体にならない(スキューされる)
    • 除算する数字は「z座標値」のため、除算前に「z座標値の2倍」の数字になっている必要がある

また、camera.worldToCameraMatrix (ビュー変換)と camera.projectionMatrix (プロジェクション変換) のそれぞれの挙動は確認できた限り以下の通りでした。

  • camera.worldToCameraMatrix (ビュー変換)
    • z座標が反転する
    • 座標がカメラ位置に関わらず固定されているとき、変換後の座標値はカメラが動いた方向と逆向きに同じ量増減した値になる
      • カメラが+10動けば、変換後の座標値は-10されている
  • camera.projectionMatrix (プロジェクション変換)
    • ビュー変換後の座標を「z座標値の2倍」の大きさの四角形に対応するように変換する
    • このタイミングでwにzの値が入る
    • (おそらくx座標に関してはこの記事の変換行列をもとに変換が行われていそう?)

Discussion