前一篇文章讲了uv.y的数据来源,本篇来看看uv.x的数据来源
一、uv2 的结构:每个字符的四个顶点
在 TMP 的顶点生成过程中,每个字符由四个顶点构成:
- 左下(vertex_BL)
- 左上(vertex_TL)
- 右上(vertex_TR)
- 右下(vertex_BR)
uv2 存储在这四个顶点中,用于后续 Shader 中的效果控制。
public partial class TextMeshProUGUI
{
protected virtual void GenerateTextMesh()
{
#region Handle UV Mapping Options
switch (m_horizontalMapping)
{
case TextureMappingOptions.Line:
characterInfos[i].vertex_BL.uv2.x = 0; // 示例,实际根据不同模式计算
// …
}
#endregion
}
}
✅ uv2 分别存放每个字符四个顶点的坐标,用来实现逐字、逐行、段落级的视觉效果。
二、TextureMappingOptions:四种映射模式
uv2 的计算方式取决于 m_horizontalMapping 设置,其类型定义如下:
public enum TextureMappingOptions
{
Character = 0,
Line = 1,
Paragraph = 2,
MatchAspect = 3
};
不同模式下,uv2.x 的归一化范围不同,从而实现不同的视觉效果。
1. Character 模式:以字符为单位

如图所示:
- 字符 A:uv2.BL = (0,0),uv2.TR = (1,1)
- 字符 g:uv2.BL = (0,0),uv2.TR = (1,1)
✅ 所有字符都拥有相同的 UV 范围,彼此独立。
case TextureMappingOptions.Character:
characterInfos[i].vertex_BL.uv2.x = 0; // + m_uvOffset.x;
characterInfos[i].vertex_TL.uv2.x = 0; // + m_uvOffset.x;
characterInfos[i].vertex_TR.uv2.x = 1; // + m_uvOffset.x;
characterInfos[i].vertex_BR.uv2.x = 1; // + m_uvOffset.x;
break;
- 每个字符的 uv2.x 独立归一化到 [0,1]
- 适用于逐字动画、颜色渐变等效果
2. Line 模式:以行为单位

case TextureMappingOptions.Line:
if (m_textAlignment != TextAlignmentOptions.Justified)
{
characterInfos[i].vertex_BL.uv2.x = (characterInfos[i].vertex_BL.position.x – lineExtents.min.x) / (lineExtents.max.x – lineExtents.min.x) + uvOffset;
characterInfos[i].vertex_TL.uv2.x = (characterInfos[i].vertex_TL.position.x – lineExtents.min.x) / (lineExtents.max.x – lineExtents.min.x) + uvOffset;
characterInfos[i].vertex_TR.uv2.x = (characterInfos[i].vertex_TR.position.x – lineExtents.min.x) / (lineExtents.max.x – lineExtents.min.x) + uvOffset;
characterInfos[i].vertex_BR.uv2.x = (characterInfos[i].vertex_BR.position.x – lineExtents.min.x) / (lineExtents.max.x – lineExtents.min.x) + uvOffset;
break;
}
else // 两端对齐(Justified)特殊处理
{
characterInfos[i].vertex_BL.uv2.x = (characterInfos[i].vertex_BL.position.x + justificationOffset.x – m_meshExtents.min.x) / (m_meshExtents.max.x – m_meshExtents.min.x) + uvOffset;
characterInfos[i].vertex_TL.uv2.x = (characterInfos[i].vertex_TL.position.x + justificationOffset.x – m_meshExtents.min.x) / (m_meshExtents.max.x – m_meshExtents.min.x) + uvOffset;
characterInfos[i].vertex_TR.uv2.x = (characterInfos[i].vertex_TR.position.x + justificationOffset.x – m_meshExtents.min.x) / (m_meshExtents.max.x – m_meshExtents.min.x) + uvOffset;
characterInfos[i].vertex_BR.uv2.x = (characterInfos[i].vertex_BR.position.x + justificationOffset.x – m_meshExtents.min.x) / (m_meshExtents.max.x – m_meshExtents.min.x) + uvOffset;
break;
}
lineExtents 的生成逻辑
// 更新当前行的包围盒
m_textInfo.lineInfo[currentLine].lineExtents.min = new Vector2(
m_textInfo.characterInfo[m_textInfo.lineInfo[currentLine].firstCharacterIndex].bottomLeft.x,
m_textInfo.lineInfo[currentLine].descender
);
m_textInfo.lineInfo[currentLine].lineExtents.max = new Vector2(
m_textInfo.characterInfo[m_textInfo.lineInfo[currentLine].lastVisibleCharacterIndex].topRight.x,
m_textInfo.lineInfo[currentLine].ascender
);
- X 范围:从行首字符的 bottomLeft 到行尾字符的 topRight
- Y 范围:从 descender(降部)到 ascender(升部)
形成一个包围盒 abde:
✅ Line 模式适用于整行统一效果,如波浪、扫描、渐变。
3. Paragraph 模式:以段落为单位

case TextureMappingOptions.Paragraph:
characterInfos[i].vertex_BL.uv2.x = (characterInfos[i].vertex_BL.position.x + justificationOffset.x – m_meshExtents.min.x) / (m_meshExtents.max.x – m_meshExtents.min.x) + uvOffset;
characterInfos[i].vertex_TL.uv2.x = (characterInfos[i].vertex_TL.position.x + justificationOffset.x – m_meshExtents.min.x) / (m_meshExtents.max.x – m_meshExtents.min.x) + uvOffset;
characterInfos[i].vertex_TR.uv2.x = (characterInfos[i].vertex_TR.position.x + justificationOffset.x – m_meshExtents.min.x) / (m_meshExtents.max.x – m_meshExtents.min.x) + uvOffset;
characterInfos[i].vertex_BR.uv2.x = (characterInfos[i].vertex_BR.position.x + justificationOffset.x – m_meshExtents.min.x) / (m_meshExtents.max.x – m_meshExtents.min.x) + uvOffset;
break;
- 使用 m_meshExtents(整个文本的包围盒)进行归一化
- 适用于多行文本的整体效果控制
m_meshExtents 的计算
// 遍历所有字符,更新全局包围盒
if (m_textInfo.characterInfo[m_characterCount].isVisible)
{
m_meshExtents.min.x = Mathf.Min(m_meshExtents.min.x, m_textInfo.characterInfo[m_characterCount].bottomLeft.x);
m_meshExtents.min.y = Mathf.Min(m_meshExtents.min.y, m_textInfo.characterInfo[m_characterCount].bottomLeft.y);
m_meshExtents.max.x = Mathf.Max(m_meshExtents.max.x, m_textInfo.characterInfo[m_characterCount].topRight.x);
m_meshExtents.max.y = Mathf.Max(m_meshExtents.max.y, m_textInfo.characterInfo[m_characterCount].topRight.y);
}
| m_meshExtents 是什么? | 整个文本的总包围盒 |
| lineExtents 是什么? | 每行的包围盒 |
| X 范围区别? | mesh 是全局最值,line 是行内首尾字符 |
| Y 范围区别? | line 用 descender/ascender,mesh 用实际顶点 |
| 用途区别? | line 用于行级效果,mesh 用于全局效果 |
4. MatchAspect 模式:匹配宽高比
它和 Paragraph 一样,也会把所有的多行文字(包括逗号、换行)视为一个整体的包围盒,但它的核心区别在于:它强制保持 UV 的 X 轴和 Y 轴缩放比例为 1:1,绝不会让贴图变形。
case TextureMappingOptions.MatchAspect:
switch (m_verticalMapping)
{
case TextureMappingOptions.Character:
characterInfos[i].vertex_BL.uv2.y = 0; // + m_uvOffset.y;
characterInfos[i].vertex_TL.uv2.y = 1; // + m_uvOffset.y;
characterInfos[i].vertex_TR.uv2.y = 0; // + m_uvOffset.y;
characterInfos[i].vertex_BR.uv2.y = 1; // + m_uvOffset.y;
break;
case TextureMappingOptions.Line:
characterInfos[i].vertex_BL.uv2.y = (characterInfos[i].vertex_BL.position.y – lineExtents.min.y) / (lineExtents.max.y – lineExtents.min.y) + uvOffset;
characterInfos[i].vertex_TL.uv2.y = (characterInfos[i].vertex_TL.position.y – lineExtents.min.y) / (lineExtents.max.y – lineExtents.min.y) + uvOffset;
characterInfos[i].vertex_TR.uv2.y = characterInfos[i].vertex_BL.uv2.y;
characterInfos[i].vertex_BR.uv2.y = characterInfos[i].vertex_TL.uv2.y;
break;
case TextureMappingOptions.Paragraph:
characterInfos[i].vertex_BL.uv2.y = (characterInfos[i].vertex_BL.position.y – m_meshExtents.min.y) / (m_meshExtents.max.y – m_meshExtents.min.y) + uvOffset;
characterInfos[i].vertex_TL.uv2.y = (characterInfos[i].vertex_TL.position.y – m_meshExtents.min.y) / (m_meshExtents.max.y – m_meshExtents.min.y) + uvOffset;
characterInfos[i].vertex_TR.uv2.y = characterInfos[i].vertex_BL.uv2.y;
characterInfos[i].vertex_BR.uv2.y = characterInfos[i].vertex_TL.uv2.y;
break;
case TextureMappingOptions.MatchAspect:
Debug.Log("ERROR: Cannot Match both Vertical & Horizontal.");
break;
}
- 根据垂直映射方式(m_verticalMapping)决定 uv2.y 的归一化范围
- 用于保持 SDF 纹理的各向同性,避免拉伸模糊
三、UV 归一化与 Pack 编码
在计算完原始 uv2 后,TMP 会对其进行归一化处理,确保即使原始坐标超过 1,也能映射到 [0,1] 区间。
float x0 = characterInfos[i].vertex_BL.uv2.x;
float y0 = characterInfos[i].vertex_BL.uv2.y;
float x1 = characterInfos[i].vertex_TR.uv2.x;
float y1 = characterInfos[i].vertex_TR.uv2.y;
float dx = (int)x0;
float dy = (int)y0;
x0 = x0 – dx;
x1 = x1 – dx;
y0 = y0 – dy;
y1 = y1 – dy;
// 将归一化后的 UV 打包进一个 float
characterInfos[i].vertex_BL.uv2.x = PackUV(x0, y0); characterInfos[i].vertex_BL.uv2.y = xScale;
characterInfos[i].vertex_TL.uv2.x = PackUV(x0, y1); characterInfos[i].vertex_TL.uv2.y = xScale;
characterInfos[i].vertex_TR.uv2.x = PackUV(x1, y1); characterInfos[i].vertex_TR.uv2.y = xScale;
characterInfos[i].vertex_BR.uv2.x = PackUV(x1, y0); characterInfos[i].vertex_BR.uv2.y = xScale;
PackUV 编码方法
public abstract class TMP_Text : MaskableGraphic
{
protected float PackUV(float x, float y)
{
double x0 = (int)(x * 511);
double y0 = (int)(y * 511);
return (float)((x0 * 4096) + y0);
}
}
编码原理:
- x 和 y 归一化到 [0,1]
- 乘以 511 → 得到 [0,511] 的整数(9 位精度)
- x0 * 4096 + y0:4096 = 2^12,因此 x 占高 9 位,y 占低 9 位
- 最终打包为一个 float,在 Shader 中通过 frac() 和 floor() 解包
✅ 这种编码方式极大节省了顶点数据量,是 TMP 高效渲染的关键设计之一。
四、总结
| Character | 每个字符独立 [0,1] | 逐字动画、独立效果 |
| Line | 当前行内归一化 | 行级波浪、扫描 |
| Paragraph | 全文本归一化 | 段落级渐变、扭曲 |
| MatchAspect | 匹配字体图集宽高比 | 保持 SDF 清晰度 |
uv2 的完整用途:
- uv2.x:归一化位置(由映射模式决定)
- uv2.y:xScale(SDF 缩放、粗体标识、字符宽度调整)
五、结语
TMP 通过复用 uv2 通道,实现了顶点数据的极致压缩与渲染控制的灵活扩展。
提示:在 Shader 中使用 uv2 时,记得先调用 UnpackUV() 解码,再根据需求实现效果。
参考资料:
- Unity TextMeshPro 源码(v3.0.9)

