
由于策划们经常需要给文字添加描边的效果,以防止文字看不清或者用来强调一些文字。之前一直用的都是Unity自带的Outline组件实现文字描边,在效果和性能上都很差。而如果使用TextMeshPro的话,又需要对每种语言都做资源,会多出很多工作量,并增加包体。所以需要另寻方法实现一个文字描边的组件。
在网上找了一圈,感觉用Shader实现挺符合需求,但一般用于Mesh描边的Shader又用不了,变成了一个方框描边,推测是Unity先对于文字形状进行了处理,计算并生成了一张文字图案的UV,然后将UV应用在一个面片上,这样就减少了最终显示在画面上的面数和定点数。而这些文字形状的顶点数据则是以UIVertex类型存在。

Text组件的Mesh是个面片
又找了一圈,找到两篇不错的文章:
《基于Shader实现的UGUI描边解决方案》(https://www.cnblogs.com/GuyaWeiren/p/9665106.html)
《【unity shader】基于UGUI字体的outline优化》(https://blog.csdn.net/HelloCLanguage/article/details/105836309)
于是就用最朴素的编程方法拿来改了改,目前实现的功能有:
文字阴影
文字、阴影描边
文字、阴影渐变(可透明,只能双色)
并添加了共用材质和保存为预制体后打开显示异常的问题的解决。

效果预览
使用方法:把脚本添加到Text组件节点上即可。
注:如果显示异常请检查Canvas的Additional Shader Channels中的TexCoord1,2,3通道是否打开。
脚本代码:
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using System.Linq;
[RequireComponent(typeof(Text))]
public class Text2DOutline : BaseMeshEffect {
private static string sOutlineShader = "TextEffect/Text2DOutline";
[Header("使用渐变")]
[SerializeField]
private bool bUseTextGradient = false;
public bool BUseTextGradient {
get {
return bUseTextGradient;
}
set {
bUseTextGradient = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("文字颜色")]
[SerializeField]
private Gradient textGradient;
public Gradient TextGradient {
get {
return textGradient;
}
set {
textGradient = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("描边颜色")]
[SerializeField]
private Color outlineColor = Color.white;
public Color OutlineColor {
get {
return outlineColor;
}
set {
outlineColor = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("描边宽度"), Range(0, 8)]
[SerializeField]
private float outlineWidth = 0;
public float OutlineWidth {
get {
return outlineWidth;
}
set {
outlineWidth = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("===== 阴影 =====")]
[SerializeField]
private bool bUseShadow = false;
public bool BUseShadow {
get {
return bUseShadow;
}
set {
bUseShadow = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("使用独立透明度")]
[SerializeField]
private bool bShadowAlphaStand = false;
public bool BShadowAlphaStand {
get {
return bShadowAlphaStand;
}
set {
bShadowAlphaStand = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("阴影文字过渡")]
[SerializeField]
private bool bUseShadowGradient = false;
public bool BUseShadowGradient {
get {
return bUseShadowGradient;
}
set {
bUseShadowGradient = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("阴影文字颜色")]
[SerializeField]
private Gradient shadowGradient;
public Gradient ShadowGradient {
get {
return shadowGradient;
}
set {
shadowGradient = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("阴影描边颜色")]
[SerializeField]
private Color shadowOutlineColor = Color.white;
public Color ShadowOutlineColor {
get {
return shadowOutlineColor;
}
set {
shadowOutlineColor = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("阴影偏移")]
[SerializeField]
private Vector2 shadowOutlineOffset = new Vector2(0, 0);
public Vector2 ShadowOutlineOffset {
get {
return shadowOutlineOffset;
}
set {
shadowOutlineOffset = value;
if (base.graphic != null)
_Refresh();
}
}
[Header("阴影描边宽度"), Range(0, 8)]
[SerializeField]
private float shadowOutlineWidth = 0;
public float ShadowOutlineWidth {
get {
return shadowOutlineWidth;
}
set {
shadowOutlineWidth = value;
if (base.graphic != null)
_Refresh();
}
}
// #if UNITY_EDITOR || UNITY_STANDALONE_WIN
int iMatHash = 0;
bool bSetPreviewCanvas = false;
// #endif
protected override void Awake() {
base.Awake();
if (Application.isPlaying) {
if (base.graphic.material.GetHashCode() != iMatHash) {
base.graphic.material = new Material(Shader.Find(sOutlineShader));
iMatHash = base.graphic.material.GetHashCode();
}
}
if (CheckShader()) {
this.SetShaderChannels();
this.SetShaderParams();
this._Refresh();
}
}
protected override void OnDestroy() {
base.OnDestroy();
base.graphic.material = null;
}
bool CheckShader() {
if (base.graphic == null) {
Debug.LogError("No Graphic Component !");
return false;
}
if (base.graphic.material == null) {
Debug.LogError("No Material !");
return false;
}
return true;
}
void SetShaderParams() {
if (base.graphic.material != null) {
base.graphic.material.SetColor("_OutlineColor", OutlineColor);
base.graphic.material.SetFloat("_OutlineWidth", OutlineWidth);
base.graphic.material.SetVector("_OutlineOffset", ShadowOutlineOffset);
base.graphic.material.SetColor("_ShadowOutlineColor", ShadowOutlineColor);
base.graphic.material.SetFloat("_ShadowOutlineWidth", ShadowOutlineWidth);
}
}
void SetShaderChannels() {
if (base.graphic.canvas) {
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;
}
v2 = AdditionalCanvasShaderChannels.TexCoord3;
if ((v1 & v2) != v2) {
base.graphic.canvas.additionalShaderChannels |= v2;
}
}
}
#pragma warning disable 0114
// #if UNITY_EDITOR || UNITY_STANDALONE_WIN
protected void OnValidate() {
if (!bSetPreviewCanvas && Application.isEditor && gameObject.activeInHierarchy) {
var can = GetComponentInParent<Canvas>();
if (can != null) {
if ((can.additionalShaderChannels & AdditionalCanvasShaderChannels.TexCoord1) == 0 || (can.additionalShaderChannels & AdditionalCanvasShaderChannels.TexCoord2) == 0) {
if (can.name.Contains("(Environment)")) {
// 处于Prefab预览场景中(需要打开TexCoord1,2,3通道,否则Scene场景上会有显示问题(因为我们有用到uv1,uv2.uv3))
can.additionalShaderChannels |= AdditionalCanvasShaderChannels.TexCoord1 | AdditionalCanvasShaderChannels.TexCoord2 | AdditionalCanvasShaderChannels.TexCoord3;
} else {
// 不是处于Prefab预览场景中,但父级Canvas的TexCoord1和TexCoord2通道没打开
UnityEngine.Debug.LogWarning("Text2DOutline may display incorrect if TexCoord1, TexCoord2 and TexCoord3 Channels are not open at Canvas where Text2DOutline object in.");
}
}
bSetPreviewCanvas = true;
}
}
base.Invoke("OnValidate", 0);
if (CheckShader()) {
this.SetShaderParams();
this._Refresh();
}
if (base.graphic.material.GetHashCode() != iMatHash) {
base.graphic.material = new Material(Shader.Find(sOutlineShader));
iMatHash = base.graphic.material.GetHashCode();
}
}
protected void Reset() {
// base.Reset();
base.Invoke("Reset", 0);
if (base.graphic.material.shader.name != sOutlineShader) {
base.graphic.material = new Material(Shader.Find(sOutlineShader));
iMatHash = base.graphic.material.GetHashCode();
}
}
// #endif
#pragma warning restore 0114
public override void ModifyMesh(VertexHelper vh) {
var lVetexList = new List<UIVertex>();
vh.GetUIVertexStream(lVetexList);
this._ProcessVertices(lVetexList, this.OutlineWidth);
List<UIVertex> lShadowVerts = new List<UIVertex>();
if (bUseShadow) {
vh.GetUIVertexStream(lShadowVerts);
this._ProcessVertices(lShadowVerts, this.ShadowOutlineWidth);
ApplayShadow(lShadowVerts, shadowGradient.colorKeys[0].color);
if (bUseShadowGradient)
ApplyGradient(lShadowVerts, shadowGradient);
}
if (bUseTextGradient) {
ApplyGradient(lVetexList, textGradient);
}
vh.Clear();
// lVetexList = lShadowVerts.Concat(lVetexList).ToList();
vh.AddUIVertexTriangleStream(lShadowVerts.Concat(lVetexList).ToList());
lShadowVerts.Clear();
lVetexList.Clear();
}
private void ApplayShadow(List<UIVertex> _lShadowVerts, Color32 _color) {
for (var i = 0; i < _lShadowVerts.Count; i++) {
UIVertex vt = _lShadowVerts[i];
vt.position += new Vector3(ShadowOutlineOffset.x, ShadowOutlineOffset.y, 0);
var newColor = _color;
if (!bShadowAlphaStand)
newColor.a = (byte)((newColor.a * vt.color.a) / 255);
vt.color = newColor;
// uv3.x = -1用来传给Shader判断是文字还是阴影
vt.uv3.x = -1;
_lShadowVerts[i] = vt;
}
}
private void ApplyGradient(List<UIVertex> _verts, Gradient _gradient) {
if (_verts.Count == 0) return;
float topY = _verts[0].position.y;
float bottomY = _verts[0].position.y;
for (var i = 0; i < _verts.Count; i++) {
var y = _verts[i].position.y;
if (y > topY)
topY = y;
else if (y < bottomY)
bottomY = y;
}
float height = topY - bottomY;
for (var i = 0; i < _verts.Count; i++) {
var vt = _verts[i];
Color32 color = _gradient.Evaluate((topY - vt.position.y) / height);
// var color = Color32.Lerp (new Color(1, 1, 1, 0), new Color(1, 1, 1, 0), (vt.position.y - bottomY) / height);
color.a = (byte)((color.a * vt.color.a) / 255);
vt.color = color;
_verts[i] = vt;
}
}
// 添加描边后,为防止描边被网格边框裁切,需要将顶点外扩,同时保持UV不变
private void _ProcessVertices(List<UIVertex> lVerts, float outlineWidth) {
for (int i = 0, count = lVerts.Count - 3; i <= count; i += 3) {
var v1 = lVerts[i];
var v2 = lVerts[i + 1];
var v3 = lVerts[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);
// 为每个顶点设置新的Position和UV,并传入原始UV框
v1 = _SetNewPosAndUV(v1, outlineWidth, posCenter, triX, triY, uvX, uvY, uvMin, uvMax, this.ShadowOutlineOffset);
v2 = _SetNewPosAndUV(v2, outlineWidth, posCenter, triX, triY, uvX, uvY, uvMin, uvMax, this.ShadowOutlineOffset);
v3 = _SetNewPosAndUV(v3, outlineWidth, posCenter, triX, triY, uvX, uvY, uvMin, uvMax, this.ShadowOutlineOffset);
// 应用设置后的UIVertex
//
lVerts[i] = v1;
lVerts[i + 1] = v2;
lVerts[i + 2] = v3;
}
}
private static UIVertex _SetNewPosAndUV(UIVertex pVertex, float pOutLineWidth,
Vector2 pPosCenter,
Vector2 pTriangleX, Vector2 pTriangleY,
Vector2 pUVX, Vector2 pUVY,
Vector2 pUVOriginMin, Vector2 pUVOriginMax, Vector2 offset) {
// 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;
var uvOffsetX = pUVX / pTriangleX.magnitude * posXOffset * (Vector2.Dot(pTriangleX, Vector2.right) > 0 ? 1 : -1);
var 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;
return pVertex;
}
private void _Refresh() {
SetShaderParams();
base.graphic.SetVerticesDirty();
}
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));
}
} Shader代码:
Shader "TextEffect/Text2DOutline"
{
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
[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
// 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
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
struct appdata
{
float4 vertex : POSITION;
float4 tangent : TANGENT;
float4 normal : NORMAL;
float2 texcoord : TEXCOORD0;
float4 uv1 : TEXCOORD1;
float2 uv2 : TEXCOORD2;
float2 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;
float2 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;
}
fixed SampleAlpha(int pIndex, v2f IN, float width, fixed4 outlineColor)
{
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]) * width; //normal.z 存放 _OutlineWidth
return IsInRect(pos, IN.uv1, IN.uv2) * (tex2D(_MainTex, pos) + _TextureSampleAdd).a * outlineColor.a; //tangent.w 存放 _OutlineColor.w
}
fixed4 frag(v2f IN) : SV_Target
{
fixed4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;//默认的文字颜色
// 使用uv3.x来区分当前顶点是文字还是阴影
if ((IN.uv3.x != -1 && _OutlineWidth > 0) || (IN.uv3.x == -1 && _ShadowOutlineWidth > 0)) //normal.z 存放 _OutlineWidth
{
color.w *= IsInRect(IN.texcoord, IN.uv1, IN.uv2); //uv1 uv2 存着原始字的uv长方形区域大小
fixed4 outlineColor = IN.uv3.x == -1 ?_ShadowOutlineColor : _OutlineColor;
half4 val = half4(outlineColor.rgb, 0);
float outlineWidth = IN.uv3.x == -1 ? _ShadowOutlineWidth : _OutlineWidth;
//val 是 _OutlineColor的rgb,a是后面计算的
val.w += SampleAlpha(0, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(1, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(2, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(3, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(4, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(5, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(6, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(7, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(8, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(9, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(10, IN, outlineWidth, outlineColor);
val.w += SampleAlpha(11, IN, outlineWidth, outlineColor);
color = (val * (1.0 - color.a)) + (color * color.a);
color.a = saturate(color.a);
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
}
}
}