夜風のMixedReality

xRと出会って変わった人生と出会った技術を書き残すためのGeekなHoloRangerの居場所

Shaderってこんな感じ発表会 + IwakenLab公開ミーティングで登壇しました。

本日はイベント登壇枠です。

本日IkwakenLabの公開イベント』に参加、登壇しました。

今回は筆者のパートの内容をまとめていきます。

※スライドと画像は後ほど更新します。

〇7分で誰でもかけるようになるUnityShader Part.1

まず最初に、Part.1と付けているのはあわよくばPart.2などもっと負荷彫りした内容のにつなげたいという理由でした。

筆者は5年にわたり独学でShaderを学んできましたが、今回はHoloRangerの先輩のイワケンさんよりお誘いを受けイベントに登壇します。

iwakenlab.connpass.com

〇Shaderの挫折ポイント

筆者がShaderを学習する上でいくつかの挫折ポイントがありました。

〇デバッグの複雑さ

Shaderもプログラミング言語ですが、通常のプログラムであればprint()やconsol()などによってその処理の内容を出力することができます。

しかしShaderの場合はそもそも画面を描画するプログラムであり、途中結果を意味のあるログとして出力することが難しいです。

つまりShader内に問題があった場合はそもそも出力ができないということになり、問題の特定が困難なことがあります。

デバッグ自体はUnity自体ではRenderingDebuggerの機能や外部のソフトであるRenderDocなどを使用することで可能ですが、ほかのプログラム言語のデバッグに比べ、初心者にとってハードルがかなり高いです。

〇複数の言語を1つのファイルに定義

筆者はネイティブC#ではなくUnityC#からプログラミングを始めました。

ShaderLabでは、○○.shaderという一つのファイルに対して、ShaderLab、HLSLという複数の言語を使用すること、またUnityC#では意識することがないエントリーポイント(プログラムが実行されるときの最初の関数)を意識する必要があります。

Shaderの場合頂点シェーダー、フラグメントシェーダーの2つのエントリーポイントの定義とメソッドがあります。(SurfaceShaderを使用している時は別)

このようなコード構築の考え方がUnityC#を記述する際と異なり、ネイティブに触れてこなかった筆者としては理解に時間がかかりました。

〇そもそもわからん

Shaderを学ぶ上で一番躓いてしまったのが、「そもそもわからん」という点で、実現したい表現に対してどのような手段を使用すればよいのか? サンプルコードを実行しても環境の違いでエラーが出る。

参考サイトはないことはないが、わかりづらい

圧倒的知識の足りなさを感じていました。

〇Shaderとは何か?

ShaderとはGPU上のグラフィックスパイプラインで実行され、画面のピクセル(画素)ごとに色を描画するプログラムです。

Shaderを理解する上でグラフィックスパイプラインを理解することが一番の早道だと筆者はお勧めしています。

〇グラフィックスパイプライン

グラフィックスパイプラインとは3Dモデルのデータがスマホやタブレット、PC、テレビといったディスプレイに描画されるまでの処理の流れを指します。

GPUやソフトウェア、グラフィックスAPIによって異なりますが基本的には次のようになります

〇InputStage(入力データ)

InputStageでは3Dモデルのもつ頂点や法線、UVといった情報を格納します。

〇頂点シェーダー(プログラマブル)

頂点シェーダーステージでは3Dモデルの持つ頂点を3D空間に配置します。

頂点シェーダーがあることで自由視点での3Dの描画や遠近法の再現などを行うことができます。

個の頂点シェーダーステージはプログラマブルな関数で、開発者が任意に処理を加えることができます。

例えば頂点の配置をカスタマイズして平行投影を実現することや、頂点を法線方向に拡大縮小することでモデルの膨張を実現することなどができます、

〇ジオメトリシェーダー(プログラマブル)

ジオメトリシェーダーはオプションであり、実際には使用されていないことが多いシェーダーです。

こちらも頂点シェーダー同様プログラマブルなシェーダーで、頂点シェーダーで定義された頂点を使用して頂点の複製や面の複製などができます。

簡単に説明すると頂点シェーダーでは頂点の配置をおもに行いますが、ジオメトリシェーダーでは処理をInputStageに返すことができます。

つまり一度処理をした頂点を複製し、再び頂点シェーダーで配置を行うといったトリッキーな使い方ができます。

一般的にシェーダー芸と呼ばれるシェーダーに使われることも多いですが、処理次第ですが複雑になることや重くなること、そして対応していないGPUやプラットフォームがあるということが問題です、

〇ラフタライズ(ラフタライゼーション)

ラフタライズは基本的にプログラマブルではなく、GPU上で自動で行われるフローです。

頂点シェーダー(もしくは存在する場合ジオメトリシェーダー)で定義された形状を基に描画領域を決定します。

個の描画領域というのはどのピクセルまでそのオブジェクトとして描画計算を行うかを決定する作業で、データ上と異なり実際のディスプレイはなめらかではなくドットとなっているため、どのドットまで描画するかを決めてあげる作業をラフタライズで行っています。

〇フラグメントシェーダー(プログラマブル)

ラフタライズステージで描画領域が決定されているため、実際に何色で描画するかの計算処理を行います。

個の描画計算とは例えば影の計算や画像を張り付けるなど見た目にかかわる多くの処理を行います。

こうして計算された値がfloat4=(RGBA=赤緑青+透明度)の値として出力されます。

このグラフィックスパイプラインを理解しているとそうでないととではShaderに関する考え方が大きく差が出てしまうので、初心者の方ほどShaderというプログラムの目的を押さえておくとより理解しやすいと思います。

〇ShaderLabであらわすと

グラフィックスパイプラインについて紹介したところで次に実際にUnityでのシェーダーコードであるShaderLabであらわすとどうなるのか紹介します。

次のコードは画像を張るだけのシンプルなシェーダーです。このコードを例に説明します。

Shader "Unlit/NewUnlitShader"//Shaderの名前
{
    Properties//マテリアルに表示されるパラメータ(Public変数)
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader//Shader本体
    {
        Pass//Shaderのパス
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                return col;
            }
            ENDCG
        }
    }
}

このコードをブロックごとに分けると次のようになります。

Shader "Unlit/NewUnlitShader"//Shaderの名前
{
    Properties//マテリアルに表示されるパラメータ(Public変数)
    {
  
    }
    SubShader//Shader本体
    {
        Pass//Shaderのパス
        {
            CGPROGRAM
            ENDCG
        }
    }
}

実際のシェーダーでは間にもっと様々な記述がありますが、おおむね大半のシェーダーは上記のようになっています。

〇最初に覚えるべきこと

冒頭に述べた通り、ShaderLabでは1つのファイルで2つの言語を使用します。上記の部分をShaderLabと呼びます。

ShaderLabの中にHLSL文で処理を記述することになります。

これはCGPROGRAM~ENDCG(もしくはHLSLPROGRAM~ENDHLSL)に記述します。

この点を覚えることで言語による表記の違いを区別できるようになります。

HLSL文でグラフィックスパイプラインに準じた処理を記述します。

〇HLSL文

次がHLSL文になります。

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                return col;
            }

ここで#pragma vertex vert、#pragma fragment fragがエントリーポイントで、これはグラフィックスパイプラインでの頂点シェーダー、フラグメントシェーダーの処理を記述する関数がどれであるのかを定義しており、今回の場合はvert、fragがそれぞれ当たります。

#include "UnityCG.cginc"はUnityが提供する様々な機能で、UnityC#でいうところのMonobehaviorのようなものというイメージで合っています。 ここでは頂点をUnity座標軸で変換するマクロやUnityのライトの取得など様々な機能が定義されています。

struct appda、struct v2fというのはそれぞれ構造体で、グラフィックスパイプラインでの各ステージを橋渡しするデータの型を定義します。

 つまり、InputStage(3Dモデルのデータから頂点シェーダーで使うデータをappdata(構造体名は任意)、頂点シェーダーで処理してフラグメントシェーダーに渡すデータ(正確にはその前にラフタライズに渡されている)をv2f構造体に定義します。

 定義の仕方は 型 変数名 : セマンティクスという形でこのセマンティクスとはそのデータがどのように使用されるかを指定します。

 例えばfloat4 pos : POSITION;ではposという変数名ですがPOSITIONという変数によって頂点の座標を意味するデータとして扱われます。

〇頂点シェーダー

 頂点シェーダーではグラフィックスパイプラインでの説明同様頂点を配置します。

 v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

 UntiyObjectToClipPosメソッドはUnityCG.cgincで提供されている機能で、頂点をUnityのカメラから見た座標軸に変換します。

 引数として頂点を渡すことでfloat4型で変換された頂点が返されます。

 またTRANSFORM_TEXメソッドも同様にUnityによって提供されており、第一引数にuv座標、第二引数にテクスチャを定義することで画像をサンプリングするための座標系を計算します。

〇フラグメントシェーダー

フラグメントシェーダーではピクセルごとの色を決めます。 イメージとしてピクセルごとに次の処理が走り、60fpsで4kディスプレイの場合で1秒間に800万×60=5億 で5億回の計算が走ります。

 fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                return col;
            }

ここでfixed4というのはfloat4同様4次元ベクトルを指しここでは出力するためのRGBAを意味します。 引数はv2fで関数内ではiと定義、最後のSV_Targetはセマンティクスでこれはターゲット色=出力色を意味します。

ここで計算結果をreturnすることでRGBAを出力しています。

ここではtex2Dメソッドでテクスチャをサンプリング(座標に対して貼り付け)しています。

〇ShaderGraphからShaderLab言語に変換

さて、ここまででグラフィックスパイプラインについての紹介とShaderLabでの対応を説明しました。しかしいざ書いてみようとなると自身の行いたい表現ができるわけではありません。

ここでは筆者のおすすめの勉強方法としてShaderGraphからShaderLabに変換する方法を紹介します。

〇ShaderGraph

ShaderGraphはアーティストなどでもShaderを使用できるような機能で、ノンプログラミングでノードを繋ぐことでShaderを構築できます。

Youtube上などで様々なサンプルが紹介されており、初心者にも触りやすいシェーダーになっています。

一度ShaderGraphで構築したコードをShaderLabでコードで記述してみるということは非常に勉強になります。

①ShaderGraphでシェーダーを構築する

自身で構築してもよいですし、Youtubeなどを参考に作ったものでも大丈夫です。

②レンダリングパイプラインを意識して、その処理が頂点シェーダー、フラグメントシェーダーのどちらで行われているかを考えてみる。

ポイントとしては3Dモデルの形状が変化する場合は頂点シェーダー、形状は変化せず色などが変化する場合はフラグメントシェーダーの処理になります。

③ノードを右クリックしてOpenDocmentを選択します。

多くのノードにはドキュメントが用意されており、ノードから開くことができます。

https://docs.unity3d.com/Packages/com.unity.shadergraph@12.1/manual/Step-Node.html

④ドキュメントに記載されているコードをShaderLabにコピペ

基本的にドキュメントにはそのノードがどのようなコードで動いているのかを記述されています。 これを自身のShaderLabのコードの頂点シェーダー、フラグメントシェーダーにペーストします。

⑤張り付けたノードのデーターの流れを意識して出力→入力の順に処理を追って記述していく。

ノードの処理を読み解いてコードを構築します。最初は難しいと思いますが、処理の流れが理解できていれば必要に応じてChatGPTなどを駆使して再現できると思います。

このようにしてShaderGraphで構築したShaderをコードでも再現する勉強を行うと非常に強力に身に着けることができると思います。