Unity TextMeshPro竖排文本实现:从顶点操控到性能优化全解析

📅 2026/7/9 21:01:21
Unity TextMeshPro竖排文本实现:从顶点操控到性能优化全解析
1. 项目概述为什么竖排文本是个“技术活”在Unity的UI开发里处理文本是家常便饭但一提到“竖排文本”很多开发者包括一些有经验的可能都会下意识地皱下眉头。这听起来像是个简单的需求——不就是把横着的字竖过来排吗但当你真正动手尤其是在使用Unity目前最主流的文本渲染方案TextMeshPro时就会发现这潭水比想象的要深。默认情况下你旋转一下TextMeshPro - Text (UI)的RectTransform或者把它的Rotation Z轴设为90度确实能看到文字竖起来了但效果往往是灾难性的字符顺序错乱、排版方向诡异、换行逻辑完全失效整个文本块变成了一团难以辨认的乱码。这背后的核心矛盾在于TextMeshPro作为一个为现代屏幕和从左到右LTR排版语言高度优化的强大工具其底层布局引擎、换行算法、甚至字符网格生成都是基于水平流式布局设计的。强行旋转整个画布相当于让一个习惯了水平奔跑的运动员去垂直攀岩动作变形在所难免。因此实现一个“高效”且“正确”的竖排文本远不止是一个视觉旋转操作它涉及到对TextMeshPro布局流程的深度干预、对字符绘制逻辑的重定向以及对性能开销的精细控制。特别是在需要动态更新、大量实例或者复杂图文混排比如聊天界面的场景下一个粗糙的实现很容易成为性能瓶颈和显示Bug的温床。接下来我将结合多年在Unity项目中的实战经验拆解几种从基础到进阶的竖排文本实现方案。我们会从最直观但问题最多的“旋转大法”开始逐步深入到修改顶点数据、自定义布局等核心层面并最终探讨一个兼顾性能、灵活性与正确性的混合方案。无论你是需要在游戏中实现古风UI、竖版公告还是处理某些特定地区的竖排文字需求相信这篇深度解析都能为你提供清晰的路径和可落地的代码。2. 方案演进从“旋转画布”到“操控顶点”在动手写代码之前我们必须先理清思路。实现竖排文本本质上是要改变字符的排列顺序和绘制位置。根据对TextMeshPro管线干预程度的不同我们可以将方案分为几个层级。2.1 方案一旋转RectTransform最简单但问题最多这是最直觉的方法也是新手最容易踩进的坑。具体操作就是直接修改承载TextMeshPro - Text (UI)组件的RectTransform的Rotation。// 不推荐问题百出的简单旋转 myTextMeshProUGUI.rectTransform.localRotation Quaternion.Euler(0, 0, 90f);为什么它行不通布局错乱TextMeshPro在生成文本网格时仍然按照水平方向计算行宽和换行。旋转后原本的“行宽”变成了视觉上的“列高”但换行判断逻辑未变导致换行点完全错误文字会挤在奇怪的位置。锚点与对齐灾难TextMeshPro的文本对齐如Left, Center, Right是基于水平轴的。旋转90度后“Left”对齐实际上变成了“Bottom”这会让控件的锚点和对齐行为变得极其反直觉难以通过RectTool进行正常布局。交互区域错位如果文本需要点击交互如超链接其点击检测区域仍然是旋转前的原始矩形与视觉位置不匹配。子物体牵连如果该文本对象下有其他UI元素如下划线、图标它们也会被一并旋转通常这不是想要的效果。注意这个方案唯一可考虑的适用场景是文本内容绝对静态、永不换行、且不需要与其他UI元素对齐的“装饰性文字”。对于任何动态或复杂的UI请直接跳过它。2.2 方案二逐字符旋转与重排核心思路要正确实现竖排我们必须更深入地控制文本的生成过程。TextMeshPro渲染文本的流程大致是解析字符串 - 根据字体、样式、布局设置生成字符信息 - 为每个字符计算顶点、UV等网格数据 - 提交网格渲染。我们的切入点就在“生成字符信息”之后“计算顶点数据”之前或之时。核心思路是不旋转整个UI控件而是改变每个字符的最终绘制位置和朝向模拟出竖排效果。这需要我们将文本的排版方向从“从左到右从上到下”改为“从上到下从右到左”这是中文古籍常见的竖排方向或“从上到下从左到右”。实现原理拆解布局重计算我们需要覆盖默认的水平排版逻辑。假设一行能容纳N个字符在竖排中就相当于一列能容纳N个字符。我们需要根据字体大小、行间距此时应理解为列间距和文本框的高度因为现在高度是行宽来计算每列能放几个字以及总共需要几列。字符位置重映射遍历TextMeshPro生成的每个字符信息TMP_CharacterInfo不修改其原始的顶点、UV数据但重新计算它在画布空间中的位置。一个在水平排版中位于第i行、第j列的字符在竖排中可能需要被放置到第j列、第i行的位置。字符朝向旋转为了让每个字符都“站”起来我们需要将每个字符的四个顶点绕其自身中心旋转90度或-90度。这个旋转操作是在局部空间进行的。这个方案是后续所有高级方案的基础。它直接操控了渲染的最终结果因此能保证视觉正确性同时避免了旋转整个UI控件带来的布局副作用。2.3 方案三自定义TMP_Text子类推荐架构方案二的思想需要有一个载体。最优雅且功能完整的方式是创建一个自定义的TMP_Text子类。通过重写关键方法我们可以将竖排逻辑无缝集成到TextMeshPro的生成流程中。TMP_Text类中有一个受保护的虚方法GenerateTextMesh它是文本网格生成的枢纽。更精准的切入点是ComputeMarginSize用于计算文本尺寸和负责实际填充顶点缓冲区的代码区域。我们可以通过继承并重写这些部分来插入我们的逻辑。using TMPro; using UnityEngine; [DisallowMultipleComponent] [RequireComponent(typeof(RectTransform))] [AddComponentMenu(UI/Advanced TextMeshPro - Vertical Text)] public class VerticalTextMeshProUGUI : TMP_Text { public enum VerticalAlignment { Top, Middle, Bottom } public enum HorizontalAlignment { Right, Center, Left } [SerializeField] private VerticalAlignment _verticalAlignment VerticalAlignment.Top; [SerializeField] private HorizontalAlignment _horizontalAlignment HorizontalAlignment.Right; [SerializeField] private bool _rotateCharacters true; // 重写核心文本生成方法 protected override void GenerateTextMesh() { // 1. 先调用父类方法让TMP完成基础的字符信息解析、字体图集准备等所有前置工作。 base.GenerateTextMesh(); // 2. 获取由父类计算好的字符信息数组。 TMP_CharacterInfo[] characterInfos m_textInfo.characterInfo; // 获取父类生成的原始顶点数据。 Vector3[] originalVertices m_textInfo.meshInfo[0].vertices; // 3. 如果文本为空或只有空格直接返回。 if (characterInfos null || characterInfos.Length 0 || string.IsNullOrWhiteSpace(text)) return; // 4. 执行我们的竖排重排与旋转逻辑详见下一节。 RearrangeAndRotateVertices(characterInfos, originalVertices); // 5. 重要更新网格使修改生效。 this.UpdateGeometry(); } private void RearrangeAndRotateVertices(TMP_CharacterInfo[] chars, Vector3[] verts) { // 具体实现逻辑在下一章详细展开。 } // 也可以选择重写此方法以在布局计算阶段就返回正确的文本尺寸。 protected override Vector2 CalculatePreferredValues() { // 先获取原始水平排版下的宽高。 Vector2 horizontalSize base.CalculatePreferredValues(); // 对于竖排宽高需要交换并且要考虑列间距原行间距的影响。 // 这是一个简化计算更精确的尺寸需要在GenerateTextMesh中根据实际排列确定。 return new Vector2(horizontalSize.y, horizontalSize.x); } }这个架构的优势在于继承完整功能自动拥有TextMeshPro的所有基础功能字体、样式、富文本、超链接等。流程集成在正确的生命周期钩子中插入逻辑确保与TextMeshPro的更新机制如SetText、SetLayoutDirty兼容。组件化可以作为一个独立的UI组件使用通过Inspector配置属性如对齐方式、是否旋转字符。3. 核心实现顶点重排与旋转算法详解现在我们深入方案三的RearrangeAndRotateVertices方法这是整个竖排功能的心脏。实现它需要清晰的数学步骤和对TextMeshPro数据结构的理解。3.1 数据结构准备与参数计算首先我们需要从父类生成的m_textInfo中提取必要信息并计算竖排布局的基本参数。private void RearrangeAndRotateVertices(TMP_CharacterInfo[] chars, Vector3[] verts) { // 获取文本框的RectTransform尺寸单位Unity单位通常是像素。 Rect rect rectTransform.rect; float rectWidth rect.width; float rectHeight rect.height; // 获取字体规格。fontSize是基础大小fontScale是当前实际缩放考虑AutoSize等。 float actualFontSize this.fontSize * this.fontScale; // 列间距我们使用水平排版时的“行间距”概念但需要转换为竖排下的“列间距”。 // TMP的行间距通常是字体大小的一个百分比偏移这里做简化处理。 float columnSpacing actualFontSize * (this.lineSpacing / 100f); // 字符间距字距也需要考虑。 float characterSpacing this.characterSpacing; // 计算每列最大容纳字符数。 // 注意这里用rectHeight因为竖排时文本框的高度限制了单列的字符数。 int maxCharsPerColumn Mathf.FloorToInt((rectHeight actualFontSize/2) / (actualFontSize columnSpacing)); if (maxCharsPerColumn 0) maxCharsPerColumn 1; // 计算总列数。 int totalCharacterCount m_textInfo.characterCount; // 实际渲染的字符数排除空格、换行符等不可见字符需注意 int totalColumns Mathf.CeilToInt((float)totalCharacterCount / maxCharsPerColumn); // 计算单列宽度。一个字符的宽度约等于其字体大小加上字符间距。 float columnWidth actualFontSize characterSpacing; // 计算文本内容的总宽度和总高度用于对齐计算。 float totalContentWidth totalColumns * columnWidth; float totalContentHeight Mathf.Min(maxCharsPerColumn, totalCharacterCount) * (actualFontSize columnSpacing); // 根据对齐方式计算起始绘制偏移。 Vector2 startOffset CalculateStartOffset(totalContentWidth, totalContentHeight, rectWidth, rectHeight); // ... 后续遍历字符进行重排 }CalculateStartOffset函数根据我们自定义的VerticalAlignment和HorizontalAlignment来计算第一个字符通常位于右上角的起始位置。private Vector2 CalculateStartOffset(float contentWidth, float contentHeight, float rectWidth, float rectHeight) { float startX 0f; float startY 0f; // 水平对齐竖排下是控制列在水平方向的对齐 switch (_horizontalAlignment) { case HorizontalAlignment.Right: // 列从右向左排所以第一列紧贴右边界 startX rectWidth / 2 - contentWidth / 2; // 居中对齐计算起点 // 更精确的startX (rectWidth - contentWidth) / 2; 但需考虑TMP原点。 break; case HorizontalAlignment.Center: startX 0; // 中心对齐起点在水平中心偏左半内容宽度处 break; case HorizontalAlignment.Left: startX -rectWidth / 2 contentWidth / 2; // 从左向右排 break; } // 垂直对齐竖排下是控制字符在列内的对齐 switch (_verticalAlignment) { case VerticalAlignment.Top: // 字符从顶部开始向下排 startY rectHeight / 2 - actualFontSize / 2; // 从顶部开始 break; case VerticalAlignment.Middle: startY 0; // 居中 break; case VerticalAlignment.Bottom: startY -rectHeight / 2 actualFontSize / 2; // 从底部开始向上排较少见 break; } return new Vector2(startX, startY); }实操心得这里的对齐计算是竖排逻辑中最繁琐的部分因为它需要将“水平对齐”和“垂直对齐”的概念在竖排坐标系下重新诠释。强烈建议在实现时绘制一个坐标系草图明确RectTransform的中心点(0,0)、左上角、右下角与你的内容宽高之间的关系。不同的锚点设置如Stretch也会影响rect.width/height的值可能需要更复杂的处理。初期可以先实现一种对齐方式如TopRight确保基本排列正确再逐步扩展。3.2 遍历字符与顶点变换有了布局参数我们就可以遍历每个可渲染的字符重新计算其顶点位置并应用旋转。// 接上一段代码 int currentCharIndex 0; for (int col 0; col totalColumns; col) { // 计算当前列的X位置从起始X位置向左偏移对于从右向左排版 float columnPosX startOffset.x - col * columnWidth; // 确定当前列的实际字符数最后一列可能不满 int charsInThisColumn Mathf.Min(maxCharsPerColumn, totalCharacterCount - currentCharIndex); for (int rowInColumn 0; rowInColumn charsInThisColumn; rowInColumn) { TMP_CharacterInfo charInfo chars[currentCharIndex]; // 跳过不可见字符如空格但TMP可能仍为其分配顶点需根据charInfo.isVisible判断 if (!charInfo.isVisible) { currentCharIndex; continue; } // 计算当前字符在列内的Y位置从起始Y位置向下偏移 float charPosY startOffset.y - rowInColumn * (actualFontSize columnSpacing); // 获取这个字符的4个顶点在原始顶点数组中的索引 int vertexIndex charInfo.vertexIndex; // 步骤1计算字符中心点用于旋转 Vector3 charCenter Vector3.zero; for (int i 0; i 4; i) { charCenter verts[vertexIndex i]; } charCenter / 4f; // 步骤2计算目标位置竖排位置 Vector3 targetPosition new Vector3(columnPosX, charPosY, 0); // 步骤3对每个顶点进行变换 for (int i 0; i 4; i) { // 获取原始顶点位置相对于字符中心 Vector3 vertexRelativeToCenter verts[vertexIndex i] - charCenter; // 如果需要旋转字符使其直立 if (_rotateCharacters) { // 绕Z轴旋转-90度顺时针90度使字符从水平变为垂直。 vertexRelativeToCenter Quaternion.Euler(0, 0, -90) * vertexRelativeToCenter; } // 将变换后的相对位置加上字符的目标世界位置得到最终顶点位置。 verts[vertexIndex i] targetPosition vertexRelativeToCenter; } currentCharIndex; } }关键点解析顶点索引TMP_CharacterInfo.vertexIndex指向该字符第一个顶点在顶点数组中的位置。每个字符由两个三角形一个四边形组成共4个顶点。字符中心计算我们计算原始四个顶点的平均值作为该字符的“中心点”。所有旋转和平移变换都应以这个中心点为参考才能保证字符不变形。旋转操作Quaternion.Euler(0, 0, -90)表示绕Z轴顺时针旋转90度。这是让水平书写的字符“站起来”的关键。如果你希望字符是倒立的旋转270度可以改为90。目标位置targetPosition是我们根据竖排版式计算出的该字符四边形中心点应该所在的位置。3.3 处理富文本与特殊字符上面的基础循环忽略了TextMeshPro的一些高级特性而一个健壮的竖排组件必须处理它们。字体样式加粗、斜体顶点旋转操作已经包含了字符的所有形变因此斜体等样式会被自动保留无需特殊处理。颜色与顶点色每个顶点除了位置vertex还有颜色color、UVuv0, uv1等属性。我们的操作只修改了vertices其他属性如colors、uv0s等保持不变因此字符颜色、贴图显示正常。下划线与删除线这些是作为额外的“字符”被添加到m_textInfo里的。它们也有自己的TMP_CharacterInfo。我们的遍历是基于characterInfo数组的所以会自动处理到它们。但需要注意下划线的几何形状是长条形的竖排时可能需要调整其朝向的逻辑例如竖排下的下划线可能应该变成右侧竖线。这需要额外判断字符类型并做特殊变换。超链接与交互TextMeshPro的超链接检测是基于字符的屏幕位置通过TMP_TextUtilities。我们大幅修改了顶点位置后默认的检测会失效。这是本方案的一个重大挑战。解决方案之一是重写OnPointerClick等方法根据修改后的顶点数据自己计算点击位置落在了哪个字符的包围盒内。Sprite表情图标内嵌的Sprite同样以“字符”形式存在。我们的顶点旋转逻辑对其同样适用但需要确保sprite的原始设计是水平方向的。如果sprite本身是竖版的旋转后反而会躺下这时可能需要根据spriteInfo判断并跳过旋转。注意事项富文本标签如b、i、color不影响顶点位置计算可以忽略。但像voffset垂直偏移或space水平空格这类直接影响位置的标签在竖排语境下可能需要被重新解释或禁用否则会导致排版错乱。一个稳妥的做法是在解析文本后过滤或转换这些可能冲突的标签。4. 性能优化与高级技巧一个功能正确但效率低下的竖排文本在滚动列表或大量使用时会是灾难。以下是提升性能的关键点。4.1 缓存与脏标记策略TextMeshPro自身有一套高效的脏标记系统SetVerticesDirty,SetLayoutDirty。我们的自定义组件必须融入这个系统。public class VerticalTextMeshProUGUI : TMP_Text { private bool _isLayoutDirty true; private int _cachedTextHash; // 用于缓存文本内容哈希判断内容是否变化 private Vector2 _cachedSize; // 缓存上一次的RectTransform尺寸 protected override void OnRectTransformDimensionsChange() { base.OnRectTransformDimensionsChange(); // 当UI尺寸变化时标记布局需要重新计算。 _isLayoutDirty true; } public override void SetText(string text, bool syncTextInputBox true) { int newHash text?.GetHashCode() ?? 0; if (newHash ! _cachedTextHash) { _cachedTextHash newHash; _isLayoutDirty true; } base.SetText(text, syncTextInputBox); } protected override void GenerateTextMesh() { // 只有在布局脏或文本脏时才执行昂贵的竖排计算 if (_isLayoutDirty || m_isTextTruncated) { base.GenerateTextMesh(); // 先让父类生成基础网格 PerformVerticalLayout(); // 执行我们的顶点变换 _isLayoutDirty false; } else { // 如果只是颜色等属性变化直接调用父类更新即可无需重排。 base.GenerateTextMesh(); } } // 提供一个强制刷新的方法 public void RefreshVerticalLayout() { _isLayoutDirty true; SetVerticesDirty(); } }4.2 网格合并与Draw Call控制TextMeshPro的优势之一是其自动的网格合批能力。我们的顶点修改是在TextMeshPro生成网格信息之后进行的只要不破坏其网格数据结构顶点、三角形索引的顺序关系合批就不会被破坏。我们的RearrangeAndRotateVertices方法只修改了m_textInfo.meshInfo[0].vertices数组中的顶点位置没有改变顶点数量、三角形索引或材质信息因此Draw Call数量与普通TextMeshPro文本保持一致这是本方案性能上的巨大优势。4.3 针对大量文本的优化当竖排文本非常长如一整篇文章时即使只修改顶点遍历所有字符的计算量也可能成为瓶颈尤其是在移动设备上。分帧计算对于极端情况可以考虑将RearrangeAndRotateVertices中的遍历计算分割到多个帧中进行使用Coroutine或MonoBehaviour.Update来逐步完成顶点变换避免单帧卡顿。但这会带来文本显示“逐步出现”的视觉效果需要权衡。对象池与复用如果场景中存在大量相同的静态竖排文本如物品描述可以考虑将计算好的网格保存为Asset或Prefab直接实例化MeshFilter或Image完全绕过TextMeshPro的实时生成。这属于更高级的优化适用于极度追求性能的场景。4.4 与Mask、RectMask2D和ScrollRect的兼容性由于我们没有改变RectTransform的旋转和缩放只是修改了其子网格的顶点位置因此UI系统的裁剪组件Mask, RectMask2D和滚动视图ScrollRect通常能正常工作。因为裁剪是基于Canvas的渲染层级和RectTransform的矩形区域进行的而我们的顶点虽然位置变了但依然在其父RectTransform的边界框内前提是计算正确。但需要特别注意RectMask2D的裁剪是精确到像素的如果旋转后的字符顶点超出了父矩形就会被裁剪掉。确保你的CalculateStartOffset和列宽/列高计算足够精确让所有顶点都落在合理的范围内。5. 常见问题与调试技巧实录即使按照上述步骤实现在实际项目中仍会遇到各种稀奇古怪的问题。这里记录一些典型的“坑”和解决方法。5.1 文字显示为“方框”或乱码问题描述竖排文本显示的不是文字而是一堆“口口口”或乱码方块。排查步骤字体检查首先确认使用的TMP Font Asset是否包含你所使用的字符。在Inspector中检查字体图集或尝试在横排状态下显示相同文字。顶点索引越界在RearrangeAndRotateVertices的循环中最可能的原因是vertexIndex计算错误导致访问了错误的顶点数据。添加安全检查if (vertexIndex 3 verts.Length) { Debug.LogError($Vertex index out of bounds for character {currentCharIndex}); break; }。不可见字符处理确保在遍历时正确跳过charInfo.isVisible为false的字符如空格、换行符。这些字符的vertexIndex可能为0或无效值。5.2 文字重叠或间距异常问题描述字符挤在一起或者列与列之间距离不对。排查步骤列宽计算检查columnWidth的计算。它应该是actualFontSize characterSpacing。actualFontSize是否计算正确fontScale在AutoSize开启时是动态变化的。列间距columnSpacing由lineSpacing派生。TMP的lineSpacing单位可能是像素、百分比或基线偏移。使用this.lineSpacing以点为单位可能更直接但需要测试。一个调试方法是在Scene视图中将顶点位置用Debug.DrawLine画出来直观查看每个字符四边形的边界。起始偏移CalculateStartOffset函数是对齐的根源。用Debug.Log输出startOffset、totalContentWidth、rectWidth等值检查逻辑是否符合你的对齐预期。为不同的锚点模式Min, Middle, Max, Stretch编写测试用例。5.3 超链接点击无效问题描述文字能正确显示但点击超链接没有反应。解决方案这是预期内的因为点击检测依赖于未修改的字符位置。你需要重写点击检测。public override void OnPointerClick(PointerEventData eventData) { // 1. 将屏幕点击坐标转换到本地文本空间。 Vector2 localClickPos; RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out localClickPos); // 2. 遍历所有字符信息使用我们修改后的顶点数据。 TMP_CharacterInfo[] chars m_textInfo.characterInfo; for (int i 0; i m_textInfo.characterCount; i) { if (!chars[i].isVisible) continue; // 3. 获取该字符修改后的四边形包围盒需要从修改后的verts数组计算。 Vector3[] modifiedVerts m_textInfo.meshInfo[0].vertices; int vi chars[i].vertexIndex; // 计算最小/最大边界 float minX Mathf.Min(modifiedVerts[vi].x, modifiedVerts[vi1].x, modifiedVerts[vi2].x, modifiedVerts[vi3].x); float maxX Mathf.Max(modifiedVerts[vi].x, modifiedVerts[vi1].x, modifiedVerts[vi2].x, modifiedVerts[vi3].x); float minY Mathf.Min(modifiedVerts[vi].y, modifiedVerts[vi1].y, modifiedVerts[vi2].y, modifiedVerts[vi3].y); float maxY Mathf.Max(modifiedVerts[vi].y, modifiedVerts[vi1].y, modifiedVerts[vi2].y, modifiedVerts[vi3].y); // 4. 判断点击是否在包围盒内。 if (localClickPos.x minX localClickPos.x maxX localClickPos.y minY localClickPos.y maxY) { // 5. 找到字符对应的链接索引需要维护或查询TMP_LinkInfo int linkIndex TMP_TextUtilities.FindIntersectingLink(this, localClickPos, eventData.pressEventCamera); // 这个函数可能失效需要自定义 if (linkIndex ! -1) { TMP_LinkInfo linkInfo m_textInfo.linkInfo[linkIndex]; Debug.Log($Link clicked: {linkInfo.GetLinkText()}); // 触发链接事件... base.OnPointerClick(eventData); // 或者调用你自己的事件 } break; } } }注意这是一个简化示例。更完整的实现需要你维护一个字符索引到链接索引的映射因为TMP_TextUtilities.FindIntersectingLink内部使用的是未修改的几何信息。5.4 在滚动列表中闪烁或抖动问题描述竖排文本放在ScrollRect下的Item中滚动时文字出现闪烁、抖动或位置不正确。排查步骤Canvas刷新模式确保所在Canvas的Render Mode不是Screen Space - Overlay并且Pixel Perfect选项可能引起细微抖动可以尝试关闭。RectTransform锚点竖排文本的RectTransform锚点最好设置为固定点如左上角TopLeft避免使用Stretch。因为Stretch会在布局计算时动态改变rect的宽高可能与我们的OnRectTransformDimensionsChange触发逻辑产生循环依赖或竞争条件。布局计算时机检查GenerateTextMesh或OnPopulateMesh如果你重写的是这个的调用频率。确保_isLayoutDirty标记只在必要时设置为true。有时父布局组件如VerticalLayoutGroup的频繁重建会导致文本反复重新生成。5.5 性能问题排查清单如果发现带有竖排文本的UI界面变得卡顿可以按以下顺序排查Profile工具使用Unity Profiler重点观察Canvas.SendWillRenderCanvases和你的VerticalTextMeshProUGUI.GenerateTextMesh/PerformVerticalLayout方法的耗时。文本长度检查是否在单帧内处理了过长的文本如超过500个字符。考虑是否真的需要一次性显示全部内容或进行分帧处理。变更频率是否每帧都在调用SetText或修改会导致文本重建的属性如fontSize尽量缓存文本减少不必要的变更。实例数量屏幕上同时存在多少个竖排文本实例即使每个消耗不大数量多了也成问题。考虑动态加载和卸载。6. 扩展方向图文混排与动画集成实现基础竖排后可以在此基础上扩展更复杂的功能满足项目特定需求。6.1 竖排图文混排在竖排文本中插入图片如表情、图标需要将图片也当作一个“特殊字符”进行布局。TextMeshPro支持通过sprite标签内嵌图片。在我们的竖排逻辑中需要识别出sprite类型的TMP_CharacterInfo。private void RearrangeAndRotateVertices(TMP_CharacterInfo[] chars, Vector3[] verts) { // ... 前面的布局计算 ... for (int i 0; i chars.Length; i) { TMP_CharacterInfo info chars[i]; if (!info.isVisible) continue; // 判断是否为Sprite if (info.elementType TMP_TextElementType.Sprite) { // 对于Sprite你可能不希望旋转它比如一个正方形的图标。 // 可以设置一个标志或根据sprite名称判断。 bool shouldRotateSprite false; // 例如表情符号不旋转 // 计算位置时Sprite的“字体大小”可能用info.scale或spriteInfo.height代替actualFontSize。 float elementHeight info.pointSize * info.scale; // 估算高度 // ... 使用elementHeight参与行高计算 ... // 在顶点变换时根据shouldRotateSprite决定是否应用旋转。 } else { // 普通字符处理逻辑 } } }图片的宽度和高度可能不相等需要根据其原始宽高比和竖排的列宽进行适当的缩放避免图片变形或布局错乱。6.2 逐字浮现动画竖排文字的逐字浮现Typewriter Effect动画原理是控制每个字符的顶点颜色Alpha值从0到1渐变或控制其顶点位置如从上方掉落。由于我们已经掌控了每个字符的最终顶点位置实现这种动画就变得非常直接。可以在自定义组件中添加一个float类型的revealProgress属性0到1然后在Update或协程中逐步增加其值。在GenerateTextMesh或一个专门的UpdateGeometry方法中根据revealProgress和每个字符的索引计算其当前的Alpha或偏移位置并动态修改顶点颜色或位置。public float revealProgress 0f; // 由外部动画驱动 private void UpdateGeometryWithAnimation() { TMP_CharacterInfo[] chars m_textInfo.characterInfo; Color32[] vertexColors m_textInfo.meshInfo[0].colors32; int totalVisibleChars m_textInfo.characterCount; // 需要计算实际可见字符数 for (int i 0; i chars.Length; i) { if (!chars[i].isVisible) continue; float charRevealThreshold (float)i / totalVisibleChars; byte alpha (revealProgress charRevealThreshold) ? (byte)255 : (byte)0; int vertexIndex chars[i].vertexIndex; for (int j 0; j 4; j) { vertexColors[vertexIndex j].a alpha; } // 或者添加位置动画从目标位置上方偏移一段距离随着进度归位。 if (revealProgress charRevealThreshold) { float offsetY 50f * (1f - (revealProgress / charRevealThreshold)); // ... 修改verts[vertexIndex j]的y值 ... } } this.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); // 仅更新颜色效率高 }6.3 作为Shader的替代方案除了C#脚本修改顶点另一种思路是使用自定义Shader来实现竖排。顶点Shader可以接收每个字符的原始UV和位置然后通过矩阵变换在屏幕空间重新排列。这种方案的优点是性能开销恒定无论文本多长Shader的计算量不变性能极佳。可实现动态效果通过Shader参数可以轻松实现波浪、扭曲等高级动画。但缺点也非常明显开发难度高需要深厚的Shader编写能力并且要深入理解TextMeshPro的Shader输入结构。功能受限在Shader中难以实现精确的点击检测、文本选择、富文本样式混合等复杂逻辑。兼容性需要为不同的渲染管线Built-in, URP, HDRP编写不同版本的Shader。因此对于大多数项目C#顶点变换方案在功能、性能和开发成本上取得了更好的平衡。Shader方案更适合于纯展示、且对动态效果有极高要求的特定场景如片头字幕、特效文字。经过以上从原理到实现从基础到进阶再到问题排查和扩展的完整拆解一个高效、健壮的Unity TextMeshPro竖排文本组件就有了清晰的实现蓝图。核心在于理解TextMeshPro的网格生成流程并巧妙地在其之后、渲染之前插入顶点变换逻辑。这个过程充满了细节挑战但一旦打通就能为你的游戏UI带来独特的视觉表现力和文化韵味。