需求分析
由于unityUGUI自带的字体描边方案会增加面片顶点数,当一个mesh字体描边过多,顶点数超过65536个顶点后,将不被渲染,而且顶点数也增加了渲染压力。
方案概述
这是一个基于 Unity UGUI 的高效字体描边实现方案,通过 C# 脚本动态修改网格顶点数据,配合自定义 Shader 实现平滑的描边效果。该方案避免了传统多重绘制带来的性能问题,同时保证了描边的质量和灵活性。
核心实现逻辑
1. C# 脚本部分 (TextOutline.cs)
顶点数据处理
-
网格扩展原理:
-
通过继承
BaseMeshEffect并重写ModifyMesh方法,在 UI 网格生成时介入处理 -
对每个文字三角形的顶点进行外扩处理,为描边效果创造空间
-
-
顶点处理流程:
-
计算三角形中心点作为扩展基准
-
根据顶点相对于中心点的位置决定扩展方向
-
保持 UV 坐标与扩展后的位置正确对应
-
-
关键技术点:
-
使用
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 部分
描边实现原理
-
多方向采样技术:
-
在12个均匀分布的方向上进行纹理采样
-
每个采样点根据主纹理的 alpha 值计算描边贡献
-
累加所有方向的采样结果形成平滑描边
-
-
关键技术点:
-
使用原始 UV 范围(通过 UV1/UV2 传递)进行裁剪,避免内部描边
-
动态调整描边宽度(通过 UV3.w 传递)
-
支持 Gamma 校正选项
-
-
混合方式:
-
描边颜色与文字颜色基于 alpha 进行混合
-
确保文字内容始终显示在描边之上
-
技术亮点
-
高效的单Pass实现:
-
传统描边需要多次绘制,本方案通过单次绘制+多方向采样实现
-
显著减少绘制调用和overdraw
-
-
动态可调参数:
-
支持运行时调整描边颜色和宽度
-
无需重新生成网格即可更新效果
-
-
精确的UV处理:
-
保持纹理映射正确性,避免变形
-
智能的UV边界处理确保描边不溢出
-
-
兼容性设计:
-
正确处理 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字体特效的一个优秀实现范例。







暂无评论内容