上一篇写了 Unity的TMP的纹理优化,核心是把 TMP Font Asset 内部的 SDF Atlas 提取出来,再按平台设置合适的纹理压缩格式。这篇继续写另一个方向:把描边、阴影这类每个文字可能不同的效果参数放进顶点数据里,避免为了不同颜色或不同宽度频繁创建材质实例。
优化目标
TMP 默认的 SDF Shader 已经支持描边、阴影、Glow 等效果,但这些参数大多来自 Material。只要某个文本的描边颜色、宽度不一样,就很容易产生新的材质实例。UI 里如果存在大量名字、伤害数字、状态文字,这部分材质数量和 Canvas 渲染批次都会变得不好控制。
我的做法是让材质只负责通用渲染逻辑,把每个文本独有的数据写入 Mesh 顶点通道。Shader 渲染每个字时,从顶点里读取自己的描边或阴影参数。这样多个文字可以共用同一个材质,同时又能显示不同效果。

为什么可以把数据放进顶点
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。
压缩步骤是:
- 把两个归一化值分别量化成 0 到 255 的整数,也就是 8 bit。
- 第一个值左移 8 位放到高 8 位,第二个值放到低 8 位。
- 合成一个 0 到 65535 的 16 bit 整数。
- 再除以 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。如果手动配置,需要确认两件事:
- 文本材质使用
TextMeshPro/Distance Field Vertex Outline。
- 所在 Canvas 开启了
AdditionalCanvasShaderChannels.TexCoord2。


总结
这套方案的核心是把“材质级参数”变成“顶点级参数”。材质只保留一份通用 Shader,具体颜色、宽度、偏移、强度都从顶点通道读取。数据量不大时,用两个 8 bit 值压进一个 float,可以在不额外增加通道的情况下放下更多参数。
它适合用于大量 TMP UGUI 文本需要不同描边或阴影的场景。限制也很明确:写入顶点后,文字内容变化、字体变化、Mesh 刷新时都要重新写入;如果还想扩展更多效果,需要重新规划 Vector4 的字段分配,或者启用更多顶点通道。
项目地址
OptimizeUnityTMP
参考文章
TMP(TextMeshPro)优化篇 — 描边投影效果