1. 程式人生 > >【Unity Shader實戰】卡通風格的Shader(一)

【Unity Shader實戰】卡通風格的Shader(一)

寫在前面

本系列其他文章:

嗚,其實很早就看到了這類Shader,實現方法很多,效果也有些許不一樣。從這篇開始,陸續學習一下接觸到的卡通型別Shader的編寫。

本篇的最後效果如下(只有怪物和蘋果部分):


本篇文章裡指的卡通效果具有如下特點:

  • 簡化了模型中使用的顏色 
  • 簡化光照,使模型具有明確的明暗區域 
  • 在模型邊緣部分繪製輪廓(也就是描邊)

我們再來回顧一下Unity Surface Shader的pipeline。(來源:Unity Gems


由上圖可以看出,我們一共有4個可修改渲染結果的機會(綠色方框中的程式碼)。在理解這個的基礎上,我們來真正學習如何實現上述效果。

簡化顏色

在第一步中,我們只實現一個最常見的Bump Diffuse Shader,在這個基礎上新增一點其他的技巧來實現簡化顏色的目的。Unity的內建Shader也包含了Bump Diffuse Shader,它的作用很簡單,就是用一張貼圖(也叫法線貼圖)記錄了模型上的凹凸情況,以此來作為頂點的法線資訊,渲染出來的模型也就有了凹凸不平的感覺(詳情可見

Unity官網)。

基本的Bump Diffuse Shader程式碼如下:

Shader "Example/Diffuse Bump" {
    Properties {
      _MainTex ("Texture", 2D) = "white" {}
      _BumpMap ("Bumpmap", 2D) = "bump" {}
    }
    SubShader {
      Tags { "RenderType" = "Opaque" }
      CGPROGRAM
      #pragma surface surf Lambert
      struct Input {
        float2 uv_MainTex;
        float2 uv_BumpMap;
      };
      sampler2D _MainTex;
      sampler2D _BumpMap;
      void surf (Input IN, inout SurfaceOutput o) {
        o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
        o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));
      }
      ENDCG
    } 
    Fallback "Diffuse"
  }

效果如下:


接下來,我們進行以下步驟:

  1. Properties塊中新增如下新的屬性:
    _Tooniness ("Tooniness", Range(0.1,20)) = 4

  2. SubShader塊中新增對應的引用:
    float _Tooniness;

  3. #pragma新增新的指令final:
    #pragma surface surf Lambert finalcolor:final

    解釋:由之前pipeline的圖可知,我們有最後一次修改畫素的機會,就是使用finalcolor:your functionfinalcolor後面緊跟就是我們的函式名,Unity將呼叫該函式進行最後的修改。其他可供選擇的準則可見官網


  4. 實現final函式:
            void final(Input IN, SurfaceOutput o, inout fixed4 color) {
                color = floor(color * _Tooniness)/_Tooniness;
            }

    解釋:我們把顏色值乘以_Tooniness,向下取整後再除以_Tooniness。由於color的範圍是0到1,乘以_Tooniness再取整將會得到一定範圍內的特定整數,這樣就使得所有的顏色都被歸入到一個已知的集合中,達到了簡化顏色的目的。_Tooniness越小,輸出的顏色種類越少。
完整程式碼如下:
Shader "Custom/Toon" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Bump ("Bump", 2D) = "bump" {}
        _Tooniness ("Tooniness", Range(0.1,20)) = 4
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        CGPROGRAM
        #pragma surface surf Lambert finalcolor:final
 
        sampler2D _MainTex;
        sampler2D _Bump;
        float _Tooniness;
 
        struct Input {
            float2 uv_MainTex;
            float2 uv_Bump;
        };
 
        void surf (Input IN, inout SurfaceOutput o) {
            half4 c = tex2D (_MainTex, IN.uv_MainTex);
            o.Normal = UnpackNormal( tex2D(_Bump, IN.uv_Bump));
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
 
        void final(Input IN, SurfaceOutput o, inout fixed4 color) {
            color = floor(color * _Tooniness)/_Tooniness;
        }
 
        ENDCG
    } 
    FallBack "Diffuse"
}

效果如下:

卡通光照

除了上述使用取整的方法簡化顏色,更常見的是使用一張漸變貼圖(ramp texture)來模擬卡通光照達到目的。下圖是我們為怪獸使用的漸變貼圖(PS裡面畫的):


這張圖的特點就是邊界明顯,而不像其他漸變圖那樣是緩慢漸變的。正如卡通風格里面經常有分界明顯的明暗變化一樣。

我們按如下步驟新增光照函式:

  1. Properties塊中新增漸變圖屬性:
    _Ramp ("Ramp Texture", 2D) = "white" {}

  2. SubShader塊中新增對應的引用:
    sampler2D _Ramp;

  3. #pragma新增新的指令:
    #pragma surface surf Toon

    解釋:我們去掉了final函式,將其功能移到了後面的surf函式中。這樣允許我們有更多的可變性。上述語句說明我們將使用名稱為Toon的光照函式。

  4. 修改surf函式:
            void surf (Input IN, inout SurfaceOutput o) {
                half4 c = tex2D (_MainTex, IN.uv_MainTex);
                o.Normal = UnpackNormal( tex2D(_Bump, IN.uv_Bump));
                o.Albedo = (floor(c.rgb * _Tooniness)/_Tooniness);
                o.Alpha = c.a;
            }

  5. 實現Toon光照函式:
            half4 LightingToon(SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten)
            {
            	float difLight = max(0, dot (s.Normal, lightDir));
            	float dif_hLambert = difLight * 0.5 + 0.5; 
            	
            	float rimLight = max(0, dot (s.Normal, viewDir));  
            	float rim_hLambert = rimLight * 0.5 + 0.5; 
            	
            	float3 ramp = tex2D(_Ramp, float2(rim_hLambert, dif_hLambert)).rgb;   
        
     		float4 c;  
                    c.rgb = s.Albedo * _LightColor0.rgb * ramp;
                    c.a = s.Alpha;
                    return c;
            }

    解釋:上述最重要的部分就是如何在ramp中取樣,我們使用了兩個值:漫反射光照方向和邊緣光照方向。max是為了防止明暗突變的區域產生奇怪的現象,0.5的相關操作則是為了改變光照區間,進一步提高整體亮度。具體可參加之前的文章

完整程式碼如下:
Shader "Custom/Toon" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Bump ("Bump", 2D) = "bump" {}
        _Ramp ("Ramp Texture", 2D) = "white" {}
        _Tooniness ("Tooniness", Range(0.1,20)) = 4
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        CGPROGRAM
        #pragma surface surf Toon
 
        sampler2D _MainTex;
        sampler2D _Bump;
        sampler2D _Ramp;
        float _Tooniness;
        float _Outline;
 
        struct Input {
            float2 uv_MainTex;
            float2 uv_Bump;
        };
 
        void surf (Input IN, inout SurfaceOutput o) {
            half4 c = tex2D (_MainTex, IN.uv_MainTex);
            o.Normal = UnpackNormal( tex2D(_Bump, IN.uv_Bump));
            o.Albedo = (floor(c.rgb * _Tooniness)/_Tooniness);
            o.Alpha = c.a;
        }
 
        half4 LightingToon(SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten)
        {
            float difLight = max(0, dot (s.Normal, lightDir));
            float dif_hLambert = difLight * 0.5 + 0.5; 
        	
            float rimLight = max(0, dot (s.Normal, viewDir));  
            float rim_hLambert = rimLight * 0.5 + 0.5; 
        	
            float3 ramp = tex2D(_Ramp, float2(rim_hLambert, dif_hLambert)).rgb;   
    
 	    float4 c;  
            c.rgb = s.Albedo * _LightColor0.rgb * ramp;
            c.a = s.Alpha;
            return c;
        }
 
        ENDCG
    } 
    FallBack "Diffuse"
}

效果如下:

新增描邊

最後,我們給模型新增描邊效果。這是通過邊緣光照(rim lighting)來實現的,在本例中我們將邊緣渲染成黑色來實現描邊。邊緣光照找到那些和觀察方向接近90°的畫素,再把他們變成黑色。你大概也想到了邊緣光照使用的方法了:點乘。

我們按如下步驟實現:

  1. 首先為描邊的寬度在Properties塊中新增屬性:
    _Outline ("Outline", Range(0,1)) = 0.4

  2. SubShader塊中新增對應的引用:
    float _Outline;

  3. 前面說了,邊緣光照需要使用觀察方向,因此我們修改Input結構體:
            struct Input {
                float2 uv_MainTex;
                float2 uv_Bump;
                float3 viewDir;
            };

    解釋:viewDir也是Unity的內建引數,其他內建引數可在官網找到。

  4. 我們在surf函式中使用如下方法檢測那些邊:
                half edge = saturate(dot (o.Normal, normalize(IN.viewDir))); 
                edge = edge < _Outline ? edge/4 : 1;
    			
                o.Albedo = (floor(c.rgb * _Tooniness)/_Tooniness) * edge;

    解釋:我們首先得到該畫素的法線方向和觀察方向的點乘結果。如果該結果小於我們的閾值,我們認為這就是我們要找的那些邊緣點,併除以4(一個實驗值)來減少它的值得到黑色;否則,讓它等於1,即沒有任何效果。

整體程式碼如下:
Shader "Custom/Toon" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Bump ("Bump", 2D) = "bump" {}
        _Ramp ("Ramp Texture", 2D) = "white" {}
        _Tooniness ("Tooniness", Range(0.1,20)) = 4
        _Outline ("Outline", Range(0,1)) = 0.4
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        CGPROGRAM
        #pragma surface surf Toon
 
        sampler2D _MainTex;
        sampler2D _Bump;
        sampler2D _Ramp;
        float _Tooniness;
        float _Outline;
 
        struct Input {
            float2 uv_MainTex;
            float2 uv_Bump;
            float3 viewDir;
        };
 
        void surf (Input IN, inout SurfaceOutput o) {
            half4 c = tex2D (_MainTex, IN.uv_MainTex);
            o.Normal = UnpackNormal( tex2D(_Bump, IN.uv_Bump));
            
            half edge = saturate(dot (o.Normal, normalize(IN.viewDir))); 
			edge = edge < _Outline ? edge/4 : 1;
			
            o.Albedo = (floor(c.rgb * _Tooniness)/_Tooniness) * edge;
            o.Alpha = c.a;
        }
 
        half4 LightingToon(SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten)
        {
        	float difLight = max(0, dot (s.Normal, lightDir));
        	float dif_hLambert = difLight * 0.5 + 0.5; 
        	
        	float rimLight = max(0, dot (s.Normal, viewDir));  
        	float rim_hLambert = rimLight * 0.5 + 0.5; 
        	
        	float3 ramp = tex2D(_Ramp, float2(rim_hLambert, dif_hLambert)).rgb;   
    
 			float4 c;  
            c.rgb = s.Albedo * _LightColor0.rgb * ramp;
            c.a = s.Alpha;
            return c;
        }
 
        ENDCG
    } 
    FallBack "Diffuse"
}


最後效果如下:

弊端

這是更新的內容。這種方法有一個明顯的弊端就是,對於那叫平坦、稜角分明的物體,使用上述描邊方法會產生突變等非預期的情況。例如下面的效果:


這是因為我們採用了頂點法向量來判斷邊界的,那麼對於正方體這種法線固定單一的情況,判斷出來的邊界要麼基本不存在要麼就大的離譜!對於這樣的物件,一個更好的方法是用Pixel&Fragment Shader、經過兩個Pass渲染描邊:第一個Pass,我們只渲染背面的網格,在它們的周圍進行描邊;第二個Pass中,再正常渲染正面的網格。其實,這是符合我們對於邊界的認知的,我們看見的物體也都是看到了它們的正面而已。

當然,對於大多數複雜的物件來說,上述方法也是可以支援的~我看到Assets Store上的Free Toony Colors也是使用相同的方法哦~

還有一個弊端就是它產生的輪廓的不確定性。按這種方法產生的輪廓是無法保證精確的寬度的,尤其對於那些不是非常平滑的表面。例如上面的小怪獸,它頭部的輪廓要比手臂的大很多。當然,可以把這個當成是一種藝術風格。但這種不確定性可能會對於某些需要精確黑色輪廓大小的專案不適用。而這個弊端可以靠精確判斷正反面交界處來處理。

第三種方法可以參見卡通風格的Shader(三)(哈哈,我還沒寫。。。)。

更新

  • 補上兩種shader,分別對應有無法線紋理;
  • 修正了半蘭伯特部分,即去掉了max操作;
有法線紋理:
Shader "MyToon/Toon-Surface_Normal" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Bump ("Bump", 2D) = "bump" {}
        _Ramp ("Ramp Texture", 2D) = "white" {}
        _Tooniness ("Tooniness", Range(0.1,20)) = 4
        _Outline ("Outline", Range(0,1)) = 0.4
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        CGPROGRAM
        #pragma surface surf Toon
 
        sampler2D _MainTex;
        sampler2D _Bump;
        sampler2D _Ramp;
        float _Tooniness;
        float _Outline;
 
        struct Input {
            float2 uv_MainTex;
            float2 uv_Bump;
            float3 viewDir;
        };
 
        void surf (Input IN, inout SurfaceOutput o) {
            half4 c = tex2D (_MainTex, IN.uv_MainTex);
            o.Normal = UnpackNormal( tex2D(_Bump, IN.uv_Bump));
            
            half edge = saturate(dot (o.Normal, normalize(IN.viewDir))); 
			edge = edge < _Outline ? edge/4 : 1;
			
            o.Albedo = (floor(c.rgb * _Tooniness)/_Tooniness) * edge;
            o.Alpha = c.a;
        }
 
        half4 LightingToon(SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten)
        {
        	float difLight = dot (s.Normal, lightDir);
        	float dif_hLambert = difLight * 0.5 + 0.5; 
        	
        	float rimLight = dot (s.Normal, viewDir);  
        	float rim_hLambert = rimLight * 0.5 + 0.5; 
        	
        	float3 ramp = tex2D(_Ramp, float2(rim_hLambert, dif_hLambert)).rgb;   
    
 			float4 c;  
            c.rgb = s.Albedo * _LightColor0.rgb * ramp * atten * 2;
            c.a = s.Alpha;
            return c;
        }
 
        ENDCG
    } 
    FallBack "Diffuse"
}

無法線紋理:
Shader "MyToon/Toon-Surface" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Ramp ("Ramp Texture", 2D) = "white" {}
        _Tooniness ("Tooniness", Range(0.1,20)) = 4
        _Outline ("Outline", Range(0,1)) = 0.4
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        CGPROGRAM
        #pragma surface surf Toon
 
        sampler2D _MainTex;
        sampler2D _Ramp;
        float _Tooniness;
        float _Outline;
 
        struct Input {
            float2 uv_MainTex;
            float3 viewDir;
        };
 
        void surf (Input IN, inout SurfaceOutput o) {
            half4 c = tex2D (_MainTex, IN.uv_MainTex);
            
            half edge = saturate(dot (o.Normal, normalize(IN.viewDir))); 
			edge = edge < _Outline ? edge/4 : 1;
			
            o.Albedo = (floor(c.rgb * _Tooniness)/_Tooniness) * edge;
            o.Alpha = c.a;
        }
 
        half4 LightingToon(SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten)
        {
        	float difLight = dot (s.Normal, lightDir);
        	float dif_hLambert = difLight * 0.5 + 0.5; 
        	
        	float rimLight = dot (s.Normal, viewDir);  
        	float rim_hLambert = rimLight * 0.5 + 0.5; 
        	
        	float3 ramp = tex2D(_Ramp, float2(dif_hLambert, rim_hLambert)).rgb;   
    
 			float4 c;  
            c.rgb = s.Albedo * _LightColor0.rgb * ramp * atten * 2;
            c.a = s.Alpha;
            return c;
        }
 
        ENDCG
    } 
    FallBack "Diffuse"
}


結束語

本篇一開始是參考了Unity Gems的一篇文章,但在學習過程中發現了一面一些錯誤和改善的地方,例如裡面對光照函式的解釋,以及漸變貼圖的實現。以後的學習還是要多思考,去其糟粕取其精華啊。

在後面的卡通Shader系列,我會首先學習Unity Gems裡面用Fragment Shader實現的方法,最後,再學習一下Unity一個資源包裡面的卡通效果實現方法

歡迎交流和指教!