Unity镂空文本描边Shader研究
最近遇到了一个非常奇葩的需求:半透明文本,并且要有描边。这简直就是简直了,然后美术小姐姐还说了一句:不就是加个描边么?我们一众程序员竟然无言以对,我内心:大姐,这是Unity,不是PS啊0.0
没办法,做不出来只能开始研究。那么为什么透明物体的描边如此难实现呢,我来分析一下。
一、Unity自带的描边
首先来看Unity自带的描边,把参数调大就会发现,只是在四个方向多显示了几份。透明物体肯定不得行,多叠了几层之后,透明度就不对了。
二、2D描边检测算法
参考资料:https://www.jianshu.com/p/c68a730e9a8b
算法:对于不透明的像素,判断上下左右,如果含有不透明像素,则显示描边。
Shader "MyShader/MyOutLine" { Properties { [PerRendererData] _MainTex ("Texture", 2D) = "white" {} _OutlineWidth ("Outline Width", float) = 1 _OutlineColor ("Outline Color", Color) = (1.0, 1.0, 1.0, 1.0) _AlphaValue ("Alpha Value", Range(0, 1)) = 0.1 _Scale("Scale", Range(0, 2)) = 1 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True"} Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct a2v { float4 vertex : POSITION; float4 color : COLOR; float2 uv : TEXCOORD0; float4 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : TEXCOORD1; half2 left : TEXCOORD2; half2 right : TEXCOORD3; half2 up : TEXCOORD4; half2 down : TEXCOORD5; }; sampler2D _MainTex; float4 _MainTex_ST; half4 _MainTex_TexelSize; float _OutlineWidth; float4 _OutlineColor; float _AlphaValue; float _Scale; v2f vert (a2v v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); //顶点外拓 世界空间 /*float4 worldPos = mul(unity_ObjectToWorld, v.vertex); float3 worldNormal = UnityObjectToWorldNormal(v.normal); float3 offset = normalize(worldNormal) * _OutlineWidth; worldPos.xyz += offset; o.vertex = mul(UNITY_MATRIX_VP, worldPos);*/ float2 uvScale = (v.uv - float2(0.5f,0.5f))*_Scale + float2(0.5f,0.5f); o.uv = TRANSFORM_TEX(uvScale, _MainTex); o.color = v.color; o.left = o.uv + half2(-1, 0) * _MainTex_TexelSize.xy * _OutlineWidth; o.right = o.uv + half2(1, 0) * _MainTex_TexelSize.xy * _OutlineWidth; o.up = o.uv + half2(0, 1) * _MainTex_TexelSize.xy * _OutlineWidth; o.down = o.uv + half2(0, -1) * _MainTex_TexelSize.xy * _OutlineWidth; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 diffuse = tex2D(_MainTex, i.uv); fixed4 col = diffuse * i.color; float transparent = tex2D(_MainTex, i.left).a + tex2D(_MainTex, i.right).a + tex2D(_MainTex, i.up).a + tex2D(_MainTex, i.down).a; if (diffuse.a < 0.1) { return step(_AlphaValue, transparent) * _OutlineColor; } else { return col; } } ENDCG } } }
乍一看,似乎成功了?但是当我们调大描边范围的时候,不出意外就要出意外了。
有很多被截断的地方,而且还有很多乱七八糟的线。初步分析是显示区域不够,于是尝试了顶点外拓和uv缩放,这些方法在2D图片上可能有效,但是文本根本不得行,改变uv看一下就知道了。
可以看到,一个文本整体是一个大图,每一个字的位置只是从图中切了一小块。乱七八糟的线是其他字上面的,而之前的算法只是处理周围一圈的纹素,在大图中,描边一旦变粗,就会描到其他文本的区域。
三、网格放大+UV裁剪+描边算法
参考资料:https://www.cnblogs.com/GuyaWeiren/p/9665106.html
原算法是不支持镂空shader的,我对最后输出部分的判断进行了一定调整。先来看网格部分的算法
using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; /// <summary> /// UGUI描边 /// </summary> public class OutLineEx : BaseMeshEffect { public Color OutlineColor = Color.white; [Range(0, 6)] public float OutlineWidth = 0; [Range(0, 1)] public float TransparentCut = 0.1f; private static List<UIVertex> m_VetexList = new List<UIVertex>(); public Shader shaderOutLineEx; protected override void Start() { base.Start(); //var shader = Shader.Find("MyShader/OutlineEx"); base.graphic.material = new Material(shaderOutLineEx); var v1 = base.graphic.canvas.additionalShaderChannels; var v2 = AdditionalCanvasShaderChannels.TexCoord1; if ((v1 & v2) != v2) { base.graphic.canvas.additionalShaderChannels |= v2; } v2 = AdditionalCanvasShaderChannels.TexCoord2; if ((v1 & v2) != v2) { base.graphic.canvas.additionalShaderChannels |= v2; } this._Refresh(); } #if UNITY_EDITOR protected override void OnValidate() { base.OnValidate(); if (base.graphic.material != null) { this._Refresh(); } } #endif private void _Refresh() { base.graphic.material.SetColor("_OutlineColor", this.OutlineColor); base.graphic.material.SetFloat("_OutlineWidth", this.OutlineWidth); base.graphic.material.SetFloat("_TransparentCut", this.TransparentCut); base.graphic.SetVerticesDirty(); } public override void ModifyMesh(VertexHelper vh) { vh.GetUIVertexStream(m_VetexList); this._ProcessVertices(); vh.Clear(); vh.AddUIVertexTriangleStream(m_VetexList); } private void _ProcessVertices() { for (int i = 0, count = m_VetexList.Count - 3; i <= count; i += 3) { var v1 = m_VetexList[i]; var v2 = m_VetexList[i + 1]; var v3 = m_VetexList[i + 2]; // 计算原顶点坐标中心点 // var minX = _Min(v1.position.x, v2.position.x, v3.position.x); var minY = _Min(v1.position.y, v2.position.y, v3.position.y); var maxX = _Max(v1.position.x, v2.position.x, v3.position.x); var maxY = _Max(v1.position.y, v2.position.y, v3.position.y); var posCenter = new Vector2(minX + maxX, minY + maxY) * 0.5f; // 计算原始顶点坐标和UV的方向 // Vector2 triX, triY, uvX, uvY; Vector2 pos1 = v1.position; Vector2 pos2 = v2.position; Vector2 pos3 = v3.position; if (Mathf.Abs(Vector2.Dot((pos2 - pos1).normalized, Vector2.right)) > Mathf.Abs(Vector2.Dot((pos3 - pos2).normalized, Vector2.right))) { triX = pos2 - pos1; triY = pos3 - pos2; uvX = v2.uv0 - v1.uv0; uvY = v3.uv0 - v2.uv0; } else { triX = pos3 - pos2; triY = pos2 - pos1; uvX = v3.uv0 - v2.uv0; uvY = v2.uv0 - v1.uv0; } // 计算原始UV框 // var uvMin = _Min(v1.uv0, v2.uv0, v3.uv0); var uvMax = _Max(v1.uv0, v2.uv0, v3.uv0); var uvOrigin = new Vector4(uvMin.x, uvMin.y, uvMax.x, uvMax.y); // 为每个顶点设置新的Position和UV,并传入原始UV框 // v1 = _SetNewPosAndUV(v1, this.OutlineWidth, posCenter, triX, triY, uvX, uvY, uvOrigin); v2 = _SetNewPosAndUV(v2, this.OutlineWidth, posCenter, triX, triY, uvX, uvY, uvOrigin); v3 = _SetNewPosAndUV(v3, this.OutlineWidth, posCenter, triX, triY, uvX, uvY, uvOrigin); // 应用设置后的UIVertex // m_VetexList[i] = v1; m_VetexList[i + 1] = v2; m_VetexList[i + 2] = v3; } } private static UIVertex _SetNewPosAndUV(UIVertex pVertex, float pOutLineWidth, Vector2 pPosCenter, Vector2 pTriangleX, Vector2 pTriangleY, Vector2 pUVX, Vector2 pUVY, Vector4 pUVOrigin) { // Position var pos = pVertex.position; var posXOffset = pos.x > pPosCenter.x ? pOutLineWidth : -pOutLineWidth; var posYOffset = pos.y > pPosCenter.y ? pOutLineWidth : -pOutLineWidth; pos.x += posXOffset; pos.y += posYOffset; pVertex.position = pos; // UV var uv = pVertex.uv0; uv += pUVX / pTriangleX.magnitude * posXOffset * (Vector2.Dot(pTriangleX, Vector2.right) > 0 ? 1 : -1); uv += pUVY / pTriangleY.magnitude * posYOffset * (Vector2.Dot(pTriangleY, Vector2.up) > 0 ? 1 : -1); pVertex.uv0 = uv; // 原始UV框 pVertex.uv1 = new Vector2(pUVOrigin.x, pUVOrigin.y); pVertex.uv2 = new Vector2(pUVOrigin.z, pUVOrigin.w); return pVertex; } private static float _Min(float pA, float pB, float pC) { return Mathf.Min(Mathf.Min(pA, pB), pC); } private static float _Max(float pA, float pB, float pC) { return Mathf.Max(Mathf.Max(pA, pB), pC); } private static Vector2 _Min(Vector2 pA, Vector2 pB, Vector2 pC) { return new Vector2(_Min(pA.x, pB.x, pC.x), _Min(pA.y, pB.y, pC.y)); } private static Vector2 _Max(Vector2 pA, Vector2 pB, Vector2 pC) { return new Vector2(_Max(pA.x, pB.x, pC.x), _Max(pA.y, pB.y, pC.y)); } }
该类继承自BaseMeshEffect类,在Graphic类的UpdateGeometry方法中,会调用所有的ModifyMesh去更新网格数据,在该函数中重写可以更新网格。定义了若干变量用于调整Shader,并将顶点List分离出来,避免重复new操作。在start函数中动态创建材质,并开启TexCoord1和TexCoord2的附加通道,运行起来长这样。操作顶点的uv1和uv2变量,可以将参数通过TexCoord1和TexCoord2传入Shader。
OnValidate函数在检查器更新时调用,可以在调参数时,实时更新显示。但是这样设计有个缺点,必须运行一次之后,检查器才有值(结束运行之后依然是有的),不然material是null。Unity采用脏数据标记方法,修改完之后需要SetVerticesDirty()才会重新绘制。
Mesh简介:https://www.cnblogs.com/jeason1997/p/4825981.html
每一个三角面的顶点三个一组,对于2D物体,找到顶点的左下角和右上角,并计算出三角面的中心。然后计算Dot点乘,看哪个向量离X轴更近,用离X轴更近的向量当做triX,对应的uv坐标向量uvX。另一个向量就是triY和uvY。这些向量后面要用作uv放大的坐标轴。由于要修改uv,原uv也要传入shader,放在uvOrigin中。
再来看_SetNewPosAndUV()函数。计算新的顶点位置时,先判断顶点位于中点的哪个方向,然后在对应顶点方向加上描边宽度。
从之前的尝试中可以发现,每个字是单独的三角面,顶点向外大了一圈之后,字不能变大,所以uv也要同比例放大。乘向量长度再除以描边长度,相当于得到了描边放大部分占三角面的比例。再根据这两个新轴的方向,决定uv的增长方向正负。
如此一来,uv就跟着顶点同比例放大了,实现了显示区域变大,但是字没有变大的效果,解决了截断问题。但是这样又带来了新的问题,uv放大之后,势必会把周围的文本显示进来,这时就要用到之前的uvOrigin进行裁剪了。接下来看Shader部分。
Shader "MyShader/OutlineEx" { Properties { [PerRendererData] _MainTex ("Main Texture", 2D) = "white" {} _Color ("Tint", Color) = (1, 1, 1, 1) _OutlineColor ("Outline Color", Color) = (1, 1, 1, 1) _OutlineWidth ("Outline Width", Float) = 1 _TransparentCut("_TransparentCut", Float) = 0.1 _StencilComp ("Stencil Comparison", Float) = 8 _Stencil ("Stencil ID", Float) = 0 _StencilOp ("Stencil Operation", Float) = 0 _StencilWriteMask ("Stencil Write Mask", Float) = 255 _StencilReadMask ("Stencil Read Mask", Float) = 255 _ColorMask ("Color Mask", Float) = 15 [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Pass { Name "OUTLINE" CGPROGRAM #pragma vertex vert #pragma fragment frag sampler2D _MainTex; fixed4 _Color; fixed4 _TextureSampleAdd; float4 _MainTex_TexelSize; float4 _OutlineColor; float _OutlineWidth; float _TransparentCut; struct appdata { float4 vertex : POSITION; float2 texcoord : TEXCOORD0; float2 texcoord1 : TEXCOORD1; float2 texcoord2 : TEXCOORD2; fixed4 color : COLOR; }; struct v2f { float4 vertex : SV_POSITION; float2 texcoord : TEXCOORD0; float2 uvOriginXY : TEXCOORD1; float2 uvOriginZW : TEXCOORD2; fixed4 color : COLOR; }; v2f vert(appdata IN) { v2f o; o.vertex = UnityObjectToClipPos(IN.vertex); o.texcoord = IN.texcoord; o.uvOriginXY = IN.texcoord1; o.uvOriginZW = IN.texcoord2; o.color = IN.color * _Color; return o; } fixed IsInRect(float2 pPos, float2 pClipRectXY, float2 pClipRectZW) { pPos = step(pClipRectXY, pPos) * step(pPos, pClipRectZW); return pPos.x * pPos.y; } fixed SampleAlpha(int pIndex, v2f IN) { const fixed sinArray[12] = { 0, 0.5, 0.866, 1, 0.866, 0.5, 0, -0.5, -0.866, -1, -0.866, -0.5 }; const fixed cosArray[12] = { 1, 0.866, 0.5, 0, -0.5, -0.866, -1, -0.866, -0.5, 0, 0.5, 0.866 }; float2 pos = IN.texcoord + _MainTex_TexelSize.xy * float2(cosArray[pIndex], sinArray[pIndex]) * _OutlineWidth; return IsInRect(pos, IN.uvOriginXY, IN.uvOriginZW) * (tex2D(_MainTex, pos) + _TextureSampleAdd).w * _OutlineColor.w; } fixed4 frag(v2f IN) : SV_Target { fixed4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color; if (_OutlineWidth > 0) { color.w *= IsInRect(IN.texcoord, IN.uvOriginXY, IN.uvOriginZW); if(color.w < _TransparentCut) { half4 val = half4(_OutlineColor.x, _OutlineColor.y, _OutlineColor.z, 0); for(int temp = 0; temp < 12; temp++) { val.w += SampleAlpha(temp, IN); } //val.w = clamp(val.w, 0, 1); //此处注释是自发光效果 //color = (val * (1.0 - color.a)) + (color * color.a); val.w = step(0.01,val.w) * _OutlineColor.w; //描边效果 color = val; } } return color; } ENDCG } } }
在顶点函数中,读入TEXCOORD1和TEXCOORD2的数据,就是之前在代码中设置的原uv数据。在IsInRect方法中判断当前uv是否在原uv框中。
在片元函数中,使用tex2D取色后先加上_TextureSampleAdd,否则默认颜色是黑的,顶点颜色color不会生效(是从UGUI中自动传入的)。然后先判断是否在原uv框中,对于不在uv框中的数据,把alpha设为0不显示,这样就不会显示到周围的文本了。
采样还是使用取周围一圈采样点的方法,只不过之前是4个点,现在是12个点。原文的算法是把12个点颜色加起来,然后利用它们的透明度之和作为描边的透明度,这实际上是自发光效果,如果只做描边,只需要判断周围一圈有没有文本即可。
对于采样透明度小于_TransparentCut的值才进行上述操作,可以实现镂空效果。
注:该Shader要求文本有一点点透明度,不能完全透明。如果需要完全透明的文本,最后再加个else,alpha=0吧。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 我与微信审核的“相爱相杀”看个人小程序副业