dark_字体样式方案分析

TMP 文本样式笔记

1. 项目里的 TMPEffectHelper

这个项目里的 TMPEffectHelper 是一个挂在 TextMeshProUGUI 上的文本特效组件,主要负责:

  • 描边
  • 阴影

它的核心思路是:把文本视觉效果从 LeanText 中拆出来,独立管理。

原来的职责大概混在一起:

1
2
3
4
LeanText
-> 语言
-> 字体
-> 描边

现在更像是:

1
2
3
4
5
6
7
LeanText
-> 语言
-> 字体

TMPEffectHelper
-> 描边
-> 阴影

LeanText 在字体加载或切换完成后,如果文本上挂了 TMPEffectHelper,就调用:

1
tmpEffectHelper.Apply();

重新应用描边和阴影效果。


2. TMPEffectHelper 的大致结构

简化结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[ExecuteAlways]
[RequireComponent(typeof(TextMeshProUGUI))]
public class TMPEffectHelper : MonoBehaviour
{
public bool overrideOutline = true;
public Color outlineColor = Color.black;
public float outlineWidth = 0.1f;
public bool isOuterStroke = true;
public float faceDilate = 0f;

public bool overrideUnderlay = true;
public Color underlayColor = new Color(0, 0, 0, 0.9f);
public float underlayOffsetX = 0.8f;
public float underlayOffsetY = -0.8f;
public float underlayDilate = 0f;
public float underlaySoftness = 0.1f;

private TextMeshProUGUI _text;

private void OnEnable() => Apply();
}

这些参数可以直接在 Prefab / Inspector 上调整。


3. 它使用的 TMP 技术

TMPEffectHelper 使用的是 TextMeshPro 的 SDF 材质能力。

描边:TMP Outline

描边使用 TMP Shader 里的 Outline 参数:

1
2
3
ShaderUtilities.ID_OutlineColor
ShaderUtilities.ID_OutlineWidth
ShaderUtilities.Keyword_Outline

对应效果:

1
2
3
_OutlineColor:描边颜色
_OutlineWidth:描边宽度
Keyword_Outline:启用描边功能

阴影:TMP Underlay

阴影使用 TMP Shader 里的 Underlay 参数:

1
2
3
4
5
6
ShaderUtilities.ID_UnderlayColor
ShaderUtilities.ID_UnderlayOffsetX
ShaderUtilities.ID_UnderlayOffsetY
ShaderUtilities.ID_UnderlayDilate
ShaderUtilities.ID_UnderlaySoftness
ShaderUtilities.Keyword_Underlay

对应效果:

1
2
3
4
5
6
_UnderlayColor:阴影颜色
_UnderlayOffsetX:阴影 X 偏移
_UnderlayOffsetY:阴影 Y 偏移
_UnderlayDilate:阴影膨胀
_UnderlaySoftness:阴影柔和度
Keyword_Underlay:启用阴影功能

注意:这里的“阴影”不是 Unity UI 的 Shadow 组件,而是 TMP Shader 自带的 Underlay 效果。


4. Apply 的核心逻辑

Apply() 的核心流程可以理解为:

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
public void Apply()
{
if (_text == null)
_text = GetComponent<TextMeshProUGUI>();

if (_text == null || _text.fontSharedMaterial == null)
return;

if (overrideOutline)
{
_text.SetOutlineColor(outlineColor);
_text.SetOutlineWidth(outlineWidth);

if (isOuterStroke)
_text.SetFaceDilate(faceDilate);
else
_text.SetFaceDilate(-outlineWidth);
}
else
{
_text.DisableOutline();
_text.SetOutlineWidth(0f);
_text.SetFaceDilate(0f);
}

if (overrideUnderlay)
{
_text.SetUnderlayColor(underlayColor);
_text.SetUnderlayOffset(underlayOffsetX, underlayOffsetY);
_text.SetUnderlayDilate(underlayDilate);
_text.SetUnderlaySoftness(underlaySoftness);
}
else
{
_text.DisableUnderlay();
}

_text.UpdateMeshPadding();
_text.SetMaterialDirty();
_text.SetVerticesDirty();
_text.RecalculateMasking();
}

这里有几个重点:

  • overrideOutline 控制是否接管描边。
  • overrideUnderlay 控制是否接管阴影。
  • isOuterStroke 控制外描边/偏内描边。
  • UpdateMeshPadding() 用来避免描边或阴影被裁切。
  • SetMaterialDirty()SetVerticesDirty() 用来通知 TMP 刷新渲染。

5. 写入 TMP 材质参数

描边宽度的写法类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void SetOutlineWidth(this TextMeshProUGUI text, float width)
{
Material mat = text.fontMaterial;

if (mat.HasProperty(ShaderUtilities.ID_OutlineWidth))
{
mat.SetFloat(ShaderUtilities.ID_OutlineWidth, width);
mat.EnableKeyword(ShaderUtilities.Keyword_Outline);
}

text.UpdateMeshPadding();
text.SetMaterialDirty();
}

描边颜色类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void SetOutlineColor(this TextMeshProUGUI text, Color color)
{
Material mat = text.fontMaterial;

if (mat.HasProperty(ShaderUtilities.ID_OutlineColor))
{
mat.SetColor(ShaderUtilities.ID_OutlineColor, color);
mat.EnableKeyword(ShaderUtilities.Keyword_Outline);
}

text.UpdateMeshPadding();
text.SetMaterialDirty();
}

阴影颜色类似:

1
2
3
4
5
6
7
8
9
10
11
12
public static void SetUnderlayColor(this TextMeshProUGUI text, Color color)
{
Material mat = text.fontMaterial;

if (mat.HasProperty(ShaderUtilities.ID_UnderlayColor))
{
mat.SetColor(ShaderUtilities.ID_UnderlayColor, color);
mat.EnableKeyword(ShaderUtilities.Keyword_Underlay);
}

text.SetMaterialDirty();
}

阴影偏移类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void SetUnderlayOffset(
this TextMeshProUGUI text,
float offsetX,
float offsetY)
{
Material mat = text.fontMaterial;

if (mat.HasProperty(ShaderUtilities.ID_UnderlayOffsetX) &&
mat.HasProperty(ShaderUtilities.ID_UnderlayOffsetY))
{
mat.SetFloat(ShaderUtilities.ID_UnderlayOffsetX, offsetX);
mat.SetFloat(ShaderUtilities.ID_UnderlayOffsetY, offsetY);
mat.EnableKeyword(ShaderUtilities.Keyword_Underlay);
}

text.UpdateMeshPadding();
text.SetMaterialDirty();
}

6. 配置驱动样式

项目里有活动标题样式配置,例如 wxcc.style

格式是:

1
fontColor|outlineColor|outlineWidth*10000|underlayColor|underlayDilate*10000

示例:

1
#FFFFFF|#1A4684|3000|#913A0D|3000

含义是:

1
2
3
4
5
字体颜色:#FFFFFF
描边颜色:#1A4684
描边宽度:3000 / 10000 = 0.3
阴影颜色:#913A0D
阴影大小:3000 / 10000 = 0.3

业务中应用时大概是:

1
2
3
4
5
6
7
8
9
titleLabel.color = data.StyleFontColor.Value;

effectHelper.outlineColor = data.StyleOutlineColor.Value;
effectHelper.outlineWidth = data.StyleOutlineWidth;

effectHelper.underlayColor = data.StyleUnderlayColor.Value;
effectHelper.underlayDilate = data.StyleUnderlayDilate;

effectHelper.Apply();

这个链路是:

1
2
3
4
5
配置字符串
-> 解析成颜色和数值
-> 写入 TMPEffectHelper
-> Apply
-> 修改 TMP 材质参数

7. 这个项目方案的优点

TMPEffectHelper 的优点:

  • 把描边、阴影从 LeanText 中拆出来,职责更清晰。
  • 使用 TMP 原生 SDF 材质能力,不需要复制多个 Text 节点。
  • Prefab 上能直观看到文本特效参数。
  • 支持少量配置驱动文本样式。
  • 调用了 UpdateMeshPadding(),考虑了描边/阴影被裁切的问题。

适合使用在:

1
2
3
4
活动标题
重点按钮
特殊标题
少量需要配置驱动的文本

8. 当前实现的隐患

当前实现的风险主要来自:

1
Material mat = text.fontMaterial;

text.fontMaterial 可能会让 TMP 为当前文本创建一个独立材质实例。

如果大量文本都这样做,可能导致:

1
2
3
4
5
材质实例数量增加
相同样式无法复用
Canvas 合批变差
Draw Call 增加
内存压力增加

准确地说,这不是 Shader 实例爆炸,而是 Material instance 变多。

所以当前 TMPEffectHelper 更适合少量特殊文本,不适合无脑挂满所有普通文本。


一般工程的 TMP 文本样式处理

9. 一般工程的思路

一般项目不会让每个文本自由设置描边、阴影参数,而是先约定一套统一文本样式。

例如:

1
2
3
4
5
6
Title_Gold
Button_White_BlackOutline
Desc_Normal
Warning_Red
Quality_Purple
BattleDamage_Crit

业务代码或配置只选择样式:

1
TextStyle.Apply(text, TextStyleId.Title_Gold);

而不是散写:

1
2
3
描边色 #1A4684
描边宽 0.3
阴影色 #913A0D

这样可以保证:

  • 风格统一
  • 材质数量可控
  • 美术和程序沟通成本低
  • 后期改版方便
  • 性能更可预期

10. 背后的核心技术:SDF 字体

TextMeshPro 的核心技术是 SDF 字体。

SDF 全称是 Signed Distance Field,可以简单理解为:

1
2
字体贴图中记录的不是单纯的“有没有字”,
而是“当前像素距离字形边缘有多远”。

因为 Shader 知道距离边缘的远近,所以可以很方便地做:

1
2
3
4
5
6
描边
阴影
发光
柔边
膨胀
收缩

TMP 的这些效果都依赖 SDF 材质系统:

1
2
3
4
5
Outline:描边
Underlay:阴影/底影
Glow:发光
Face Dilate:字面膨胀或收缩
Softness:柔边

11. 常见方案一:TMP Material Preset

固定样式通常使用 TMP Material Preset。

它是在 Unity 编辑器里提前做好的 TMP 材质预设。

例如同一个中文字体,可以提前做几份材质:

1
2
3
4
Chinese_White_BlackOutline.mat
Chinese_Gold_BrownOutline.mat
Chinese_Red_Shadow.mat
Chinese_Normal.mat

Prefab 上的 TextMeshProUGUI 直接引用这些材质。

优点:

  • 不需要运行时创建材质。
  • 美术可以直接调整材质参数。
  • 材质数量可控。
  • 合批和性能更可预期。
  • 适合大量固定 UI 文本。

适合:

1
2
3
4
5
按钮文字
普通标题
通用描述
奖励数量
品质颜色

12. 常见方案二:Runtime Material Cache

如果样式来自活动配置,不能全部提前做好,就可以运行时生成材质,但要缓存。

核心思路是:用样式参数组成 key。

1
font + faceColor + outlineColor + outlineWidth + underlayColor + underlayOffset + softness

相同 key 只创建一份材质。

示例:

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
public static Material GetMaterial(TMP_FontAsset font, TextStyle style)
{
string key = BuildKey(font, style);

if (cache.TryGetValue(key, out Material cached))
return cached;

Material mat = new Material(font.material);

if (style.useOutline)
{
mat.SetColor(ShaderUtilities.ID_OutlineColor, style.outlineColor);
mat.SetFloat(ShaderUtilities.ID_OutlineWidth, style.outlineWidth);
mat.EnableKeyword(ShaderUtilities.Keyword_Outline);
}

if (style.useUnderlay)
{
mat.SetColor(ShaderUtilities.ID_UnderlayColor, style.underlayColor);
mat.SetFloat(ShaderUtilities.ID_UnderlayOffsetX, style.underlayOffsetX);
mat.SetFloat(ShaderUtilities.ID_UnderlayOffsetY, style.underlayOffsetY);
mat.SetFloat(ShaderUtilities.ID_UnderlayDilate, style.underlayDilate);
mat.SetFloat(ShaderUtilities.ID_UnderlaySoftness, style.underlaySoftness);
mat.EnableKeyword(ShaderUtilities.Keyword_Underlay);
}

cache[key] = mat;
return mat;
}

应用时:

1
2
3
4
text.color = style.faceColor;
text.fontSharedMaterial = TextStyleManager.GetMaterial(text.font, style);
text.UpdateMeshPadding();
text.SetMaterialDirty();

这里重点是使用:

1
text.fontSharedMaterial = cachedMaterial;

而不是:

1
text.fontMaterial.SetFloat(...)

前者复用材质,后者容易让每个文本生成自己的材质实例。


13. 推荐的工程结构

比较稳的结构是:

1
2
3
4
TextStyleId
-> TextStyleManager
-> TMP Material Preset / Runtime Material Cache
-> TextMeshProUGUI

固定样式:

1
2
TextStyleId
-> 直接映射到 TMP Material Preset

动态样式:

1
2
3
4
配置参数
-> 解析成 TextStyle
-> Runtime Material Cache
-> 复用材质

普通颜色变化:

1
2
只改 text.color
不动材质

特殊文本:

1
少量使用 TMPEffectHelper

14. 总结

这个项目里的 TMPEffectHelper 做的是:

1
2
3
把描边、阴影组件化
通过 TMP Outline / Underlay 修改字体材质参数
支持 Prefab 配置和少量活动配置驱动

它的方向是对的,适合管理少量重点文本效果。

但当前实现直接修改:

1
text.fontMaterial

如果大规模使用,可能导致材质实例过多。

一般工程更推荐:

1
2
3
4
固定样式用 TMP Material Preset
动态样式用 Runtime Material Cache
业务层只选择 TextStyleId
普通颜色只改 text.color

最终目标是:

1
2
3
4
5
样式统一
材质复用
性能可控
维护方便