Unity UI文字排版进阶:基于ModifyMesh实现字间距与行间距调整 📅 2026/8/9 5:47:12 1. 项目概述为什么UI文字排版值得你投入精力在Unity项目开发中UI界面的视觉呈现是玩家或用户的第一印象。一个按钮、一段说明文字如果排版拥挤、行距过密即便功能再强大也会给人一种廉价、粗糙的感觉。很多开发者尤其是刚入行的朋友常常会忽略UI文字排版的细节认为Unity内置的Text或TextMeshProTMP组件提供的默认间距就“够用”了。直到项目临近上线美术或策划指着屏幕说“这里的字太挤了能不能拉开一点”时你才会发现Unity的UI组件并没有直接提供调整字间距Letter Spacing和行间距Line Spacing的滑块。这就是“ModifyMesh”技术登场的场景。它不是一个现成的组件而是一种底层渲染流程中的回调机制允许我们在UI元素如Text的网格Mesh被提交给GPU渲染之前动态地修改其顶点数据。通过介入这个流程我们可以精确地移动每一个文字即每一个字符网格的位置从而实现字间距的调整同样我们也可以整体偏移每一行文字的顶点来控制行间距。这听起来有点“黑客”的味道但它正是Unity UI系统留给我们的、用于实现高级自定义效果的合法后门。掌握这项技术意味着你不再受限于UI系统的默认表现。无论是需要营造复古打字机那种紧凑的等宽字体效果还是实现时尚杂志般的宽松行距你都能通过代码精准控制。这对于提升游戏品质、打造独特的视觉风格乃至满足严格的本地化排版需求例如某些语言字符间距就是更宽都至关重要。接下来我将手把手带你深入ModifyMesh的底层从原理到实践彻底搞定UI文字排版。2. 核心原理ModifyMesh与UI渲染管线在动手写代码之前我们必须搞清楚Unity UI的渲染机制以及ModifyMesh在其中的位置。这能让你明白我们究竟在修改什么以及为什么这样修改是有效的。2.1 Canvas与网格重建Unity的UI系统基于Canvas画布构建。所有UI元素包括Image、RawImage、Text、TextMeshPro - Text等本质上都是通过生成网格由三角形构成的几何体来绘制的。Text组件的工作流程是根据你设置的文本内容、字体、大小、对齐方式等属性在CPU端计算出每个字符的位置和UV纹理坐标然后生成一个包含所有字符顶点数据的网格。当文本内容或其属性如文字、字体大小、对齐方式发生变化时UI系统会触发一次“网格重建”Rebuild。这个过程包括布局重建Layout Rebuild计算UI元素的矩形大小和位置。图形重建Graphic Rebuild为需要绘制的UI元素如Text、Image生成或更新其网格数据。我们的目标就是在图形重建过程中网格数据已经生成但尚未提交渲染的“最后一刻”介入并修改它。2.2 IMeshModifier接口与ModifyMesh方法Unity为UI元素提供了IMeshModifier接口。任何继承了MonoBehaviour并实现了此接口的脚本都可以挂载到UI元素上并参与到其网格修改流程中。该接口的核心是一个方法void ModifyMesh(Mesh mesh); // 或者较新版本 void ModifyMesh(VertexHelper vertexHelper);Mesh mesh旧版直接传入一个Unity的Mesh对象。你可以通过mesh.vertices等数组直接访问和修改顶点数据。但这种方式效率较低且需要处理索引、法线等更多数据。VertexHelper vertexHelper新版/推荐这是一个封装好的辅助类它以一种更高效、更易用的方式提供了对网格顶点数据的访问。它内部管理着顶点列表和三角形索引列表。我们主要使用这个版本。当UI元素的网格需要重建时系统会收集该元素及其所有子物体上挂载的IMeshModifier组件并按顺序调用它们的ModifyMesh方法。VertexHelper中包含了该UI元素当前帧生成的、未经修改的原始网格数据。我们的脚本通过读取这些数据进行计算和修改再将修改后的数据写回VertexHelper从而影响最终的渲染结果。注意ModifyMesh方法在每一帧网格重建时都会被调用。对于静态文本它只在文本变化时调用但对于动态文本如血量数字它可能每帧都调用。因此其中的计算逻辑应保持高效。2.3 顶点数据的结构要移动文字我们需要理解VertexHelper中存储的顶点数据。在UI系统中每个“字符”或“图形”通常由两个三角形即一个四边形构成对应4个顶点。VertexHelper提供了PopulateUIVertex方法来获取单个顶点的完整信息这些信息被封装在一个UIVertex结构体中。对于我们调整位置来说最关键的字段是position(Vector3): 顶点的三维空间位置。uv(Vector2): 纹理坐标用于从字体纹理中采样。color(Color32): 顶点颜色用于实现文字颜色、渐变等。一个简单的Text组件其网格顶点顺序通常是按字符顺序排列的。例如字符串“AB”会生成8个顶点A4个B4个前4个顶点属于‘A’后4个顶点属于‘B’。我们的任务就是找出属于同一个字符的4个顶点然后整体移动它们。3. 实战实现字间距Letter Spacing调整理解了原理我们开始动手实现第一个核心功能字间距调整。我们将创建一个名为LetterSpacing的脚本。3.1 创建LetterSpacing组件首先创建一个新的C#脚本并让它继承自BaseMeshEffect这是一个已经实现了IMeshModifier接口的基类比直接实现接口更方便和ILayoutElement可选用于影响布局计算让增加的字间距能正确推开其他UI元素。using UnityEngine; using UnityEngine.UI; [AddComponentMenu(UI/Effects/Letter Spacing, 15)] public class LetterSpacing : BaseMeshEffect { [SerializeField] private float m_spacing 0f; // 字间距单位可以是像素或“字体单位” public float spacing { get { return m_spacing; } set { if (m_spacing value) return; m_spacing value; if (graphic ! null) graphic.SetVerticesDirty(); // 触发网格重建 } } public override void ModifyMesh(VertexHelper vh) { if (!IsActive() || vh.currentVertCount 0) return; // 获取原始的文本字符串 Text text graphic as Text; if (text null) return; string str text.text; // 这里需要一个关键步骤获取每个字符的顶点索引范围。 // 由于Unity不直接提供此信息我们需要自己解析。 // 一种常见方法是使用TextGenerator来获取字符信息但可能不精确 // 更可靠但复杂的方法是分析顶点流。这里展示分析顶点流的方法。 ListUIVertex vertexList new ListUIVertex(); vh.GetUIVertexStream(vertexList); // 将VertexHelper中的数据提取到列表 // 我们需要知道每个字符对应哪4个或更多对于像‘i’这样的窄字符可能少于4个不UI文本总是四边形顶点。 // 一个简单但有效的假设顶点是按字符顺序、每4个一组排列的。 // 这对于大多数左对齐、单行、无富文本的简单Text组件是成立的。 // 但对于多行、对齐、富文本等情况这种方法会失效。我们先实现基础版本。 int charCount str.Length; // 检查顶点数量是否匹配字符数 * 4 if (vertexList.Count ! charCount * 4) { // 如果不匹配可能是多行、有换行符、或者使用了TMP等。 // 为了教程清晰我们先处理简单情况。更健壮的实现见后续章节。 Debug.LogWarning(LetterSpacing: 顶点数与字符数不匹配可能不支持此文本的复杂布局。); return; } // 计算每个字符的偏移量 // 思路第一个字符不动从第二个字符开始每个字符向右移动 (spacing * 字符索引) for (int i 1; i charCount; i) { float offset m_spacing * i; int vertexIndexStart i * 4; for (int j 0; j 4; j) { UIVertex vert vertexList[vertexIndexStart j]; vert.position.x offset; vertexList[vertexIndexStart j] vert; } } // 将修改后的顶点数据写回VertexHelper vh.Clear(); vh.AddUIVertexTriangleStream(vertexList); } }3.2 基础实现的局限性分析与改进上面的代码是一个极简的Demo它暴露了几个严重问题不支持多行文本一旦文本换行顶点顺序就不再是简单的字符顺序*4。换行符本身不产生顶点但会打断顶点流的连续性。不支持对齐方式对于居中对齐或右对齐的文本简单地给每个字符累加偏移会导致整体错位。布局错乱我们只修改了渲染网格的顶点位置但UI系统的布局系统如HorizontalLayoutGroup并不知道文字变宽了会导致重叠。改进方案针对问题1和2多行与对齐我们需要更精确地获取每个字符的原生位置信息。一个更健壮的方法是使用Unity的TextGenerator类。虽然官方文档说它主要用于编辑器但在运行时也可以用来获取字符的精确位置信息。// 在ModifyMesh中使用TextGenerator获取字符信息 Text textComp graphic as Text; TextGenerator gen textComp.cachedTextGenerator; TextGenerationSettings settings textComp.GetGenerationSettings(textComp.rectTransform.rect.size); gen.Populate(textComp.text, settings); // 重新生成布局信息注意性能 IListUIVertex origVerts gen.verts; // 现在origVerts中的顶点是带有原生布局信息的。 // 我们需要将当前vh中的顶点位置按照origVerts中字符的位置进行偏移。 // 步骤 // 1. 遍历origVerts每4个顶点一组计算该组即一个字符的中心点或左下角点。 // 2. 根据该字符在字符串中的索引i计算其目标偏移量 offsetX m_spacing * i。 // 3. 找到当前vh中对应字符的顶点应用相同的偏移。 // 注意需要处理换行。当检测到字符位置的y坐标发生变化时说明换行了此时水平偏移应重置并可能叠加行间距的垂直偏移下一节讲。这种方法更复杂但能正确处理换行、对齐和富文本样式如颜色的基础位置。不过TextGenerator的调用有性能开销不适合频繁更新的文本。针对问题3布局实现ILayoutElement接口告诉布局系统我们这个元素需要额外的宽度。public class LetterSpacing : BaseMeshEffect, ILayoutElement { // ... 之前的 spacing 属性和 ModifyMesh ... public float preferredWidth { get { Text text graphic as Text; if (text null) return -1; // 计算增加间距后的总宽度 // 简单估算原文本宽度 间距 * (字符数 - 1) // 更准确的方法是使用TextGenerator获取每行的宽度。 string str text.text; int charCount str.Length; if (charCount 1) return text.preferredWidth; // 获取原生的首选宽度无间距 float nativeWidth text.preferredWidth; // 估算假设字符平均宽度为 nativeWidth/charCount但间距是额外加的。 // 实际上间距是加在字符之间的有 (charCount - 1) 个间隙。 return nativeWidth m_spacing * (charCount - 1); } } // 同样需要实现其他ILayoutElement的成员如minWidth, flexibleWidth等通常直接返回-1或0。 public float minWidth { get { return -1; } } public float flexibleWidth { get { return -1; } } public float preferredHeight { get { return -1; } } public float minHeight { get { return -1; } } public float flexibleHeight { get { return -1; } } public int layoutPriority { get { return 1; } } // 设置一个优先级 public void CalculateLayoutInputHorizontal() { } public void CalculateLayoutInputVertical() { } }实现ILayoutElement后当你的LetterSpacing组件挂在Text上时AutoLayout系统如ContentSizeFitter就会考虑你计算出的preferredWidth从而自动调整RectTransform的宽度避免重叠。实操心得在实际项目中我通常会将“基础版”简单顶点流分析用于性能要求高、文本简单的场景如飘字、固定位置的Label。而对于复杂的UI布局如对话气泡、物品描述则会使用结合TextGenerator和ILayoutElement的“完整版”并做好缓存优化避免每帧调用Populate。4. 实战实现行间距Line Spacing调整调整行间距的思路与字间距类似但操作维度从水平变成了垂直。我们需要识别出哪些顶点属于同一行然后将整行顶点进行上下平移。4.1 创建LineSpacing组件我们创建另一个脚本LineSpacing。这次我们直接采用更健壮的、基于字符原始位置的方法来识别行。using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; [AddComponentMenu(UI/Effects/Line Spacing, 16)] public class LineSpacing : BaseMeshEffect { [SerializeField] private float m_lineSpacing 0f; // 行间距增量正值加大间距 public float lineSpacing { get { return m_lineSpacing; } set { if (m_lineSpacing value) return; m_lineSpacing value; if (graphic ! null) graphic.SetVerticesDirty(); } } public override void ModifyMesh(VertexHelper vh) { if (!IsActive() || vh.currentVertCount 0 || Mathf.Approximately(m_lineSpacing, 0f)) return; Text textComp graphic as Text; if (textComp null) return; // 使用TextGenerator获取行信息关键步骤 TextGenerator generator textComp.cachedTextGenerator; TextGenerationSettings settings textComp.GetGenerationSettings(textComp.rectTransform.rect.size); // 注意如果文本内容或样式没变generator可能已经有数据了。这里为了通用性重新生成。 // 对于动态文本应考虑缓存generator结果。 generator.Populate(textComp.text, settings); // 获取生成的顶点和行信息 IListUIVertex origVerts generator.verts; IListUILineInfo lines generator.lines; // 这是关键包含了每行的起始字符索引和高度。 if (lines.Count 1) return; // 单行文本无需调整行间距 // 将当前VertexHelper的顶点数据提取出来 ListUIVertex outputVerts new ListUIVertex(); vh.GetUIVertexStream(outputVerts); // 我们需要建立一个映射原始生成顶点(origVerts)中的每个顶点对应输出顶点(outputVerts)中的哪个顶点 // 一个简化假设在无其他MeshEffect干扰下两者的顶点顺序和数量是一致的。 // 这个假设在只有本效果时基本成立。 if (origVerts.Count ! outputVerts.Count) { Debug.LogWarning(LineSpacing: 原始顶点数与当前顶点数不匹配可能与其他效果冲突。); return; } // 计算每行需要下移的累积偏移量 // 规则第一行不动第二行下移 m_lineSpacing第三行下移 2*m_lineSpacing以此类推。 float[] lineVerticalOffsets new float[lines.Count]; for (int i 1; i lines.Count; i) { lineVerticalOffsets[i] m_lineSpacing * i; } // 遍历所有顶点判断它属于哪一行然后应用该行的垂直偏移 for (int i 0; i origVerts.Count; i 4) // 每4个顶点一个字符 { // 取该字符第一个顶点的原始Y坐标作为判断依据 float charTopY origVerts[i].position.y; // 注意UI坐标系原点可能在左上或中心这里假设原点在中心Y向上为正。 // 实际上我们需要一个更精确的方法来匹配字符到行。 // 一个更可靠的方法是使用lines信息。lines[i].startCharIdx给出了每行开始的字符索引。 // 我们需要知道当前顶点组(i/4)对应的字符索引。 int charIndex i / 4; int lineIndex -1; for (int l 0; l lines.Count; l) { int startCharIdx lines[l].startCharIdx; int endCharIdx (l lines.Count - 1) ? textComp.text.Length : lines[l 1].startCharIdx; if (charIndex startCharIdx charIndex endCharIdx) { lineIndex l; break; } } if (lineIndex 0 lineIndex lineVerticalOffsets.Length) { float offsetY lineVerticalOffsets[lineIndex]; // 对属于这个字符的4个顶点应用垂直偏移 for (int j 0; j 4; j) { UIVertex vert outputVerts[i j]; vert.position.y offsetY; // 应用偏移 outputVerts[i j] vert; } } } // 写回修改后的顶点 vh.Clear(); vh.AddUIVertexTriangleStream(outputVerts); } }4.2 处理对齐与布局影响与字间距类似单纯移动渲染顶点会导致视觉与布局分离。例如文字向下移动了但Text组件的RectTransform高度没变可能造成下方内容被遮挡。解决方案实现ILayoutElement计算增加行间距后的总高度。总高度 ≈ 原生文本高度 lineSpacing * (行数 - 1)。在preferredHeight的getter中返回这个值。调整文本锚点行间距调整后文本的整体视觉中心可能会下移。如果Text是居中对齐的你可能需要同时微调rectTransform.pivot或使用一个额外的ContentSizeFitter来让容器适应新高度。更常见的做法是将行间距视为“行与行之间的额外空间”而保持第一行的位置不变正如我们代码中所做的这样对于顶部对齐的文本是最直观的。注意事项TextGenerator.Populate是一个比较耗时的操作尤其是在文本较长或每帧变化时。在实现生产级代码时必须考虑性能优化缓存只有当文本内容、字体、尺寸等影响布局的属性发生变化时才重新调用Populate。可以通过监听Text的OnPopulateMesh回调或比较相关属性的哈希值来实现。分帧处理对于极长的文本如日志可以考虑分帧计算偏移量避免卡顿。慎用富文本富文本标签如会影响顶点流使字符与顶点的简单映射关系失效。处理富文本需要解析标签并跳过它们对应的“不可见”顶点这非常复杂。对于富文本需求强烈的项目建议直接使用TextMeshPro它原生支持字间距和行间距调整。5. 高级技巧与性能优化将两个字间距和行间距功能组合使用你已经能解决大部分排版问题。但要想打造鲁棒的生产级代码还需要考虑更多。5.1 合并效果与执行顺序你可以创建一个TextSpacing组件同时控制字间距和行间距。但要注意ModifyMesh的执行顺序。如果同一个GameObject上有多个BaseMeshEffect如ShadowOutline 和你的TextSpacing它们的执行顺序就是它们在Inspector中的顺序从上到下。你的间距调整应该在所有“装饰性”效果如描边、阴影之前执行因为你需要移动的是文字网格的基础顶点阴影和描边应该基于移动后的位置生成。因此通常把你的组件放在Shadow和Outline组件之上。5.2 支持TextMeshProTextMeshPro是Unity官方推荐的、功能更强大的文本解决方案。它本身就已经提供了characterSpacing和lineSpacing属性。所以对于新项目强烈建议直接使用TextMeshPro。你只需要在Inspector中调整即可无需自己写ModifyMesh。那么我们这篇文章的意义何在理解原理通过实现自己的间距调整你深入理解了UI渲染和网格操作这是Shader、自定义UI效果的基础。处理遗留项目很多老项目仍在使用旧版UI Text无法轻易迁移到TMP。实现TMP不支持的效果TMP虽然强大但ModifyMesh提供的灵活性是无限的。你可以基于此原理实现字符波动、分散动画、自定义环绕排版等TMP内置功能没有的效果。如果你想为TMP实现超规格的定制效果TMP也提供了类似的回调TMPro.TMP_Text.OnPreRenderText它提供了一个TMP_TextInfo结构体包含了所有字符、单词、行的详细信息比UI Text的TextGenerator更友好、更强大。5.3 性能优化实战避免每帧Populate在LetterSpacing/LineSpacing脚本中添加一个字段缓存上一次的文本、字体大小、宽度等关键信息。private string m_LastText; private float m_LastSpacing; private Vector2 m_LastRectSize; private Font m_LastFont; private int m_LastFontSize; private TextGenerator m_CachedGenerator; // 谨慎使用注意生命周期 void UpdateCache(Text textComp) { // 检查关键属性是否变化 if (m_LastText textComp.text Mathf.Approximately(m_LastSpacing, spacing) m_LastRectSize textComp.rectTransform.rect.size m_LastFont textComp.font m_LastFontSize textComp.fontSize) { return; // 无变化可使用缓存 } // 更新缓存 m_LastText textComp.text; m_LastSpacing spacing; m_LastRectSize textComp.rectTransform.rect.size; m_LastFont textComp.font; m_LastFontSize textComp.fontSize; // 标记需要重新计算 m_IsDirty true; }在ModifyMesh中先调用UpdateCache如果!m_IsDirty且缓存了顶点数据则直接使用缓存数据修改当前vh。使用对象池频繁创建ListUIVertex会产生GC垃圾回收压力。可以在类中声明一个静态或非静态的ListUIVertex复用。private static readonly ListUIVertex s_TempVerts new ListUIVertex(1024); // 在ModifyMesh中使用 s_TempVerts.Clear(); vh.GetUIVertexStream(s_TempVerts); // ... 修改 s_TempVerts ... vh.Clear(); vh.AddUIVertexTriangleStream(s_TempVerts);按需启用对于完全静态的文本如菜单标题可以在Awake或Start中计算一次后禁用这个组件或者将计算结果缓存为静态Mesh直接使用。6. 常见问题与排查技巧实录在实际使用自制的间距调整组件时你肯定会遇到一些“坑”。以下是我在项目中总结的常见问题及解决方法。问题现象可能原因排查与解决思路文字重叠或位置错乱1. 顶点顺序假设错误如存在富文本、换行。2. 未实现ILayoutElement布局系统未更新。3. 与其他BaseMeshEffect如Shadow顺序错误。1. 在ModifyMesh开始处打印vertexList.Count和text.Length检查倍数关系。使用TextGenerator获取精确信息。2. 检查是否实现了preferredWidth/Height并确保LayoutGroup或ContentSizeFitter生效。3. 在Inspector中调整组件顺序确保你的间距组件在最上面。性能卡顿特别是滚动列表中有大量带间距的文本每帧都在调用TextGenerator.Populate且未缓存结果。1. 实现上述的缓存机制。2. 对于列表项考虑使用对象池并复用已计算好的间距信息。3. 如果文本内容不变尝试在初始化时计算一次后禁用组件。调整间距后点击区域Raycast不对UI的点击检测基于RectTransform的矩形区域而非实际的网格形状。修改顶点不会改变点击框。这是一个已知限制。如果需要精确的点击检测如点击某个字符不能依赖标准的Button组件。需要改用GraphicRaycaster 自定义的Raycast检测或者使用TextMeshPro的链接点击事件。在ScrollRect中文字超出边界却未触发裁剪Mask或RectMask2D的裁剪是基于RectTransform的移动顶点后文字视觉上出去了但RectTransform没变大所以可能不会被正确裁剪。确保实现了ILayoutElement并正确计算了preferredWidth/Height让RectTransform的实际尺寸随内容扩大。这样Mask才能正确裁剪。或者考虑使用更精确但性能开销更大的MaskableGraphic的SetClipRect方式但这通常不需要。使用了自定义Shader后间距效果异常自定义Shader可能使用了不同的顶点数据流或变换方式。确保你的ModifyMesh只修改position不修改uv、color等传递给Shader的数据。检查自定义Shader是否依赖顶点的原始模型空间位置。一个典型的调试流程简化场景创建一个新的Canvas只放一个带你的LetterSpacing组件的Text看基础功能是否正常。打印数据在ModifyMesh中使用Debug.Log输出顶点数、字符数、每行的起始索引等关键信息验证你的逻辑假设。逐步复杂化先让单行左对齐工作再加入多行再测试居中对齐最后放入复杂的布局组中。性能分析在Profiler中观察Canvas.BuildBatch和Canvas.SendWillRenderCanvases的耗时如果发现你的组件导致耗时激增重点检查缓存和Populate调用。最后我个人在实际项目中的体会是对于新的UI系统优先使用TextMeshPro是最高效、最稳定的选择。它的TextMeshProUGUI组件在Inspector中直接提供了Character Spacing、Line Spacing、Paragraph Spacing等属性并且性能经过优化。将本文的ModifyMesh技术视为一把“手术刀”它用于解决TMP也无法处理的、极其特殊的定制化渲染需求或者是在维护无法升级到TMP的遗留项目时的终极手段。掌握它意味着你拥有了对Unity UI文字渲染的底层控制能力这无疑是高级UI开发者的标志性技能之一。