目标
以Unity默认渲染管线为例,打造不需要图片内边距,描边平滑,高性能的描边解决方案
前言
在2d游戏中经常需要给2d对象添加描边,来突出强调2d对象
当你去网上查找2d描边shader,移植到项目里面,大概率会得到这个情况
如果描边的基本原理不清楚的可以看我之前的文章 文本描边
①出现了边缘看起来像是被截断了,这个是因为使用在超过的范围,三角形没有覆盖到,这里没有片元着色器进行渲染,所以我们要扩展三角形的顶点,同时扩展uv
图片如果有内边距,并且描边宽度较小,可能不会出现。有时即使图片内边距足够了也会出现这个情况,这是因为生成的三角形的原因(同上),在图片的导入设置MeshType设置为FullRect可以解决,如下图,但是这会增加片元着色器的负担,会有更多的片元需要渲染,唯一的好处是可以减少顶点数据的内存。这里我们为性能考虑,使用三角形渲染,扩展多边形的顶点和uv
但是如果所以要描边的物体加内边距,会增加内存消耗
②不该有描边的区域出现了描边,
要解决的问题
- 边缘被截断了=>扩展多边形的顶点和uv
- 描边有锯齿感=>采样次数不足,只沿着4个或8个方向采样,在outlineWidth较大时会出现问题,增多采样次数,在实际测试中,权衡效果和性能,12次最佳
- 描边和图片过渡处不平滑=>在原图片边缘,边缘aphla为0-1,lerp(outlineCol,col,a),a为0,显示描边,a为1显示原来的图片
- 不该有描边的区域出现了描边
①tex2D得到的a,a>0认为是描边,图片在透明部分a不完全为0导致,提高阈值即可a>0.2
②当outlineWidth过大导致,uv可能会偏移到1.1,即采样uv为0.1的像素,该像素a为1导致的=>C#传入原始的uv范围,超过这个范围的不采样
最终效果演示
代码讲解
Shader部分
tex2D这个采样函数十分消耗性能,可以说,shader性能大部分由tex2D采样次数决定,在本shader中要想尽办法减少tex2D的采样
for会极大消耗性能,不使用for循环
half4 frag(g2f i) : SV_Target
{
float4 col = tex2D(_MainTex, i.uv);
col *= i.color;//乘以顶点颜色
_ShowBound = float4(0, 0, 1, 1);
col.a *= isInRange(i.uv);//扩展的uv不在原始uv范围,a设置为0
float sum_a = 0;
int iteration = 12;//当第一次采样a>0.9,说明片元为正常的像素,直接渲染,不采样邻近像素
for (int ii = 0; ii < iteration; ++ii)//为了代码可读性,使用for,最终代码不使用for
{
if(sum_a<0.5)//如果采样结果累计>0.5,不进行采样,这样能减少tex采样
{
sum_a += SampleTex(i, ii, iteration);
}
}
sum_a=step(0.5,sum_a)+sum_a;//a>0.5的部分认为1
sum_a = saturate(sum_a);
float4 outLineColor = float4(_OutlineColor.rgb, sum_a);
//如果_OutlineWidth为0时,显示原来图片的颜色
float a = step(_OutlineWidth, 0.001);
//为0,描边区域;1,原始图片;0-1,图片边缘,用图片颜色和描边颜色插值过渡
float4 finalCol = lerp(outLineColor, col,saturate(a+col.a));
return finalCol;
}
float isInRange(float2 uv)
{
float2 rs = step(_ShowBound.xy, uv) * step(uv, _ShowBound.zw);
return rs.x * rs.y;
}
float SampleTex(g2f i, float ii, int sum)
{
//使用预先计算好的结果,减少sincos的计算,将上下左右优先放在最前面,因为绝大部分描边由上下左右偏移得到,
//可以大幅度减少在描边区域的采样次数,一旦上下左右采样得到a>threshold,就不会进行采样了
const float OffsetX[12] = {1, 0, -1, 0, 0.866, 0.5, -0.5, -0.866, -0.866, -0.5, 0.5, 0.866};
const float OffsetY[12] = {0, 1, 0, -1, 0.5, 0.866, 0.866, 0.5, -0.5, -0.866, -0.866, -0.5};
float2 offset_uv = i.uv + float2(OffsetX[ii], OffsetY[ii]) * _MainTex_TexelSize.xy * _OutlineWidth;
float sample_a=0;
if(isInRange(offset_uv)>0)//如果偏移后的uv不在原始uv范围不进行采样,a为1
{
sample_a = tex2D(_MainTex, offset_uv).a;
}
float a = sample_a;
a = step(0.2, a) * a;//采样结果<0.2时,不认为是描边
return a;
}
C#部分
在解决上面的问题后,C#要解决最后的一个问题, 边缘看起来被截断了
如果使用的是FullRect渲染Sprite,是扩展矩形的顶点,问题会简单得多。可以在几何着色器geometry中扩展顶点和uv,但是苹果的Metal不支持几何着色器,而且FullRect渲染性能差,所以方案不行。
要扩展多边形的顶点,
首先要知道SpriteRender.sprite的vertices和uvs是只能读不可以修改的。
在网上找了一圈后,幸好unity提供了sprite.SetVertexAttribute这个扩展方法可以修改顶点
在Start时,设置原始的uv范围和描边宽度
ppu即n个像素对应1个单位长度m
扩展多边形得顶点,通过v[i-1]-v[i]和v[i+1]-v[i]得到PA和PB,(PA+PB).normalized得到PC,判断OP和PC方向夹角是否小于90,否则,PC取反,将点P沿PC方向偏移即可
因为使用sprite.SetVertexAttribute修改顶点,会自动计算修改后得uv,所以这里不需要修改uv了
void Start()
{
// 获取SpriteRenderer组件和Sprite
spriteRenderer = GetComponent<SpriteRenderer>();
sprite = spriteRenderer.sprite;
spriteRenderer.material.SetVector("_ShowBound",bound);
spriteRenderer.material.SetFloat("_OutlineWidth",outlineWidth);
// 获取原始的顶点、三角形和UV数据
originalVertices = sprite.vertices;
ppu = 1/sprite.pixelsPerUnit;
// 扩展顶点
Vector2[] expandedVertices = ExpandVertices(originalVertices, outlineWidth);
Vector3[] vertices = System.Array.ConvertAll(expandedVertices, v => (Vector3)v);
NativeArray<Vector3> array = new NativeArray<Vector3>(vertices, Allocator.Temp);
//将Vector3转换到NativeArray<Vector3>类型
sprite.SetVertexAttribute(VertexAttribute.Position,array);
}
private void OnDestroy()//在销毁时还原到之前的顶点
{
Vector3[] vertices = System.Array.ConvertAll(originalVertices, v => (Vector3)v);
NativeArray<Vector3> array = new NativeArray<Vector3>(vertices, Allocator.Temp);
sprite.SetVertexAttribute(VertexAttribute.Position,array);
}
sprite.vertices顶点不是按逆时针排列的,先得到一个按角度排列的顶点
private int CompareByAngle(Vector2 a, Vector2 b)
{
float angleA = Mathf.Atan2(a.y, a.x);
float angleB = Mathf.Atan2(b.y, b.x);
return angleA.CompareTo(angleB);
}
Vector2[] ExpandVertices(Vector2[] vertices, float len)
{
Vector2[] expandedVertices = new Vector2[vertices.Length];
Vector2[] sortVertices = new Vector2[vertices.Length];
for (int i = 0; i < sortVertices.Length; i++)
{
sortVertices[i] = vertices[i];
}
//将顶点按逆时针排列
Array.Sort(sortVertices, (a, b) => CompareByAngle(a, b));
for (int i = 0; i < sortVertices.Length; i++)
{
Vector2 vector2= sortVertices[i];
int index = -1;
for (int j = 0; j < vertices.Length; j++)
{
Vector2 v= vertices[j];
if (Vector2.Distance(v,vector2)<0.01f)
{
index = j;//得到原来在vertices对应的索引
break;
}
}
Vector2 dir1 = sortVertices[(i + 1)% sortVertices.Length] - sortVertices[i];
int index2 = (i - 1) % sortVertices.Length;
if (index2 < 0)
{
index2 = sortVertices.Length + index2;
}
Vector2 dir2 = sortVertices[index2] - sortVertices[i];
dir1 = dir1.normalized;//得到P为原点的2个向量AP,BP,将其相加得到PC,结果和PO点乘,大于90度结果取反
dir2 = dir2.normalized;
Vector2 dir = (dir1 + dir2).normalized;
int rs = Vector2.Dot(dir, vector2.normalized)>0 ? 1: -1;
dir *= rs;//沿得到的dir偏移
expandedVertices[index] = sortVertices[i] + dir * len * ppu;
}
return expandedVertices;
}
完整代码
Shader
Shader "Custom/SpriteOutline"
{
Properties
{
[PerRendererData]_MainTex ("Sprite Texture", 2D) = "white" {}
_OutlineWidth ("Outline Width", Range(0,30)) = 5
_OutlineColor ("Outline Color", Color) = (1,1,1,1)
_ShowBound("Show Bound" ,Vector)=(0,0,1,1)
}
SubShader
{
Tags
{
"Queue"="Transparent" "IgnoreProjector"="true" "RenderType"="Transparent"
}
Cull Off
Lighting Off
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float4 color : COLOR;
float4 uv : TEXCOORD0;
float4 uv2 : TEXCOORD1;
float4 tangent : TANGENT;
};
struct g2f
{
float2 uv : TEXCOORD0;
half4 color : COLOR;
float4 vertex : SV_POSITION;
float2 lightingUV:TEXCOORD1;
float2 uv2 : TEXCOORD2;
float4 tangent : TANGENT;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float4 _MainTex_TexelSize;
float _OutlineWidth;
float4 _OutlineColor;
float4 _ShowBound;
g2f vert(appdata v)
{
g2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.color = v.color;
o.tangent = v.tangent;
o.uv2=v.uv2;
o.lightingUV = half2(ComputeScreenPos(o.vertex / o.vertex.w).xy);
return o;
}
float isInRange(float2 uv)
{
float2 rs = step(_ShowBound.xy, uv) * step(uv, _ShowBound.zw);
return rs.x * rs.y;
}
float SampleTex(g2f i, float ii)
{
const float OffsetX[12] = {1, 0, -1, 0, 0.866, 0.5, -0.5, -0.866, -0.866, -0.5, 0.5, 0.866};
const float OffsetY[12] = {0, 1, 0, -1, 0.5, 0.866, 0.866, 0.5, -0.5, -0.866, -0.866, -0.5};
float2 offset_uv = i.uv + float2(OffsetX[ii], OffsetY[ii]) * _MainTex_TexelSize.xy * _OutlineWidth;
float sample_a=0;
if(isInRange(offset_uv)>0)
{
sample_a = tex2D(_MainTex, offset_uv).a;
}
float a = sample_a;
a = step(0.2, a) * a;
return a;
}
half4 frag(g2f i) : SV_Target
{
float4 col = tex2D(_MainTex, i.uv);
col *= i.color;
//_ShowBound = float4(0, 0, 1, 1);
_ShowBound = i.tangent;
_OutlineWidth=i.uv2.x;
col.a *= isInRange(i.uv);
float sum_a = 0;
float threshold=0.5;
if(col.a<threshold)
{
sum_a += SampleTex(i, 0);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 1);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 2);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 3);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 4);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 5);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 6);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 7);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 8);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 9);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 10);
if (sum_a < threshold)
{
sum_a += SampleTex(i, 11);
}
}
}
}
}
}
}
}
}
}
}
}
sum_a=step(threshold,sum_a)+sum_a;
sum_a = saturate(sum_a);
float4 outLineColor = float4(_OutlineColor.rgb, sum_a);
float a = step(_OutlineWidth, 0.001);
float4 finalCol = lerp(outLineColor, col,saturate(a+col.a));
return finalCol;
}
ENDCG
}
}
Fallback "Sprites/Default"
}
C#
public class SpriteOutline : MonoBehaviour
{
private SpriteRenderer spriteRenderer;
private Sprite sprite;
private Vector2[] originalVertices;
public float outlineWidth = 0f;
private float ppu;
void Start()
{
// 获取SpriteRenderer组件和Sprite
spriteRenderer = GetComponent<SpriteRenderer>();
sprite = spriteRenderer.sprite;
Vector4 bound = new Vector4();
Vector2[] uvs= sprite.uv;
bound =new Vector4(1, 1, 0, 0);
for (int i = 0; i < uvs.Length; i++)
{
var uv = uvs[i];
bound.x = Mathf.Min(bound.x, uv.x);
bound.y = Mathf.Min(bound.y, uv.y);
bound.z = Mathf.Max(bound.z, uv.x);
bound.w = Mathf.Max(bound.w, uv.y);
}
//spriteRenderer.material.SetVector("_ShowBound",bound);
//spriteRenderer.material.SetFloat("_OutlineWidth",outlineWidth);
// 获取原始的顶点、三角形和UV数据
originalVertices = sprite.vertices;
ppu = 1/sprite.pixelsPerUnit;
// 扩展顶点
Vector2[] expandedVertices = ExpandVertices(originalVertices, outlineWidth);
Vector3[] vertices = System.Array.ConvertAll(expandedVertices, v => (Vector3)v);
NativeArray<Vector3> array = new NativeArray<Vector3>(vertices, Allocator.Temp);
sprite.SetVertexAttribute(VertexAttribute.Position,array);
Vector2[] uv2Vector4s=new Vector2[vertices.Length];
for (int i = 0; i < uv2Vector4s.Length; i++)
{
uv2Vector4s[i] =new Vector2(outlineWidth, 0);
}
NativeArray<Vector2> uv2s_array = new NativeArray<Vector2>(uv2Vector4s, Allocator.Temp);
sprite.SetVertexAttribute(VertexAttribute.TexCoord1,uv2s_array);
Vector4[] tangents=new Vector4[vertices.Length];
for (int i = 0; i < tangents.Length; i++)
{
tangents[i] = bound;
}
NativeArray<Vector4> tangent_array = new NativeArray<Vector4>(tangents, Allocator.Temp);
sprite.SetVertexAttribute(VertexAttribute.Tangent,tangent_array);
}
private void OnDestroy()
{
Vector3[] vertices = System.Array.ConvertAll(originalVertices, v => (Vector3)v);
NativeArray<Vector3> array = new NativeArray<Vector3>(vertices, Allocator.Temp);
sprite.SetVertexAttribute(VertexAttribute.Position,array);
}
private int CompareByAngle(Vector2 a, Vector2 b)
{
float angleA = Mathf.Atan2(a.y, a.x);
float angleB = Mathf.Atan2(b.y, b.x);
return angleA.CompareTo(angleB);
}
Vector2[] ExpandVertices(Vector2[] vertices, float len)
{
Vector2[] expandedVertices = new Vector2[vertices.Length];
Vector2[] sortVertices = new Vector2[vertices.Length];
for (int i = 0; i < sortVertices.Length; i++)
{
sortVertices[i] = vertices[i];
}
Array.Sort(sortVertices, (a, b) => CompareByAngle(a, b));
for (int i = 0; i < sortVertices.Length; i++)
{
Vector2 vector2= sortVertices[i];
int index = -1;
for (int j = 0; j < vertices.Length; j++)
{
Vector2 v= vertices[j];
if (Vector2.Distance(v,vector2)<0.01f)
{
index = j;
break;
}
}
Vector2 dir1 = sortVertices[(i + 1)% sortVertices.Length] - sortVertices[i];
int index2 = (i - 1) % sortVertices.Length;
if (index2 < 0)
{
index2 = sortVertices.Length + index2;
}
Vector2 dir2 = sortVertices[index2] - sortVertices[i];
dir1 = dir1.normalized;
dir2 = dir2.normalized;
Vector2 dir = (dir1 + dir2).normalized;
int rs = Vector2.Dot(dir, vector2.normalized)>0 ? 1: -1;
dir *= rs;
expandedVertices[index] = sortVertices[i] + dir * len * ppu;
}
return expandedVertices;
}
}