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吧。