Unity Text 描边和阴影的实现

1,462 阅读6分钟

前言

       最近由于已上线的游戏根据数据反馈需要缩小包体,过了一篇游戏内缩减包体比较快和有效的办法就是去掉TMP文本控件,使用旧版的Text文本控件,这样就可以省下TMP需要的各种多语言字体空间,综合下来省下的量还是比较可观的。但TMP自带的描边和阴影等效果就不能使用了,旧版Text的outline效果简值就是应付式的交付,没啥作用。如果游戏里面需要用到描边和阴影,就只能自己想办法实现了。于是,从各种网友那儿过了一篇实现的方法,再经过自己的整理和修改,最终实现的效果还算可以,应该可以应用到游戏中了。在此,先上图:

      下面我们一起来探讨一下TEXT的描边和阴影是如果实现的。

一、描边实现的思路

       总体的思路大概是这样:

       1、把Text 顶点外扩

       2、重新映射外扩后正确的顶点UV

       3、通过alpha值检测边缘并采样

       4、通过采样后的alpha值透出描边颜色

       5、对描边进行效果进行锐化,清晰描边

二、阴影实现的思路

      总体的思路大概是这样:

      1、复制多一份顶点数据并偏移至目标方向

      2、对顶点数据进行区分描边和阴影分别处理

      3、描边和阴影是可选的,也可同时存在

      4、存在阴影时,阴影渲染过滤掉描边数据,恢复纯正的阴影

三、C#完整代码

      首先我们来看C#的完整代码,如下:

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.UI;
using UnityEngine.UIElements;

namespace Gamelogic
{
    /// <summary>
    /// 描边阴影特效
    /// </summary>
    public class OutlineExt : BaseMeshEffect
    {
        public Color OutlineColor = Color.white;
        public int OutlineWidth = 1;
        /// <summary>
        /// 描边锐化
        /// </summary>
        public bool OutlineSharpening = false;
        public bool Shadow = false;
        public Vector2 ShadowOffset;
        public Color ShadowColor = Color.white;
        public Shader ShaderExt;        


        protected override void Start()
        {
            base.Awake();
            graphic.material = new Material(ShaderExt);

            //开启多个uv通道,以将数据传入shader处理
            var v1 = graphic.canvas.additionalShaderChannels;
            var v2 = AdditionalCanvasShaderChannels.TexCoord1;
            if ((v1 & v2) != v2)
                graphic.canvas.additionalShaderChannels = v2;

            v2 = AdditionalCanvasShaderChannels.TexCoord2;
            if((v1 & v2) != v2)
                graphic.canvas.additionalShaderChannels |= v2;

            Refresh();
        }

#if(UNITY_EDITOR)
        protected override void OnValidate()
        {
            base.OnValidate();
            if (graphic.material != null)
            {
                Refresh();
            }
        }
#endif
        private void Refresh()
        {
            graphic.material.SetColor("_OutlineColor", OutlineColor);
            graphic.material.SetInt("_OutlineWidth", OutlineWidth);
            graphic.material.SetInt("_Sharpening", OutlineSharpening ? 1 : 0);
            graphic.material.SetInt("_Shadow", Shadow ? 1 : 0);
            graphic.material.SetVector("_ShadowOffset", ShadowOffset);
            graphic.material.SetColor("_ShadowColor", ShadowColor);
            graphic.SetVerticesDirty();
        }
        /// <summary>
        /// 修改顶点
        /// </summary>
        /// <param name="vh"></param>
        public override void ModifyMesh(VertexHelper vh)
        {
            List<UIVertex> allVertexs = null;
            if (Shadow)
            {//处理阴影
                allVertexs = new List<UIVertex>();
                var vertexs = ProcessShadowVertices(vh);      
                allVertexs.AddRange(vertexs);
            }            
            if (OutlineWidth > 0)
            {//处理描边
                if(allVertexs == null)
                    allVertexs = new List<UIVertex>();

                var vertexs = ProcessOutlineVertices(vh);             
                allVertexs.AddRange(vertexs);
            }
            if(allVertexs != null && allVertexs.Count > 0)
            {
                vh.Clear();
                vh.AddUIVertexTriangleStream(allVertexs);
            }
        }

        /// <summary>
        /// 处理阴影顶点
        /// </summary>
        /// <param name="vh"></param>
        /// <param name="offset"></param>
        /// <returns></returns>
        private List<UIVertex> ProcessShadowVertices(VertexHelper vh)
        {
            var allVertexs = new List<UIVertex>();
            var vertexs = new List<UIVertex>();
            vh.GetUIVertexStream(vertexs);
            for (var i = 0; i < vertexs.Count; i++)
            {//复制一份顶点,并将顶点进行偏移即可
                var v = vertexs[i];
                v.position += new Vector3(ShadowOffset.x, ShadowOffset.y, 0);
                v.uv2.x = -1; //存储-1,标志此顶点是阴影顶点
                vertexs[i] = v;
            }
            allVertexs.AddRange(vertexs);
            if (OutlineWidth <= 0)
            {//如果描边没有生成,需要保持原来的顶点。顶点是后加进列表后渲染。
                vertexs.Clear();
                vh.GetUIVertexStream(vertexs);
                allVertexs.AddRange(vertexs);
            }
            return allVertexs;
        }
        /// <summary>
        /// 处理描边顶点,对顶点进行外扩后得到描边
        /// </summary>
        /// <param name="vh"></param>
        /// <returns></returns>
        private List<UIVertex> ProcessOutlineVertices(VertexHelper vh)
        {
            var vtexs = new List<UIVertex>();
            vh.GetUIVertexStream(vtexs);

            var count = vtexs.Count - 3;
            for(var i = 0; i <= count; i += 3)
            {
                //获取三个顶点,顶点顺充是依次排列的
                var v1 = vtexs[i];
                var v2 = vtexs[i + 1];
                var v3 = vtexs[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 pCenter = 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;
                }

                var uvMin = Min(v1.uv0, v2.uv0, v3.uv0);
                var uvMax = Max(v1.uv0, v2.uv0, v3.uv0);        

                v1 = GetNewPosAndUV(v1, pCenter, trix, triy, uvx, uvy, uvMin, uvMax);
                v2 = GetNewPosAndUV(v2, pCenter, trix, triy, uvx, uvy, uvMin, uvMax);
                v3 = GetNewPosAndUV(v3, pCenter, trix, triy, uvx, uvy, uvMin, uvMax);

                vtexs[i] = v1;
                vtexs[i + 1] = v2;
                vtexs[i + 2] = v3;             
            }
            return vtexs;
        }
        /// <summary>
        /// 获取偏移后新的顶点和uv数据
        /// </summary>
        /// <param name="orgVertex"></param>
        /// <param name="posCenter"></param>
        /// <param name="trix"></param>
        /// <param name="triy"></param>
        /// <param name="uvx"></param>
        /// <param name="uvy"></param>
        /// <param name="uvMin"></param>
        /// <param name="uvMax"></param>
        /// <returns></returns>
        private UIVertex GetNewPosAndUV(UIVertex orgVertex, Vector2 posCenter, Vector2 trix, Vector2 triy, Vector2 uvx, Vector2 uvy, Vector2 uvMin, Vector2 uvMax)
        {
            //偏移顶点
            var pos = orgVertex.position;
            var posOffsetx =  pos.x > posCenter.x ? OutlineWidth : -OutlineWidth;
            var posOffsety = pos.y > posCenter.y ? OutlineWidth : -OutlineWidth;
            pos.x += posOffsetx;
            pos.y += posOffsety;
            orgVertex.position = pos;

            //映射回正确的顶点UV
            var uv = orgVertex.uv0;
            var uvOffsetx = uvx / trix.magnitude * posOffsetx * (Vector2.Dot(trix, Vector2.right) > 0 ? 1 : -1);
            var uvOffsety = uvy / triy.magnitude * posOffsety * (Vector2.Dot(triy, Vector2.up) > 0 ? 1 : -1);
            uv.x += (uvOffsetx.x + uvOffsety.x);
            uv.y += (uvOffsetx.y + uvOffsety.y);
            orgVertex.uv0 = uv;

            orgVertex.uv1 = new Vector4(uvMin.x, uvMin.y, uvMax.x, uvMax.y);
            orgVertex.uv2.x = 0;//存储0,标志此顶点不是阴影顶点
            return orgVertex;
        }

        private float Min(float pa, float pb, float pc)
        {
            return Mathf.Min(Mathf.Min(pa, pb), pc);
        }

        private float Max(float pa, float pb, float pc)
        {
            return Mathf.Max(Mathf.Max(pa, pb), pc);
        }

        private 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 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));
        }
    }
}

       代码比较简短,关键的地方都有注释,比较难理解的是外扩UV映射那段。特别要注意的是,阴影与描边的顶点添加,需要按先渲染的先添加到列表内,否则有可能阴影会渲染到字面上。

四、Shader完整代码

       我们再来看Shader的完整代码,如下:

Shader "UI/My/OutlineExt"
{
    Properties
    {
        [PerRendererData]
        _MainTex ("Main Texture", 2D) = "white" {}
        _Color("Tint", COLOR) = (1, 1, 1, 1)
        _OutlineColor("Outline Color", COLOR) = (1, 1, 1, 1)
        _OutlineWidth("OutlineWidth", int) = 1      
         [Toggle]_Sharpening("Sharpening", int) = 0
         [Toggle]_Shadow("Shadow", int) = 0
        _ShadowOffset("ShadowOffset", Vector) = (1, 1, 0, 0)
        _ShadowColor("ShadowColor", COLOR) = (1, 1, 1, 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         

            #include "UnityCG.cginc"

            sampler2D _MainTex;
            fixed4 _Color;
            fixed4 _TextureSampleAdd;
            float4 _MainTex_TexelSize;

            float4 _OutlineColor;
            int _OutlineWidth;
            int _Sharpening;
            int _Shadow;
            float2 _ShadowOffset;
            float4 _ShadowColor;

            struct appdata
            {
                float4 pos : POSITION;
                float2 tex : TEXCOORD0;      //原uv       
                fixed4 col : COLOR;         
                float4 uv1 : TEXCOORD1;     //偏移顶点后映射后的uv
                float4 uv2 : TEXCOORD2;     //顶点是否是阴影的uv
            };

            struct v2f
            {
                float4 svPos : SV_POSITION;
                float2 tex : TEXCOORD0;             
                fixed4 col : COLOR;    
                float4 uv1 : TEXCOORD1;     
                float4 uv2 : TEXCOORD2;
            };       

            v2f vert (appdata ind)
            {
                v2f outd; 
                outd.svPos = UnityObjectToClipPos(ind.pos);
                outd.tex = ind.tex;
                outd.uv1 = ind.uv1;      
                outd.uv2 = ind.uv2;
                outd.col = ind.col;             
                return outd;
            }
            //偏移后的顶点有可能进入其它贴图范围,所以裁剪属于自身范围内的
            fixed IsInRect(float2 pos, float2 minXY, float2 maxXY)
            {
                pos = step(minXY, pos) * step(pos, maxXY);
                return pos.x * pos.y;
            }

            //对alpha 取样,边缘一般都是有灰度的像素,有灰度就会有alpha值,所以会得到正确的描边
            fixed SampleAlpha(int pIndex, v2f v)
            {
                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 = v.tex + _MainTex_TexelSize.xy * float2(cosArray[pIndex], sinArray[pIndex]) * _OutlineWidth;               
                return IsInRect(pos, v.uv1.xy, v.uv1.zw) * (tex2D(_MainTex, pos) + _TextureSampleAdd).a * _OutlineColor.a;
             
            }

            fixed4 frag (v2f v) : SV_Target
            {             
                //原颜色采样,unity 字体必须加上 _TextureSampleAdd 采样,否则始终会返回黑色,其它的tex2d采样就不需要
                fixed4 col = (tex2D(_MainTex, v.tex) + _TextureSampleAdd) * v.col; 
                if(_Shadow > 0 && v.uv2.x == -1)
                {              
                    col = _ShadowColor * col.a;//渲染阴影顶点     
                }
                if(_OutlineWidth > 0 && v.uv2.x != -1 )
                {
                    col.w *= IsInRect(v.tex, v.uv1.xy, v.uv1.zw);
                    half4 val = half4(_OutlineColor.x, _OutlineColor.y, _OutlineColor.z, 0);
                    //alpha 叠加值作为描边的透明度
                    val.w += SampleAlpha(0, v);
                    val.w += SampleAlpha(1, v);
                    val.w += SampleAlpha(2, v);
                    val.w += SampleAlpha(3, v);
                    val.w += SampleAlpha(4, v);
                    val.w += SampleAlpha(5, v);
                    val.w += SampleAlpha(6, v);
                    val.w += SampleAlpha(7, v);
                    val.w += SampleAlpha(8, v);
                    val.w += SampleAlpha(9, v);
                    val.w += SampleAlpha(10, v);
                    val.w += SampleAlpha(11, v);

                    val.w = clamp(val.w, 0, 1);                                  
                    if(_Sharpening  > 0)
                    {
                          val.w =  step(0.5f, val.w); //描边锐化处理
                    }                  
                    col = (val * (1.0 - col.a)) + (col * col.a);//反向透明透出描边颜色,叠加上原来的预乘颜色值,预乘能减少模糊抗锯齿
                    col.a *= v.col.a;                                                    
                }

                return col;               
            }
            ENDCG
        }
    }
}

       Shader 代码也是比较简短,关键的地方也有注释。需要注意的时,因为描边与阴影可以同时存在,所以在frag阶段要分开着色,它们的区分点是UV2从C#传过来的-1值。描边锐化的操作,实际上是将alpha值去除头尾。

五、效果测试

       阴影,如图:

       描边,如图:

        阴影和描边,如图:

         属性的设置如图:

六、提供菜单方便使用

        我们再来看看编辑器的拓展,主要方便使用,代码如下:

using Gamelogic;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;

public class WinOutline : EditorWindow
{

    [MenuItem("Tools/AddOutlineExt", false, 10)]
    static void DoIt()
    {
        AddOuline();
    }

    //右键菜单
    [MenuItem("GameObject/Effects/OutlineExt", false, 10)]
    static void DoIt2()
    {
        AddOuline();
    }
    private static void AddOuline()
    {
        var selected = Selection.activeGameObject;
        if (selected != null)
        {
            var mat = AssetDatabase.LoadAssetAtPath<Material>("Assets/Res/Shaders/OutlineExt/matOutlineExt.mat");
            var oext = selected.GetComponent<OutlineExt>();
            if(oext == null)
            {
                oext = selected.AddComponent<OutlineExt>();
            }            
            oext.ShaderExt = mat.shader;
            var txt = selected.GetComponent<Text>();
            txt.material = mat;          
        }
    }
}

感谢阅读!