本文最后更新于 2025-04-23T12:52:22+00:00
我看到过很多角色描边的博客,大部分在讲3D的角色描边,在3D的情况下,最好的办法就是使用法线外扩+两次Pass来实现描边。简单来说就是因为模型有法线,那么顶点就会有法向,可以直接向外扩展部分,并额外渲染一个描边色的、不带正面的描边层,然后正常使用第二个Pass渲染模型本身就可以了。
而在2D中,没有模型,只有图片,并且图片也不存在硬边界,大部分的图片都是由一个简单的多边形加上扣掉无用的点组成的,这里给一个简单的例子来说明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| Shader "Hidden/test" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Cull Off ZWrite Off ZTest Always Tags { "Queue" = "Transparent" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; fixed4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = fixed4(1,0,0,1); return col; } ENDCG } } }
|
选择一个附带透明像素的图片,将他赋予这个材质,你就会发现其实图片的边缘和我们看到的其实不一样。所以我们不能简单的通过控制顶点来实现我们的边缘效果。

强行控制会得到如下效果,可以看出并不能覆盖我们的源图片。


那我们该怎么做呢?
向像素边缘内收缩
这个方法的思路就是判断==非透明像素==的边缘像素,我们需要判断周围的像素中是否有透明像素即可。代码实现也是非常的简单,一个Pass就可以解决。直接上代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| Shader "Custom/OutLineShader" { Properties { _MainTex ("Texture", 2D) = "white" {} _OutLineWidth("OutLineWidth",Range(0,5)) = 0.1 _OutLineColor("OutLineColor",Color)=(1,1,1,1) } SubShader { Tags{ "Queue" = "Geometry-20" } Cull off Blend SrcAlpha OneMinusSrcAlpha Pass { ZWrite off CGPROGRAM #include "UnityCG.cginc" #pragma vertex vert #pragma fragment frag
struct inputV { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct outputV { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_TexelSize; float _OutLineWidth; float3 _OutLineColor; outputV vert(inputV v) { outputV o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; }
fixed4 frag(outputV i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float2 uv_up = i.uv + _MainTex_TexelSize.xy * float2(0,1) * _OutLineWidth; float2 uv_down = i.uv + _MainTex_TexelSize.xy * float2(0,-1) * _OutLineWidth; float2 uv_left = i.uv + _MainTex_TexelSize.xy * float2(-1,0) * _OutLineWidth; float2 uv_right = i.uv + _MainTex_TexelSize.xy * float2(1,0) * _OutLineWidth; float w = tex2D(_MainTex, uv_up).a * tex2D(_MainTex, uv_down).a * tex2D(_MainTex, uv_left).a * tex2D(_MainTex, uv_right).a; col.rgb = lerp(_OutLineColor.rgb,col.rgb,w); return col; } ENDCG }
} }
|
是的,为了不出现不需要的开销,无论我们设置描边的大小为多少,我们都只采样材质五次,也就是说向周围的四个点采样,而不会逐像素采样(他们的区别是一个需要采样多次,而一个在描边宽度很大的时候会出现错误的结果。)
效果如下;


优点是快速、任何图片都可以使用,并且边缘比较均匀。缺点显而易见,它改变了图形原来的样子,向内侵占了部分像素。
图片偏移渲染边缘
既然不能控制顶点的移动,那么我们把图片简单的平移,正常渲染位置就好了。问题就是这种做法的成本还是有点太高了(((
代码我就只写一部分了,毕竟有四个高度重合的Pass:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
| Shader "Hidden/test" { Properties { _MainTex ("Texture", 2D) = "white" {} _BorderColor("BorderColor",Color)=(1,0,0,1) _Offset("Offset",Range(0,1)) = 0.05 } SubShader { Cull Off ZWrite Off Tags { "Queue" = "Transparent" } Blend SrcAlpha OneMinusSrcAlpha pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; fixed4 _MainTex_ST; fixed4 _BorderColor; float _Offset; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.vertex.y += _Offset; o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = _BorderColor*tex2D(_MainTex, i.uv).a; return col; } ENDCG } pass { ... } pass { ... } pass { ... } pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; fixed4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); return col; } ENDCG } } }
|

效果是很不错的,就是渲染需要整整四个Pass,实在不是太优雅,并且不能出现太大的描边,而且对很细的描边没办法很好处理:

判断边缘变透明像素为边界法
故名思意,判断==透明像素==是否为边缘像素,那么将该透明像素设置为边界颜色。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| Shader "Hidden/test" { Properties { _MainTex ("Texture", 2D) = "white" {} _BiggerBorder("BiggerBorder",Range(0,1)) = 0.5 _Offset("Offset",Range(0,1)) = 0.05 } SubShader { Cull Off ZWrite Off Tags { "Queue" = "Transparent" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; fixed4 _MainTex_ST; float _Offset; float _BiggerBorder; v2f vert (appdata v) { v2f o; v.vertex.xy += normalize(v.vertex.xy) * _BiggerBorder; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); if(col.a > 0.5) { return col; } else { fixed4 up = tex2D(_MainTex, i.uv + float2(0, _Offset)); fixed4 down = tex2D(_MainTex, i.uv - float2(0, _Offset)); fixed4 left = tex2D(_MainTex, i.uv - float2(_Offset, 0)); fixed4 right = tex2D(_MainTex, i.uv + float2(_Offset, 0)); if(up.a > 0.5 || down.a > 0.5 || left.a > 0.5 || right.a > 0.5) { return fixed4(1,0,0,1); } discard; return fixed4(0,0,0,0); } } ENDCG } } }
|
效果与第一种方法不同,不会出现侵占纹理像素的情况,但是会被图片的边界所节点描边,导致描边不均匀。


不过这种方法也不失为一种好办法,因为可以在纹理图很细的时候实现比较好的效果,它不会修改任何的纹理上的像素。