Unity Shader-描邊效果
簡介
描邊效果是游戲里面非常常用的一種效果,一般是為了凸顯游戲中的某個對象,會給對象增加一個描邊效果。最近又跑回去玩了玩《劍靈》,雖然出了三年了,感覺在現在的網游里面畫面仍然算很好的了,劍靈里面走近或者選中NPC的一瞬間,NPC就會出現描邊效果,不過這個描邊效果是漸變的,會很快減弱最后消失(抓了好久才抓住一張圖....)
還有就是最常見的LOL中的塔,我們把鼠標移動到塔上,就會有很明顯的描邊效果:
簡單描邊效果的原理
描邊效果有幾種實現方式。其實邊緣光效果與描邊效果有些類似,適當調整邊緣光效果,其實也可以達到凸顯要表達的對象的意思。邊緣光的實現最為簡單,只是在計算的時候增加了一次計算法線方向與視線方向的夾角計算,用1減去結果作為系數乘以一個邊緣光顏色就達到了邊緣光的效果,是性能最好的一種方法,關于邊緣光效果,可以參考一下之前的一篇文章:邊緣光效果。邊緣光的效果如下圖所示:
原始模型渲染:
使用了邊緣光的效果:
邊緣光效果雖然簡單,但是有很大的局限性,邊緣光效果只是在當前模型本身的光照計算時調整了邊緣位置的顏色值,并沒有達到真正的“描邊”(當然,有時候我們就是想要這種邊緣光的效果),而我們希望的描邊效果,一般都是在正常模型的渲染狀態下,在模型外面擴展出一個描邊的效果。既然要讓模型的形狀有所改變(向外拓一點),那么肯定就和vertex shader有關系了。而我們的描邊效果,肯定就是要讓模型更“胖”一點,能夠把我們原來的大小包裹住;微觀一點來看,一個面,如果我們讓它向外拓展,而我們指的外,也就是這個面的法線所指向的方向,那么就讓這個面朝著法線的方向平移一點;再微觀一點來看,對于頂點來說,也就是我們的vertex shader真正要寫的內容了,我們正常計算頂點的時候,傳入的vertex會經過MVP變換,最終傳遞給fragment shader,那么我們就可以在這一步讓頂點沿著法線的方向稍微平移一些。我們在描邊后,描邊這一次渲染的邊緣其實是沒有辦法和我們正常的模型進行區分的,為了解決這個問題,就需要用兩個Pass來渲染,第一個Pass渲染描邊的效果,進行外拓,而第二個Pass進行原本效果的渲染,這樣,后面顯示的就是稍微“胖”一點的模型,然后正常的模型貼在上面,把中間的部分擋住,邊緣擋不住就露出了描邊的部分了。
開啟深度寫入,剔除正面的描邊效果
知道了原理,我們來考慮一下外拓的實現,我們可以在vertex階段獲得頂點的坐標,并且有法線的坐標,最直接的方式就是直接用頂點坐標+法線方向*描邊粗細參數,然后用這個偏移的坐標值再進行MVP變換;但是這樣做有一個弊端,其實就是我們透視的近大遠小的問題,模型上離相機近的地方描邊效果較粗,而遠的地方描邊效果較細。一種解決的方案是先進行MPV變換,變換完之后再去按照法線方向調整外拓。代碼如下:
//描邊Shader
//by:puppet_master
//2017.1.5
Shader "ApcShader/Outline"
{
//屬性
Properties{
_Diffuse("Diffuse", Color) = (1,1,1,1)
_OutlineCol("OutlineCol", Color) = (1,0,0,1)
_OutlineFactor("OutlineFactor", Range(0,1)) = 0.1
_MainTex("Base 2D", 2D) = "white"{}
}
//子著色器
SubShader
{
//描邊使用兩個Pass,第一個pass沿法線擠出一點,只輸出描邊的顏色
Pass
{
//剔除正面,只渲染背面,對于大多數模型適用,不過如果需要背面的,就有問題了
Cull Front
CGPROGRAM
#include "UnityCG.cginc"
fixed4 _OutlineCol;
float _OutlineFactor;
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata_full v)
{
v2f o;
//在vertex階段,每個頂點按照法線的方向偏移一部分,不過這種會造成近大遠小的透視問題
//v.vertex.xyz += v.normal * _OutlineFactor;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//將法線方向轉換到視空間
float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
//將視空間法線xy坐標轉化到投影空間,只有xy需要,z深度不需要了
float2 offset = TransformViewToProjection(vnormal.xy);
//在最終投影階段輸出進行偏移操作
o.pos.xy += offset * _OutlineFactor;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
//這個Pass直接輸出描邊顏色
return _OutlineCol;
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
//正常著色的Pass
Pass
{
CGPROGRAM
//引入頭文件
#include "Lighting.cginc"
//定義Properties中的變量
fixed4 _Diffuse;
sampler2D _MainTex;
//使用了TRANSFROM_TEX宏就需要定義XXX_ST
float4 _MainTex_ST;
//定義結構體:vertex shader階段輸出的內容
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float2 uv : TEXCOORD1;
};
//定義頂點shader,參數直接使用appdata_base(包含position, noramal, texcoord)
v2f vert(appdata_base v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//通過TRANSFORM_TEX宏轉化紋理坐標,主要處理了Offset和Tiling的改變,默認時等同于o.uv = v.texcoord.xy;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.worldNormal = mul(v.normal, (float3x3)_World2Object);
return o;
}
//定義片元shader
fixed4 frag(v2f i) : SV_Target
{
//unity自身的diffuse也是帶了環境光,這里我們也增加一下環境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;
//歸一化法線,即使在vert歸一化也不行,從vert到frag階段有差值處理,傳入的法線方向并不是vertex shader直接傳出的
fixed3 worldNormal = normalize(i.worldNormal);
//把光照方向歸一化
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
//根據半蘭伯特模型計算像素的光照信息
fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
//最終輸出顏色為lambert光強*材質diffuse顏色*光顏色
fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
//進行紋理采樣
fixed4 color = tex2D(_MainTex, i.uv);
color.rgb = color.rgb* diffuse;
return fixed4(color);
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
//前面的Shader失效的話,使用默認的Diffuse
FallBack "Diffuse"
}</code></pre>
開啟了描邊效果:

原始模型渲染采用了 半蘭伯特Diffuse
進行渲染,主要是前面多了一個描邊的Pass。這個Pass里,我們沒有關閉深度寫入,主要是開啟了模型的正面剔除,這樣,在這個Pass渲染的時候,就只會渲染模型的背面,讓背面向外拓展一下,既不會影響什么,并且背面一般都在正面的后面,一般情況下不會遮擋住正面,正好符合我們后面的部分外拓的需求。這個的主要優點是沒有關閉深度寫入,因為關閉深度寫入,引入的其他問題實在是太多了。
附上一張進行了Cull Front操作的效果,只渲染了我們正常看不到的面,效果比較驚悚:

然后再來看看轉換的部分,我們通過UNITY_MATRIX_IT_MV矩陣將法線轉換到視空間,這里可能會比較好奇,為什么不用正常的頂點轉化矩陣來轉化法線,其實主要原因是如果按照頂點的轉換方式,對于非均勻縮放(scalex, scaley,scalez不一致)時,會導致變換的法線歸一化后與面不垂直。如下圖所示,左邊是變化前的,而中間是沿x軸縮放了0.5倍的情況,顯然變化后就不滿足法線的性質了,而最右邊的才是我們希望的結果。造成這一現象的主要原因是法線只能保證方向的一致性,而不能保證位置的一致性;頂點可以經過坐標變換變換到正確的位置,但是法線是一個向量,我們不能直接使用頂點的變換矩陣進行變換。

我們可以推導一個法線的變換矩陣,就能夠保證轉化后的法線與面垂直,法線的變換矩陣為模型變換矩陣的逆轉置矩陣。具體推導過程可以參考 這篇文章 。
在把法線變換到了視空間后,就可以取出其中只與xy面有關的部分,視空間的z軸近似于深度,我們只需要法線在x,y軸的方向,再通過TransformViewToProjection方法,將這個方向轉化到投影空間,最后用這個方向加上經過MVP變換的坐標,實現輕微外拓的效果。(從網上和書上看到了不少在這一步計算的時候,又乘上了pos.z的操作,個人感覺沒有太大的用處,而且會導致描邊效果越遠,線條越粗的情況,離遠了就會出現一團黑的問題,所以把這個去掉了)
上面說過,一般情況下背面是在我們看到的后面的部分,但是理想很美好,現實很殘酷,具體情況千差萬別,比如我之前常用的一個模型,模型的袖子里面,其實用的就是背面,如果想要渲染,就需要關閉背面剔除(Cull Off),這種情況下,使用Cull Front只渲染背面,就有可能和第二次正常渲染的時候的背面穿插,造成效果不對的情況,比如:

不過,解決問題的方法肯定要比問題多,我們可以用深度操作神器Offset指令,控制深度測試,比如我們可以讓渲染描邊的Pass深度遠離相機一點,這樣就不會與正常的Pass穿插了,修改一下描邊的Pass,其實只多了一句話Offset 1,1:
//描邊使用兩個Pass,第一個pass沿法線擠出一點,只輸出描邊的顏色
Pass
{
//剔除正面,只渲染背面,對于大多數模型適用,不過如果需要背面的,就有問題了
Cull Front
//控制深度偏移,描邊pass遠離相機一些,防止與正常pass穿插
Offset 1,1
CGPROGRAM
#include "UnityCG.cginc"
fixed4 _OutlineCol;
float _OutlineFactor;
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata_full v)
{
v2f o;
//在vertex階段,每個頂點按照法線的方向偏移一部分,不過這種會造成近大遠小的透視問題
//v.vertex.xyz += v.normal * _OutlineFactor;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//將法線方向轉換到視空間
float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
//將視空間法線xy坐標轉化到投影空間
float2 offset = TransformViewToProjection(vnormal.xy);
//在最終投影階段輸出進行偏移操作
o.pos.xy += offset * _OutlineFactor;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
//這個Pass直接輸出描邊顏色
return _OutlineCol;
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}</code></pre>
這樣,我們的描邊效果也可以支持不能背面剔除的模型了:

Offset指令
寫到這里強行插一波基礎知識。上面的描邊效果,我們用了一個Offset指令,很好地解決了穿插的問題。其實Offset就是解決Stiching和Z-Fighting的最佳途徑之一。當然,也可以用模板測試,但是Offset操作更快一點。關于Stiching和Z-Fighting,引用一下 這篇文章 :
在OpenGL中,如果想繪制一個多邊形同時繪制其邊界,可是先使用多邊形模式GL_FILL繪制物體,然后使用多邊形模式GL_LINE和不同的顏色再次繪制這個多邊形。但是由于直線和多邊形的光柵化方式不同,導致位于同一位置的多邊形和直線的深度值并不相同,進而導致直線有時在多邊形的里面,有時在多邊形的外面,這種現象就是"Stiching"。而Z-fighting主要是指當兩個面共面時,二者的深度值一樣,深度緩沖就不能清楚的將它們兩者分離開來,位于后面的圖元上的一些像素就會被渲染到前面的圖元上,最終導致圖象在幀與幀之間產生微弱的閃光。
比如我們要繪制兩個面完全共面時,兩者深度值完全相同,那么我們在進行深度測試的時候,就不能分辨到底哪個在前,哪個在后了。類似我們上面的例子,當我們需要渲染背面時,通過背面進行外拓的Pass渲染的結果就和正常的Pass有穿插了。那么,要解決這個問題,很明顯,我們就可以強行設置某個pass的深度偏移,推測這個offset的偏移值是針對ZTest階段,在進行深度測試的時候,將當前pass的深度用offset進行調整再與深度緩沖區中的值進行比對。附上一張官方文檔中關于Offset的部分: 
Offset指令有兩個參數,一個是Factor,主要影響我們繪制多邊形的深度斜率slope的最大值;另一個是Units,主要影響的是能產生在窗口坐標系的深度值中可變分辨率差異的最小值r,這個r一般是OpenGL平臺給定的常量。最終的Offset = slope * Factor + r * Units。Units我們一般在有使用Offset指令的地方給一個統一的值就可以了,主要起作用的就是Factor。Offset操作的層面是像素級別的,多邊形光柵化之后對應的每個Fragment都有一個偏移值,我們調整Factor,其實相當于沿著當前多邊形的斜率深度前進或者后退了一段距離,默認的深度方向是向Z正方向,如果我們給一個大于0的Factor,那么偏移值就會指向Z正方向,深度測試的時候相當于更遠了一點;而如果給了個小于0的Factor,相當于原理Z正方向,深度測試時就更近了一點。
總結一句話就是:Offset大于0,Pass對應的模型離攝像機更遠;Offset小于0,Pass對應的模型離攝像機更近。
有一種描邊效果的實現,其實是利用Offset強行導致Z-Fighting達到描邊的目的,不過效果很差,這里簡單實驗了一版:
//描邊Shader
//by:puppet_master
//2017.1.10
Shader "ApcShader/OutlineZOffset"
{
//屬性
Properties{
_Diffuse("Diffuse", Color) = (1,1,1,1)
_OutlineCol("OutlineCol", Color) = (1,0,0,1)
_MainTex("Base 2D", 2D) = "white"{}
}
//子著色器
SubShader
{
//描邊使用兩個Pass,第一個Pass渲染背面,但是拉近一點
Pass
{
//剔除正面,只渲染背面
Cull Front
//拉近一點,為了與后面的Pass重疊
Offset -1,-1
CGPROGRAM
#include "UnityCG.cginc"
fixed4 _OutlineCol;
float _OutlineFactor;
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata_full v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
//這個Pass直接輸出描邊顏色
return _OutlineCol;
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
//正常著色的Pass,拉遠一點
Pass
{
//拉遠一點,強行導致上一個Pass渲染的背面與此處發生Z-Fighting
Offset 3,-1
CGPROGRAM
//引入頭文件
#include "Lighting.cginc"
//定義Properties中的變量
fixed4 _Diffuse;
sampler2D _MainTex;
//使用了TRANSFROM_TEX宏就需要定義XXX_ST
float4 _MainTex_ST;
//定義結構體:vertex shader階段輸出的內容
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float2 uv : TEXCOORD1;
};
//定義頂點shader,參數直接使用appdata_base(包含position, noramal, texcoord)
v2f vert(appdata_base v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//通過TRANSFORM_TEX宏轉化紋理坐標,主要處理了Offset和Tiling的改變,默認時等同于o.uv = v.texcoord.xy;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.worldNormal = mul(v.normal, (float3x3)_World2Object);
return o;
}
//定義片元shader
fixed4 frag(v2f i) : SV_Target
{
//unity自身的diffuse也是帶了環境光,這里我們也增加一下環境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;
//歸一化法線,即使在vert歸一化也不行,從vert到frag階段有差值處理,傳入的法線方向并不是vertex shader直接傳出的
fixed3 worldNormal = normalize(i.worldNormal);
//把光照方向歸一化
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
//根據半蘭伯特模型計算像素的光照信息
fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
//最終輸出顏色為lambert光強*材質diffuse顏色*光顏色
fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
//進行紋理采樣
fixed4 color = tex2D(_MainTex, i.uv);
color.rgb = color.rgb* diffuse;
return fixed4(color);
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
//前面的Shader失效的話,使用默認的Diffuse
FallBack "Diffuse"
}
效果如下:

效果確實不怎么樣,圓球的描邊很明顯會看出Z-Fighting的痕跡,而人物的渲染,帽子直接就不對了。不過這種實現的描邊效果計算最為簡單,而且不存在邊緣不連續時會出現描邊的斷裂的問題。這種方式,主要是通過把后面的描邊Pass向前提前,由于描邊Pass只渲染了背面,正常情況下是不可見的,而正常的Pass又向后推了一點,導致重合的部分發生了Z-Fighting。
關于Offset指令,再附上一篇參考文章。
關閉深度寫入的描邊效果實現
個人不是很喜歡這種方式,關了深度寫入麻煩事太多。還是硬著頭皮練習一下吧。上面的描邊shader,如果注意觀察的話,其實并不僅僅是描物體的外輪廓邊,在模型內部(模型面前,不是邊緣的部分)也被描上了邊,不過并不影響表現。而我們通過關閉深度寫入實現的描邊效果,則僅僅會描模型的外輪廓。代碼如下:
//描邊Shader
//by:puppet_master
//2017.1.9
Shader "ApcShader/OutlineZWriteOff"
{
//屬性
Properties{
_Diffuse("Diffuse", Color) = (1,1,1,1)
_OutlineCol("OutlineCol", Color) = (1,0,0,1)
_OutlineFactor("OutlineFactor", Range(0,1)) = 0.1
_MainTex("Base 2D", 2D) = "white"{}
}
//子著色器
SubShader
{
//描邊使用兩個Pass,第一個pass沿法線擠出一點,只輸出描邊的顏色
Pass
{
//剔除正面,只渲染背面
Cull Front
//關閉深度寫入
ZWrite Off
CGPROGRAM
#include "UnityCG.cginc"
fixed4 _OutlineCol;
float _OutlineFactor;
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata_full v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//將法線方向轉換到視空間
float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
//將視空間法線xy坐標轉化到投影空間
float2 offset = TransformViewToProjection(vnormal.xy);
//在最終投影階段輸出進行偏移操作
o.pos.xy += offset * _OutlineFactor;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
//這個Pass直接輸出描邊顏色
return _OutlineCol;
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
//正常著色的Pass
Pass
{
CGPROGRAM
//引入頭文件
#include "Lighting.cginc"
//定義Properties中的變量
fixed4 _Diffuse;
sampler2D _MainTex;
//使用了TRANSFROM_TEX宏就需要定義XXX_ST
float4 _MainTex_ST;
//定義結構體:vertex shader階段輸出的內容
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float2 uv : TEXCOORD1;
};
//定義頂點shader,參數直接使用appdata_base(包含position, noramal, texcoord)
v2f vert(appdata_base v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//通過TRANSFORM_TEX宏轉化紋理坐標,主要處理了Offset和Tiling的改變,默認時等同于o.uv = v.texcoord.xy;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.worldNormal = mul(v.normal, (float3x3)_World2Object);
return o;
}
//定義片元shader
fixed4 frag(v2f i) : SV_Target
{
//unity自身的diffuse也是帶了環境光,這里我們也增加一下環境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;
//歸一化法線,即使在vert歸一化也不行,從vert到frag階段有差值處理,傳入的法線方向并不是vertex shader直接傳出的
fixed3 worldNormal = normalize(i.worldNormal);
//把光照方向歸一化
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
//根據半蘭伯特模型計算像素的光照信息
fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
//最終輸出顏色為lambert光強*材質diffuse顏色*光顏色
fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
//進行紋理采樣
fixed4 color = tex2D(_MainTex, i.uv);
color.rgb = color.rgb* diffuse;
return fixed4(color);
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
//前面的Shader失效的話,使用默認的Diffuse
FallBack "Diffuse"
}
結果如下:

看著效果不錯,而且只有最外邊有黑色輪廓。然而事情沒有這么簡單....比如我們加一個天空盒,描邊效果就不見鳥! 
萬惡的ZWrite Off,一定要慎用啊!其實這個問題在上一篇文章中遇到過,簡單解釋一下,默認的渲染隊列是Geometry,而天空盒渲染在Geometry之后,描邊部分沒有寫深度,那么當渲染天空盒的時候,深度小于無窮,深度測試通過,就會把描邊的部分覆蓋了。如下圖,在畫完模型本身時描邊還是可見的,再畫天空盒就覆蓋了描邊。 
通過上一篇文章我們可以知道,調整渲染隊列就可以解決這個問題。但是對于同一個渲染隊列,又會有別的問題,我們復制一個一樣的對象,有一部分重合,重合的部分描邊效果又不見鳥!!!

出現這個情況的原因也是沒寫深度造成描邊被覆蓋了:對于不透明類型的物體,unity的渲染順序是從前到后。前面的描邊渲染之后,渲染后面的模型,后面的模型在描邊部分深度測試仍然通過,就覆蓋了。
怎么解決這個問題呢?首先我們需要找到一個靠后渲染的渲染隊列,保證我們的描邊效果不被其他geomerty類型的對象遮擋;而對于同一渲染隊列,我們也希望最前面的物體描邊效果不被遮擋,也就是說渲染順序最好是從后向前。那么,答案已經有了,把渲染隊列改成Transparent,unity對于透明類型的物體渲染順序是從后到前,這就符合我們的需求了。修改后的shader如下,只加了一句話,把隊列改成Transparent。
//描邊Shader
//by:puppet_master
//2017.1.9
Shader "ApcShader/OutlineZWriteOff"
{
//屬性
Properties{
_Diffuse("Diffuse", Color) = (1,1,1,1)
_OutlineCol("OutlineCol", Color) = (1,0,0,1)
_OutlineFactor("OutlineFactor", Range(0,1)) = 0.1
_MainTex("Base 2D", 2D) = "white"{}
}
//子著色器
SubShader
{
//讓渲染隊列靠后,并且渲染順序為從后向前,保證描邊效果不被其他對象遮擋。
Tags{"Queue" = "Transparent"}
//描邊使用兩個Pass,第一個pass沿法線擠出一點,只輸出描邊的顏色
Pass
{
//剔除正面,只渲染背面
Cull Front
//關閉深度寫入
ZWrite Off
CGPROGRAM
#include "UnityCG.cginc"
fixed4 _OutlineCol;
float _OutlineFactor;
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata_full v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//將法線方向轉換到視空間
float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
//將視空間法線xy坐標轉化到投影空間
float2 offset = TransformViewToProjection(vnormal.xy);
//在最終投影階段輸出進行偏移操作
o.pos.xy += offset * _OutlineFactor;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
//這個Pass直接輸出描邊顏色
return _OutlineCol;
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
//正常著色的Pass
Pass
{
CGPROGRAM
//引入頭文件
#include "Lighting.cginc"
//定義Properties中的變量
fixed4 _Diffuse;
sampler2D _MainTex;
//使用了TRANSFROM_TEX宏就需要定義XXX_ST
float4 _MainTex_ST;
//定義結構體:vertex shader階段輸出的內容
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float2 uv : TEXCOORD1;
};
//定義頂點shader,參數直接使用appdata_base(包含position, noramal, texcoord)
v2f vert(appdata_base v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//通過TRANSFORM_TEX宏轉化紋理坐標,主要處理了Offset和Tiling的改變,默認時等同于o.uv = v.texcoord.xy;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.worldNormal = mul(v.normal, (float3x3)_World2Object);
return o;
}
//定義片元shader
fixed4 frag(v2f i) : SV_Target
{
//unity自身的diffuse也是帶了環境光,這里我們也增加一下環境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;
//歸一化法線,即使在vert歸一化也不行,從vert到frag階段有差值處理,傳入的法線方向并不是vertex shader直接傳出的
fixed3 worldNormal = normalize(i.worldNormal);
//把光照方向歸一化
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
//根據半蘭伯特模型計算像素的光照信息
fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
//最終輸出顏色為lambert光強*材質diffuse顏色*光顏色
fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
//進行紋理采樣
fixed4 color = tex2D(_MainTex, i.uv);
color.rgb = color.rgb* diffuse;
return fixed4(color);
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
//前面的Shader失效的話,使用默認的Diffuse
FallBack "Diffuse"
}
這樣,我們的ZWrite Off版本的描邊效果也OK了。效果如下:

仔細觀察一下,雖然腿的部分描邊效果正常了,但是手的部分,由于穿插過于密集,還是有一些穿幫的地方。總之,沒事不要關閉深度寫入...
基于后處理的描邊效果
除了Cull Front+法線外拓+Offset實現的一版描邊效果還不錯,其他的描邊效果弊端都比較明顯,而法線外拓實現的描邊都存在一個問題,如果相鄰面的法線方向彼此分離,比如一個正方體,相鄰面的法線方向互相垂直,就會造成輪廓間斷,如下圖所示:

有一種解決方案,就是使用模型空間的頂點方向和法線方向插值得到的值進行外拓,并且需要判斷頂點的指向(可以參考《Shader Lab開發實戰詳解》)。不過個人感覺一般描邊效果用于的模型面數較高,法線方向過渡較為平緩,也就不會出現這種斷裂的情況。
要放大招啦,當普通shader搞不定的時候,那就用后處理吧!用后處理進行描邊的原理,就是把物體的輪廓渲染到一張RenderTexture上,然后把RT按照某種方式再貼回原始場景圖。那么,我們要怎么樣得到物體的輪廓呢?首先,我們可以渲染一個物體到RT上,可以通過RenderCommond進行處理,不過RenderCommond是Unity5才提供的特性,加上這篇文章實在拖太久了,決定還是先用比較土的辦法實現吧。
用一個額外的攝像機:通過增加一個和Main Camera一樣的攝像機,通過設置攝像機的LayerMask,將要渲染的對象設置到這個層中,然后將攝像機的Render Target設置為我們設定好的一張Render Texture上,就實現了渲染到RT上的部分,而這張RT由于我們需要在后處理的時候使用,所以我們在之前獲得這張RT,Unity為我們提供了一個 OnPreRender函數,這個函數是在渲染之前的回調,我們就可以在這個地方完成RT的渲染。但是還有一個問題,就是我們默認的shader是模型自身設置的shader,而不是純色的shader,我們要怎么臨時換成一個純色的shader呢?其實Unity也為我們準備好了一個函數:Camera.RenderWithShader,可以讓攝像機的本次渲染采用我們設置的shader,這個函數接受兩個參數,第一個是需要用的shader,第二個是一個字符串,還記得shader里面經常寫的RenderType嗎,其實主要就是為了RenderWithShader服務的,如果我們沒給RenderType,那么攝像機需要渲染的所有物體都會被替換shader渲染,如果我們給了RenderType,Unity就會去對比目前使用的shader中的RenderType,有的話才去渲染,不匹配的不會被替換shader渲染(關于RenderWithShader,可以參考 這篇文章 )。到了這里,我們就能夠得到渲染到RT上的純色的渲染RT了,如下圖:

下一步,為了讓輪廓出現,我們需要考慮的是怎么讓這個輪廓圖“胖一點”,回想一下之前的幾篇文章,通過模糊效果,就可以讓輪廓圖胖一些,所謂模糊,就是讓當前像素的顏色值從當前像素以及像素周圍的幾個采樣點按照加權平均重新計算,很明顯,上面的這張圖進行計算時,人邊緣部分的顏色肯定會和周圍的黑色平均,導致顏色溢出,進而達到發胖的效果。關于模糊,可以參考之前的兩篇文章:簡單均值模糊和高斯模糊,這里就不多做解釋了,經過模糊后的結果如下:

然后呢,我們就可以讓兩者相減一下,讓胖的扣去瘦的部分,就留下了輪廓部分:

最后,再把這張RT和我們正常渲染的場景圖進行結合,就可以得到基于后處理的描邊效果了。最后的結合方式有很多種,最簡單的方式是直接疊加,附上后處理的C#及Shader代碼,為了更清晰,此處把每個步驟拆成單獨的Pass實現了。
C#腳本部分(PostEffect為后處理基類,見簡單屏幕較色):
/********************************************************************
FileName: OutlinePostEffect.cs
Description: 后處理描邊效果
Created: 2017/01/12
history: 12:1:2017 0:42 by puppet_master
*********************************************************************/
using UnityEngine;
using System.Collections;
public class OutlinePostEffect : PostEffectBase
{
private Camera mainCam = null;
private Camera additionalCam = null;
private RenderTexture renderTexture = null;
public Shader outlineShader = null;
//采樣率
public float samplerScale = 1;
public int downSample = 1;
public int iteration = 2;
void Awake()
{
//創建一個和當前相機一致的相機
InitAdditionalCam();
}
private void InitAdditionalCam()
{
mainCam = GetComponent<Camera>();
if (mainCam == null)
return;
Transform addCamTransform = transform.FindChild("additionalCam");
if (addCamTransform != null)
DestroyImmediate(addCamTransform.gameObject);
GameObject additionalCamObj = new GameObject("additionalCam");
additionalCam = additionalCamObj.AddComponent<Camera>();
SetAdditionalCam();
}
private void SetAdditionalCam()
{
if (additionalCam)
{
additionalCam.transform.parent = mainCam.transform;
additionalCam.transform.localPosition = Vector3.zero;
additionalCam.transform.localRotation = Quaternion.identity;
additionalCam.transform.localScale = Vector3.one;
additionalCam.farClipPlane = mainCam.farClipPlane;
additionalCam.nearClipPlane = mainCam.nearClipPlane;
additionalCam.fieldOfView = mainCam.fieldOfView;
additionalCam.backgroundColor = Color.clear;
additionalCam.clearFlags = CameraClearFlags.Color;
additionalCam.cullingMask = 1 << LayerMask.NameToLayer("Additional");
additionalCam.depth = -999;
if (renderTexture == null)
renderTexture = RenderTexture.GetTemporary(additionalCam.pixelWidth >> downSample, additionalCam.pixelHeight >> downSample, 0);
}
}
void OnEnable()
{
SetAdditionalCam();
additionalCam.enabled = true;
}
void OnDisable()
{
additionalCam.enabled = false;
}
void OnDestroy()
{
if (renderTexture)
{
RenderTexture.ReleaseTemporary(renderTexture);
}
DestroyImmediate(additionalCam.gameObject);
}
//unity提供的在渲染之前的接口,在這一步渲染描邊到RT
void OnPreRender()
{
//使用OutlinePrepass進行渲染,得到RT
if(additionalCam.enabled)
{
//渲染到RT上
//首先檢查是否需要重設RT,比如屏幕分辨率變化了
if (renderTexture != null && (renderTexture.width != Screen.width >> downSample || renderTexture.height != Screen.height >> downSample))
{
RenderTexture.ReleaseTemporary(renderTexture);
renderTexture = RenderTexture.GetTemporary(Screen.width >> downSample, Screen.height >> downSample, 0);
}
additionalCam.targetTexture = renderTexture;
additionalCam.RenderWithShader(outlineShader, "");
}
}
void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (_Material && renderTexture)
{
//renderTexture.width = 111;
//對RT進行Blur處理
RenderTexture temp1 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0);
RenderTexture temp2 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0);
//高斯模糊,兩次模糊,橫向縱向,使用pass0進行高斯模糊
_Material.SetVector("_offsets", new Vector4(0, samplerScale, 0, 0));
Graphics.Blit(renderTexture, temp1, _Material, 0);
_Material.SetVector("_offsets", new Vector4(samplerScale, 0, 0, 0));
Graphics.Blit(temp1, temp2, _Material, 0);
//如果有疊加再進行迭代模糊處理
for(int i = 0; i < iteration; i++)
{
_Material.SetVector("_offsets", new Vector4(0, samplerScale, 0, 0));
Graphics.Blit(temp2, temp1, _Material, 0);
_Material.SetVector("_offsets", new Vector4(samplerScale, 0, 0, 0));
Graphics.Blit(temp1, temp2, _Material, 0);
}
//用模糊圖和原始圖計算出輪廓圖
_Material.SetTexture("_BlurTex", temp2);
Graphics.Blit(renderTexture, temp1, _Material, 1);
//輪廓圖和場景圖疊加
_Material.SetTexture("_BlurTex", temp1);
Graphics.Blit(source, destination, _Material, 2);
RenderTexture.ReleaseTemporary(temp1);
RenderTexture.ReleaseTemporary(temp2);
}
else
{
Graphics.Blit(source, destination);
}
}
}
Prepass Shader(用于把模型渲染到RT的shader):
//描邊Shader(輸出純色)
//by:puppet_master
//2017.1.12
Shader "ApcShader/OutlinePrePass"
{
//子著色器
SubShader
{
//描邊使用兩個Pass,第一個pass沿法線擠出一點,只輸出描邊的顏色
Pass
{
CGPROGRAM
#include "UnityCG.cginc"
fixed4 _OutlineCol;
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata_full v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
//這個Pass直接輸出描邊顏色
return fixed4(1,0,0,1);
}
//使用vert函數和frag函數
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
}
后處理shader(三個Pass,模糊處理,摳出輪廓,最終混合):
//后處理描邊Shader
//by:puppet_master
//2017.1.12
Shader "Custom/OutLinePostEffect" {
Properties{
_MainTex("Base (RGB)", 2D) = "white" {}
_BlurTex("Blur", 2D) = "white"{}
}
CGINCLUDE
#include "UnityCG.cginc"
//用于剔除中心留下輪廓
struct v2f_cull
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
//用于模糊
struct v2f_blur
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 uv01 : TEXCOORD1;
float4 uv23 : TEXCOORD2;
float4 uv45 : TEXCOORD3;
};
//用于最后疊加
struct v2f_add
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float2 uv1 : TEXCOORD1;
};
sampler2D _MainTex;
float4 _MainTex_TexelSize;
sampler2D _BlurTex;
float4 _BlurTex_TexelSize;
float4 _offsets;
//Blur圖和原圖進行相減獲得輪廓
v2f_cull vert_cull(appdata_img v)
{
v2f_cull o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.texcoord.xy;
//dx中紋理從左上角為初始坐標,需要反向
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
o.uv.y = 1 - o.uv.y;
#endif
return o;
}
fixed4 frag_cull(v2f_cull i) : SV_Target
{
fixed4 colorMain = tex2D(_MainTex, i.uv);
fixed4 colorBlur = tex2D(_BlurTex, i.uv);
//最后的顏色是_BlurTex - _MainTex,周圍0-0=0,黑色;邊框部分為描邊顏色-0=描邊顏色;中間部分為描邊顏色-描邊顏色=0。最終輸出只有邊框
//return fixed4((colorBlur - colorMain).rgb, 1);
return colorBlur - colorMain;
}
//高斯模糊 vert shader(之前的文章有詳細注釋,此處也可以用BoxBlur,更省一點)
v2f_blur vert_blur(appdata_img v)
{
v2f_blur o;
_offsets *= _MainTex_TexelSize.xyxy;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.texcoord.xy;
o.uv01 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1);
o.uv23 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 2.0;
o.uv45 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 3.0;
return o;
}
//高斯模糊 pixel shader
fixed4 frag_blur(v2f_blur i) : SV_Target
{
fixed4 color = fixed4(0,0,0,0);
color += 0.40 * tex2D(_MainTex, i.uv);
color += 0.15 * tex2D(_MainTex, i.uv01.xy);
color += 0.15 * tex2D(_MainTex, i.uv01.zw);
color += 0.10 * tex2D(_MainTex, i.uv23.xy);
color += 0.10 * tex2D(_MainTex, i.uv23.zw);
color += 0.05 * tex2D(_MainTex, i.uv45.xy);
color += 0.05 * tex2D(_MainTex, i.uv45.zw);
return color;
}
//最終疊加 vertex shader
v2f_add vert_add(appdata_img v)
{
v2f_add o;
//mvp矩陣變換
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//uv坐標傳遞
o.uv.xy = v.texcoord.xy;
o.uv1.xy = o.uv.xy;
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
o.uv.y = 1 - o.uv.y;
#endif
return o;
}
fixed4 frag_add(v2f_add i) : SV_Target
{
//取原始場景圖片進行采樣
fixed4 ori = tex2D(_MainTex, i.uv1);
//取得到的輪廓圖片進行采樣
fixed4 blur = tex2D(_BlurTex, i.uv);
//輸出:直接疊加
fixed4 final = ori + blur;
return final;
}
ENDCG
SubShader
{
//pass 0: 高斯模糊
Pass
{
ZTest Off
Cull Off
ZWrite Off
Fog{ Mode Off }
CGPROGRAM
#pragma vertex vert_blur
#pragma fragment frag_blur
ENDCG
}
//pass 1: 剔除中心部分
Pass
{
ZTest Off
Cull Off
ZWrite Off
Fog{ Mode Off }
CGPROGRAM
#pragma vertex vert_cull
#pragma fragment frag_cull
ENDCG
}
//pass 2: 最終疊加
Pass
{
ZTest Off
Cull Off
ZWrite Off
Fog{ Mode Off }
CGPROGRAM
#pragma vertex vert_add
#pragma fragment frag_add
ENDCG
}
}
}
描邊結果(把要描邊的對象放到Additional層中):

換個顏色,加大一下模糊程度:

這種類型的shader其實跟最上面的劍靈中的描邊效果很像,尤其是第一張圖,描邊并不是一個硬邊,而是一個柔和的,漸變的邊緣效果,在最靠近模型的部分顏色最強,越向外,描邊效果逐漸減弱。個人最喜歡這個描邊效果,不過這個后處理是真的費啊,強烈不推薦移動上使用,一般帶模糊的效果,都要慎用,超級費(然而本人超級喜歡的效果基本都是需要模糊來實現的,比如景深,Bloom,毛玻璃等等,效果永遠是跟性能成反比的)。這個后處理還有一個問題,就是不能遮擋,因為渲染到RT之后,再通過模糊減去原圖,只會留下整體的邊界,而不會把中間重疊的部分留下。暫時沒想到什么好辦法,如果哪位熱心人有好點子,還望不吝賜教。
下面再調整一下這個shader,首先,我們把這個描邊效果換成一個硬邊,跟我們最早通過增加個外拓Pass達到一樣的效果;然后就是讓我們輸出的顏色是我們自己想要的顏色,因為上面的實現實際上是一種疊加,并不是我們原始的寫在Prepass那個shader里面輸出的顏色,而且那個是寫死在shader里的,不能調整。我們希望給一個可調整的參數;最后,由于上面shader中最后的兩個Pass其實是可以合并成一個Pass來實現的,通過增加一個貼圖槽,這樣就可以省下一次全屏Pass。
/********************************************************************
FileName: OutlinePostEffect.cs
Description: 后處理描邊效果
Created: 2017/01/12
history: 12:1:2017 0:42 by puppet_master
*********************************************************************/
using UnityEngine;
using System.Collections;
public class OutlinePostEffectX : PostEffectBase
{
private Camera mainCam = null;
private Camera additionalCam = null;
private RenderTexture renderTexture = null;
public Shader outlineShader = null;
//采樣率
public float samplerScale = 0.01f;
public int downSample = 0;
public int iteration = 0;
public Color outlineColor = Color.green;
void Awake()
{
//創建一個和當前相機一致的相機
InitAdditionalCam();
}
private void InitAdditionalCam()
{
mainCam = GetComponent<Camera>();
if (mainCam == null)
return;
Transform addCamTransform = transform.FindChild("additionalCam");
if (addCamTransform != null)
DestroyImmediate(addCamTransform.gameObject);
GameObject additionalCamObj = new GameObject("additionalCam");
additionalCam = additionalCamObj.AddComponent<Camera>();
SetAdditionalCam();
}
private void SetAdditionalCam()
{
if (additionalCam)
{
additionalCam.transform.parent = mainCam.transform;
additionalCam.transform.localPosition = Vector3.zero;
additionalCam.transform.localRotation = Quaternion.identity;
additionalCam.transform.localScale = Vector3.one;
additionalCam.farClipPlane = mainCam.farClipPlane;
additionalCam.nearClipPlane = mainCam.nearClipPlane;
additionalCam.fieldOfView = mainCam.fieldOfView;
additionalCam.backgroundColor = Color.clear;
additionalCam.clearFlags = CameraClearFlags.Color;
additionalCam.cullingMask = 1 << LayerMask.NameToLayer("Additional");
additionalCam.depth = -999;
if (renderTexture == null)
renderTexture = RenderTexture.GetTemporary(additionalCam.pixelWidth >> downSample, additionalCam.pixelHeight >> downSample, 0);
}
}
void OnEnable()
{
SetAdditionalCam();
additionalCam.enabled = true;
}
void OnDisable()
{
additionalCam.enabled = false;
}
void OnDestroy()
{
if (renderTexture)
{
RenderTexture.ReleaseTemporary(renderTexture);
}
DestroyImmediate(additionalCam.gameObject);
}
//unity提供的在渲染之前的接口,在這一步渲染描邊到RT
void OnPreRender()
{
//使用OutlinePrepass進行渲染,得到RT
if (additionalCam.enabled)
{
//渲染到RT上
//首先檢查是否需要重設RT,比如屏幕分辨率變化了
if (renderTexture != null && (renderTexture.width != Screen.width >> downSample || renderTexture.height != Screen.height >> downSample))
{
RenderTexture.ReleaseTemporary(renderTexture);
renderTexture = RenderTexture.GetTemporary(Screen.width >> downSample, Screen.height >> downSample, 0);
}
additionalCam.targetTexture = renderTexture;
additionalCam.RenderWithShader(outlineShader, "");
}
}
void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (_Material && renderTexture)
{
//renderTexture.width = 111;
//對RT進行Blur處理
RenderTexture temp1 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0);
RenderTexture temp2 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0);
//高斯模糊,兩次模糊,橫向縱向,使用pass0進行高斯模糊
_Material.SetVector("_offsets", new Vector4(0, samplerScale, 0, 0));
Graphics.Blit(renderTexture, temp1, _Material, 0);
_Material.SetVector("_offsets", new Vector4(samplerScale, 0, 0, 0));
Graphics.Blit(temp1, temp2, _Material, 0);
//如果有疊加再進行迭代模糊處理
for (int i = 0; i < iteration; i++)
{
_Material.SetVector("_offsets", new Vector4(0, samplerScale, 0, 0));
Graphics.Blit(temp2, temp1, _Material, 0);
_Material.SetVector("_offsets", new Vector4(samplerScale, 0, 0, 0));
Graphics.Blit(temp1, temp2, _Material, 0);
}
//用模糊圖和原始圖計算出輪廓圖,并和場景圖疊加,節省一個Pass
_Material.SetTexture("_OriTex", renderTexture);
_Material.SetTexture("_BlurTex", temp2);
_Material.SetColor("_OutlineColor", outlineColor);
Graphics.Blit(source, destination, _Material, 1);
RenderTexture.ReleaseTemporary(temp1);
RenderTexture.ReleaseTemporary(temp2);
}
else
{
Graphics.Blit(source, destination);
}
}
}
描邊Shader部分:
//后處理描邊Shader
//by:puppet_master
//2017.1.12
Shader "Custom/OutLinePostEffectX" {
Properties{
_MainTex("Base (RGB)", 2D) = "white" {}
_BlurTex("Blur", 2D) = "white"{}
_OriTex("Ori", 2D) = "white"{}
}
CGINCLUDE
#include "UnityCG.cginc"
//用于模糊
struct v2f_blur
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 uv01 : TEXCOORD1;
float4 uv23 : TEXCOORD2;
float4 uv45 : TEXCOORD3;
};
//用于最后疊加
struct v2f_add
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float2 uv1 : TEXCOORD1;
float2 uv2 : TEXCOORD2;
};
sampler2D _MainTex;
float4 _MainTex_TexelSize;
sampler2D _BlurTex;
float4 _BlurTex_TexelSize;
sampler2D _OriTex;
float4 _OriTex_TexelSize;
float4 _offsets;
fixed4 _OutlineColor;
//高斯模糊 vert shader(之前的文章有詳細注釋,此處也可以用BoxBlur,更省一點)
v2f_blur vert_blur(appdata_img v)
{
v2f_blur o;
_offsets *= _MainTex_TexelSize.xyxy;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.texcoord.xy;
o.uv01 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1);
o.uv23 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 2.0;
o.uv45 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 3.0;
return o;
}
//高斯模糊 pixel shader
fixed4 frag_blur(v2f_blur i) : SV_Target
{
fixed4 color = fixed4(0,0,0,0);
color += 0.40 * tex2D(_MainTex, i.uv);
color += 0.15 * tex2D(_MainTex, i.uv01.xy);
color += 0.15 * tex2D(_MainTex, i.uv01.zw);
color += 0.10 * tex2D(_MainTex, i.uv23.xy);
color += 0.10 * tex2D(_MainTex, i.uv23.zw);
color += 0.05 * tex2D(_MainTex, i.uv45.xy);
color += 0.05 * tex2D(_MainTex, i.uv45.zw);
return color;
}
//最終疊加 vertex shader
v2f_add vert_add(appdata_img v)
{
v2f_add o;
//mvp矩陣變換
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//uv坐標傳遞
o.uv.xy = v.texcoord.xy;
o.uv1.xy = o.uv.xy;
o.uv2.xy = o.uv.xy;
#if UNITY_UV_STARTS_AT_TOP
//if (_OriTex_TexelSize.y < 0)
o.uv.y = 1 - o.uv.y;
o.uv2.y = 1 - o.uv2.y;
#endif
return o;
}
fixed4 frag_add(v2f_add i) : SV_Target
{
//取原始場景紋理進行采樣
fixed4 scene = tex2D(_MainTex, i.uv1);
//return scene;
//對blur后的紋理進行采樣
fixed4 blur = tex2D(_BlurTex, i.uv);
//對blur之前的rt進行采樣
fixed4 ori = tex2D(_OriTex, i.uv);
//輪廓是_BlurTex - _OriTex,周圍0-0=0,黑色;邊框部分為描邊顏色-0=描邊顏色;中間部分為描邊顏色-描邊顏色=0。最終輸出只有邊框
fixed4 outline = blur - ori;
//輸出:blur部分為0的地方返回原始圖像,否則為0,然后疊加描邊
fixed4 final = scene * (1 - all(outline.rgb)) + _OutlineColor * any(outline.rgb);//0.01,1,1
return final;
}
ENDCG
SubShader
{
//pass 0: 高斯模糊
Pass
{
ZTest Off
Cull Off
ZWrite Off
Fog{ Mode Off }
CGPROGRAM
#pragma vertex vert_blur
#pragma fragment frag_blur
ENDCG
}
//pass 1: 剔除中心部分以及最后和場景圖疊加
Pass
{
ZTest Off
Cull Off
ZWrite Off
Fog{ Mode Off }
CGPROGRAM
#pragma vertex vert_add
#pragma fragment frag_add
ENDCG
}
}
}
結果如下:

總結
本篇文章主要研究了一下描邊效果的幾種類型(邊緣發光型,硬描邊,柔和邊緣的描邊)以及實現方式(邊緣光,深度偏移,法線外拓,后處理):幾種描邊效果各有各的優點和缺點,最省的是邊緣光效果,深度偏移+法線外拓的方式基本可以滿足真正的描邊需求,而后處理的效果比較好,但是如果能只增加一個pass就能得到的效果,就沒有必要用后處理了,尤其是移動平臺上。最后推薦一個后處理的插件:Highting System,里面有各種類型的描邊效果,不過這個插件也是通過后處理來實現的(使用了RenderCommand+后處理),也是比較費。插件中模糊的描邊效果:

硬邊的描邊效果:

來自:http://blog.csdn.net/puppet_master/article/details/54000951