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);

为什么它行不通?

  1. 布局错乱:TextMeshPro在生成文本网格时,仍然按照水平方向计算行宽和换行。旋转后,原本的“行宽”变成了视觉上的“列高”,但换行判断逻辑未变,导致换行点完全错误,文字会挤在奇怪的位置。
  2. 锚点与对齐灾难:TextMeshPro的文本对齐(如Left, Center, Right)是基于水平轴的。旋转90度后,“Left”对齐实际上变成了“Bottom”,这会让控件的锚点和对齐行为变得极其反直觉,难以通过RectTool进行正常布局。
  3. 交互区域错位:如果文本需要点击交互(如超链接),其点击检测区域仍然是旋转前的原始矩形,与视觉位置不匹配。
  4. 子物体牵连:如果该文本对象下有其他UI元素(如下划线、图标),它们也会被一并旋转,通常这不是想要的效果。

注意:这个方案唯一可考虑的适用场景是,文本内容绝对静态、永不换行、且不需要与其他UI元素对齐的“装饰性文字”。对于任何动态或复杂的UI,请直接跳过它。

2.2 方案二:逐字符旋转与重排(核心思路)

要正确实现竖排,我们必须更深入地控制文本的生成过程。TextMeshPro渲染文本的流程大致是:解析字符串 -> 根据字体、样式、布局设置生成字符信息 -> 为每个字符计算顶点、UV等网格数据 -> 提交网格渲染。我们的切入点就在“生成字符信息”之后,“计算顶点数据”之前或之时。

核心思路是:不旋转整个UI控件,而是改变每个字符的最终绘制位置和朝向,模拟出竖排效果。这需要我们将文本的排版方向从“从左到右,从上到下”改为“从上到下,从右到左”(这是中文古籍常见的竖排方向)或“从上到下,从左到右”。

实现原理拆解:

  1. 布局重计算:我们需要覆盖默认的水平排版逻辑。假设一行能容纳N个字符,在竖排中,就相当于一列能容纳N个字符。我们需要根据字体大小、行间距(此时应理解为列间距)和文本框的高度(因为现在高度是行宽)来计算每列能放几个字,以及总共需要几列。
  2. 字符位置重映射:遍历TextMeshPro生成的每个字符信息(TMP_CharacterInfo),不修改其原始的顶点、UV数据,但重新计算它在画布空间中的位置。一个在水平排版中位于第i行、第j列的字符,在竖排中可能需要被放置到第j列、第i行的位置。
  3. 字符朝向旋转:为了让每个字符都“站”起来,我们需要将每个字符的四个顶点绕其自身中心旋转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的更新机制(如SetTextSetLayoutDirty)兼容。
  • 组件化:可以作为一个独立的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函数根据我们自定义的VerticalAlignmentHorizontalAlignment来计算第一个字符(通常位于右上角)的起始位置。

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++; } }

关键点解析:

  1. 顶点索引TMP_CharacterInfo.vertexIndex指向该字符第一个顶点在顶点数组中的位置。每个字符由两个三角形(一个四边形)组成,共4个顶点。
  2. 字符中心计算:我们计算原始四个顶点的平均值作为该字符的“中心点”。所有旋转和平移变换都应以这个中心点为参考,才能保证字符不变形。
  3. 旋转操作Quaternion.Euler(0, 0, -90)表示绕Z轴顺时针旋转90度。这是让水平书写的字符“站起来”的关键。如果你希望字符是倒立的(旋转270度),可以改为90。
  4. 目标位置targetPosition是我们根据竖排版式计算出的该字符四边形中心点应该所在的位置。

3.3 处理富文本与特殊字符

上面的基础循环忽略了TextMeshPro的一些高级特性,而一个健壮的竖排组件必须处理它们。

  • 字体样式(加粗、斜体):顶点旋转操作已经包含了字符的所有形变,因此斜体等样式会被自动保留,无需特殊处理。
  • 颜色与顶点色:每个顶点除了位置(vertex),还有颜色(color)、UV(uv0, uv1)等属性。我们的操作只修改了vertices,其他属性如colorsuv0s等保持不变,因此字符颜色、贴图显示正常。
  • 下划线与删除线:这些是作为额外的“字符”被添加到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中的遍历计算分割到多个帧中进行,使用CoroutineMonoBehaviour.Update来逐步完成顶点变换,避免单帧卡顿。但这会带来文本显示“逐步出现”的视觉效果,需要权衡。
  • 对象池与复用:如果场景中存在大量相同的静态竖排文本(如物品描述),可以考虑将计算好的网格保存为Asset或Prefab,直接实例化MeshFilter或Image,完全绕过TextMeshPro的实时生成。这属于更高级的优化,适用于极度追求性能的场景。

4.4 与Mask、RectMask2D和ScrollRect的兼容性

由于我们没有改变RectTransform的旋转和缩放,只是修改了其子网格的顶点位置,因此UI系统的裁剪组件(Mask, RectMask2D)和滚动视图(ScrollRect)通常能正常工作。因为裁剪是基于Canvas的渲染层级和RectTransform的矩形区域进行的,而我们的顶点虽然位置变了,但依然在其父RectTransform的边界框内(前提是计算正确)。但需要特别注意RectMask2D的裁剪是精确到像素的,如果旋转后的字符顶点超出了父矩形,就会被裁剪掉。确保你的CalculateStartOffset和列宽/列高计算足够精确,让所有顶点都落在合理的范围内。

5. 常见问题与调试技巧实录

即使按照上述步骤实现,在实际项目中仍会遇到各种稀奇古怪的问题。这里记录一些典型的“坑”和解决方法。

5.1 文字显示为“方框”或乱码

  • 问题描述:竖排文本显示的不是文字,而是一堆“口口口”或乱码方块。
  • 排查步骤
    1. 字体检查:首先确认使用的TMP Font Asset是否包含你所使用的字符。在Inspector中检查字体图集,或尝试在横排状态下显示相同文字。
    2. 顶点索引越界:在RearrangeAndRotateVertices的循环中,最可能的原因是vertexIndex计算错误,导致访问了错误的顶点数据。添加安全检查:if (vertexIndex + 3 >= verts.Length) { Debug.LogError($"Vertex index out of bounds for character {currentCharIndex}"); break; }
    3. 不可见字符处理:确保在遍历时正确跳过charInfo.isVisible为false的字符(如空格、换行符)。这些字符的vertexIndex可能为0或无效值。

5.2 文字重叠或间距异常

  • 问题描述:字符挤在一起,或者列与列之间距离不对。
  • 排查步骤
    1. 列宽计算:检查columnWidth的计算。它应该是actualFontSize + characterSpacingactualFontSize是否计算正确?fontScale在AutoSize开启时是动态变化的。
    2. 列间距columnSpacinglineSpacing派生。TMP的lineSpacing单位可能是像素、百分比或基线偏移。使用this.lineSpacing(以点为单位)可能更直接,但需要测试。一个调试方法是:在Scene视图中,将顶点位置用Debug.DrawLine画出来,直观查看每个字符四边形的边界。
    3. 起始偏移CalculateStartOffset函数是对齐的根源。用Debug.Log输出startOffsettotalContentWidthrectWidth等值,检查逻辑是否符合你的对齐预期。为不同的锚点模式(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[vi+1].x, modifiedVerts[vi+2].x, modifiedVerts[vi+3].x); float maxX = Mathf.Max(modifiedVerts[vi].x, modifiedVerts[vi+1].x, modifiedVerts[vi+2].x, modifiedVerts[vi+3].x); float minY = Mathf.Min(modifiedVerts[vi].y, modifiedVerts[vi+1].y, modifiedVerts[vi+2].y, modifiedVerts[vi+3].y); float maxY = Mathf.Max(modifiedVerts[vi].y, modifiedVerts[vi+1].y, modifiedVerts[vi+2].y, modifiedVerts[vi+3].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中,滚动时文字出现闪烁、抖动或位置不正确。
  • 排查步骤
    1. Canvas刷新模式:确保所在Canvas的Render Mode不是Screen Space - Overlay,并且Pixel Perfect选项可能引起细微抖动,可以尝试关闭。
    2. RectTransform锚点:竖排文本的RectTransform锚点最好设置为固定点(如左上角TopLeft),避免使用Stretch。因为Stretch会在布局计算时动态改变rect的宽高,可能与我们的OnRectTransformDimensionsChange触发逻辑产生循环依赖或竞争条件。
    3. 布局计算时机:检查GenerateTextMeshOnPopulateMesh(如果你重写的是这个)的调用频率。确保_isLayoutDirty标记只在必要时设置为true。有时父布局组件(如VerticalLayoutGroup)的频繁重建会导致文本反复重新生成。

5.5 性能问题排查清单

如果发现带有竖排文本的UI界面变得卡顿,可以按以下顺序排查:

  1. Profile工具:使用Unity Profiler,重点观察Canvas.SendWillRenderCanvases和你的VerticalTextMeshProUGUI.GenerateTextMesh/PerformVerticalLayout方法的耗时。
  2. 文本长度:检查是否在单帧内处理了过长的文本(如超过500个字符)。考虑是否真的需要一次性显示全部内容,或进行分帧处理。
  3. 变更频率:是否每帧都在调用SetText或修改会导致文本重建的属性(如fontSize)?尽量缓存文本,减少不必要的变更。
  4. 实例数量:屏幕上同时存在多少个竖排文本实例?即使每个消耗不大,数量多了也成问题。考虑动态加载和卸载。

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带来独特的视觉表现力和文化韵味。