草莓♭布丁

导航

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

posted on 2022-05-13 15:11  草莓♭布丁  阅读(557)  评论(0编辑  收藏  举报

Live2D