凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity のトゥーンシェーダについて調べてみた

はじめに

自前のトゥーンシェーダを作りたくて、まずは Unity 付属のトゥーンシェーダについて調べてみました。Unity のシェーダについては以前の記事をサラッと読んでいただけると理解しやすいと思います。

環境

  • Unity 5.0.1f1

追記:2021/02/11

新しいバージョンの Unity には Effects パッケージが標準では含まれておらず、アセットストアからダウンロードできる Standard Assets へと移動しました。

assetstore.unity.com

Toon/Basic

トゥーンシェーダは Assets > Import Package > Effects アセットについてきます。まずは一番シンプルな Toon/Basic を見てみます。

f:id:hecomi:20150425144236p:plain

コードを見てみます。

Shader "Toon/Basic" {
    Properties {
        _Color ("Main Color", Color) = (.5,.5,.5,1)
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _ToonShade ("ToonShader Cubemap(RGB)", CUBE) = "" { }
    }


    SubShader {
        Tags { "RenderType"="Opaque" }
        Pass {
            Name "BASE"
            Cull Off
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            sampler2D _MainTex;
            samplerCUBE _ToonShade;
            float4 _MainTex_ST;
            float4 _Color;

            struct appdata {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                float3 normal : NORMAL;
            };
            
            struct v2f {
                float4 pos : SV_POSITION;
                float2 texcoord : TEXCOORD0;
                float3 cubenormal : TEXCOORD1;
                UNITY_FOG_COORDS(2)
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = mul (UNITY_MATRIX_MVP, v.vertex);
                o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
                o.cubenormal = mul (UNITY_MATRIX_MV, float4(v.normal,0));
                UNITY_TRANSFER_FOG(o,o.pos);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = _Color * tex2D(_MainTex, i.texcoord);
                fixed4 cube = texCUBE(_ToonShade, i.cubenormal);
                fixed4 c = fixed4(2.0f * cube.rgb * col.rgb, col.a);
                UNITY_APPLY_FOG(i.fogCoord, c);
                return c;
            }
            ENDCG           
        }
    } 

    Fallback "VertexLit"
}

とてもシンプルなコードです。何をやっているかというと、シンプルな頂点 / フラグメントシェーダに以下の様なキューブマップテクスチャを入力として与えている形になります。

f:id:hecomi:20150425144830p:plain

上記は標準の Effects アセットをインポートした時についてくるテクスチャです。Unity 5 からはキューブマップテクスチャとして入力された画像のアスペクト比を見て、インポート時に自動的にキューブマップテクスチャにしてくれます。上記は 128x128 と 1:1 なテクスチャなのでスフィアマップなテクスチャと認識されます。

スフィアマップとして扱われるので、中心の色が上側、周辺の色が下側として描画されて良い感じに影ができるわけです。Toon/Basic は計算量的には軽量ですが、ライトの位置に関係なく下側に影が描画される形になります(試しにディレクショナルライトをグリグリしてみても影の位置は変わりません)。

ちなみに本エントリの主題からは外れますが、#pragma multi_compile_fogUNITY_FOG_COORDSUNITY_TRANSFER_FOG は Unity 5 から Vertex / Fragment シェーダへ Fog の効果を追加する便利機能のようです。

Toon/Lit

ライトの影響を受けるトゥーンシェーダとして Toon/Lit が用意されています。

f:id:hecomi:20150425172923p:plain

Toon Ramp というプロパティに以下の様なテクスチャを与えています。

f:id:hecomi:20150425173027p:plain

コードを見てみます。

Shader "Toon/Lit" {
    Properties {
        _Color ("Main Color", Color) = (0.5,0.5,0.5,1)
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Ramp ("Toon Ramp (RGB)", 2D) = "gray" {} 
    }

    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
        
CGPROGRAM
#pragma surface surf ToonRamp

sampler2D _Ramp;

// custom lighting function that uses a texture ramp based
// on angle between light direction and normal
#pragma lighting ToonRamp exclude_path:prepass
inline half4 LightingToonRamp (SurfaceOutput s, half3 lightDir, half atten)
{
    #ifndef USING_DIRECTIONAL_LIGHT
    lightDir = normalize(lightDir);
    #endif
    
    half d = dot (s.Normal, lightDir)*0.5 + 0.5;
    half3 ramp = tex2D (_Ramp, float2(d,d)).rgb;
    
    half4 c;
    c.rgb = s.Albedo * _LightColor0.rgb * ramp * (atten * 2);
    c.a = 0;
    return c;
}


sampler2D _MainTex;
float4 _Color;

struct Input {
    float2 uv_MainTex : TEXCOORD0;
};

void surf (Input IN, inout SurfaceOutput o) {
    half4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Alpha = c.a;
}
ENDCG

    } 

    Fallback "Diffuse"
}

Surface Shaderカスタムライティングモデルを利用した実装になっています。

Surface Shader は、表面の材質を記述する Surface Function と、ライティングとの相互作用を記述する Lighting Model からなるシェーダです。通常は Lighting Model は組み込みの Lambert(Diffuse)か Blingphone(Specular)を指定しますが、これらで表現できない今回のような場合に、カスタムライティングモデルを利用する形になります。カスタムライティングは Lighting* 接頭辞から始まる関数を作成し、#pragma surface でその関数を使用するライティングモデルとして指定します。

それではコードを見てみます。ミソは以下の行で、ここで光のあたっている量を計算しています。

half d = dot (s.Normal, lightDir)*0.5 + 0.5;

簡単に説明すると、面の法線方向と入射光の方向を比較し、光源方向と表面の法線方向が一致しているほど 1.0 に近づき、一致していない(逆を向いている)ほど 0.0 に近づきます。図解した詳細は以下のページが詳しいです。

そして、この値 d をテクスチャの UV 座標 (d, d) として与え、影の強さとして取り出し、これと表面の色、ライトの色、減衰率を掛けて最終的な色として採用します。

half3 ramp = tex2D (_Ramp, float2(d,d)).rgb;
c.rgb = s.Albedo * _LightColor0.rgb * ramp * (atten * 2);

なお、Forward 用に #pragma lighting で Diferred で効かないように記述されています。また、減衰率(attenuation)を示す atten が 2 倍で与えられるのは、Unity 4 系までの負債で Unity 5 からは要らないのですが、おそらく修正し忘れではないかと思います(どっかで報告できないのかな)。

ちなみに影ありのディレクショナルライトと一緒にそのまま使うと結構汚い感じになってしまいます。

f:id:hecomi:20150425173338p:plain

影を消せば綺麗にはなるのですが、影を表示したい場合の解決方法はちょっと思いつかないので保留です。。

Toon/Basic Outine

f:id:hecomi:20150425213730p:plain

Toon/Basic OutlineToon/Lit Outline がありますが、両方共 2-Pass なシェーダを利用して再現しています。

Shader "Toon/Basic Outline" {
    Properties {
        _Color ("Main Color", Color) = (.5,.5,.5,1)
        _OutlineColor ("Outline Color", Color) = (0,0,0,1)
        _Outline ("Outline width", Range (.002, 0.03)) = .005
        _MainTex ("Base (RGB)", 2D) = "white" { }
        _ToonShade ("ToonShader Cubemap(RGB)", CUBE) = "" { }
    }
    
    CGINCLUDE
    #include "UnityCG.cginc"
    
    struct appdata {
        float4 vertex : POSITION;
        float3 normal : NORMAL;
    };

    struct v2f {
        float4 pos : SV_POSITION;
        UNITY_FOG_COORDS(0)
        fixed4 color : COLOR;
    };
    
    uniform float _Outline;
    uniform float4 _OutlineColor;
    
    v2f vert(appdata v) {
        v2f o;
        o.pos = mul(UNITY_MATRIX_MVP, v.vertex);

        float3 norm   = mul ((float3x3)UNITY_MATRIX_IT_MV, v.normal);
        float2 offset = TransformViewToProjection(norm.xy);

        o.pos.xy += offset * o.pos.z * _Outline;
        o.color = _OutlineColor;
        UNITY_TRANSFER_FOG(o,o.pos);
        return o;
    }
    ENDCG

    SubShader {
        Tags { "RenderType"="Opaque" }
        UsePass "Toon/Basic/BASE"
        Pass {
            Name "OUTLINE"
            Tags { "LightMode" = "Always" }
            Cull Front
            ZWrite On
            ColorMask RGB
            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog
            fixed4 frag(v2f i) : SV_Target
            {
                UNITY_APPLY_FOG(i.fogCoord, i.color);
                return i.color;
            }
            ENDCG
        }
    }
    
    Fallback "Toon/Basic"
}

UsePass "Toon/Basic/BASE" で先ほどの Toon/Basic シェーダを使って描画します。そして次に2つ目の Pass でアウトラインを描画します。やっていることとしては WebGL の解説ですが、アルゴリズムは同じなので wgld.org さんの図を見ると分かりやすいと思います。

ざっくり言うと、ちょっと拡大して裏側だけ描画するようにして指定されたアウトライン色で塗りつぶす、という感じです。拡大する部分のコードを見てみます。

float3 norm   = mul ((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float2 offset = TransformViewToProjection(norm.xy);

ここの計算はちょっと複雑なのですが、要は法線の方向を求めています。v.normal は元々のモデルの法線方向ですが、ライティングするためには回転・スケーリングされた後の法線が欲しいわけです。回転は単純に回転すればよいのですが、スケーリングに関しては面倒で、例えば球をペチャンコに潰すと法線は上面は (0, 1, 0)、下面は (0, -1, 0) となり、方向が変わるのが想像できると思います。また法線の大きさも正規化しないと行けません。このあたりを良い感じに計算しているのが上記2行なわけですね。ちなみに UNITY_MATRIX_IT_MV はモデルビュー転置行列の逆行列(IT = Inverse Transposed)です。

z 方向は拡大しても描画に関係ないので、得られた xy 成分だけを offset として取り出し o.pos に加えます。

o.pos.xy += offset * o.pos.z * _Outline;

ここで、o.pos.z をかけているのは、物体を遠ざけても同じサイズのアウトラインの太さを得るためです。o.pos.z を取り除くと近づくほど太く、遠ざかるほど細くなるアウトライン(= 物体の縁をマーカで黒く塗ったような感じ)になります。

これで横と縦に膨らんだモデルが得られます。後は _OutlineColor で塗りつぶして描画すれば、めでたくアウトラインが描画できるわけです。ただし、この方法には弱点があって、法線方向に膨らめているだけなので直角に交わる面付近などで線が途切れてしまいます。

f:id:hecomi:20150425200922p:plain

Toon/Lit Outline

f:id:hecomi:20150425213848p:plain

Toon/Lit Outline では、ここで作った OUTLINEUsePass してる実装になってます。再利用出来るの良いですね。

Shader "Toon/Lit Outline" {
    Properties {
        _Color ("Main Color", Color) = (0.5,0.5,0.5,1)
        _OutlineColor ("Outline Color", Color) = (0,0,0,1)
        _Outline ("Outline width", Range (.002, 0.03)) = .005
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Ramp ("Toon Ramp (RGB)", 2D) = "gray" {} 
    }

    SubShader {
        Tags { "RenderType"="Opaque" }
        UsePass "Toon/Lit/FORWARD"
        UsePass "Toon/Basic Outline/OUTLINE"
    } 
    
    Fallback "Toon/Lit"
}

改造してみる

色々分かったところで自前でトゥーンシェーダを書いてみます。以下のページで紹介されている方法で 1 Pass でアウトラインを描画してみます。

具体的には、Toon/Lit では法線 - 光源方向の内積の値のみ使ってテクスチャから1次元で色を取り出していましたが、法線 - 視線方向の内積の値も使って2次元でテクスチャから色を取り出します。

Shader "Toon/LitWithOutline" {
    Properties {
        _Color ("Main Color", Color)  = (0.5, 0.5, 0.5, 1)
        _MainTex ("Base (RGB)", 2D)   = "white" {}
        _Ramp ("Toon Ramp (RGB)", 2D) = "gray" {} 
    }

    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
        
CGPROGRAM
#pragma surface surf ToonRamp

sampler2D _Ramp;
sampler2D _MainTex;
half4     _Color;

inline half4 LightingToonRamp(SurfaceOutput s, half3 lightDir, half3 viewDir, half atten)
{
    #ifndef USING_DIRECTIONAL_LIGHT
    lightDir = normalize(lightDir);
    #endif
    
    half u = dot(s.Normal, lightDir) * 0.5 + 0.5;
    half v = dot(s.Normal, viewDir);
    half3 ramp = tex2D(_Ramp, half2(u, v)).rgb;
    
    half4 c;
    c.rgb = s.Albedo * _LightColor0.rgb * ramp;
    c.a = 0;
    return c;
}

struct Input {
    half2 uv_MainTex : TEXCOORD0;
};

void surf (Input IN, inout SurfaceOutput o) {
    half4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Alpha = c.a;
}
ENDCG

    } 

    Fallback "Diffuse"
}

カスタムライティングモデルで指定する Lighting Function では、引数を追加すれば視線方向である half3 viewDir を受け取ることができるので、これを利用しています。入力する画像は以下の様なものです。

f:id:hecomi:20150425224417p:plain

結果がコレです。

f:id:hecomi:20150425205427p:plain

...うーん、微妙ですね。

おまけ:Unity 4.x から Unity 5.x へのトゥーンシェーダの移行

これまで Unity 4.x 系でトゥーンシェーダを使っていたプロジェクトを Unity 5 に持ってくるとアウトラインが極太で変になると思います。これは以下の移行ガイドに書いてあるように、法線方向へのふくらめ度合いの計算時に normalize するように修正してみてください。

// float3 norm   = mul ((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float3 norm   = normalize(mul ((float3x3)UNITY_MATRIX_IT_MV, v.normal));

おわりに

仕組みがわかると色々と工夫しがいがあると思いますので、みなさんも色々と工夫したトゥーンシェーダ作ってみてください。