Unity UGUI  字体描边方案

Unity UGUI 字体描边方案

需求分析

由于unityUGUI自带的字体描边方案会增加面片顶点数,当一个mesh字体描边过多,顶点数超过65536个顶点后,将不被渲染,而且顶点数也增加了渲染压力。

方案概述

这是一个基于 Unity UGUI 的高效字体描边实现方案,通过 C# 脚本动态修改网格顶点数据,配合自定义 Shader 实现平滑的描边效果。该方案避免了传统多重绘制带来的性能问题,同时保证了描边的质量和灵活性。

核心实现逻辑

1. C# 脚本部分 (TextOutline.cs)

顶点数据处理

  1. 网格扩展原理

    • 通过继承 BaseMeshEffect 并重写 ModifyMesh 方法,在 UI 网格生成时介入处理

    • 对每个文字三角形的顶点进行外扩处理,为描边效果创造空间

  2. 顶点处理流程

    • 计算三角形中心点作为扩展基准

    • 根据顶点相对于中心点的位置决定扩展方向

    • 保持 UV 坐标与扩展后的位置正确对应

  3. 关键技术点

    • 使用 AdditionalShaderChannels 传递额外数据(UV1-UV3)到 Shader

    • 通过三角形边向量计算确保 UV 扩展方向正确

    • 将描边颜色和宽度信息编码到 UV3 通道传递给 Shader

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

namespace GMModule
{
    [RequireComponent(typeof(CustomText))]
    public class TextOutline : BaseMeshEffect
    {
        [HideInInspector]
        List<UIVertex> lVetexList = new List<UIVertex>();
        List<UIVertex> mergedVerts = new List<UIVertex>();
        bool mFindRenderCanvas;
        #region 描边
        [Header("描边颜色")]
        [SerializeField]
        private Color outlineColor = Color.white;
        
        [Header("描边宽度"), Range(0, 8)]
        [SerializeField]
        private float outlineWidth = 0;
        public float OutlineWidth
        {
            get
            {
                return outlineWidth;
            }
        }
        #endregion
        
        protected override void Awake()
        {
            base.Awake();
            SetShaderChannels();
        }
        
        private void Update()
        {
            if (!mFindRenderCanvas)
            {
                SetShaderChannels();
        
            }
        }
        
        //这段代码的作用是确保 Canvas 的 AdditionalShaderChannels 属性启用了 TexCoord1、TexCoord2 和 TexCoord3 通道。
        //这些通道用于传递额外的顶点数据(例如 UV 坐标)到着色器中
        void SetShaderChannels()
        {
            if (graphic.canvas)
            {
                mFindRenderCanvas = true;
                AdditionalCanvasShaderChannels v1 = graphic.canvas.additionalShaderChannels;
                AdditionalCanvasShaderChannels v2 = AdditionalCanvasShaderChannels.TexCoord1;
                if ((v1 & v2) != v2)
                {
                    graphic.canvas.additionalShaderChannels |= v2;
                }
                v2 = AdditionalCanvasShaderChannels.TexCoord2;
                if ((v1 & v2) != v2)
                {
                    graphic.canvas.additionalShaderChannels |= v2;
                }
                v2 = AdditionalCanvasShaderChannels.TexCoord3;
                if ((v1 & v2) != v2)
                {
                    graphic.canvas.additionalShaderChannels |= v2;
                }

            }
        }
        
        public override void ModifyMesh(VertexHelper vh)
        {
            if (!IsActive()) return;
            lVetexList.Clear();
            // 获取UIVertex流
            vh.GetUIVertexStream(lVetexList);
            // 调用_ProcessVertices方法,传入参数lVetexList和OutlineWidth
            _ProcessVertices(lVetexList, OutlineWidth);
            vh.Clear();
            mergedVerts.Clear();
            mergedVerts.AddRange(lVetexList);
            // 将合并后的顶点流添加到vh中
            vh.AddUIVertexTriangleStream(mergedVerts);
            lVetexList.Clear();
        }
        
        // 添加描边后,为防止描边被网格边框裁切,需要将顶点外扩,同时保持UV不变
        private void _ProcessVertices(List<UIVertex> lVerts, float outlineWidth)
        {
            //在 Unity 的 UIVertex 数据中,顶点是按照三角形的顺序存储的,每三个顶点组成一个三角形。
            //因此,遍历时需要以步长为 3 的方式进行迭代,确保每次处理的是一个完整的三角形。
            //通过获取这三个顶点,可以计算三角形的中心点、UV 坐标、以及其他相关信息。
            for (int i = 0, count = lVerts.Count - 3; i <= count; i += 3)
            {
                UIVertex v1 = lVerts[i];
                UIVertex v2 = lVerts[i + 1];
                UIVertex v3 = lVerts[i + 2];
                
                // 计算原顶点坐标中心点
                //三角形顶点的最小 x 和 y 坐标
                float minX = _Min(v1.position.x, v2.position.x, v3.position.x);
                float minY = _Min(v1.position.y, v2.position.y, v3.position.y);
                //三角形顶点的最大 x 和 y 坐标
                float maxX = _Max(v1.position.x, v2.position.x, v3.position.x);
                float maxY = _Max(v1.position.y, v2.position.y, v3.position.y);
                //通过 (min + max) * 0.5 计算出的中心点坐标
                Vector2 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;
                
                //计算出的边向量和 UV 向量能够正确反映三角形的顶点排列方向,从而在后续的 UV 坐标计算中保持一致性。
                //根据顶点的排列方向选择三角形的两个边向量(triX 和 triY)以及对应的 UV 向量(uvX 和 uvY)
                //使用 Vector2.Dot 计算两个边向量与 Vector2.right(水平向量)的点积,判断哪个边向量更接近水平方向。
                //Mathf.Abs 用于取绝对值,确保比较的是方向的强度而不是正负。
                //如果 (pos2 - pos1) 的方向更接近水平(点积较大),则选择 pos2 - pos1 和 pos3 - pos2 作为三角形的两个边向量,同时选择对应的 UV 向量。
                //否则,选择 pos3 - pos2 和 pos2 - pos1 作为边向量,并调整对应的 UV 向量。
                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框
                Vector2 uvMin = _Min(v1.uv0, v2.uv0, v3.uv0);
                Vector2 uvMax = _Max(v1.uv0, v2.uv0, v3.uv0);

                // 为每个顶点设置新的Position和UV,并传入原始UV框
                v1 = _SetNewPosAndUV(v1, outlineWidth, posCenter, triX, triY, uvX, uvY, uvMin, uvMax);
                v2 = _SetNewPosAndUV(v2, outlineWidth, posCenter, triX, triY, uvX, uvY, uvMin, uvMax);
                v3 = _SetNewPosAndUV(v3, outlineWidth, posCenter, triX, triY, uvX, uvY, uvMin, uvMax);

                // 应用设置后的UIVertex
                lVerts[i] = v1;
                lVerts[i + 1] = v2;
                lVerts[i + 2] = v3;
            }
        }
        
        UIVertex _SetNewPosAndUV(UIVertex pVertex, float pOutLineWidth,
           Vector2 pPosCenter,
           Vector2 pTriangleX, Vector2 pTriangleY,
           Vector2 pUVX, Vector2 pUVY,
           Vector2 pUVOriginMin, Vector2 pUVOriginMax)
        {
            // Position
            Vector3 pos = pVertex.position;
            //如果顶点的 x 或 y 坐标大于中心点的 x 或 y 坐标,则将顶点向外偏移 outlineWidth。
            //如果顶点的 x 或 y 坐标小于中心点的 x 或 y 坐标,则将顶点向内偏移 -outlineWidth。
            //顶点会根据其相对于中心点的位置向外扩展,从而形成一个描边效果。
            //这种逻辑确保描边宽度在所有方向上是均匀的。
            float posXOffset = pos.x > pPosCenter.x ? pOutLineWidth : -pOutLineWidth;
            float posYOffset = pos.y > pPosCenter.y ? pOutLineWidth : -pOutLineWidth;
            pos.x += posXOffset;
            pos.y += posYOffset;
            pVertex.position = pos;
            // UV
            //根据顶点相对于三角形中心点的位置,动态调整 UV 坐标,使得描边效果在纹理映射中保持正确的视觉效果。
            
            Vector2 uv = pVertex.uv0;
            //计算 UV 的 X 方向偏移:
            //pUVX 是三角形顶点间的 UV 差值(X 方向)。
            //pTriangleX.magnitude 是三角形边的长度(X 方向)。
            //posXOffset 是顶点在 X 方向上的偏移量,决定 UV 偏移的大小。
            //Vector2.Dot(pTriangleX, Vector2.right) 用于判断三角形边向量是否与 X 轴方向一致,结果为正或负,决定偏移方向。
            //根据顶点相对于三角形中心点的位置,动态调整 UV 坐标,使得描边效果在纹理映射中保持正确的视觉效果。
            //通过点积判断边向量的方向,确保 UV 偏移方向与三角形的顶点排列方向一致。
            Vector2 uvOffsetX = pUVX / pTriangleX.magnitude * posXOffset * (Vector2.Dot(pTriangleX, Vector2.right) > 0 ? 1 : -1);
            Vector2 uvOffsetY = pUVY / pTriangleY.magnitude * posYOffset * (Vector2.Dot(pTriangleY, Vector2.up) > 0 ? 1 : -1);
            uv.x += (uvOffsetX.x + uvOffsetY.x);
            uv.y += (uvOffsetX.y + uvOffsetY.y);
            pVertex.uv0 = uv;

            pVertex.uv1 = pUVOriginMin;     //uv1 uv2 可用  tangent  normal 在缩放情况 会有问题
            pVertex.uv2 = pUVOriginMax;

            pVertex.uv3 = outlineColor;
            pVertex.uv3.w = outlineWidth;

            return pVertex;
        }
        
        static float _Min(float pA, float pB, float pC)
        {
            return Mathf.Min(Mathf.Min(pA, pB), pC);
        }
        
        static float _Max(float pA, float pB, float pC)
        {
            return Mathf.Max(Mathf.Max(pA, pB), pC);
        }
        
        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));
        }
        
        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));
        }
        
        protected override void OnDestroy()
        {
            base.OnDestroy();
        }
    }
}

2. Shader 部分

描边实现原理

  1. 多方向采样技术

    • 在12个均匀分布的方向上进行纹理采样

    • 每个采样点根据主纹理的 alpha 值计算描边贡献

    • 累加所有方向的采样结果形成平滑描边

  2. 关键技术点

    • 使用原始 UV 范围(通过 UV1/UV2 传递)进行裁剪,避免内部描边

    • 动态调整描边宽度(通过 UV3.w 传递)

    • 支持 Gamma 校正选项

  3. 混合方式

    • 描边颜色与文字颜色基于 alpha 进行混合

    • 确保文字内容始终显示在描边之上

技术亮点

  1. 高效的单Pass实现

    • 传统描边需要多次绘制,本方案通过单次绘制+多方向采样实现

    • 显著减少绘制调用和overdraw

  2. 动态可调参数

    • 支持运行时调整描边颜色和宽度

    • 无需重新生成网格即可更新效果

  3. 精确的UV处理

    • 保持纹理映射正确性,避免变形

    • 智能的UV边界处理确保描边不溢出

  4. 兼容性设计

    • 正确处理 RectMask2D 裁剪

    • 支持 UI 系统的 AlphaClip 功能

Shader"Roulette/UI/TextOutline"
{
	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
		//_OutlineOffset("Outline Offset", Vector) = (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

		//_ShadowOutlineColor("Shadow Outline Color", Color) = (1, 1, 1, 1)
		//_ShadowOutlineWidth("Shadow Outline Width", Float) = 1

		[Enum(UnityEngine.Rendering.CullMode)]_Cull ("Cull Mode", Int) = 0

		[Toggle(IS_GAMMA_CORRECTION)] _IS_GAMMA_CORRECTION("Is Gamma Correction", Float) = 0

		[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 [_Cull]
			Lighting Off
			ZWrite Off
			ZTest[unity_GUIZTestMode]
			Blend SrcAlpha OneMinusSrcAlpha
			// Blend Off
			ColorMask[_ColorMask]

			Pass
			{
				Name "OUTLINE"

				CGPROGRAM
				#pragma vertex vert
				#pragma fragment frag
				#pragma target 2.0

			//Add for RectMask2D
			#include "UnityUI.cginc"
			//End for RectMask2D
			#include "UnityCG.cginc"

			sampler2D _MainTex;
			fixed4 _Color;
			fixed4 _TextureSampleAdd;//或许可以不要
			float4 _MainTex_TexelSize;

			//float4 _OutlineColor;
			float _OutlineWidth;
			//float4 _OutlineOffset;

			// fixed4 _ShadowOutlineColor;
			// float _ShadowOutlineWidth;

			//Add for RectMask2D
			float4 _ClipRect;
			//End for RectMask2D
			//float2 uv_MainTex;
			float _IS_GAMMA_CORRECTION;

			struct appdata
			{
				float4 vertex : POSITION;
				//float4 tangent : TANGENT;
				//float4 normal : NORMAL;
				float2 texcoord : TEXCOORD0;
				float4 uv1 : TEXCOORD1;
				float2 uv2 : TEXCOORD2;
				float4 uv3 : TEXCOORD3;
				fixed4 color : COLOR;

			};


			struct v2f
			{
				float4 vertex : SV_POSITION;
				//float4 tangent : TANGENT;
				//float4 normal : NORMAL;
				float2 texcoord : TEXCOORD0;
				float4 uv1 : TEXCOORD1;
				float2 uv2 : TEXCOORD2;
				float4 uv3 : TEXCOORD3;
				//Add for RectMask2D
				float4 worldPosition : TEXCOORD4;
				//End for RectMask2D
				fixed4 color : COLOR;
			};

			v2f vert(appdata IN)
			{
				v2f o;

				//Add for RectMask2D
				o.worldPosition = IN.vertex;
				//End for RectMask2D

				o.vertex = UnityObjectToClipPos(IN.vertex);
				//o.tangent = IN.tangent;
				o.texcoord = IN.texcoord;
				o.color = IN.color;
				o.uv1 = IN.uv1;
				o.uv2 = IN.uv2;
				o.uv3 = IN.uv3;
				//o.normal = IN.normal;
				return o;
			}

			fixed IsInRect(float2 pPos, float2 pClipRectMin, float2 pClipRectMax)
			{
				pPos = step(pClipRectMin, pPos) * step(pPos, pClipRectMax);
				return pPos.x * pPos.y;
			}
			//这个函数主要用于在Shader中计算轮廓效果的透明度,使得轮廓能够根据纹理的透明度和指定的轮廓颜色进行渲染
			fixed OutlineAlpha(v2f IN, float outlineWidth, fixed4 outlineColor, fixed sin, fixed cos)
			{
				//根据轮廓宽度和旋转角度计算偏移量。
				float2 pos = IN.texcoord + _MainTex_TexelSize.xy * float2(cos, sin) * outlineWidth;
				//检查新的纹理坐标pos是否在指定的矩形范围内。IN.uv1和IN.uv2定义了矩形的边界。
				//在新的纹理坐标pos处采样主纹理_MainTex。
				//最终返回值是上述几个值的乘积,表示轮廓的透明度
				return IsInRect(pos, IN.uv1, IN.uv2) * (tex2D(_MainTex, pos) + _TextureSampleAdd).a * outlineColor.a;
			}

			//描边的基础逻辑是根据偏移画12个字围绕本体,然后描边像素在本体内的不显示,这样看起来就是描边了
			fixed4 frag(v2f IN) : SV_Target
			{
				fixed4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;//默认的文字颜色
				// 使用uv3.x来区分当前顶点是文字还是阴影
				_OutlineWidth = IN.uv3.w;
				// if (_OutlineWidth > 0)
				// {
					color.w *= IsInRect(IN.texcoord, IN.uv1, IN.uv2);	//uv1 uv2 存着原始字的uv长方形区域大小
					float outlineWidth = _OutlineWidth;
					fixed4 outlineColor = IN.uv3;
					if (_IS_GAMMA_CORRECTION == 1)
                    {
                        outlineColor.rgb = GammaToLinearSpace(outlineColor.rgb);
                    }
					half4 val = half4(outlineColor.rgb, 0);//val 是 _OutlineColor的rgb,a是后面计算的
					
					//这里需要避免for循环以及new数组
					//通过在12个不同的方向上计算轮廓透明度,并将这些透明度值累加到val.w中。这些方向向量实际上形成了一个围绕物体表面的圆形,从而在各个方向上增强轮廓效果。
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, 0, 1);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, 0.5, 0.866);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, 0.866, 0.5);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, 1, 0);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, 0.866, -0.5);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, 0.5, -0.866);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, 0, -1);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, -0.5, -0.866);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, -0.866, -0.5);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, -1, 0);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, -0.866, 0.5);
					val.w += OutlineAlpha(IN, outlineWidth, outlineColor, -0.5, 0.866);

					color = (val * (1.0 - color.a)) + (color * color.a);
					color.a = saturate(color.a);//限制在0-1之间 clamp类似
					color.a *= IN.color.a;	//字逐渐隐藏时,描边也要隐藏
				//}

				//Add for RectMask2D
				color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
				#ifdef UNITY_UI_ALPHACLIP
					clip(color.a - 0.001);
				#endif
					//End for RectMask2D

					return color;
				}

				ENDCG
			}
		}
}

适用场景

  • 游戏中的UI文字效果

  • 需要突出显示的重要文本

  • 动态变化的文字特效

  • 性能敏感但仍需高质量描边的场合

使用方法

挂在Text组件上即可

材质脚本

 

总结

该方案通过创新的顶点扩展+多方向采样技术,在保证效果质量的同时实现了高性能的字体描边。其核心思想是将计算从CPU转移到GPU,利用Shader的并行处理能力,是Unity UGUI字体特效的一个优秀实现范例。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容