Unityで学ぶ2Dシェーダー その1

はじめに

シェーダーの知識0の人が読んで分かるような資料がネット上に無さそうだったので書きました。
自分の知識もゼロなので手探りで学びながら書いています。

なお、この記事ではタイトルで書いたように2Dのことにのみ触れていきます。

今回はシェーダーの基本知識と、スプライトの表示まで触れていきます。

基本事項

Unityにおけるシェーダー

UnityではShaderLabという言語で記述します。
そしてShaderLab内にはCg/HLSLという、C言語のように書くことが出来る部分があり、これを記述することで様々な表現が出来ます。

頂点シェーダーとフラグメントシェーダー

HLSLの箇所に頂点シェーダーとフラグメントシェーダーを用いて描画していきます。

頂点シェーダー (Vertex Sharder)

頂点情報の計算を行います。
Unityの座標系は3次元で、GPUに渡すのも3次元空間です。
が、実際のディスプレイは2次元なので、変換して上げる必要があります。
3次元空間における頂点が2次元空間でどこに当たるのかを計算してくれるのがこの頂点シェーダーです。

フラグメントシェーダー (Fragment Shader)

色情報の計算を行います。
頂点シェーダーによって計算された図形を元に、図形のピクセルをどのように塗るかを計算してくれます。
ピクセルの数だけ実行されます。

テンプレート

Shader "Custom/Shader1" {
    Propaties { }
    SubShader {
        Pass{
            Tags { }
            [RenderSetup]
            CGPROGRAM
            //HLSL Program
            ENDCG
        }
    }
} 

プロパティを用いてInspectorからShaderで使う情報を渡すことが出来ます。
それぞれのShaderはSubShader内で書き、SubShaderは一つ以上のPassから構成されます。
Passは1回分の塗り(レンダリング)に相当し、複数書けばそれだけ塗られます。

Pass内には、TagやRenderSetUpで「いつどのように」レンダリングするのかの設定が出来るほか、
CGOPROGRAMからENDCGまでにCg(HLSL)を書くことが出来ます。

最も簡単な形

Shader "Custom/Minimum" {
    SubShader {
        Pass {}
    }
} 

最もシンプルな形のShaderLabの形です。
これを適用すると真っ白になります。

Propaties

プロパティを設定することで、Unityのマテリアルインスペクターからシェーダーにデータを投げることが出来ます。
プロパティには以下のような種類があります。

Shader "Custom/SampleShader" {
    Propaties {
        //int
        _name ("display name", Int) = number
        //float
        _name ("display name", Float) = num
        //範囲付きfloat
        _name ("display name", Range(min, max)) = num

        //2Dテクスチャ
        _name ("display name", 2D) = "name" {}
        //長方形テクスチャ
        _name ("display name", Rect) = "name" {}
        //キューブマップテクスチャ
        _name ("display name", Cube) = "name" {}

        //Vector4
        _name ("display name", Vector) = (num, num, num, num)
        //Color (R,G,B,A)
        _name ("display name", Color) = (num, num, num, num)
    }
    SubShader {}
} 

Unityで作成したシェーダーを実行する方法

  1. ProjectウィンドウにてCreate > Shaderからシェーダーファイルを作成
  2. ProjectウィンドウにてCreate > Materialからマテリアルを作成
  3. シェーダーの名前(ファイル名ではない)を変更した上でマテリアルから紐付ける
  4. Sprite等をScene上に配置し、作成したMaterialを参照する

シェーダーサンプル

実際にサンプル書きながら説明していきます。
コピペすれば動きます。

単色

コード

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

            struct VertexInput {
                float4 pos:  POSITION;    // 3D座標
            };

            //
            struct VertexOutput {
                float4 sv_pos:    SV_POSITION; // 2D座標
            };


            //頂点シェーダー
            VertexOutput vert(VertexInput input) {
            	VertexOutput output; //フラグメントシェーダーに渡す構造体の宣言
                output.sv_pos = UnityObjectToClipPos (input.pos); //3D座標を2D座標に変換する
                return output;  //フラグメントシェーダーに渡す
            }

            //フラグメントシェーダー
            float4 frag() : SV_Target {
                return float4(1.0, 0.0, 0.0, 1.0); //赤色を返す
            }
            ENDCG
        }
    }
}

解説

赤色に塗りつぶすシェーダーです。
以下、順に解説していきます。

#pragma vertex vert
#pragma fragment frag

で頂点シェーダーはvert関数に、
フラグメントシェーダーはfrag関数に記述することを宣言しています。

続いてこれ。

struct VertexInput {
    float4 pos:  POSITION;    // 3D座標
};
struct VertexOutput {
    float4 sv_pos:    SV_POSITION; // 2D座標
};

ここでVertexInputは頂点シェーダへの入力、VertexOutputは頂点シェーダの出力かつピクセルシェーダの入力です。
このように:でつなげて書くものを「セマンティクス」と呼び、シェーダーの入力と出力に用います。
つまり、VertexInput構造体の変数posで3D座標を受け取り、
VertexOutput構造体の変数sv_posに変換後の2D座標を代入し、これをフラグメントシェーダーに投げています。

A semantic is a string attached to a shader input or output that conveys information about the intended use of a parameter.

フラグメントシェーダーでは、すべてのピクセルで色(1,0,0,1)を返しているので、すべてのピクセルで赤色を描画します。

なお、上記コードで変数が1つなのに構造体にしているのは、
今後返すものが増えても基本形が変わらないようにして読みやすくするためです。

グラデーション

コード

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

            struct VertexInput {
                float4 pos:  POSITION;    // 3D座標
                float2 uv:   TEXCOORD0;   // テクスチャ座標
            };

            struct VertexOutput {
                float4 sv_pos:    SV_POSITION; // 2D座標
                float2 uv:   TEXCOORD0;   // テクスチャ座標
            };

            // 頂点シェーダー
            VertexOutput vert (VertexInput input) {
                VertexOutput output;
                output.sv_pos = UnityObjectToClipPos(input.pos);
                output.uv = input.uv;

                return output;
            }

            // フラグメントシェーダー
            float4 frag (VertexOutput output) : SV_Target {
                float2 tex = output.uv;
                return float4( tex.x, tex.y, 1.0, 1.0); //座標に応じて色を変更
            }

            ENDCG
        }
    }
} 

結果

グラデーションシェーダー

解説

先ほどと比べ構造体の内容が1つ増えました。
テクスチャー座標、いわゆる「UV」です。

シェーダー内ではテクスチャは読み込みませんが、書き込む際のスプライトにおける座標によって色を変えるため、描画先のテクスチャ座標を見ています。

頂点シェーダーでやっていることはさっきと同じですね。
フラグメントシェーダーで返す色を座標基準にすることでグラデーションにしています。

結果から、座標は左下が(0,0)で右上が(1,1)となっていることがわかります。

スプライトシェーダー(簡易版)

コード

 
Shader "Custom/Sprite-Minimum" {
    Properties {
        _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)
    }

    SubShader{
        Tags { 
            "Queue"="Transparent"
        }
       
	ZWrite Off
        Blend One OneMinusSrcAlpha //乗算済みアルファ

        Pass {
        CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            struct VertexInput {
                float4 pos	:	POSITION;    // 3D座標
                float4 color:	COLOR;
                float2 uv	:	TEXCOORD0;   // テクスチャ座標
            };

            struct VertexOutput {
                float4 v	:	SV_POSITION; // 2D座標
                float4 color:	COLOR;	
                float2 uv	:   TEXCOORD0;   // テクスチャ座標
            };

            //プロパティの内容を受け取る
            float4 _Color;
            sampler2D _MainTex;

            VertexOutput vert (VertexInput input) {
            	VertexOutput output;
            	output.v = UnityObjectToClipPos(input.pos);
            	output.uv = input.uv;

            	//もとの色(SpriteRendererのColor)と設定した色(TintColor)を掛け合わせる
            	output.color = input.color * _Color; 

            	return output;
            }

            float4 frag (VertexOutput output) : SV_Target {
            	float4 c = tex2D(_MainTex, output.uv) * output.color;
                c.rgb *= c.a;
                return c;
            }
        ENDCG
        }
    }
}

解説

概要

指定したテクスチャを表示するシェーダーです。
ビルトインシェーダーであるSprites/Defaultとおおよそ同じ挙動をしますが、削れるところを削っています。

プロパティ

Properties {
    _MainTex ("Sprite Texture", 2D) = "white" {}
    _Color ("Tint", Color) = (1,1,1,1)
}

テクスチャの読み込み用の_MainTex、彩色用の_Colorプロパティを用意します。
TintColorがピンと来ない人は、Materialを作ると出て来るこいつです。(画像はSprites/Defaultを選んだ場合)

以上のプロパティをHLSL内で受け取る変数として、

float4 _Color;
sampler2D _MainTex;

を宣言しています。プロパティと同名にすることで初期化は自動で行われます。
宣言する際、HLSLにおける変数の型には注意しましょう。

タグ

Tags { 
    "Queue"="Transparent"
}

ここではレンダリングの順序の設定をしています。
これを外すと透明度の情報が無視されてしまいます。

その他設定

ZWrite Off
Blend One OneMinusSrcAlpha //乗算済みアルファ

まずZWriteについて。
詳しい話は公式のドキュメントに記載されていますので割愛。
要は、不透明であればOn(デフォルト)に、半透明部分があればOffにします。

カリングは、視点と反対側に向いたポリゴンをレンダリングしない、最適化です。すべてのポリゴンは表と裏側があります。カリングは、オブジェクトのほとんどのが閉じている事実を利用します、もしキューブがあった場合、視点と反対側を見ることはない(必ず視点の側を向いている面がある)ので、反対側の面は描画する必要がありません。このため...

これを書くことで既存のSprites/Defaultシェーダーとの共存が可能になります。
これをコメントアウトした状態で、SpriteRendererのOrder in Layerの値を弄ってみるのが分かりやすいです。
Order in Layerが負の数字のとき、描画順序がおかしくなり、半透明部分が正しく描画されなくなります。

上がZWrite Offで下がZWrite Onの場合です。
OffのときはOrder in Layerがきちんと適用されていることがわかります。

続いてBlendについて。
ここでは半透明部分のブレンド方法の設定をしています。
乗算済みアルファについての説明は以下の記事が分かりやすいです。

とりあえず、一般的なブレンド方法がこれである、ということです。
これ以外のブレンドタイプはUnityのドキュメントにリストアップされています。

Blending は透過のカラー作成に使用します。

頂点シェーダー

グラデーションから増えた箇所はこれだけですね。

//もとの色(SpriteRendererのColor)と設定した色(TintColor)を掛け合わせる
output.color = input.color * _Color; 

SpriteRendererを使っている場合、input.colorはSpriteRendererのColor値を指しています。
これとプロパティの値をかけ合わせて、最終的にテクスチャの色と掛ける色を計算します。

フラグメントシェーダー

グラデーションのときはUV座標に応じて適当な色を返しましたが、
今回はUV座標に応じてテクスチャの色を返すことでテクスチャの表示を行います。

テクスチャにおける指定座標のピクセル色取得はtex2Dを使います。

float4 tex2D (sampler2D texture, float2 uv);

これでピクセルごとの色の取得が出来ました。
続いて、アルファ値の適用を行います。
現段階では、RGB値はアルファ値を考慮しない値になっているため、これを補正します。

c.rgb *= c.a;

これの計算式にピンと来る人なら良いんですが、僕は分からなかったので少し解説します。
Wikipediaのアルファチャンネルについての説明が分かりやすいですね。

RGBAと一口に言っても色データはRGBのみで、A、つまりアルファ値は色データに対する補助データです。
ここで、単純なアルファブレンドを考えてみましょう。

背景となる既に描画されたRGBデータに対し、いま描画しようとしているRGBAデータがあるとします。
このとき、計算式は以下のようになります。

計算色 = 背景色 * (1.0 - アルファ値) + ピクセル色 * アルファ値

このことから、A値におけるアルファブレンドをHLSL内で行っていると推測できます。
試しに、以下のような画像を用意します。

(クリックで拡大してみると透明・半透明が分かります)

上記シェーダーからアルファブレンドの式を削除したものを適用して例が以下です。
スプライトの変化に注目してみてください。

計算して確かめてみましょう。

  • 背景黒 + 半透明白 のとき
    背景黒に半透明な白ですから、アルファブレンドなら当然灰色になります。
    が、アルファ値を考慮しなければ
    (0, 0, 0) + (1, 1, 1) = (1, 1, 1)
    となり不透明な白になります。上のGIFでは実際にそうなっていますね。

  • 背景透明 + 半透明白 のとき
    これも同様に(1,1,1)となり不透明な白になっていることが分かります。

以上から、事前にアルファブレンドの準備をしておく必要があり、その計算をしていたということが分かりました。
どうやらパフォーマンス上の都合でこのようにしているようですが、詳細は不明です。

さいごに

HLSLについての入門記事のようなものはいくつもありますが、
隅から隅まで解説されているものが見当たらなかったので書いてみました。

まさかUnityで画像表示するだけでこんなにつらいとは。
その2の記事が出せるよう頑張ります。

参考文献