はじめに
透視投影と並行投影を滑らかにつなぐため、外部から取得した投影行列を用いて投影変換をするシェーダを作ろうとしたら予想外のところでだいぶハマったので備忘として記しておきます。
vertex Shader における座標変換
Unity で Unlit Shader を新規作成すると、vertex Shader を記述する部分は以下のような処理になっています。
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
この UnityObjectToClipPos
でモデル空間からプロジェクション空間への変換を行っており、これはモデル→ワールド変換、ワールド→ビュー変換、ビュー→プロジェクション変換に分割できます。またそれぞれの変換は頂点座標値に行列を掛ける演算に相当します。
参考:
https://docs.unity3d.com/Manual/SL-UnityShaderVariables.html
https://light11.hatenadiary.com/entry/2019/01/27/160541
https://virtualcast.jp/blog/2020/06/the-reason-unity-is-overwriting-your-shader-code/
実験
試してみましょう。以下のような Unlit Shaderを用意します。
Shader "Unlit/test0"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
[MaterialToggle] _defaultMatrix("useDefaultMatrix", Float) = 0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct v2f
{
float2 uv : TEXCOORD0;
float3 viewDir : TEXCOORD1;
float3 lightDir : TEXCOORD2;
float3 normal : TEXCOORD3;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float _defaultMatrix;
v2f vert (appdata v)
{
v2f o;
if (_defaultMatrix) {
o.vertex = UnityObjectToClipPos(v.vertex);
}
else {
o.vertex = mul(UNITY_MATRIX_M, v.vertex);
o.vertex = mul(UNITY_MATRIX_V, o.vertex);
o.vertex = mul(UNITY_MATRIX_P, o.vertex);
}
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
o.lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
o.normal = UnityObjectToWorldNormal(v.normal);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
col *= fixed4(1, 0, 0, 1);
col *= dot(i.normal, i.lightDir);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}
デフォルトの Unlit Shader に適当なシェーディングを足したものに、_defaultMatrixによる分岐を作っています。これを切り替えてみても、描画される結果は変わりません。
unity_CameraProjection
シェーダ内で利用できるデフォルト変数の中に、unity_CameraProjection
というのがあります。公式のドキュメント には、
Camera’s projection matrix.
と説明があります。一方、先ほど登場した UNITY_MATRIX_P
は、次のように説明されています。
Current projection matrix.
はじめにこれを同一のものと考えてしまったことから沼にハマってしまったのですが、両者は実は異なるものです。実験してみましょう。先ほどのシェーダを一部書き換えます。
if (_defaultMatrix) {
o.vertex = UnityObjectToClipPos(v.vertex);
}
else {
o.vertex = mul(UNITY_MATRIX_M, v.vertex);
o.vertex = mul(UNITY_MATRIX_V, o.vertex);
//o.vertex = mul(UNITY_MATRIX_P, o.vertex);
o.vertex = mul(unity_CameraProjection , o.vertex);
}
なんだか上下が逆転していたり、陰影がおかしかったりします。
原因
こちらのフォーラムに回答がありました。要するに、unity_CameraProjection
はOpenGL を想定した変換行列である一方、UNITY_MATRIX_P
は実際にレンダリングに用いられている変換行列であり、想定しているグラフィックAPIに齟齬があるとこういったことが起きてしまうそうです。
さて、そうなると、unity_CameraProjection
を無理やり用いるために、この齟齬を解消してやらねばなりません。こちらの記事によれば、プラットフォーム依存の差異は垂直方向の向きと奥行きのクリッピング範囲に現れるようです。公式のドキュメントにも言及があります。(https://docs.unity3d.com/Manual/SL-PlatformDifferences.html)
投影行列の中身
こちらの記事 を参考に、投影行列を補正します。
if (_defaultMatrix) {
o.vertex = UnityObjectToClipPos(v.vertex);
}
else {
o.vertex = mul(UNITY_MATRIX_M, v.vertex);
o.vertex = mul(UNITY_MATRIX_V, o.vertex);
float4x4 tmp = unity_CameraProjection;
tmp[1][1] *= _ProjectionParams.x;
float near = _ProjectionParams.y;
float far = _ProjectionParams.z;
tmp[2][2] = (near) / (far - near);
tmp[2][3] = (1 * far*near) / (far - near);
//o.vertex = mul(UNITY_MATRIX_P, o.vertex);
//o.vertex = mul(unity_CameraProjection, o.vertex);
o.vertex = mul(tmp, o.vertex);
}
公式ドキュメントとこちらの記事を参考に、_ProjectionParams
というビルトイン変数の情報を元に投影行列を補正します。
tmp[1][1] *= _ProjectionParams.x
で垂直方向の向きを補正、 tmp[2][2] = (near) / (far - near); tmp[2][3] = (1 * far*near) / (far - near);
で、奥行き方向を 0 ~ 1 とした場合の行列の要素の値を再計算しています。
結果
UnityObjectToClipPos
と同じ結果を再現できました。