【Unity】プロジェクション行列は掛けるだけじゃなくてw除算しなきゃダメだよという話

カメラ空間からプロジェクション空間に座標変換する過程の理解が曖昧だったので、それに関する記事です。

プロジェクション座標変換とは?

そもそもプロジェクション座標変換とは、カメラに映る視錐台の領域を、xyzがぞれぞれ-1から1に収まるような領域に変換することです。

f:id:halya_11:20180610221657p:plain:w500

この結果を使って、xとyが共に-1の部分をディスプレイの右下に、xとyが共に1の部分をディスプレイの右上に描画すれば、カメラで映した3D空間をディスプレイに投影できたことになります。

f:id:halya_11:20180610223847p:plain:w500

プロジェクション座標変換はこのような意味を持つ行列です。

ちなみに正確には上述のz値の範囲はプラットフォームにより異なりますが、目的は同じです。
この辺りは下の記事が詳しいです。

【Unity】【数学】Unityでのビュー&プロジェクション行列とプラットフォームの関係 – 株式会社ロジカルビート

プロジェクション行列掛けるだけじゃダメ

このプロジェクション座標変換の手順を見ていきます。

前提として、プロジェクション座標変換を行う前の座標はカメラ空間にある座標です。
これはローカル座標をいわゆるMV行列に掛けて算出しますが、詳細は割愛します。
また座標変換の行列は4x4なので、上記で得られる座標は3次元の座標(xyz)にw値が1としてくっついている4次元の値となっています。

プロジェクション座標変換ではまずプロジェクション行列(射影変換行列 / P行列)にこの座標を掛けます。
これは頂点シェーダで行う処理です。UnityObjectToClipPos(v.vertex)とかやってるアレです。

ただ注意しなければいけないのは、これだけではプロジェクション座標変換は完了していないという点です。
ここまでで求められた座標の各成分をこの座標のw値で除算することで初めて座標変換は完了します。

このw除算の部分だけは頂点シェーダに記述せずGPU側で暗黙的に行われる処理であるため非常に認識しづらいですが、そういう処理になっています。

なぜこのような仕組みになっているのかは下のサイトで分かりやすく説明されています。

その70 完全ホワイトボックスなパースペクティブ射影変換行列

Unityで確認する

上記の挙動を簡単にUnity上で確認してみます。
確認手順は下記の通り。

  1. カメラの視錐台と同じ形のメッシュをレンダリングする
  2. P行列で座標変換する
  3. w値で除算する

この結果、xyzが-1から1の立方体がレンダリングされれば座標が正常に変換されたことになります。

次の節から上記の手順を順番に確認していきます。

確認1 視錐台と同じ形のメッシュ

カメラの視錐台と同じ形のメッシュを生成します。

using UnityEngine;

public class CoordinateTransformation : MonoBehaviour {

    [SerializeField]
    private Camera _camera;

    private Mesh _mesh;
    private Vector3[] _vertices;

    private void Awake()
    {
        _mesh = new Mesh();
        GetComponent<MeshFilter>().mesh = _mesh;
        _vertices = new Vector3[8];
        
        // メッシュを初期化
        var triangles = new int[]{
            0, 2, 1,
            1, 2, 3,
            1, 3, 5,
            7, 5, 3,
            3, 2, 7,
            6, 7, 2,
            2, 0, 6,
            4, 6, 0,
            0, 1, 4,
            5, 4, 1,
            4, 7, 6,
            5, 7, 4
        };
        var colors = new Color[]{
            new Color(0.0f, 0.0f, 0.0f),
            new Color(1.0f, 0.0f, 0.0f),
            new Color(0.0f, 1.0f, 0.0f),
            new Color(1.0f, 1.0f, 0.0f),
            new Color(0.0f, 0.0f, 1.0f),
            new Color(1.0f, 0.0f, 1.0f),
            new Color(0.0f, 1.0f, 1.0f),
            new Color(1.0f, 1.0f, 1.0f),
        };
        _mesh.vertices = _vertices;
        _mesh.triangles = triangles;
        _mesh.colors = colors;
        UpdateVertices();
    }

    private void Update ()
    {
        UpdateVertices();
    }

    /// <summary>
    /// 頂点をカメラの視錐台に合わせたものに更新する
    /// </summary>
    private void UpdateVertices()
    {
        var near = _camera.nearClipPlane;
        var far = _camera.farClipPlane;

        // 視錐台の大きさの求め方は下記を参考
        // https://docs.unity3d.com/jp/current/Manual/FrustumSizeAtDistance.html
        var nearFrustumHeight = 2.0f * near * Mathf.Tan(_camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
        var nearFrustumWidth = nearFrustumHeight * _camera.aspect;
        var farFrustumHeight = 2.0f * far * Mathf.Tan(_camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
        var farFrustomWidth = farFrustumHeight * _camera.aspect;

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

        _mesh.vertices = _vertices;
        _mesh.RecalculateBounds();
    }
}

(2022/02/21追記)
このスクリプトを適当なGameObjectにアタッチし、さらにMeshRendererをアタッチします。
次に、カメラを生成し、このスクリプト_cameraアサインします。
この時、この二つのGameObjectのTransformはリセットしておいてください。

マテリアルには頂点カラーを出力するだけのシンプルなシェーダをアサインしておきます。

再生すると、以下の結果が得られます。

f:id:halya_11:20180610231151p:plain

視錐台と同じ大きさのオブジェクトが生成されました。

確認2 P行列で座標変換する

次にこれにP行列を適用します。
UpdateVertices()内で頂点座標を求めた後にVP行列を掛けます。

// VP行列を適用する
for (int i = 0; i < _vertices.Length; i++) {
    // 検証のため頂点情報を4次元に
    var vertex = new Vector4(_vertices[i].x, _vertices[i].y, _vertices[i].z, 1);
    // VP行列を作成
    var mat = _camera.projectionMatrix * _camera.worldToCameraMatrix;
    // VP行列を適用
    vertex = mat * vertex;

    _vertices[i] = vertex;
}

結果は次のようになります。

f:id:halya_11:20180610231959p:plain

まだ想定する結果が得られていないことが確認できます。

ちなみに上記ではCamera.projectionMatrixを使用していますが、プラットフォームごとに適したP行列を得るにはGL.GetGPUProjectionMatrix()を使うようです。
つまりGPUにP行列を渡すときにはこっちを使います。今回はCPU計算かつ確認するだけが目的なので使いません。詳細が知りたい方は下の記事へ。

【Unity】【数学】Unityでのビュー&プロジェクション行列とプラットフォームの関係 – 株式会社ロジカルビート

確認3 w値で除算する

最後に、先ほど得られた頂点座標をw値で除算してみます。
一行追加しただけですが、最終的なソースコードになるので全文記載します。

using UnityEngine;

public class CoordinateTransformation : MonoBehaviour {

    [SerializeField]
    private Camera _camera;
    [SerializeField]
    private bool _applyMatrix;

    private Mesh _mesh;
    private Vector3[] _vertices;

    private void Awake()
    {
        _mesh = new Mesh();
        GetComponent<MeshFilter>().mesh = _mesh;
        _vertices = new Vector3[8];
        
        // メッシュを初期化
        var triangles = new int[]{
            0, 2, 1,
            1, 2, 3,
            1, 3, 5,
            7, 5, 3,
            3, 2, 7,
            6, 7, 2,
            2, 0, 6,
            4, 6, 0,
            0, 1, 4,
            5, 4, 1,
            4, 7, 6,
            5, 7, 4
        };
        var colors = new Color[]{
            new Color(0.0f, 0.0f, 0.0f),
            new Color(1.0f, 0.0f, 0.0f),
            new Color(0.0f, 1.0f, 0.0f),
            new Color(1.0f, 1.0f, 0.0f),
            new Color(0.0f, 0.0f, 1.0f),
            new Color(1.0f, 0.0f, 1.0f),
            new Color(0.0f, 1.0f, 1.0f),
            new Color(1.0f, 1.0f, 1.0f),
        };
        _mesh.vertices = _vertices;
        _mesh.triangles = triangles;
        _mesh.colors = colors;
        UpdateVertices();
    }

    private void Update ()
    {
        UpdateVertices();
    }

    /// <summary>
    /// 頂点をカメラの視錐台に合わせたものに更新する
    /// </summary>
    private void UpdateVertices()
    {
        var near = _camera.nearClipPlane;
        var far = _camera.farClipPlane;

        // 視錐台の大きさの求め方は下記を参考
        // https://docs.unity3d.com/jp/current/Manual/FrustumSizeAtDistance.html
        var nearFrustumHeight = 2.0f * near * Mathf.Tan(_camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
        var nearFrustumWidth = nearFrustumHeight * _camera.aspect;
        var farFrustumHeight = 2.0f * far * Mathf.Tan(_camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
        var farFrustomWidth = farFrustumHeight * _camera.aspect;

        _vertices[0] = new Vector3(nearFrustumWidth * -0.5f, nearFrustumHeight * -0.5f, near);
        _vertices[1] = new Vector3(nearFrustumWidth * 0.5f, nearFrustumHeight * -0.5f, near);
        _vertices[2] = new Vector3(nearFrustumWidth * -0.5f, nearFrustumHeight * 0.5f, near);
        _vertices[3] = new Vector3(nearFrustumWidth * 0.5f, nearFrustumHeight * 0.5f, near);
        _vertices[4] = new Vector3(farFrustomWidth * -0.5f, farFrustumHeight * -0.5f, far);
        _vertices[5] = new Vector3(farFrustomWidth * 0.5f, farFrustumHeight * -0.5f, far);
        _vertices[6] = new Vector3(farFrustomWidth * -0.5f, farFrustumHeight * 0.5f, far);
        _vertices[7] = new Vector3(farFrustomWidth * 0.5f, farFrustumHeight * 0.5f, far);
        
        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);
                // VP行列を作成
                var mat = _camera.projectionMatrix * _camera.worldToCameraMatrix;
                // VP行列を適用
                vertex = mat * vertex;
                // W除算
                vertex /= vertex.w;

                _vertices[i] = vertex;
            }
        }

        _mesh.vertices = _vertices;
        _mesh.RecalculateBounds();
    }
}

レンダリング結果はこのようになります。

f:id:halya_11:20180610232719p:plain:w500

ちょっと大きさはわかりづらいですが、目的の立方体が得られ、プロジェクション座標変換が行えたことが確認できました。

参考

その70 完全ホワイトボックスなパースペクティブ射影変換行列

【Unity】【数学】Unityでのビュー&プロジェクション行列とプラットフォームの関係 – 株式会社ロジカルビート