Unityでなるべくシェーディング処理を自作してみる
はじめに
以前記事にした
こちらの知識を使って、なるべくUnityのライトやShaderLibrary(.cginc)を使わずシェーディングを行ってみました。
Unityのグラフィックスを勉強するには結構いい題材だったかなと思います。
プロジェクトは以下で公開しています。
変換行列をシェーダーに渡す
普段から使っているUNITY_MATRIXですが、これは描画するオブジェクトごとにUNITY_MATRIX_Mが変化します(描画するオブジェクトごとにモデル変換行列が設定されます)。また、描画を行うカメラに応じてUNITY_MATRIX_Vも変化します。
今回はこれらの変換行列をC#で計算して渡すようにしています。
using System.Collections; using UnityEngine; [ExecuteInEditMode] public class MVP : MonoBehaviour { [SerializeField] private Material material; public void OnWillRenderObject() { if(material == null) return; Camera renderCamera = Camera.current; //Matrix4x4 m = gameObject.transform.localToWorldMatrix; Matrix4x4 m = GetComponent<Renderer>().localToWorldMatrix; Matrix4x4 v = renderCamera.worldToCameraMatrix; Matrix4x4 p = renderCamera.cameraType == CameraType.SceneView ? GL.GetGPUProjectionMatrix(renderCamera.projectionMatrix, true) : renderCamera.projectionMatrix; Matrix4x4 mvp = p * v * m; Matrix4x4 mv = v * m; //DynamicBatchingによって複数のオブジェクトでマテリアルを共有すると //matrixがオブジェクトごとに作用してくれない。こういった用途では //shaderで素直にUNITY_MATRIXを使うべき material.SetMatrix("mvp_matrix", mvp); material.SetMatrix("mv_matrix", mv); material.SetMatrix("v_matrix", v); } }
上記の例ではシェーダーのuniform変数「mvp_matrix」「mv_matrix」「v_matrix」にそれぞれの変換行列をセットしています。
シェーダーは簡易的に以下の様なものを作りました。
Shader "Custom/MVP" { Properties{ _Color("Color",COLOR) = (1,1,1,1) } SubShader { Pass{ CGPROGRAM #pragma vertex vert #pragma fragment frag struct app_data { float4 vertex:POSITION; }; struct v2f { float4 position:SV_POSITION; }; uniform float4x4 mvp_matrix; uniform float4x4 mv_matrix; uniform float4x4 v_matrix; float4 _Color; v2f vert(app_data i) { v2f o; o.position = mul(mvp_matrix, i.vertex); return o; } float4 frag(v2f i) :SV_TARGET{ return _Color; } ENDCG } } }
このスクリプトをレンダリングするオブジェクトごとにアタッチすればいいのですが、UnityでDynamic Batchingが行われる場合、Batchingされた複数のオブジェクトは同一の変換行列を渡されることとなり、結果的にちゃんと表示されません。
Dynamic Batchingは描画パフォーマンスを大きく向上させてくれるので、これを無効にする(たとえばシェーダーを意味もなくマルチパスにしてしまう等する)のはナンセンスな気がします。しかし、うまい回避方法も思いつかなかったため、後述するシェーディングでは変換行列にUNITY_MATRIXを使っています。
ランバートシェーダー
ランバート反射モデルを表す式は
$$R_{\mathrm{diffuse}}=k_{\mathrm{diffuse}}(p)<n,l>L(p)$$
でした。今回は頂点シェーダーでランバート反射モデルを実装します。
また、環境光$R_{\mathrm{ambient}}$についても考慮します(といっても出力色に定数値を加算するだけです)。
まずC#からシェーダーに情報を渡す部分です。
using System.Collections; using UnityEngine; [ExecuteInEditMode] public class Lambert : MonoBehaviour { [SerializeField] private Material lambert; [SerializeField] private Transform lightTransform; [SerializeField] private float k_diffuse; [SerializeField] private float k_ambient; public void OnWillRenderObject() { Vector4 lightPos = lightTransform.position; lambert.SetVector("light_pos", lightPos); lambert.SetFloat("k_diffuse", k_diffuse); lambert.SetFloat("k_ambient", k_ambient); } }
シェーダーに与えている情報は
- 光源の位置
- ランバート反射係数
- 環境光
です。環境光に関してはカラーを考慮せず適当に
$$R_{\mathrm{ambient}}=k_{\mathrm{ambient}}$$
としています。
シェーダーは以下のようになります。
Shader "Custom/Lambert"{ Properties{ _Color("Color", COLOR) = (1,1,1,1) } SubShader{ Pass{ CGPROGRAM #pragma vertex vert #pragma fragment frag struct app_data { float4 vertex:POSITION; float3 normal:NORMAL; }; struct v2f { float4 vertex:SV_POSITION; float4 color:COLOR0; }; uniform float4 light_pos; uniform float k_diffuse; uniform float k_ambient; float4 _Color; v2f vert(app_data i) { v2f o; float3 vs_normal = mul(UNITY_MATRIX_IT_MV, float4(i.normal, 1)); float3 vs_light = mul(UNITY_MATRIX_V, light_pos); float3 vs_pos = mul(UNITY_MATRIX_MV, i.vertex); float3 vs_l = normalize(vs_light - vs_pos); float r_diffuse = k_diffuse*max(dot(vs_normal, vs_l), 0); o.vertex = mul(UNITY_MATRIX_MVP, i.vertex); o.color = _Color * r_diffuse + k_ambient; return o; } float4 frag(v2f i) :SV_TARGET{ return i.color; } ENDCG } } }
ほぼ頂点シェーダーで完結しています。 フラグメントシェーダーではラスタライザーで線形補間された頂点色をそのまま返しているだけです。
v2fのvertexは使用していませんが、頂点シェーダーがPOSITION(SV_POSITION)セマンティクスの値を返さなければ描画が無視されてしまいます。
では処理を詳しく見ていきます。
頂点シェーダー前半で
float3 vs_normal = mul(UNITY_MATRIX_IT_MV, float4(i.normal, 1));
float3 vs_light = mul(UNITY_MATRIX_V, light_pos);
float3 vs_pos = mul(UNITY_MATRIX_MV, i.vertex);
float3 vs_l = normalize(vs_light - vs_pos);
という処理をしています。これはそれぞれ
- 頂点の法線をModelSpaceからViewSpaceに変換
- 光源の座標をWorldSpaceからViewSpaceに変換
- 頂点の座標をModelSpaceからViewSpaceに変換
- ViewSpaceにおける頂点位置から光源方向のベクトルを計算
を意味します。3、4に関しては見慣れているので1、2について解説します。
頂点の法線をModelSpaceからViewSpaceに変換
頂点の法線をViewSpaceに変換する場合、UNITY_MATRIX_MVを使うわけには行きません。以下の理由からです。
- 変換にはT変換(平行移動)が含まれる(法線は方向を表すため不適切)
- 変換にはS変換(拡大縮小)が含まれる(法線に対してはこの変換の逆数をとる必要がある)
T変換成分は行列内で孤立しているのでこれを無視するのは簡単で、左上3x3の行列を取り出して使えばいいだけです。 この取り出した行列を$A$とすると、$A$はModel変換とView変換のRS行列で表せるので
$$A=R_{V} S_{M} R_{M}$$
となります。ここで、$R_{V}$はView変換のR行列、$S_{M}$はModel変換のS行列、$R_{M}$はModel変換のR行列です。
法線の拡大縮小変換は、この変換の逆数をとる必要があるので
$$A=R_{V} S_{M} ~ ^{-1} R_{M}$$
となります。回転行列の転置行列は自身の逆行列と等しくなることと、拡大縮小行列の転置行列は自身と等しくなることから
$$A=(R_{V} ~ ^{-1})^T (S_{M} ~ ^{-1})^T (R_{M} ~ ^{-1})^T$$
です。行列の転置は纏めることができるので(行/列優先の変換の際の転置の利用と同様)
$$A=(R_{M} ~ ^{-1} S_{M} ~ ^{-1} R_{V} ~ ^{-1})^T$$
となります(演算順は変わります)。そして、同様に逆行列も纏めることができるので
$$A=((R_{V} S_{M} R_{M})^{-1}) ~ ^T$$
となります(演算順が変わり、元の順に戻ります)。 つまり、法線をModelSpaceからViewSpaceに変換するには、MV変換行列の左上3x3成分に関して逆行列を求め、転置した行列で変換を行えば良いことになります。
ここで気付かれた方もいるかもしれませんが、今回のシェーダーでは左上3x3の行列を取りしていません。 これは、行列の平行移動成分が逆行列を求める際に、拡大縮小/回転成分に直接影響を及ぼさないことと、行列の転置によって変換後は平行移動成分の逆数が結果の$w$成分にしか現れないことを利用しています。この$w$成分は利用しないため、どんな値が代入されていようと問題になりません。
光源の座標をWorldSpaceからCameraSpaceに変換
こちらは簡単です。 光源の座標を変換するのですが、光源は頂点(Model)の変換に合わせる必要が無いので、Model変換はせず光源のWorld座標をそのままV変換しています。 C#から与えられる光源の座標はWorldSpaceであることに注目して下さい。
フォンシェーダー
ブリンの近似によって得られるフォンの反射モデルです。 先ほどのランバート反射をするシェーダーに
$$R_{\mathrm{specular}}(p,v)=k_{\mathrm{specular}}(p)<n,h>^{S}L(p)$$
の計算を追加しただけです。
C#では以下のようにシェーダーに値を渡しています。
using System.Collections; using UnityEngine; [ExecuteInEditMode] public class Phong : MonoBehaviour { [SerializeField] private Material specular; [SerializeField] private Transform lightTransform; [SerializeField] private float k_diffuse; [SerializeField] private float k_ambient; [SerializeField] private float k_specular; [SerializeField] private float shininess; public void OnWillRenderObject() { Vector4 lightPos = lightTransform.position; specular.SetVector("light_pos", lightPos); specular.SetFloat("k_diffuse", k_diffuse); specular.SetFloat("k_ambient", k_ambient); specular.SetFloat("k_specular", k_specular); specular.SetFloat("shininess", shininess); } }
スペキュラー反射用の反射係数と鏡面反射指数が加わっただけです。
シェーダーは以下のようになりました。
Shader "Custom/Phong"{ Properties{ _DiffuseColor("Diffuse Color", COLOR) = (1,1,1,1) _SpecularColor("Specular Color", COLOR) = (1,1,1,1) } SubShader{ Pass{ CGPROGRAM #pragma vertex vert #pragma fragment frag struct app_data { float4 vertex:POSITION; float3 normal:NORMAL; }; struct v2f { float4 vertex:SV_POSITION; float4 color:COLOR0; }; uniform float4 light_pos; uniform float4 camera_pos; uniform float k_diffuse; uniform float k_ambient; uniform float k_specular; uniform float shininess; float4 _DiffuseColor; float4 _SpecularColor; v2f vert(app_data i) { v2f o; float3 vs_normal = mul(UNITY_MATRIX_IT_MV, float4(i.normal, 1)); float3 vs_light = mul(UNITY_MATRIX_V, light_pos); float3 vs_camera = mul(UNITY_MATRIX_V, camera_pos); float3 vs_pos = mul(UNITY_MATRIX_MV, i.vertex); float3 vs_l = normalize(vs_light - vs_pos); float r_diffuse = k_diffuse * max(dot(vs_normal, vs_l), 0); float3 vs_v = normalize(vs_camera - vs_pos); float3 vs_h = normalize(vs_l + vs_v); float r_specular = k_specular * pow(max(dot(vs_normal, vs_h), 0),shininess); o.vertex = mul(UNITY_MATRIX_MVP, i.vertex); o.color = r_diffuse * _DiffuseColor + r_specular * _SpecularColor + k_ambient; return o; } float4 frag(v2f i) :SV_TARGET{ return i.color; } ENDCG } } }
視点方向のViewベクトルと光源方向のLightベクトルからハーフベクトルを求め、先ほどの式に代入しました。 スペキュラー反射は視点方向のベクトルに依存するため、ディフューズ反射と違って視点移動で変化が生まれます。
フォンシェーディング
「フォンシェーディング≠スペキュラー反射(フォンの反射モデル)」なので注意です。 先ほどのフォンシェーダーでは、頂点に設定された法線を用いて「頂点シェーダー」でスペキュラーを計算していました。 しかし、頂点数の少ないオブジェクト等では、ラスタライザーによる線形補間が問題になり、不自然なボケ方をしてしまいます。 そこで用いるのがフォンシェーディングです。
フォンシェーディングは、ラスタライザーに法線を入力し、線形補間してもらう手法のことです。 ラスタライザーはTEXCOORDやCOLORセマンティクスが付加された値が入力されると、その値を線形補間します(COLORを線形補間し、フラグメントを埋める処理はグローシェーディングまたはスムーズシェーディングと呼びます)。
このフォンシェーディングを用いて、先ほどのフォンシェーダーをピクセルごとの法線で計算します。 このシェーダーを「パーピクセルフォンシェーディング」と呼ぶことにします。
C#スクリプトはフォンシェーダーのものをそのまま使えるので省略します。
シェーダーは以下のようにしました。
Shader "Custom/PerPixelPhongShader"{ Properties{ _DiffuseColor("Diffuse Color", COLOR) = (1,1,1,1) _SpecularColor("Specular Color", COLOR) = (1,1,1,1) } SubShader{ Pass{ CGPROGRAM #pragma vertex vert #pragma fragment frag struct app_data { float4 vertex:POSITION; float3 normal:NORMAL; }; struct v2f { float4 vertex:SV_POSITION; float4 position:TEXCOORD0; float4 normal:TEXCOORD1; }; uniform float4 light_pos; uniform float4 camera_pos; uniform float k_diffuse; uniform float k_ambient; uniform float k_specular; uniform float shininess; float4 _DiffuseColor; float4 _SpecularColor; v2f vert(app_data i) { v2f o; o.vertex = mul(UNITY_MATRIX_MVP, i.vertex); o.position = i.vertex; o.normal = mul(UNITY_MATRIX_IT_MV, float4(i.normal, 1)); return o; } float4 frag(v2f i) :SV_TARGET{ float3 vs_light = mul(UNITY_MATRIX_V, light_pos).xyz; float3 vs_pos = mul(UNITY_MATRIX_MV, i.position).xyz; float3 vs_camera = mul(UNITY_MATRIX_V, camera_pos).xyz; float3 vs_n = normalize(i.normal.xyz); float3 vs_l = normalize(vs_light - vs_pos); float3 vs_v = normalize(vs_camera - vs_pos); float3 vs_h = normalize(vs_l + vs_v); float r_diffuse = k_diffuse * max(dot(vs_n, vs_l), 0); float r_specular = k_specular * pow(max(dot(vs_n, vs_h), 0),shininess); return r_diffuse * _DiffuseColor + r_specular * _SpecularColor + k_ambient; } ENDCG } } }
頂点カラーでシェーディングしなくなるため、COLORセマンティクスは不要になりました。
代わりにv2f構造体には
float4 position:TEXCOORD0; float4 normal:TEXCOORD1;
という記述が増えています。 特に説明は不要だと思いますが、positionはピクセル単位でViewSpaceの位置を得たかったため、ラスタライザーに線形補間させた値を使えるようにしています。 normalもピクセルごとに法線を得たかったため、ラスタライザーに線形補間させた値を使えるようにしています(フォンシェーディング)。