上一篇写了 Unity的TMP的纹理优化,核心是把 TMP Font Asset 内部的 SDF Atlas 提取出来,再按平台设置合适的纹理压缩格式。这篇继续写另一个方向:把描边、阴影这类每个文字可能不同的效果参数放进顶点数据里,避免为了不同颜色或不同宽度频繁创建材质实例。

优化目标

TMP 默认的 SDF Shader 已经支持描边、阴影、Glow 等效果,但这些参数大多来自 Material。只要某个文本的描边颜色、宽度不一样,就很容易产生新的材质实例。UI 里如果存在大量名字、伤害数字、状态文字,这部分材质数量和 Canvas 渲染批次都会变得不好控制。

我的做法是让材质只负责通用渲染逻辑,把每个文本独有的数据写入 Mesh 顶点通道。Shader 渲染每个字时,从顶点里读取自己的描边或阴影参数。这样多个文字可以共用同一个材质,同时又能显示不同效果。

这里替换为顶点工具 Inspector 截图

为什么可以把数据放进顶点

Unity 的 UI Mesh 顶点不只有 position、color、uv0。Canvas 还可以通过 additionalShaderChannels 额外传递 uv1、uv2、normal、tangent 等数据。对 TMP UGUI 来说,我们可以把自定义数据写入 Mesh 的 UV3,也就是代码里的 SetUVs(2, data),在 Shader 里对应 TEXCOORD2

这里不把数据写进 color,是因为 TMP 原本就会使用顶点颜色做文字颜色和透明度。也不写进 uv0,因为 uv0 要采样字体 Atlas。选择 TEXCOORD2 的好处是与 TMP 原有采样逻辑解耦,结构也足够清晰:一个 Vector4 就有四个 float 可以使用。

需要注意的是,UGUI 默认不会把所有额外通道都传给 Shader,所以必须打开 Canvas 的 TexCoord2

1
2
3
4
5
6
7
8
9
10
private void EnsureCanvasChannels()
{
Canvas canvas = GetComponentInParent<Canvas>();
if (canvas == null)
{
return;
}

canvas.additionalShaderChannels |= AdditionalCanvasShaderChannels.TexCoord2;
}

写入顶点数据

每次参数变化或文字内容变化时,先让 TMP 刷新 Mesh,然后遍历 textInfo.meshInfo。每个 sub mesh 都填入同一份效果数据,再写到 UV3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public void ApplyVertexEffect()
{
if (isApplying)
{
return;
}

CacheTextComponent();
if (textComponent == null)
{
return;
}

isApplying = true;
try
{
textComponent.ForceMeshUpdate(false, false);

TMP_TextInfo textInfo = textComponent.textInfo;
Vector4 effectData = PackEffectData();
TMP_MeshInfo[] meshInfos = textInfo.meshInfo;

for (int i = 0; i < meshInfos.Length; i++)
{
Mesh mesh = meshInfos[i].mesh;
if (mesh == null || mesh.vertexCount == 0)
{
continue;
}

FillChannel(effectChannel, mesh.vertexCount, effectData);
mesh.SetUVs(2, effectChannel);
textComponent.UpdateGeometry(mesh, i);
}

if (textComponent.mesh != null)
{
textComponent.canvasRenderer.SetMesh(textComponent.mesh);
}
}
finally
{
isApplying = false;
}
}

这段逻辑的关键是 mesh.SetUVs(2, effectChannel)。下标 2 对应 UV3,在 Shader 的输入结构里就是 texcoord2 : TEXCOORD2。只要顶点数一致,每个字的四个顶点都能拿到同一组效果参数。

数据如何设计

我把效果分成三种模式:

1
2
3
4
5
6
public enum TMPVertexEffectMode
{
None = 0,
Outline = 1,
Shadow = 2
}

Vector4 的四个分量被这样使用:

模式 x y z w
描边 R G B + width mode + direction
阴影 offsetX offsetY R + G mode + B + strength

描边和阴影不会同时作为像素效果写入同一个 Vector4,这样可以用同一套通道表达两套数据。弯曲效果不进 Shader,而是在 C# 端直接修改顶点位置,所以可以和描边或阴影叠加。

数据压缩原理

一个 Vector4 看起来有四个 float,但要表达描边颜色 RGB、宽度、方向,或者阴影颜色 RGB、偏移、强度和模式,直接存会不够。解决办法是把两个 0 到 1 的值压进一个 float。

压缩步骤是:

  1. 把两个归一化值分别量化成 0 到 255 的整数,也就是 8 bit。
  2. 第一个值左移 8 位放到高 8 位,第二个值放到低 8 位。
  3. 合成一个 0 到 65535 的 16 bit 整数。
  4. 再除以 65535,变回 0 到 1 的 float,方便走顶点通道传给 Shader。

核心代码如下:

1
2
3
4
5
6
private static float PackTwoBytes(float highValue, float lowValue)
{
int highByte = Mathf.RoundToInt(Mathf.Clamp01(highValue) * 255f);
int lowByte = Mathf.RoundToInt(Mathf.Clamp01(lowValue) * 255f);
return ((highByte << 8) | lowByte) / 65535f;
}

描边模式里,x/y 存颜色的 R/G,z 存 B 和描边宽度,w 存模式和描边方向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static Vector4 PackOutlineData(
Color color,
float width,
TMPVertexOutlineDirection direction)
{
int blue = Mathf.RoundToInt(Mathf.Clamp01(color.b) * 255f);
int widthByte = Mathf.RoundToInt(Mathf.Clamp01(width) * 255f);
float packedBlueAndWidth = ((blue << 8) | widthByte) / 65535f;
float packedModeAndDirection =
(float)TMPVertexEffectMode.Outline +
(direction == TMPVertexOutlineDirection.Outer ? 0.1f : 0f);

return new Vector4(
color.r,
color.g,
packedBlueAndWidth,
packedModeAndDirection);
}

阴影模式里,x/y 直接存偏移。颜色和强度被拆成两组压缩数据:z 存 R/G,w 的小数部分存 B/strength:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static Vector4 PackShadowData(
Color color,
Vector2 offset,
float strength)
{
float packedRedAndGreen = PackTwoBytes(color.r, color.g);
float packedBlueAndStrength = PackTwoBytes(color.b, strength);

return new Vector4(
offset.x,
offset.y,
packedRedAndGreen,
(float)TMPVertexEffectMode.Shadow + packedBlueAndStrength * 0.1f);
}

这里把阴影的 packedBlueAndStrength 乘以 0.1 放进 w 的小数部分,是为了让 w 的整数部分仍然能表示模式 2。Shader 端用四舍五入取模式,用 (w - 2) * 10 取回被压缩的数据。

这个方案的代价是精度从 float 降到 8 bit。颜色通道和宽度、强度用 0 到 255 的精度已经足够;阴影偏移没有压缩,仍然直接存 float。

Shader 解包

Shader 顶点输入结构需要增加 TEXCOORD2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct vertex_t
{
float4 position : POSITION;
fixed4 color : COLOR;
float2 texcoord0 : TEXCOORD0;
float2 texcoord1 : TEXCOORD1;
float4 texcoord2 : TEXCOORD2;
};

struct pixel_t
{
float4 position : SV_POSITION;
fixed4 color : COLOR;
float2 atlas : TEXCOORD0;
float4 param : TEXCOORD1;
float4 vertexEffect : TEXCOORD6;
};

顶点阶段只负责把数据传到片元阶段:

1
output.vertexEffect = float4(input.texcoord2.xyz, input.texcoord2.w);

解包函数与 C# 的打包规则对应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
float2 DecodePackedBlueAndWidth(float packedValue)
{
float packedInt = floor(saturate(packedValue) * 65535.0 + 0.5);
float highValue = floor(packedInt / 256.0) / 255.0;
float lowValue = (packedInt - floor(packedInt / 256.0) * 256.0) / 255.0;
return float2(highValue, lowValue);
}

float DecodeVertexEffectMode(float packedMode)
{
return floor(packedMode + 0.5);
}

float DecodeShadowPackedBlueAndStrength(float packedMode)
{
return saturate((packedMode - 2.0) * 10.0);
}

float DecodeOutlineDirection(float packedMode)
{
return step(0.5, saturate((packedMode - 1.0) * 10.0));
}

片元阶段先判断当前模式,再决定是使用材质上的描边参数,还是使用顶点传进来的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float effectMode = DecodeVertexEffectMode(input.vertexEffect.w);
float outlineMode = 1.0 - step(0.5, abs(effectMode - 1.0));
float shadowMode = 1.0 - step(0.5, abs(effectMode - 2.0));

float2 decodedOutline = DecodePackedBlueAndWidth(input.vertexEffect.z);
float vertexOutlineWidth = decodedOutline.y * outlineMode;

half4 vertexOutlineColor =
half4(input.vertexEffect.rg, decodedOutline.x, 1);

half4 outlineColor = lerp(
_OutlineColor,
vertexOutlineColor,
step(0.0001, vertexOutlineWidth));

阴影也是同理。偏移从 x/y 直接取,颜色和强度从压缩值里解出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
float2 decodedShadowRedAndGreen =
DecodePackedBlueAndWidth(input.vertexEffect.z);

float2 decodedShadowBlueAndStrength =
DecodePackedBlueAndWidth(
DecodeShadowPackedBlueAndStrength(input.vertexEffect.w));

float2 vertexShadowOffset =
-input.vertexEffect.xy * _GradientScale /
float2(_TextureWidth, _TextureHeight);

float shadowSample = tex2D(_MainTex, input.atlas + vertexShadowOffset).a;
float shadowAlpha =
saturate((shadowSample - 0.5) * scale + 0.5) *
decodedShadowBlueAndStrength.y *
shadowMode;

half4 vertexShadowColor = half4(
decodedShadowRedAndGreen.x,
decodedShadowRedAndGreen.y,
decodedShadowBlueAndStrength.x,
1);

vertexShadowColor.a *= shadowAlpha;
vertexShadowColor.rgb *= vertexShadowColor.a;
faceColor += vertexShadowColor * (1 - faceColor.a);

弯曲效果

弯曲没有必要进 Shader,因为它本质上是改顶点位置,不是像素颜色。这里直接根据文字包围盒把 x 坐标归一化到 0 到 1,再用 AnimationCurve 得到 y 方向偏移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void ApplyBend(TMP_MeshInfo meshInfo)
{
Vector3[] vertices = meshInfo.vertices;
Bounds bounds = textComponent.textBounds;
float width = Mathf.Max(bounds.size.x, 0.001f);
float bendScale = bendStrength * width * 0.25f;
AnimationCurve curve = bendCurve ?? CreateArchCurve();

for (int i = 0; i < vertices.Length; i++)
{
float normalizedX =
Mathf.InverseLerp(bounds.min.x, bounds.max.x, vertices[i].x);
vertices[i].y += curve.Evaluate(normalizedX) * bendScale;
}

meshInfo.mesh.vertices = vertices;
meshInfo.mesh.RecalculateBounds();
}

这种方式的好处是弯曲可以和描边、阴影同时存在:弯曲改 position,描边和阴影读 TEXCOORD2,两者互不抢数据。

使用方式

我额外加了一个创建入口:GameObject/UI/TMP Vertex Outline Text。它会自动创建 TMP 文本、挂上 TMPVertexOutlineUGUI,并切换到自定义 Shader 材质。

已有的 TextMeshProUGUI 也可以在组件右键菜单中选择 Add Vertex Outline Controller。如果手动配置,需要确认两件事:

  1. 文本材质使用 TextMeshPro/Distance Field Vertex Outline
  2. 所在 Canvas 开启了 AdditionalCanvasShaderChannels.TexCoord2

这里替换为创建菜单或组件右键菜单截图

弯曲、描边和阴影总效果

总结

这套方案的核心是把“材质级参数”变成“顶点级参数”。材质只保留一份通用 Shader,具体颜色、宽度、偏移、强度都从顶点通道读取。数据量不大时,用两个 8 bit 值压进一个 float,可以在不额外增加通道的情况下放下更多参数。

它适合用于大量 TMP UGUI 文本需要不同描边或阴影的场景。限制也很明确:写入顶点后,文字内容变化、字体变化、Mesh 刷新时都要重新写入;如果还想扩展更多效果,需要重新规划 Vector4 的字段分配,或者启用更多顶点通道。

项目地址

OptimizeUnityTMP

参考文章

TMP(TextMeshPro)优化篇 — 描边投影效果