【Unity】Surface Shaderの基本を総まとめ!難しい計算はUnity任せでサクッとシェーダ作成

UnityのSurface Shaderを使うと、簡単にシェーダが作成できます。
複雑なライティング計算はUnityが用意したものを使うことができるので、
表面の色や材質を定義するだけでシェーダが出来上がります。

Unity2018.3.12

Surface Shaderとは?

Unityに限らず、3DCGで物体のライティングを行うにはシェーダに計算式を書きます。
そしてこのシェーダを書くには、まず頂点シェーダで座標変換を行って、
フラグメントシェーダでライティング計算をいっぱい書いて…という作業をやらなければいけません。

せっかくUnityを使っているので、もっと簡単に書きたいものです。シェーダ作成の民主化
頂点シェーダとかどうせ同じようなことしか書かないし…

そこで登場するのがSurface Shaderです。
Surface ShaderはSurface(= 表面)という名前の通り、物体の表面の材質情報(色とか、金属かどうかとか、表面の荒さとか)を定義するだけでいい感じにライティングしてくれる機能です。

また内部的な話をすると、Surface Shaderに記述した内容は最終的に頂点シェーダとフラグメントシェーダに変換されます。
名前にシェーダとついているので頂点シェーダやフラグメントシェーダと同列の何かのように思えますがそうではなく、
あくまで頂点シェーダやフラグメントシェーダをUnity上で簡単に書けるようにするための便利機能、という位置付けです。

まずは最小のSurface Shaderを理解する

まず、最小のSurface Shaderを書いてみます。

Shader "ExampleSurfaceShader"
{
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        // surfaceディレクティブで使用する関数やライティングの方法を定義する
        #pragma surface surf Lambert

        // surf関数の入力に使う構造体
        // 今回は使用しないが定義する必要があるのでダミーの変数を定義しておく
        struct Input
        {
            float dummy : TEXCOORD1;
        };

        // この関数の中で表面の材質に関するパラメータを設定する
        void surf(Input IN, inout SurfaceOutput o)
        {
            o.Albedo = 1;
        }

        ENDCG
    }
}

Surface Shaderの記述は通常の(Surface Shaderを使わない)記述と同じくCGPROGRAM内に書きます。

そしてまずこれがSurface Shaderであることを示すために#pragma surfaceディレクティブを書きます。
このディレクティブは次のような書式になっています。

#pragma surface [Surface Shader関数名] [ライティングの方法]

今回はSurface Shader関数名としてsurfを指定しています。
そして下の方でsurf関数を定義しています。

surf関数はvoid surf(Input IN, inout SurfaceOutput o)のように二つの構造体を引数に取る関数です。
この関数内で、o.Albedo = 1;のようにSurfaceOutputを編集することで、表面の材質を定義していきます。 今回はシンプルに表面の色を白に設定しているだけです。

Inputは自分で定義する構造体ですが、今回は使用しないので定義だけ行っています。
これについては後ほど詳しく説明します。

ここまでのシェーダで描画を行うと次のような結果になります。

f:id:halya_11:20190512181443p:plain

ライティング方法を変更する

前節ではライティングの方法としてLambertを指定しました。
この節ではライティングの方法を変更してみます。

Shader "ExampleSurfaceShader"
{
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        // ライティング方法をLambertからStandardに変更
        #pragma surface surf Standard

        struct Input
        {
            float dummy : TEXCOORD1;
        };

        // アウトプット用構造体もSurfaceOutputからSurfaceOutputStandardに変更
        void surf(Input IN, inout SurfaceOutputStandard  o)
        {
            o.Albedo = 1;
            // 金属かどうか
            o.Metallic = 1;
            // 表面のなめらかさ
            o.Smoothness = 0.5;
        }

    ENDCG
    }
}

まず、ディレクティブの記述のライティング方法の部分をLambertからStandardに変更しています。
これらのライティング方式はUnityであらかじめ用意されていて、以下の種類があります。

ライティング方法 説明
Lambert ランバート(非PBR)でライティングを計算
BlinnPhong ブリンフォン(非PBR)でライティングを計算
Standard UnityのStandardシェーダで使っているPBR計算
StandardSpecular スペキュラセットアップのPBR計算

このライティング方式を変更すると、surf関数の第二引数の構造体もそれに応じた型に変更しないといけません。
今回はSurfaceOutputからSurfaceOutputStandardに変更しています。
各ライティング方式とそれに対応した出力構造体の型は以下の通りです。

ライティング方法 出力構造体
Lambert SurfaceOutput
BlinnPhong SurfaceOutput
Standard SurfaceOutputStandard
StandardSpecular SurfaceOutputStandardSpecular

それぞれの構造体は次のように定義されています。

struct SurfaceOutput
{
    fixed3 Albedo;
    fixed3 Normal; 
    fixed3 Emission;
    half Specular;
    fixed Gloss;
    fixed Alpha;
};

struct SurfaceOutputStandard
{
    fixed3 Albedo;
    fixed3 Normal;
    half3 Emission;
    half Metallic;
    half Smoothness;
    half Occlusion;
    fixed Alpha;
};

struct SurfaceOutputStandardSpecular
{
    fixed3 Albedo;
    fixed3 Specular;
    fixed3 Normal;
    half3 Emission;
    half Smoothness;
    half Occlusion;
    fixed Alpha;
};

今回はせっかくPBRの計算方式に変えたので、surf関数内でMetallicとSmoothnessの値を変えてみました。
これにより、少しザラザラした金属のような質感になりました。

f:id:halya_11:20190512181531p:plain

テクスチャを貼る

次にテクスチャを貼ってみます。

Shader "ExampleSurfaceShader"
{
    // プロパティは普通に定義
    Properties{
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        #pragma surface surf Standard

        struct Input
        {
            // Input構造体にuv_[テクスチャ名]と宣言すればUVが取得できる
            float2 uv_MainTex;
        };

        sampler2D _MainTex;

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            // サンプリング
            o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
            o.Metallic = 1;
            o.Smoothness = 0.5;
        }

        ENDCG
    }
}

テクスチャは非Surface Shaderの時と同じようにプロパティを定義して、
surf関数内でこれまた同じようにtex2D関数を使ってサンプリングすればいいのですが、
UV座標の取得方法だけがSurface Shader特有のものとなっています。

Surface ShaderでUV座標を取得するには、Input構造体にuv_[テクスチャ名]と書きます。
あとはsurf関数内でこの変数にアクセスするだけでUV座標を取得できます。

ここまでのレンダリング結果は以下の通りです。

f:id:halya_11:20190512184918p:plain

正常にテクスチャが貼られていることがわかります。

Input構造体で使える変数

前節の例では、Input構造体にuv_[テクスチャ名]を宣言するだけでUV座標を取得できました。
この説ではこのようにInput構造体に定義するだけで使えるようになる変数を紹介します。

宣言 説明 備考
float4 color : COLOR 頂点カラー
float3 worldPos ワールド座標
float4 screenPos スクリーン座標
float3 viewDir ビュー方向
float3 worldRefl ワールド空間の反射ベクトル surf関数でo.Normalへの書き込みを行う場合INTERNAL_DATAも定義する
float3 worldNormal ワールド空間の法線 surf関数でo.Normalへの書き込みを行う場合INTERNAL_DATAも定義する

例えばviewDirを使うと、以下のようなシェーダを掛けます。

Shader "ExampleSurfaceShader"
{
    Properties{
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        #pragma surface surf Standard

        struct Input
        {
            float2 uv_MainTex;
            // ビュー方向
            float3 viewDir;
        };

        sampler2D _MainTex;

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
            o.Metallic = 1;
            o.Smoothness = 0.5;
            // IN.viewDirでビュー方向が取得できる
            o.Emission = 1 - dot(IN.viewDir, o.Normal);
        }

        ENDCG
    }
}

レンダリング結果は以下の通りです。

f:id:halya_11:20190512213817p:plain

リムが明るく発行していることがわかります。

頂点シェーダを書く

頂点を変形したい場合や頂点で計算を行いたい場合は頂点シェーダを書くこともできます。

Shader "ExampleSurfaceShader"
{
    Properties{
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        // vertexオプションを追加する
        #pragma surface surf Standard vertex:vert

        struct Input
        {
            float2 uv_MainTex;
            float3 viewDir;
        };

        sampler2D _MainTex;

        // 頂点シェーダはappdata_full構造体を引数に取る
        void vert(inout appdata_full v) {
            v.vertex.x += v.vertex.y;
        }

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
            o.Metallic = 1;
            o.Smoothness = 0.5;
            o.Emission = 1 - dot(IN.viewDir, o.Normal);
        }

        ENDCG
    }
}

頂点シェーダを書くにはsurfディレクティブにvertexオプションを追加して頂点シェーダの関数を指定します。
頂点シェーダの引数はappdata_fullにinoutキーワードをつけたものを使います。

ここまでのレンダリング結果は以下の通りです。

f:id:halya_11:20190512215028p:plain

頂点シェーダからsurf関数に値を受け渡す

次に頂点シェーダで計算した値をsurf関数に渡してみます。

Shader "ExampleSurfaceShader"
{
    Properties{
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        #pragma surface surf Standard vertex:vert

        struct Input
        {
            float2 uv_MainTex;
            float3 viewDir;
            // 頂点シェーダから受け渡す変数を定義する
            float3 rimColor;
        };

        sampler2D _MainTex;

        // 第二引数にInputを指定する
        void vert(inout appdata_full v, out Input o) {
            // UNITY_INITIALIZE_OUTPUTでInputを初期化
            UNITY_INITIALIZE_OUTPUT(Input, o);
            // Inputの変数に値を代入
            o.rimColor = float3(1, 0, 0);
        }

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
            o.Metallic = 1;
            o.Smoothness = 0.5;
            // IN.rimColorを使う
            o.Emission = (1 - dot(IN.viewDir, o.Normal)) * IN.rimColor;
        }

        ENDCG
    }
}

前節と同様にvertexオプションで頂点シェーダを指定していますが、
今回は頂点シェーダの第二引数にoutキーワードをつけたInput構造体を指定しています。

この方法でInput構造体を通してsurf関数に値を受け渡すことができます。

今回の例ではリムの色を変えてみました。

f:id:halya_11:20190512215000p:plain

計算結果の最後に処理を追加する

さてこのようにしてsurf関数で設定した値を使ってライティング計算が行われますが、
finalcolorオプションを使うと、ライティング計算が終わった後に色を調整できます。

Shader "ExampleSurfaceShader"
{
    Properties{
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        // finalcolorオプションを追加
        #pragma surface surf Standard vertex:vert finalcolor:final

        struct Input
        {
            float2 uv_MainTex;
            float3 viewDir;
            float3 rimColor;
        };

        sampler2D _MainTex;

        void vert(inout appdata_full v, out Input o) {
            UNITY_INITIALIZE_OUTPUT(Input, o);
            o.rimColor = float3(1, 0, 0);
        }

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
            o.Metallic = 1;
            o.Smoothness = 0.5;
            o.Emission = (1 - dot(IN.viewDir, o.Normal)) * IN.rimColor;
        }

        // finalcolor関数
        void final(Input IN, SurfaceOutputStandard o, inout fixed4 color)
        {
            color = pow(color, 3);
        }

        ENDCG
    }
}

finalcolorオプションを追加し、final関数を指定しています。
引数のcolorに最終的な色が入ってくるので、これを書き換えることで色を変更できます。

今回の例ではコントラストを上げてみました。

f:id:halya_11:20190512220238p:plain

カスタムライティング

Surface Shaderでは4つのライティング方法が用意されていますが、
自分でライティング計算用の関数を定義することもできます。

ただ、ここまでやるとSurface Shaderを使う意味がそもそもあるのか疑問なので、
細かい説明はせず簡単なソースコードを書いておくだけにとどめます。

Shader "ExampleSurfaceShader"
{
    Properties{
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        // finalcolorオプションを追加
        // 自作したライティング関数からLightingのPrefixを削除したもの
        #pragma surface surf HalfLambert vertex:vert finalcolor:final

        struct Input
        {
            float2 uv_MainTex;
            float3 viewDir;
            float3 rimColor;
        };

        sampler2D _MainTex;

        void vert(inout appdata_full v, out Input o) {
            UNITY_INITIALIZE_OUTPUT(Input, o);
            o.rimColor = float3(1, 0, 0);
        }

        void surf(Input IN, inout SurfaceOutput o)
        {
            o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
        }

        // カスタムのライティング関数
        // Lightingから始まる名前を付ける必要がある
        half4 LightingHalfLambert(SurfaceOutput s, half3 lightDir, half atten) {
            half ndotl = dot(s.Normal, lightDir);
            half4 c;
            c.rgb = s.Albedo * _LightColor0.rgb * atten * (ndotl * 0.5 + 0.5);
            c.a = s.Alpha;
            return c;
        }

        void final(Input IN, SurfaceOutput o, inout fixed4 color)
        {
            color = pow(color, 3);
        }

        ENDCG
    }
}

ハーフランバートでライティングを行ってみました。
レンダリング結果は次の通りです。

f:id:halya_11:20190512222622p:plain

まとめ

最後にこれまでの情報を簡単にまとめて終わりにします。

まずSurface Shaderの注意点をコメントにまとめたソースコードです。

Shader "ExampleSurfaceShader"
{
    Properties{
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM

        // ライティングモデルはここで指定
        // 頂点シェーダ関数やfinalcolor関数もここで指定
        #pragma surface surf Standard vertex:vert finalcolor:final

        struct Input
        {
            // 特定の名前の変数を定義するとUnityが値を入れてくれる
            float2 uv_MainTex;
            float3 viewDir;
            // 独自の変数を定義して頂点シェーダからsurfに値を受け渡すこともできる
            float3 rimColor;
        };

        sampler2D _MainTex;

        void vert(inout appdata_full v, out Input o) {
            // Input構造体は初期化が必要
            UNITY_INITIALIZE_OUTPUT(Input, o);
            // Input構造体を通してsurf関数に値を受け渡す
            o.rimColor = float3(1, 0, 0);
        }

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
            o.Metallic = 1;
            o.Smoothness = 0.5;
            o.Emission = (1 - dot(IN.viewDir, o.Normal)) * IN.rimColor;
        }

        void final(Input IN, SurfaceOutputStandard o, inout fixed4 color)
        {
            color = pow(color, 3);
        }

        ENDCG
    }
}

次にライティング方法のまとめです。

ライティング方法 説明 出力構造体
Lambert ランバート(非PBR)でライティングを計算 SurfaceOutput
BlinnPhong ブリンフォン(非PBR)でライティングを計算 SurfaceOutput
Standard UnityのStandardシェーダで使っているPBR計算 SurfaceOutputStandard
StandardSpecular スペキュラセットアップのPBR計算 SurfaceOutputStandardSpecular

最後にInput構造体に定義できる変数のまとめです。

宣言 説明 備考
float4 color : COLOR 頂点カラー
float3 worldPos ワールド座標
float4 screenPos スクリーン座標
float3 viewDir ビュー方向
float3 worldRefl ワールド空間の反射ベクトル surf関数でo.Normalへの書き込みを行う場合INTERNAL_DATAも定義する
float3 worldNormal ワールド空間の法線 surf関数でo.Normalへの書き込みを行う場合INTERNAL_DATAも定義する

参考

docs.unity3d.com

docs.unity3d.com

docs.unity3d.com