尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity UI自定义文本间距:基于ModifyMesh的底层网格修改实战

Unity UI自定义文本间距:基于ModifyMesh的底层网格修改实战 1. 项目概述为什么Unity原生Text的排版总让人头疼在Unity UI开发里Text组件无论是老版的uGUI Text还是新版的TextMeshPro是信息展示的绝对主力。但只要你做过稍微复杂一点的界面比如需要精美排版的公告、需要紧凑显示的道具描述、或者需要对齐多语言文本大概率都踩过同一个坑字间距和行间距的控制太不灵活了。Unity原生的Text组件字间距Character Spacing和行间距Line Spacing要么没有暴露接口要么调整范围有限效果生硬。特别是当你接到一个要求“视觉像素级完美”的UI需求时那种无力感尤为明显。我自己就遇到过无数次美术给的设计稿上标题文字字字珠玑间距考究但到了Unity里无论怎么调Font Size、Line Spacing总觉得不是那么回事——要么挤在一起显得小气要么松散开来缺乏力量感。更头疼的是多语言适配英文单词和中文的排版习惯不同用同一套间距参数往往顾此失彼。这时候一个深入引擎渲染流程的解决方案就显得至关重要而ModifyMesh方法正是打开这扇门的钥匙。简单来说这个项目就是通过深入Unity UI的底层网格Mesh生成流程亲手接管每个字符顶点数据的计算从而实现高度自定义的字间距与行间距调整。这不仅仅是调几个参数而是理解Unity如何将一串文字变成屏幕上像素的过程。掌握了它你就能从“被UI系统限制”变成“定义UI系统规则”的人。无论你是想优化游戏内公告板的阅读体验还是打造独具一格的数字艺术字体效果这套方法都能给你带来底层控制力。2. 核心原理ModifyMesh如何赋予我们“改造文字”的能力要理解ModifyMesh我们得先看看Unity的UI文本是怎么画出来的。Unity的UI系统基于Canvas而Canvas下的所有图形包括Text最终都会被转换为由三角形构成的网格Mesh然后提交给GPU渲染。Text组件这里指UnityEngine.UI.Text继承自MaskableGraphic它内部就包含一个生成这个文本网格的过程。ModifyMesh是IMeshModifier接口中的一个核心方法。这个接口允许我们在UI元素如Text的网格数据最终被使用之前比如提交给CanvasRenderer之前对顶点数据Vertices、三角形索引Triangles、UV坐标、顶点颜色等进行修改。这是一个“钩子”Hook让我们能介入到渲染管线中。2.1 网格数据的构成一个Text组件生成的网格本质上是为每一个显示的字符Character生成一个由两个三角形组成的矩形对于非位图字体如TTF字符形状由字形轮廓决定但最终渲染时仍会三角化。每个顶点包含以下关键信息Position (顶点位置)决定字符在屏幕上的坐标。UV (纹理坐标)决定从字体纹理Font Texture的哪个位置采样字形。Color (顶点颜色)通常用于实现整体颜色或渐变如果支持。当我们调整字间距时本质上是在水平方向上有规律地增加或减少每个字符矩形所有顶点的X坐标。而行间距则是在垂直方向上有规律地增加或减少每一行字符矩形所有顶点的Y坐标。2.2 自定义Text组件的实现路径Unity原生的UnityEngine.UI.Text组件没有直接暴露字间距属性。因此我们的标准做法是创建一个自定义组件继承自Text并实现IMeshModifier接口。这样我们既保留了原生Text的所有功能富文本、对齐、溢出等又获得了修改其生成网格的能力。using UnityEngine; using UnityEngine.UI; // 1. 继承自原生Text组件 // 2. 实现IMeshModifier接口 public class CustomTextWithSpacing : Text, IMeshModifier { // 公开我们需要的自定义参数 public float characterSpacing 0f; // 字间距单位可以是像素或相对单位 public float lineSpacing 1f; // 行间距乘数1为默认 // 3. 实现ModifyMesh方法 public void ModifyMesh(Mesh mesh) { if (!IsActive()) return; // 如果组件未激活直接返回 // 在这里编写修改网格顶点位置的逻辑 } // 4. 实现另一个接口方法通常留空 public void ModifyMesh(VertexHelper vh) { // 这是较新的接口方法使用VertexHelper操作顶点更高效。 // 我们通常重写这个方法而不是上面那个。 } }注意从Unity的版本迭代来看ModifyMesh(Mesh mesh)是比较老的方法而ModifyMesh(VertexHelper vh)是更推荐使用的。VertexHelper是一个封装好的工具类用于高效地访问和修改网格数据避免了直接操作Mesh对象的开销。我们接下来的实战将基于VertexHelper。3. 实战拆解一步步实现CustomTextWithSpacing组件理论清楚了我们开始动手。创建一个名为CustomTextWithSpacing.cs的脚本。3.1 组件基础结构与属性定义首先我们定义组件的公开属性并确保修改属性时能触发网格重建。using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; // 因为我们会用到List [AddComponentMenu(UI/Advanced Text/Custom Text With Spacing)] public class CustomTextWithSpacing : Text { [SerializeField, Range(-10, 50)] private float m_CharacterSpacing 0f; [SerializeField, Range(0.5f, 3f)] private float m_LineSpacingMultiplier 1f; public float characterSpacing { get { return m_CharacterSpacing; } set { if (Mathf.Approximately(m_CharacterSpacing, value)) return; m_CharacterSpacing value; // 标记顶点数据已脏需要重建 SetVerticesDirty(); } } public float lineSpacingMultiplier { get { return m_LineSpacingMultiplier; } set { if (Mathf.Approximately(m_LineSpacingMultiplier, value)) return; m_LineSpacingMultiplier value; SetVerticesDirty(); } } }这里我们使用了[SerializeField]让私有变量在Inspector中可见并提供了公共属性在set方法中调用SetVerticesDirty()。这是关键一步它通知Unity的UI系统“我的顶点数据变了下次更新UI时请重新生成网格。”3.2 重写ModifyMesh方法核心逻辑接下来是重头戏。我们不需要显式实现IMeshModifier接口因为Text基类已经实现了它。我们只需要重写overrideModifyMesh方法即可。protected override void OnPopulateMesh(VertexHelper vh) { // 1. 先让基类原生Text生成默认的网格数据 base.OnPopulateMesh(vh); // 2. 如果没有自定义间距或者生成的顶点数为0直接返回 if ((Mathf.Approximately(m_CharacterSpacing, 0f) Mathf.Approximately(m_LineSpacingMultiplier, 1f)) || vh.currentVertCount 0) return; // 3. 将VertexHelper中的顶点数据提取到列表中便于操作 ListUIVertex vertexList new ListUIVertex(); vh.GetUIVertexStream(vertexList); // 4. 调用我们的核心方法修改顶点位置 ApplySpacing(vertexList, m_CharacterSpacing, m_LineSpacingMultiplier); // 5. 清空VertexHelper并重新填入修改后的顶点数据 vh.Clear(); vh.AddUIVertexTriangleStream(vertexList); }OnPopulateMesh是Text类中用于填充网格数据的关键受保护方法它内部会调用ModifyMesh。重写它并先调用base.OnPopulateMesh(vh)是标准做法确保了我们先获得一个由Unity正确生成的、包含所有字符几何和UV信息的原始网格。3.3 核心算法ApplySpacing的实现这是整个组件的灵魂所在。我们需要遍历每一个字符每4个顶点构成一个字符的矩形并根据其所在行和列调整其顶点位置。private void ApplySpacing(ListUIVertex verts, float charSpacing, float lineSpacingMultiplier) { // 1. 准备一个列表来存储处理后的顶点 ListUIVertex newVerts new ListUIVertex(); // 2. 获取当前文本的生成器信息用于判断换行和字符位置 // 注意TextGenerator是内部类我们无法直接获取每行的精确信息。 // 因此我们需要自己分析顶点流来推断行和字符。 // 一个字符由6个顶点两个三角形表示但通常我们按4个顶点一个矩形来处理更直观。 // 在顶点流中每6个顶点构成一个字符两个三角形0,1,2 和 2,3,0。 // 但通过观察每4个顶点0,1,2,3的Y坐标基本相同可以代表一个字符的四边形。 int charCount verts.Count / 4; // 估算字符数对于普通字符 if (charCount 0) return; // 3. 我们需要知道哪些顶点属于同一行。 // 一个简单但有效的方法遍历顶点当发现当前顶点的Y坐标与上一个“行起始字符”的Y坐标差异超过某个阈值如字体大小的一半则认为换行。 // 更稳健的方法是在修改前先扫描一遍记录每个字符四边形的基线Y坐标然后对Y坐标进行聚类。 // 这里采用简化版逻辑假设顶点是按行、按字符顺序排列的。 // 首先找出所有不同的“行基线Y值” Listfloat lineBaseYList new Listfloat(); for (int i 0; i verts.Count; i 4) { if (i 3 verts.Count) break; // 取一个字符矩形的第一个顶点的Y值作为该字符的基线参考实际上应该是底部但这里用于区分行 // 注意UI文本的坐标系Y向上为正。 float charBaseY verts[i].position.y; bool isNewLine true; foreach (float baseY in lineBaseYList) { if (Mathf.Abs(charBaseY - baseY) 0.1f) // 阈值可根据字体大小调整 { isNewLine false; break; } } if (isNewLine) { lineBaseYList.Add(charBaseY); } } // 对行基线进行排序从上到下因为屏幕坐标系可能原点在左上或左下但顶点Y值大的在上方 lineBaseYList.Sort((a, b) b.CompareTo(a)); // 降序排列第一行Y值最大 // 4. 创建一个字典记录每个字符属于哪一行 Dictionaryint, int charToLineMap new Dictionaryint, int(); for (int i 0; i verts.Count; i 4) { if (i 3 verts.Count) break; float charBaseY verts[i].position.y; for (int lineIdx 0; lineIdx lineBaseYList.Count; lineIdx) { if (Mathf.Abs(charBaseY - lineBaseYList[lineIdx]) 0.1f) { charToLineMap[i / 4] lineIdx; // 键字符索引值行索引 break; } } } // 5. 计算行高偏移应用行间距 // 默认行间距由font.lineHeight决定。lineSpacingMultiplier是乘数。 // 我们需要计算每一行相对于第一行的垂直偏移量。 float defaultLineHeight fontSize * 1.2f; // 这是一个近似值实际值应从font获取但这里简化处理 float totalLineHeight defaultLineHeight * lineSpacingMultiplier; float lineHeightOffset totalLineHeight - defaultLineHeight; // 每行额外增加的高度 // 6. 遍历每个字符应用偏移 for (int charIndex 0; charIndex charCount; charIndex) { int vertStartIndex charIndex * 4; if (vertStartIndex 3 verts.Count) break; // 获取该字符所在行 int lineIndex; if (!charToLineMap.TryGetValue(charIndex, out lineIndex)) { lineIndex 0; // 默认第一行 } // 计算该字符的水平偏移字间距 * 字符索引在本行内 // 注意这里需要计算本行内的字符索引而不是全局索引。我们需要知道这是本行的第几个字符。 // 为了简化我们先计算水平偏移稍后处理垂直偏移。 float horizontalOffset charSpacing * charIndex; // **这是一个有问题的简化需要按行内索引计算** // 计算该字符的垂直偏移行高额外偏移 * 行索引 // 行索引越大说明行越靠下在屏幕坐标系中Y值应该越小。 float verticalOffset -lineHeightOffset * lineIndex; // 注意负号 // 7. 应用偏移到该字符的4个顶点 for (int v 0; v 4; v) { UIVertex vt verts[vertStartIndex v]; Vector3 pos vt.position; // 水平偏移所有顶点X坐标增加 pos.x horizontalOffset; // 垂直偏移所有顶点Y坐标增加下移为负 pos.y verticalOffset; vt.position pos; newVerts.Add(vt); } } // 8. 用修改后的顶点列表替换原始列表 verts.Clear(); verts.AddRange(newVerts); }上面的代码是一个基础但存在问题的版本。它最大的问题在于第6步计算水平偏移时错误地使用了全局字符索引charIndex而不是字符在当前行内的索引。这会导致换行后第二行的第一个字符继承了第一行最后一个字符的巨大偏移完全错位。3.4 修复水平偏移按行内字符索引计算我们需要在遍历字符前先统计每一行有哪些字符。然后在应用水平偏移时使用字符在它所在行内的局部索引。private void ApplySpacing(ListUIVertex verts, float charSpacing, float lineSpacingMultiplier) { // ... [前面获取lineBaseYList和charToLineMap的代码不变] ... // 5. 按行分组字符索引 ListListint charsPerLine new ListListint(); for (int i 0; i lineBaseYList.Count; i) { charsPerLine.Add(new Listint()); } for (int charIdx 0; charIdx charCount; charIdx) { int lineIdx; if (charToLineMap.TryGetValue(charIdx, out lineIdx)) { charsPerLine[lineIdx].Add(charIdx); } } // 6. 计算行高偏移 float defaultLineHeight fontSize * 1.2f; float totalLineHeight defaultLineHeight * lineSpacingMultiplier; float lineHeightOffset totalLineHeight - defaultLineHeight; // 7. 遍历每一行再遍历行内的每个字符 for (int lineIdx 0; lineIdx charsPerLine.Count; lineIdx) { Listint charIndicesInThisLine charsPerLine[lineIdx]; for (int localCharIdx 0; localCharIdx charIndicesInThisLine.Count; localCharIdx) { int globalCharIdx charIndicesInThisLine[localCharIdx]; int vertStartIndex globalCharIdx * 4; if (vertStartIndex 3 verts.Count) break; // **关键修正使用行内局部索引计算水平偏移** float horizontalOffset charSpacing * localCharIdx; float verticalOffset -lineHeightOffset * lineIdx; for (int v 0; v 4; v) { UIVertex vt verts[vertStartIndex v]; Vector3 pos vt.position; pos.x horizontalOffset; pos.y verticalOffset; vt.position pos; // 直接修改原列表无需newVerts verts[vertStartIndex v] vt; } } } // 不再需要verts.Clear()和AddRange因为我们是原地修改。 }这个版本正确地将字符按行分组并使用localCharIdx来计算水平偏移解决了换行错位的问题。3.5 处理文本对齐Alignment然而还有一个重大问题文本对齐失效了。当我们增加字间距后整行文字变宽了但它的“锚点”或“中心”没有变。例如对于居中对齐Middle Center的文本增加字间距会导致文字整体向右“飘走”而不是以中心点对称地向外扩展。这是因为Unity在base.OnPopulateMesh中已经根据对齐方式对所有顶点的初始位置进行了计算。我们后续的偏移操作是在这个“已对齐”的基础上进行的加法破坏了原有的对齐布局。解决方案我们需要在应用了所有字符的间距偏移后根据对齐方式对整行文字进行反向补偿平移。对于左对齐Left不需要处理因为左对齐的基准点是行首我们是从行首开始累加偏移符合左对齐逻辑。对于居中对齐Center需要计算整行文字在应用间距后的总宽度增量然后将该行所有字符的顶点向左移动“总增量的一半”。对于右对齐Right需要计算整行文字的总宽度增量然后将该行所有字符的顶点向左移动“总增量”。我们需要在ApplySpacing方法的行遍历内部完成水平偏移后立即计算这一行的补偿。private void ApplySpacing(ListUIVertex verts, float charSpacing, float lineSpacingMultiplier) { // ... [前面获取lineBaseYList、charToLineMap、charsPerLine的代码不变] ... // 获取当前文本的对齐方式 TextAnchor anchor alignment; // Text是枚举例如UpperLeft, MiddleCenter等 // 判断水平对齐方式 bool isCenterAligned anchor TextAnchor.UpperCenter || anchor TextAnchor.MiddleCenter || anchor TextAnchor.LowerCenter; bool isRightAligned anchor TextAnchor.UpperRight || anchor TextAnchor.MiddleRight || anchor TextAnchor.LowerRight; // 遍历每一行 for (int lineIdx 0; lineIdx charsPerLine.Count; lineIdx) { Listint charIndicesInThisLine charsPerLine[lineIdx]; int charCountInLine charIndicesInThisLine.Count; // 计算这一行在应用字间距后的总宽度增量 // 假设有N个字符字间距会在字符间添加(N-1)个间隔。 float totalWidthIncrease charSpacing * (charCountInLine - 1); // 计算这一行需要应用的补偿偏移 float compensationX 0f; if (isCenterAligned) { compensationX -totalWidthIncrease * 0.5f; // 向左移动一半 } else if (isRightAligned) { compensationX -totalWidthIncrease; // 向左移动全部 } // 左对齐时compensationX 0 // 遍历行内字符应用字间距和补偿 for (int localCharIdx 0; localCharIdx charCountInLine; localCharIdx) { int globalCharIdx charIndicesInThisLine[localCharIdx]; int vertStartIndex globalCharIdx * 4; if (vertStartIndex 3 verts.Count) break; float horizontalOffset charSpacing * localCharIdx compensationX; // 合并补偿 float verticalOffset -lineHeightOffset * lineIdx; for (int v 0; v 4; v) { UIVertex vt verts[vertStartIndex v]; Vector3 pos vt.position; pos.x horizontalOffset; pos.y verticalOffset; vt.position pos; verts[vertStartIndex v] vt; } } } }现在我们的CustomTextWithSpacing组件已经具备了基本功能可调节的字间距、行间距并且能正确支持左、中、右对齐。4. 高级优化与避坑指南上面的实现能跑起来但在实际项目中可能会遇到性能和效果上的问题。下面分享几个我踩过坑后总结的优化点。4.1 性能优化避免每帧重建网格我们的组件在characterSpacing或lineSpacingMultiplier属性改变时会调用SetVerticesDirty()这会导致Canvas在下一帧重建整个网格。如果文本很长或者Canvas下元素很多频繁修改间距可能会引起卡顿。优化策略属性设置节流如果间距值是通过动画或代码频繁变化的可以考虑在Update中累积变化每几帧或变化超过一定阈值时才应用一次。对象池复用List在ApplySpacing方法中我们创建了Listfloat lineBaseYList等临时容器。对于频繁更新的文本可以考虑将这些列表声明为类的成员变量避免每次调用都分配新的堆内存减少GC垃圾回收压力。谨慎使用富文本如果文本中包含富文本标签如OnPopulateMesh生成的顶点流会复杂很多我们的按4顶点一个字符的简单假设会失效。处理富文本需要解析标签并跳过不影响排版的标签顶点逻辑会复杂数倍。一个实用的建议是如果必须使用富文本和自定义间距考虑将不同样式的文本拆分成多个CustomTextWithSpacing对象分别控制。4.2 精度与兼容性问题字体纹理与渲染模式我们的修改是在顶点层面。如果字体纹理Font Texture的Padding内边距设置得太小当字间距为负值让字符靠拢时可能会导致字符的纹理采样区域重叠产生渲染瑕疵。确保在字体导入设置Font Import Settings中将Character Padding设置为一个足够大的值如5或以上。与ContentSizeFitter的冲突ContentSizeFitter组件会根据Text的原始大小修改网格前来调整RectTransform。我们的顶点偏移操作不会改变Text组件报告的preferredWidth和preferredHeight。这会导致ContentSizeFitter无法正确适配调整间距后的文本大小可能造成文本显示不全或被裁剪。解决方案需要重写Text类的CalculateLayoutInputHorizontal和CalculateLayoutInputVertical方法根据我们实际的间距计算出新的首选宽高。这涉及到根据字体信息、文本内容、间距参数来模拟计算文本的包围盒是另一个复杂的主题。一个取巧的办法是将文本放在一个额外的空父物体下由父物体负责布局文本本身不参与自动布局。Best Fit选项启用Best Fit后字体会自动缩放以适应RectTransform的大小。我们的顶点偏移计算是基于当前fontSize的。如果Best Fit动态改变了fontSize我们的间距逻辑可能不会自动更新因为fontSize不是我们的属性不会触发SetVerticesDirty。需要在OnEnable或Update中监听fontSize的变化或者直接不建议在需要精确间距控制的场景下使用Best Fit。4.3 针对TextMeshPro (TMP)的说明如果你在使用TextMeshPro这是Unity官方推荐的新一代文本系统事情会简单很多。TMP的TMP_Text组件直接提供了characterSpacing和lineSpacing属性在Extra Settings里并且是原生支持的性能更好功能更全支持字间距、词间距、行间距等多种调整。在绝大多数新项目中应优先使用TextMeshPro。那么为什么还要研究基于旧版uGUI Text的ModifyMesh方案呢原因有几个维护老项目很多存量项目大量使用了旧版UI Text全面替换为TMP成本高昂。理解底层原理ModifyMesh的技术不限于Text它可以用于任何继承自Graphic的UI元素如Image实现顶点动画、扭曲、自定义裁剪等高级效果。学习它对深入理解Unity UI渲染管线大有裨益。极限定制即使TMP功能强大偶尔也会有极其特殊的定制需求比如非线性的字间距变化、基于字符属性的动态偏移这时可能仍需回到修改顶点数据的老路上来。5. 效果测试与常见问题排查将CustomTextWithSpacing组件挂载到GameObject上替换原有的Text组件。在Inspector中你会看到新增的Character Spacing和Line Spacing Multiplier滑块。测试场景基础功能输入多行文字分别调整两个参数观察变化是否符合预期。对齐测试将Text的Alignment分别设置为左、中、右调整字间距观察文字是否始终能基于设定的对齐点正确排列。富文本测试输入包含和的文本观察颜色和样式是否保留同时间距调整是否正常注意我们的简单实现可能会被富文本的顶点插入打乱导致错位。动态修改写一个简单的脚本在Update中随时间变化characterSpacing观察性能和平滑度。常见问题与排查表问题现象可能原因解决方案调整间距后文字完全错乱、重叠1.ApplySpacing中字符索引计算错误。2. 顶点数不是4的倍数可能包含富文本标签生成的额外顶点。1. 检查charToLineMap和charsPerLine的构建逻辑用Debug.Log输出中间值验证。2. 在遍历顶点前打印verts.Count确认其能被4整除。处理富文本需要更复杂的解析。居中对齐或右对齐时文字整体偏移未处理对齐补偿。确保实现了根据alignment计算compensationX的逻辑并正确加到horizontalOffset上。换行后第二行文字起始位置不对水平偏移使用了全局字符索引而非行内索引。严格按照4.4节的修复方案先按行分组字符再使用行内局部索引localCharIdx计算偏移。修改间距后ContentSizeFitter失效文字被裁剪Text组件报告的首选尺寸未更新。重写CalculateLayoutInputHorizontal/Vertical方法或放弃使用ContentSizeFitter改用手动布局或其它容器。负字间距导致字符纹理重叠、出现黑边字体纹理Font Texture的Padding值太小。在字体文件的导入设置中增大Character Padding例如设置为8然后重新导入字体并生成纹理。在Scroll View中文字渲染异常或闪烁Canvas的渲染模式或父Canvas的设置问题。修改顶点可能触发了额外的批次Batch。确保文本所在的Canvas渲染模式合适如Screen Space - Overlay。检查是否有多个Canvas尝试合并。对于复杂UI性能问题可能需整体优化。我个人在实际项目中的体会是这套自定义方案最适合用于标题、Logo、数字等静态或变化不频繁的短文本的精细化排版。对于大段动态更新的聊天文本、公告还是优先考虑TextMeshPro或者在万不得已时将我们的自定义组件进行深度优化和封装。最后一定要在目标平台尤其是移动端上进行充分的性能和内存测试确保自定义的顶点操作不会成为性能瓶颈。
返回列表