1. 程式人生 > 其它 >Unity鏤空文字描邊Shader研究

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