#射影行列(UNITY_MATRIX_P)
Unityに限定せずに射影行列の各要素の値や、射影変換について調べているとありがたいことに詳細な記事が見つかります。
しかし、それらの内容を基にUnityでシェーダーの処理を書いたり、ビルトンシェーダー関数を見てみるとどうも整合しないということがあったので、射影行列(以降P行列)の中身やそれにまつわる話をメモとしてまとめます。間違いや補足があればご指摘頂けると助かります。
本記事では式を見やすくする為に以下の対応表で示した文字で式中の語を省略します。
省略後の文字 | 意味 |
---|---|
n | カメラのnearClipPlaneの値 |
f | カメラのfarClipPlaneの値 |
fov | カメラのField of Viewの値 |
aspect | カメラのアスペクト比 |
viewZ | ビュー空間上でのZ座標 |
#P行列の中身
P行列の中身の一部はProjectSettigsで設定したGraphics APIによって変化します。
Direct3D11を使用している場合は、P行列の中身は以下のようになるようです。
\begin{pmatrix}
\frac{aspect}{tan(\frac{fov}{2} * Deg2Rad)} & 0 & 0 & 0\\
0 & \frac{1}{tan(\frac{fov}{2} * Deg2Rad)} & 0 & 0\\
0 & 0 & \frac{n}{f-n} & \frac{nf}{f-n}\\
0 & 0 & -1 & 0\\
\end{pmatrix}
OpenGLCoreを使用している場合は、P行列の中身は以下のようになるようです。
\begin{pmatrix}
\frac{aspect}{tan(\frac{fov}{2} * Deg2Rad)} & 0 & 0 & 0\\
0 & \frac{1}{tan(\frac{fov}{2} * Deg2Rad)} & 0 & 0\\
0 & 0 & \frac{-(f+n)}{f-n} & \frac{-2nf}{f-n}\\
0 & 0 & -1 & 0\\
\end{pmatrix}
#SV_POSITION
UnityではSV_POSITION
セマンティクスがバインドされたクリップ座標が頂点シェーダーから出力されフラグメントシェーダに渡るまでに、自動でクリップ座標のxyz成分がw成分で除算されます。さらにxy成分は画面の解像度が乗算されます。
(頂点シェーダーで算出したクリップ座標とフラグメントシェーダに渡ってきた同じ変数は中身が異なるという事です。)
このwによる除算の結果、z成分は非線形な分布をした深度になり0~1の範囲に収まるのですが、描画APIによってnearClipPlaneの深度が0であったり、farClipPlaneの深度が0であったりします。描画APIと深度の対応は以下のようになっているようです。
Direct3D11 | OpenGLCore | |
---|---|---|
nearの深度 | 1 | 0 |
farの深度 | 0 | 1 |
この深度の反転を作る為にP行列の中身が描画APIに依存しているのだと思われます。
射影変換について調べていると、正規化デバイス座標系と言われる座標系を目にしますが、頂点シェーダーからフラグメントシェーダに渡る間で自動で行われるw除算をした時点でクリップ座標は正規化デバイス座標になるということになると思います。フラグメントシェーダに渡たった時点では更にxy成分に解像度が乗算されているので、Unityにおいてシェーダーの書き手が正規化デバイス座標を意識することは無さそうです。
#Linear01Depth()とLinearEyeDepth()と_ZBufferParams
深度を利用したシェーダー(例、ソフトパーティクル、レイマーチング)を書く際に、非線形な分布をした深度を線形に直したい場面があります。これらを実現する関数がUnityCG.cginc
に2種類定義されています。
// 非線形の深度を線形な0~1に補正する関数
// Z buffer to linear 0..1 depth
inline float Linear01Depth( float z )
{
return 1.0 / (_ZBufferParams.x * z + _ZBufferParams.y);
}
// 非線形の深度を線形な0~farClipに補正する関数
// Z buffer to linear depth
inline float LinearEyeDepth( float z )
{
return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}
この2つの関数は、描画APIに依存せずに同じ値を返します。
深度値が反転しているのに、補正した値は統一された値になるように_ZBufferParams
に入っている値も描画APIによって異なっています。_ZBufferParams
はUnityShaderVariables.cginc
内に以下のように定義されています。
// Values used to linearize the Z buffer (http://www.humus.name/temp/Linearize%20depth.txt)
// x = 1-far/near
// y = far/near
// z = x/far
// w = y/far
// or in case of a reversed depth buffer (UNITY_REVERSED_Z is 1)
// x = -1+far/near
// y = 1
// z = x/far
// w = 1/far
float4 _ZBufferParams;
UNITY_REVERSED_Z
はHLSLSupport.cginc
内に以下のように定義されています。
#if defined(SHADER_API_D3D11) || defined(SHADER_API_PSSL) || defined(SHADER_API_METAL) || defined(SHADER_API_VULKAN) || defined(SHADER_API_SWITCH)
// D3D style platforms where clip space z is [0, 1].
#define UNITY_REVERSED_Z 1
#endif
恐らく、
SHADER_API_D3D11
SHADER_API_PSSL
SHADER_API_METAL
SHADER_API_VULKAN
SHADER_API_SWITCH
の5つの描画APIの場合は非線形の深度はnearが1でfarが0になるんだと思いますが未検証です。
P行列のm22とm23の導出
_m22と_m23の値は、ざっくり以下のようにして導出しました。導出と言っても、こう計算してみたら辻褄があったというだけでUnityが頂点シェーダーとフラグメントシェーダの間でどんな計算をしてるのかについて十分な情報がないと確かとは言い切れないです。
射影変換後のzw座標は以下のように表現できます。
clipPos.z = _m22 * viewZ + _m23
clipPos.w = -viewZ
そして、頂点シェーダーとフラグメントシェーダの間で
clipPos.xyz /= clipPos.w
の処理が入るので、フラグメントシェーダに渡ってきたZ座標は
clipPos.z = (_m22 * viewZ + _m23) / -viewZ
と等しくなります。この値が非線形の深度にあたります。
これ以降の導出課程はは描画APIによって違います。
Direct3D11の場合
頂点がnearの位置にある場合深度値は1、farの場合は深度値は0になるので、
1 = (_m22 * -near + _m23) / near
0 = (_m22 * -far + _m23) / far
になるので、この2式を連立方程式として解くと_m22と_m23が得られます。
この時、Unityではビュー空間でのカメラの前方は-Zの向きなので、viewZは負の値になることに注意します。つまり頂点がnearの位置にあるならビュー空間ではz座標は-nearになり、farの位置にいる時はビュー空間ではz座標は-farになります。
OpenGLCoreの場合
同様に
-1 = (_m22 * -near + _m23) / near
1 = (_m22 * -far + _m23) / far
で連立方程式の解を求めると_m22とm23が得られます。
元々OpenGLでの正規化デバイス座標は[near, far] = [-1~+1]の範囲でz座標が表現されるようなので、それに合わせて上記の式を組み立て解いたところ、P行列の値と一致した結果が得られました。
しかし、実際にはUnityでOpeGLCoreを使用した際には前述したようにフラグメントシェーダに渡ってきたSV_POSITIONのw成分は[near, far] = [0, 1]になるっているので、
0 = (_m22 * -near + _m23) / near
とするのが結果に即していることになってしまいます。恐らく、OpenGL系の描画APIを使用している場合はwで除算した後に[-1~+1]を[0,1]にするスケーリングとオフセットの処理があるのだと思われます。
この辺りは結果を基に推測するしかないので不確かにならざるを得ないと思います。
どこかにUnityTechnologiesの方がこの辺りの処理について明言してる資料があれば良いんですが...。ご存じの方いらしたら教えて頂けると助かります。
#参考にした記事
http://marupeke296.com/DXG_No70_perspective.html
https://wgld.org/d/webgl/w069.html
Unityで射影変換やP行列の中身を考える際には、以下の2点に注意しておいた方が理解しやすいと思います。
- 描画APIによって、頂点シェーダーからフラグメントシェーダに渡ってきたSV_POSITIONのW成分(深度)がnearが0だったり、farが0だったりする。
- ビュー座標上では描画APIに依存せずにカメラの前方が-Z方向である。