シェーダーの記述

前回までのシェーダーの記述は、単調な処理を行う固定関数と呼ばれるものです。
今回からは自由に計算できる頂点/フラグメントシェーダーを使い、Cg 言語で記述をしていきます。

頂点シェーダーは頂点単位の計算を担当、主な役割は座標変換です。
フラグメントシェーダーはピクセル単位の計算を担当、主な役割は陰影付けです。

Unity には各種計算を簡略化できるサーフェースシェーダーが用意されていますが、この場では割愛します。

座標変換の基礎

先に進む前に、3D グラフィックスにおける座標変換の基礎を復習しておきます。

通常、3 次元の情報を構成するためには (\(x, \ y, \ z\)) の座標軸が必要になりますが、
最終的に映像が出力されるスクリーンは (\(x, \ y\)) の 2 次元の平面であることがほとんどです。
つまり、3 次元の情報は (\(x, \ y, \ z\)) の座標を (\(x, \ y\)) に変換して 2 次元の平面で表現しなければなりません。

身近な例としては、カメラと写真が想像しやすいです。
現実世界をカメラで撮影した写真は 2 次元の平面になりますが、平面上でも物体の前後関係などを知覚できるはずです。
これは、近いものほど大きく表示する、遠いものほど小さく表示する、遠近表現が主な要因の一つです。

一例として、奥方向を \(z\) 軸の正とした左手系の直交座標で (\(x, \ y, \ z\)) を (\(x', \ y'\)) に変換する遠近表現を考えてみます。
奥の座標ほど \(z\) が大きくなるため、変換後の \(x'\) との関係は反比例であり \(y'\) も同様です。
このことから変換式を推測すると、変換後の座標は \(x\) や \(y\) を \(z\) で除算した結果が近似することがわかります。

$$ (x', \ y') \fallingdotseq (x \div z, \ y \div z) $$

上記は極めて簡易な変換例ですが、Direct3D や OpenGL では、

  • ローカル座標系
  • ワールド座標系
  • ビュー座標系
  • プロジェクション座標系
  • スクリーン座標系

の各座標系の変換を順に経て結果を求めます。

頂点シェーダーでは、ローカル座標系からプロジェクション座標系までの変換を取り扱い、
頂点の位置ベクトルと各座標系に変換する行列の乗算をしていくことになります。
また、変換式を行列のみで表現するため、同次座標を用いた (\(x, \ y, \ z, \ w\)) のベクトルと \(4 \times 4\) 行列で計算を考えます。

以下に各変換行列を示します。

拡大縮小

2 次元の座標(\(x, \ y\)) を (\(s_x , \ s_y \)) 拡大した座標は、

$$ ( x \times s_x , \ y \times s_y ) $$

になるため、拡大縮小には、

$$ S = \begin{pmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} $$

を使用します。

回転

2 次元の座標(\(x, \ y\)) を \(\theta\) 回転した座標は加法定理から、

$$ ( x \cos \theta - y \sin \theta , \ x \sin \theta + y \cos \theta ) $$

になります。

これを行列に置き換えると、

$$ \begin{pmatrix} \cos \theta & - \sin \theta \\ \sin \theta & \cos \theta \end{pmatrix} \left( \begin{array}{c} x \\ y \end{array} \right) $$

になるので 3 次の同次座標系に拡張して、

$$ R_z = \begin{pmatrix} \cos \theta & - \sin \theta & 0 & 0 \\ \sin \theta & \cos \theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} $$

を Z 軸の回転行列として使用します。

同様に X 軸 と Y 軸の回転行列は、

$$ R_x = \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & \cos \theta & - \sin \theta & 0 \\ 0 & \sin \theta & \cos \theta & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} $$

$$ R_y = \begin{pmatrix} \cos \theta & 0 & \sin \theta & 0 \\ 0 & 1 & 0 & 0 \\ - \sin \theta & 0 & \cos \theta & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} $$

を使用します。

平行移動

2 次元の座標(\(x, \ y\)) を (\(t_x , \ t_y \)) 移動した座標は、

$$ ( x + t_x , \ y + t_y ) $$

になるため、平行移動には、

$$ T = \begin{pmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{pmatrix} $$

を使用します。

ワールド変換

「ローカル座標系」から「ワールド座標系」に変換します。

ワールド変換行列は、前述の拡大縮小・回転・平行移動行列を合成して生成するため、

$$ M = T \times R \times S $$

のような行列になります。

ただし、行列の積には交換法則があてはまらないため、合成の順序次第で結果は異なります。
例えば、原点を中心に回転してから移動、移動してから原点を中心に回転、両者は別物です。

ビュー変換

「ワールド座標系」から「ビュー座標系」に変換します。

ビュー変換行列は、カメラの回転・平行移動成分の逆になりますが、このままでは扱いづらいため、
カメラの視点(\(\vec{Eye}\))、注視点(\(\vec{At}\))、上向き(\(\vec{Up}\))で求める LookAt から意図した行列を生成します。

$$ \vec{Z} = normalize(\vec{At} - \vec{Eye}) \\[10pt] \vec{X} = normalize(\vec{Up} \times \vec{Z}) \\[10pt] \vec{Y} = \vec{Z} \times \vec{X} $$

と置くと、左手座標系においてビュー変換行列は、

$$ V = \begin{pmatrix} \vec{X}_x & \vec{Y}_x & \vec{Z}_x & 0 \\ \vec{X}_y & \vec{Y}_y & \vec{Z}_y & 0 \\ \vec{X}_z & \vec{Y}_z & \vec{Z}_z & 0 \\ -(\vec{X} \cdot \vec{Eye}) & -(\vec{Y} \cdot \vec{Eye}) & -(\vec{Z} \cdot \vec{Eye}) & 1 \end{pmatrix} $$

になります。

プロジェクション変換

「ビュー座標系」から「プロジェクション座標系」に変換します。

投影方式によって定義が異なりますが、ここでは透視投影を考えた場合、
視野角(\(Fov_{y}\))、アスペクト比(\(Aspect\))、視錘台の前方距離(\(Z_{near}\))、視錘台の後方距離(\(Z_{far}\))から、

$$ H = \cot \frac{Fov_{y}}{2} \\[10pt] W = \frac{H}{Aspect} $$

と置くと、左手座標系においてプロジェクション変換行列は、

$$ P = \begin{pmatrix} W & 0 & 0 & 0 \\ 0 & H & 0 & 0 \\ 0 & 0 & \frac{Z_{far}}{Z_{far} - Z_{near}} & 1 \\ 0 & 0 & - Z_{near} \frac{Z_{far}}{Z_{far} - Z_{near}} & 0 \end{pmatrix} $$

になります。

頂点変換の実装

頂点変換の計算式が定義できたので、これを頂点シェーダーで実装します。
UNITY_MATRIX_MVP が前項までの合成変換行列になります。

Shader "Custom/Shader" {
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            float4 vert(float4 vertex : POSITION) : SV_POSITION {
                return mul(UNITY_MATRIX_MVP, vertex);
            }

            half4 frag() : SV_Target {
                return UNITY_LIGHTMODEL_AMBIENT;
            }
            ENDCG
        }
    }
}

法線ベクトルの変換

頂点計算の次は陰影計算に移りますが、陰影には法線ベクトルが大きく影響します。
そのため、まずは法線ベクトルをワールド座標系へ変換し、光源と同じ座標系に整える必要があります。

頂点の位置ベクトルと同様、法線ベクトルも変換行列の乗算で変換しますが、
向きを示す法線に平行移動という概念はないため、頂点と同じ変換行列を使うことはできません。

また、回転は共通ですが、拡大縮小は逆数で打ち消さなければならないため、
3D プログラミングでは、頂点変換で生成したワールド変換行列の逆行列の転置行列を使うことが一般的です。

これは、頂点の変換行列である \(4 \times 4\) 行列において、
4 列目から平行移動成分だけを除けることと、
特異値分解で回転成分と拡大縮小成分に分解できるためです。

平行移動成分を除いた頂点の変換行列に対して、

$$ M = U \Sigma V^{\mathrm{T}} $$

と特異値分解した場合、修正対象は対角成分の \(\Sigma\) であり、

$$ M = U \Sigma^{-1} V^{\mathrm{T}} $$

が法線の変換行列です。

直交の回転成分は転置行列と逆行列が等しく、
対角の拡大縮小成分は転置行列と自身が等しいことから、
変換行列を逆転置した行列は、回転を保ち拡大縮小を打ち消せることがわかります。

ライティングの実装

法線変換の計算式が定義できたので、ランバート反射によるライティングを実装します。
unity_WorldToObject がワールド変換の逆行列になるため、乗算の左右を入れ替えて転置結果を得ます。

Shader "Custom/Shader" {
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            struct appdata {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f {
                float4 pos   : SV_POSITION;
                half4  color : COLOR0;
            };

            fixed4 _LightColor0;

            v2f vert(appdata v) {
                v2f o;
                o.pos = mul(UNITY_MATRIX_MVP, v.vertex);

                float3 normal  = normalize(mul(float4(v.normal, 0), unity_WorldToObject).xyz);
                float3 light   = normalize(_WorldSpaceLightPos0.xyz);
                half3  diffuse = _LightColor0.rgb * max(0, dot(light, normal));
                o.color = fixed4(diffuse, 1.0);

                return o;
            }

            half4 frag(v2f i) : SV_Target {
                return i.color;
            }
            ENDCG
        }
    }
}

ここまでの計算で、頂点変換と法線変換を確認することができました。

ライセンス表記

ユニティちゃんライセンス

この作品はユニティちゃんライセンス条項の元に提供されています