新卒研修で行ったシェーダー講義について

こんにちは、Applibot Voxel Studioの小松原です。

Applibotでは新卒・内定者へのサポートの一環として、現場社員による基礎的な知識の講義を行っています。
本記事では、新卒・内定者向けの講義資料を再編し、Unityを前提としたシェーダプログラミングの基礎について解説します。

コンピュータグラフィックスにおいて、シェーダへの理解は不可欠です。
本記事に沿って実際に手元の環境で実行結果を確認すると、より理解が深まります。

目次

  • シェーダとは
  • レンダリングパイプラインについて
  • やってみよう
  • 頂点シェーダ
  • フラグメントシェーダ
  • 色の合成
  • フォグ
  • 背面カリング
  • まとめ

シェーダとは

シェーダとは、3次元コンピュータグラフィックスにおいて、陰影付けや表示色の決定などの画面の描画を行うコンピュータプログラムのことです。
特に、一般の開発者がシェーダ言語を記述して制御できるものをプログラマブルシェーダと呼びます。
シェーダは複数のステップから成り、GPUを制御して、最終的に画面上のピクセルに表示する色を決定させることを目的としています。

CPUとGPUの特徴は下図のように比較されます。

レンダリングパイプラインについて

ここで、データとして存在する3Dのオブジェクトをディスプレイに表示したいとします。
このとき、主に必要な処理は以下のようになります。
(各手法によって違いがありますが、代表的なものを取り上げます)

  1. モデルやテクスチャの入力
  2. 座標変換、カメラ座標への変換など
  3. ポリゴンを画面上のピクセルに対応
  4. 色情報の反映
  5. 出力

このうち、ステップ2(頂点シェーダ)、ステップ4(フラグメントシェーダ)は独自に記述することが可能です。

では、実際に頂点シェーダの処理を記述してみましょう。

やってみよう

まず、新規にUnityプロジェクトを作成し、Projectウインドウで右クリック→Create→Shader→Unlit Shaderを選択、シェーダファイルを作成します。

スクリーンショット 2021-07-02 17.57.59.png

このシェーダは3Dモデルの描画に最低限必要な機能が記述されています。
Unlitとはライトの影響を受けないという意味です。

シェーダの効果を確認するために、Materialファイルを作成、作成したシェーダをアサインしてHierarchyに作成したキューブに適用しましよう。

スクリーンショット 2021-07-16 18.50.40.png

影のない、真っ白なキューブが表示されました。

頂点シェーダ

作成したUnlitシェーダをテキストエディタで開き、関数vertに着目します。

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;
}

ここでは頂点シェーダの処理を行っています。
引数のvには1つの頂点の情報がappdataに格納されて渡されます。ここでは頂点座標とUV座標が渡されています。

struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
};

頂点シェーダで最も頻繁に行われる処理は、モデルの座標を画面上に表示する座標に変換することです。
一般的に4段階の座標変換(モデル座標→ワールド座標→ビュー座標→クリップ座標)が必要ですが、UnityObjectToClipPosメソッドによって簡潔に書き表すことができます。
この変換処理を理解するために、シェーダを書き換えてみましょう。

座標変換を行うコードUnityObjectToClipPosの前に、下記のようにx値に1を加算する行を挿入します。

v.vertex.x = v.vertex.x + 1;
o.vertex = UnityObjectToClipPos(v.vertex);

すると、このようにモデルのx軸方向に沿って空間上の位置が移動しました。

スクリーンショット 2021-07-20 21.03.18.png

次に、先ほどの追加行を消し、座標変換後の行に下記のように挿入します。

o.vertex = UnityObjectToClipPos(v.vertex);
o.vertex.x = o.vertex.x + 1;
スクリーンショット 2021-07-20 21.03.39.png

モデルが左に移動しました。
先ほどと異なり、パースペクティブの変化はなく描画された像がそのままスライドしたことに着目してください。
前者はモデル座標での変化、後者はクリップ座標の変化になります。

フラグメントシェーダ

次に、フラグメントシェーダを見ていきます。
このシェーダはピクセルごとに実行され、カラー値を算出します。
基本的には貼り付けたテクスチャのUV座標を参照してテクスチャから色を持ってくることになりますが、この色を変化させることで、影や反射、凹凸などを表現することが可能です。

関数fragはデフォルトでは下記のようになっています。

fixed4 frag (v2f i) : SV_Target
{
    // sample the texture
    fixed4 col = tex2D(_MainTex, i.uv);
    // apply fog
    UNITY_APPLY_FOG(i.fogCoord, col);
    return col;
}

試しに、色を上書きしてみましょう。
関数最後の行を下記のように書き換えます。

return float4(0, 0, 1, 1);
スクリーンショット 2021-07-16 18.56.34.png

真っ青な箱が表示されました。

4つの値はそれぞれRGBAに対応していそうです。
ここで4つ目の値を0.5にしてみましょう。
半透明表示になると思われましたが、特に変化はありません。

これはレンダータイプが半透明に対応していないためです。
試しに、コード上部にあるTagsの定義 Tags { "RenderType" = "Opaque" } を下記に書き換えてみましょう。

Tags { "RenderType" = "Transparent" "Queue"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
スクリーンショット 2021-07-16 19.01.31.png

意図通り、半透明の箱が表示されました。
追記したコードではレンダータイプの指定の他に合成モードも指定しています。
今回はよく利用されるアルファブレンドにしています。


オブジェクトが半透明で描画され得るかは、グラフィック処理においては重要です。
不透明な要素のみの描画では、1ピクセルごとに深度値がZバッファに記録されており、後から描こうとしたピクセルがより奥のものだと分かったら描画処理をスキップすることができます(深度テスト)。
このため、手前にあるオブジェクトから描画することで不必要な処理を大きく削減することができます。

しかし、不透明なオブジェクトは前景の色と後景の色を任意の式で合成する処理が不可欠です。
このため、必ず奥から描画する必要があり処理のスキップができないというデメリットがあります。

ゲームなどのリアルタイム処理では、パフォーマンスを保つためのマネジメントが重要です。
例えば、半透明のオブジェクトが画面を覆うようなカメラアングルを避けるなど、様々な工夫が求められます。


ほか、ディザ表現で擬似的に半透明を表現するケースもよく見られます。
例えば、キャラクタの消滅時のみ透明になって消えていくような表現がしたい、であればこちらの方がマッチしているかもしれません。

スクリーンショット 2021-07-20 18.43.36.png
ディザ表現の例

色の合成

先ほど紹介した色の合成モードには多くの種類があります。
その中でも、よく使われるものとして下記が挙げられます。

合成モード計算式
アルファブレンドC = Cf * α + Cb * (1 – α)
加算C = Cf + Cb
乗算C = Cf * Cb
スクリーンC= Cf + Cb – Cf * Cb

α:アルファ値 Cf:手前のカラー値 Cb:奥のカラー値 C:出力カラー値

フラグメントシェーダの項で説明したアルファブレンドも、このような合成式になっています。

実際の講義では、時間中に下記の課題を解いてもらいました。


課題1
アルファブレンド、加算、乗算、スクリーンの合成モードで、2つのテクスチャ(アルファなし)をシェーダで合成して表示してください。
シェーダへ渡すパラメータは、既存の実装を参考に右図のようにしてください。

スクリーンショット 2021-07-16 19.08.18.png
課題1の実行例

解答例は本記事末尾に掲載してあります。

フォグ

フォグとは、カメラから遠い物体に任意の色を重ねて奥行きを表現する方法です。
絵画では「空気遠近法」と呼ばれています。


現実の世界では、天候が悪いと遠景が白く見え、よく晴れた日中では遠景が青く見えます。
これらの現象は光(太陽光)が大気中の粒子に衝突し、散乱が起きる事によって発生します。
粒子のサイズによって起きる現象が異なり、前者をミー散乱、後者をレイリー散乱と呼びます。

スクリーンショット 2021-07-20 11.52.42.png
ミー散乱の例 正面奥の橋やビルの像が白く霞んでいる

スクリーンショット 2021-07-20 11.52.16.png
レイリー散乱の例 近くの山と比べて遠くの山は青いシルエットのようになっている

このような表現は、単にカメラからの距離に応じて任意のカラーを重ねるだけで近似することができます。

Unlitシェーダは初期状態でフォグに対応しています。
試しに Window → Rendering → Lighting Settings からフォグを有効にし、適当な色を設定するとその効果を確認することができます。

スクリーンショット 2021-07-16 19.16.08.png
スクリーンショット 2021-07-16 19.21.49.png

シェーダコードを見ると、頂点シェーダ内でフォグのかかり具合を計算しています。

UNITY_TRANSFER_FOG(o,o.vertex);

また、フラグメントシェーダではかかり具合に応じて色を補完しています。

UNITY_APPLY_FOG(i.fogCoord, col);

この行を削除すると、かかっていたフォグが効かなくなることが確認できます。

背面カリング

隙間なくポリゴンで覆われた立体は、どの角度から観察してもポリゴンの裏側を見ることはできません。
このような立体の描画を前提とした時、カメラに対して裏側を向けているポリゴンは処理をスキップすることができます。
この処理を背面カリングと呼び、描画負荷を削減することができます。

Unlitシェーダではデフォルトで背面カリングが有効になっています。
この効果はキューブなどに設定している状態では確認できないので、Planeオブジェクトを作成して確認します。

スクリーンショット 2021-07-16 19.22.41.png
背面カリングが有効な状態

カリングを無効にするため、Tags定義の下に下記の行を追加してみましょう。

CULL OFF
スクリーンショット 2021-07-16 19.22.47.png
背面カリングを無効にした状態

今まで描画されていなかった背面が描画されることが確認できました。

ちなみに背面カリングの他にも、視錐台カリングやオクルージョンカリングなどいくつかの描画負荷の軽減アプローチが存在します。

最後に、Unlitシェーダにライティング処理を追加してみましょう。

反射のないザラザラした表面の多面体があり、光源が1つだけあるとします(他の物体から反射された光は無視します)。
この時、各面ごとに光源に向き合っているほど明るくすればそれらしい表現ができそうです。

下図のように、光源のベクトルと法線の角度が小さいほど大きな値を取れば良いので、内積をとった値が適しています。

スクリーンショット 2021-07-20 13.02.03.png

この手法をランバート反射と呼びます。

実行例とサンプルコードは下記になります。
現状のUnlitシェーダと見比べながら実行してみましょう。

スクリーンショット 2021-07-20 18.51.55.png
Shader "Unlit/NewUnlitShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    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;
                half3 normal : NORMAL;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                half3 normal: TEXCOORD2;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _LightColor0;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                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.rgb *= max(0, dot(i.normal,_WorldSpaceLightPos0.xyz));
                col *= _LightColor0;
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

実際の講義では、時間中に下記の課題を解いてもらいました。


課題2
前述の実装では、直接光が当たらない面は真っ暗になってしまいます。
しかし日常的には部屋や大気からの反射があり、どの面にも一定量の光が当たります。
これを表現するため、計算した内積値が0.2以上にクランプされるよう実装を変更してみましょう。

スクリーンショット 2021-07-20 18.52.53.png

まとめ

UnityのUnlitシェーダを通して、基本的な技法を紹介しました。
実際の講義ではシェーダの記法に戸惑うケースもありましたが、簡単なシェーダを改造しながら挙動を確認することで、その記述を理解しやすくなります。
この記事がシェーダ実装の理解の助けになれば幸いです。

解答例

課題1
アルファブレンドの例を示します。

※アルファなし画像であることを前提としています。

Shader "Unlit/NewUnlitShader"
{
    Properties
    {
        _MainTex ("Main Texture", 2D) = "white" {}
        _SubTex ("Sub Texture", 2D) = "white" {}
        _Blend("Blend",Range (0, 1)) = 1
    }
    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;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _SubTex;
            float _Blend;

            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;
            }


            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 mcol = tex2D(_MainTex, i.uv);
                fixed4 scol = tex2D(_SubTex, i.uv);
                fixed4 col = mcol * (1 - _Blend) + scol * _Blend;
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

課題2
上記ランバート反射のシェーダでは、RGBに係数をかける処理で0で足切りをしています。
これを0.2に変更するだけで達成します。

col.rgb *= max(0.2, dot(i.normal,_WorldSpaceLightPos0.xyz));

関連記事一覧

  1. この記事へのコメントはありません。

てっくぼっと!をもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む