做UI做得多了,迟早会遇到“圆形环绕文字”这种需求。钟表刻度、环形菜单、圆盘上的标签、游戏Logo旁边的装饰文字,都要让一行字符顺着圆弧走,而不是横在屏幕上。Unity 2019的UGUI里没有现成组件能直接做这件事,网上找插件又绑定了各种项目结构,于是我干脆自己写了一个。试过几条路之后,最终留下来的是继承Text类、重写OnPopulateMesh的做法,代码量不大,效果却很干净。这篇文章就把完整实现思路、源码和踩过的坑一起放出来,适合正在做UI环绕排版、又不想引入重型插件的开发者直接参考。
1. 动手前先理清方案:三种思路怎么选
圆形环绕文字听起来像个特效,其实本质是“把一排字符从直线坐标弯到圆弧坐标”。方案选择决定了后续工作量,我先把常见的三条路对比一遍。
1.1 方案A:逐字生成Text再摆位置
最简单的思路是动态创建多个Text子物体,一个字符挂一个,然后分别计算每个字符的角度、半径和旋转量,摆到圆环上。很多刚接触这个需求的人会选这条,因为每一步都“看得见摸得着”。
但实际做起来很痛苦。一个字就是一个GameObject,也就是一个Canvas元素,10个字符就要10个节点。如果文字数量还会动态变化,那就得反复创建销毁,Canvas重建的开销很快就上来了。更麻烦的是,每个子物体的字间距、行高等排版参数无法统一控制,想做“字符间距微调”或“整段文字绕圆旋转”时,要改的东西特别多。这个方案适合做静态、字符数极少的装饰性文本,不适合作为通用功能沉淀下来。
1.2 方案B:用TextMeshPro做顶点变换
TextMeshPro在Unity 2019中已经通过Package Manager集成,质量不错,社区也有一堆逐字动画案例。用TMP的思路是读取textInfo,拿到每个字符的顶点索引和包围盒,然后做同样的圆弧变换。TMP最大的优势是顶点数据非常规整,字符信息也能通过API直接拿,做RichText、动态字体、多行混排时的稳定性比UGUI强。
但问题是,TMP进入项目会带来一套新的字体管线。很多存量项目压根没启用TMP包,为了一个环绕文字效果引入它,还要重新处理所有字体资源,代价偏高。如果项目本来就在用TMP,那方案B是很自然的;否则我倾向于用UGUI原生的Text解决。
1.3 方案C:继承UGUI的Text重写OnPopulateMesh
这是我在实际项目中最终采用的方案。UGUI的Text类本身就暴露了OnPopulateMesh这个虚方法,它在文本网格生成时被调用。我们可以在里面先让Text按默认规则生成直线排布的网格,再把顶点按圆弧弯折。全过程只改一个组件,不新增任何GameObject,不引入额外字体系统,字体的RGB颜色、对齐方式、超链接等基础功能全都保留。
UGUI的Text本身经历了很多版本迭代,内部已经很稳定,我们只是在一个固定的钩子点上对顶点做变换,改动面和风险都可控。后续想加动画,也只需要修改几个参数。这个方案看起来“技术含量”高一点,实际上核心数学只有弧长映射和旋转两个公式,值得花时间掌握。
2. 核心原理:UGUI的Text网格是怎么长出来的
在动手写代码之前,有必要把UGUI文字网格的生成机制讲清楚,否则后面代码写出来也只是照抄。
2.1 一个字符就是四个顶点两个三角形
UGUI的Text最终生成的是CanvasRenderer能直接理解的网格数据。每个字符在网格中占一个四边形,由4个顶点和6个索引(两个三角形)组成。顶点带三个信息:position(位置)、uv(字体图集上的采样坐标)、color(顶点颜色)。
顶点顺序在标准情况下是固定的:索引0为左下,1为左上,2为右上,3为右下。四个点形成一个矩形,矩形宽高就是当前字号的字符宽高。UV则指向字体动态图集里这个字符对应的纹理区域,字体渲染就是靠这个UV从图集采样的。
所以“把文字弯成圆”这件事,本质上就是:把这4个顶点从原来的位置平移到圆上的新位置,同时让整个字符组旋转一个合适的角度。只要四个顶点之间的相对关系不变、UV不变、三角形索引顺序不变,字形就绝不会花掉。
2.2 OnPopulateMesh是UGUI留给我们的后门
UGUI的文字网格是在布局与渲染流程中动态构建的。当文本内容、字体、字号或RectTransform尺寸发生变化时,Graphic会调用SetVerticesDirty,随后在Canvas渲染重建阶段执行OnPopulateMesh。
Text类已经完整实现了“按字体和字号生成顶点”这个过程。我们要做的不是在它之外另起炉灶,而是继承它,在override方法里先调用base.OnPopulateMesh,让Text把默认的直线文本网格填进VertexHelper,然后遍历所有顶点,把属于同一个字符的4个顶点作为一个小组,做圆弧坐标变换。这个思路能少写很多底层代码,比如字体加载、字符索引、图集UV映射这些事,Text类都替我们做完了。
2.3 直线坐标怎么弯到圆上
理解圆弧变换可以拿“弯纸带”类比。在一张直的纸带上从左到右印好文字,然后把这个纸带沿着一个圆半径贴上去。纸带上每个字符的中心点,原本的横坐标x对应一个角度位置,纸带弯折后,字符中心被放到圆上的对应点,并且字符本身要顺着纸带的弯曲方向旋转,否则字会歪。
换算成数学表达式:字符中心原来的横向位置归一化后得到一个比例t,t=0代表第一个字符,t=1代表最后一个字符。目标角度θ = startAngle + totalAngle * t。新中心点就是圆心O加上半径R乘以方向向量(cosθ, sinθ)。为了让字符“顶朝外”,字符组需要绕自己的中心旋转θ - 90度。简单推导一下就能验证:标准极坐标下,方向向量与x轴夹角是θ,默认字符上方向是(0,1),两者相差θ - 90度。
把这三个步骤合起来,就是完整的OnPopulateMesh重写逻辑。
3. 完整实现:CircleText组件源码
下面直接上代码。这个组件在Unity 2019和之后的版本都能用,核心API没有做过时的依赖。
3.1 参数设计
我保留了四个对外参数:
Radius是圆弧半径。圆心默认取RectTransform中心点正下方radius距离处,所以radius为正时,文字排在圆的外侧;如果设成负值,圆心跑到文字上方,文字会弯向下,形态类似碗底。
StartAngle和EndAngle是一段圆弧的起止角度,用标准极坐标表示:0度在正右方,90度在正上方。默认起止角度为180度和0度,这样文字会从左端经过顶部走到右端,形成一道拱门。
AlignByCharWidth决定角度分配方式。如果打开,代码会按照每个字符的实际quad宽度占比来分配角度,这样大写的W、M这些宽度大的字不会被挤成一团;关闭则严格按照字符个数平均分配,适合等宽字体场景。
3.2 核心代码
using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(RectTransform))] public class CircleText : Text { [Header("圆弧参数")] [SerializeField] private float m_Radius = 200f; [SerializeField] private float m_StartAngle = 180f; [SerializeField] private float m_EndAngle = 0f; [SerializeField] private bool m_AlignByCharWidth = true; public float Radius { get { return m_Radius; } set { m_Radius = value; SetVerticesDirty(); } } public float StartAngle { get { return m_StartAngle; } set { m_StartAngle = value; SetVerticesDirty(); } } public float EndAngle { get { return m_EndAngle; } set { m_EndAngle = value; SetVerticesDirty(); } } protected override void OnPopulateMesh(VertexHelper toFill) { if (Mathf.Abs(m_Radius) <= 0.01f || string.IsNullOrEmpty(text)) { base.OnPopulateMesh(toFill); return; } base.OnPopulateMesh(toFill); int vertexCount = toFill.currentVertCount; if (vertexCount <= 0) { return; } int charCount = vertexCount / 4; // 1. 把VertexHelper里的顶点全部读出来 UIVertex[] vertices = new UIVertex[vertexCount]; for (int i = 0; i < vertexCount; i++) { UIVertex v = new UIVertex(); toFill.PopulateUIVertex(ref v, i); vertices[i] = v; } // 2. 收集每个字符的包围盒中心和宽度 Vector2[] charCenters = new Vector2[charCount]; float[] charWidths = new float[charCount]; for (int i = 0; i < charCount; i++) { Vector3 p0 = vertices[i * 4 + 0].position; Vector3 p1 = vertices[i * 4 + 1].position; Vector3 p2 = vertices[i * 4 + 2].position; Vector3 p3 = vertices[i * 4 + 3].position; float minX = Mathf.Min(p0.x, p1.x, p2.x, p3.x); float maxX = Mathf.Max(p0.x, p1.x, p2.x, p3.x); float minY = Mathf.Min(p0.y, p1.y, p2.y, p3.y); float maxY = Mathf.Max(p0.y, p1.y, p2.y, p3.y); charCenters[i] = new Vector2((minX + maxX) * 0.5f, (minY + maxY) * 0.5f); charWidths[i] = Mathf.Max(maxX - minX, 0.01f); } // 3. 计算每个字符的目标角度 float totalWidth = 0f; for (int i = 0; i < charCount; i++) { totalWidth += charWidths[i]; } float totalAngle = m_EndAngle - m_StartAngle; float[] targetAngles = new float[charCount]; if (m_AlignByCharWidth) { float accumulatedWidth = 0f; for (int i = 0; i < charCount; i++) { float t = (accumulatedWidth + charWidths[i] * 0.5f) / totalWidth; targetAngles[i] = m_StartAngle + totalAngle * t; accumulatedWidth += charWidths[i]; } } else { for (int i = 0; i < charCount; i++) { float t = (i + 0.5f) / charCount; targetAngles[i] = m_StartAngle + totalAngle * t; } } // 4. 将字符从直线位置弯到圆弧位置 Rect rect = rectTransform.rect; Vector2 circleCenter = new Vector2(rect.center.x, rect.center.y - m_Radius); for (int i = 0; i < charCount; i++) { float angle = targetAngles[i] * Mathf.Deg2Rad; Vector2 dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); Vector2 newCenter = circleCenter + dir * m_Radius; // 字符上方向指向圆外侧,默认字符上方向是(0,1) float charRotation = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f; Quaternion rotation = Quaternion.Euler(0f, 0f, charRotation); for (int j = 0; j < 4; j++) { int index = i * 4 + j; Vector2 localPos = (Vector2)vertices[index].position - charCenters[i]; Vector2 rotatedPos = rotation * localPos; vertices[index].position = newCenter + rotatedPos; } } // 5. 把所有顶点写回去 for (int i = 0; i < vertexCount; i++) { toFill.SetUIVertex(vertices[i], i); } } #if UNITY_EDITOR protected override void OnValidate() { base.OnValidate(); SetVerticesDirty(); } #endif }这段代码的流程可以概括为:先调用base生成默认网格,然后读出所有顶点,按每4个顶点为一组计算原始字符中心,接着利用字符宽度占比把横向位置映射成圆弧角度,最后将每个字符组旋转并平移到圆上。顶点写回后,GUI框架会自动用新网格参与渲染。
3.3 使用时的基础设置
把这个脚本挂到UI物体上,它会自动替换掉原来的Text组件。挂好后需要手动把Text的Alignment设置为MiddleCenter,Pivot保持(0.5, 0.5),Horizontal Overflow和Vertical Overflow都设为Overflow而不是Wrap,确保文本不会换行。这些设置不强制,但符合圆形环绕文字的使用习惯,否则多行文本会映射出非常奇怪的结果。
挂完组件之后,在Inspector里直接填Radius和角度就能看到实时效果。所有setter都调用了SetVerticesDirty,所以参数改动会立刻触发顶点重建,编辑器里无需任何额外按钮。
4. 调参与效果实测
代码只是基础,能不能做出想要的效果,调参才是关键。我在几个不同场景里实测了这组件,下面记录最常用的两组参数组合。
4.1 拱形半圆:钟表与仪表文字
做钟表盘或仪表盘上的刻度说明时,文字通常分布在半圆上。我建议把RectTransform尺寸设成需要显示区域的二倍左右,Radius设成Rect宽度的一半,StartAngle设为180度,EndAngle设为0度。这样文字从左端开始,经过顶点,再到右端,形成完整的拱形。
这里有个容易踩的误区:不要把StartAngle和EndAngle设成90和-90度。从极坐标的角度看,90度是顶点位置,左右两边分别在180度和0度。想从左到右经过顶部,必须经过180度,也就是弧线从左边开始。所以默认值我用180到0,而不是90到-90。
文字数量多的时候,把AlignByCharWidth打开,效果会比平均分配均匀得多。尤其汉字和数字混排时,汉字宽度接近正方形,数字是半角宽度,按字符个数平均分配会导致数字周围空洞很大,按宽度分配一下就自然了。
4.2 整圈环绕:徽章与圆盘标签
整圈环绕时,文字要首尾相接绕满360度。参数上把EndAngle设成StartAngle减360度或加360度都可以,看旋转方向需求。例如StartAngle=180,EndAngle=-180,就是顺时针绕一整圈。
整圈环绕最需要注意的是字符朝向。现在的代码默认字符的“上”方向指向圆心外,也就是文字底部朝圆心、顶部朝外侧,这是圆形徽章最常见的样式。但如果想做成文字字头朝圆心的内圈样式,只要把旋转角改成charRotation = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg + 90f即可,也就是让字符上方向指向圆心。
整圈场景下的圆心位置也要重新理解。组件生成的圆心默认在RectTransform中心正下方radius处,如果你想让圆心正好在场景里的某个圆环中心,那就要保证CircleText所在物体的锚点中心正好位于圆环圆心,并且radius取圆环半径。实操上我是把CircleText挂到圆环的中心点物体上,再把Rect尺寸设成比圆环直径略大一些,方便配合遮罩处理。
4.3 与Shadow和Outline特效叠加
UGUI的Shadow和Outline是BaseMeshEffect,它们在Text的OnPopulateMesh之后被调用。也就是说,我们的顶点已经弯到圆上了,阴影效果会基于弯折后的网格再做偏移,最终阴影也跟着弧线走,不会出现文字弯了阴影还横在原地的怪象。实测下来叠加Shadow没有出现异常,只是阴影偏移是一个相对固定的方向,视觉上相当于文字在弧线上方或下方做了平行偏移,可以作为低成本立体效果用。
如果用了Outline,边缘描边同样基于弯折后的顶点生成,效果正常。要注意的是,这些特效会额外复制顶点,所以应用特效后的顶点总量会变成原来的好几倍,但这对一个Text组件来说压力很小,无需担心性能。
5. 踩坑记录与排查速查
这个组件我前后试过好几轮,把遇到过的典型问题整理成一张排查表,照着查基本都能解决。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 文字全部挤在一个点 | Radius太小或为0,圆心和字符中心重合 | 调大Radius绝对值;检查是否有脚本摸过Radius |
| 字符朝向反了 | 需要的是内圈文字,但旋转方向没改 | 把旋转角加180度或改用+90度方向公式 |
| 文字排列顺序从右往左 | 角度差方向和字符宽度累积方向不匹配 | 反转StartAngle和EndAngle,或整体加360度调整方向 |
| 文字被父节点裁掉一半 | RectTransform不够大,圆弧超出可视区域 | 拉大Rect尺寸,降低Radius,或调整圆心位置 |
| 换行导致字符错位 | HorizontalOverflow设成了Wrap | 将Overflow改为Overflow,确保单行 |
| 字符串里有空格时前端出现空洞 | 空格本身占一个字符quad但看不见 | 关闭AlignByCharWidth或给空格单独处理宽度 |
| 点击区域和看到的文字不吻合 | Text默认RaycastTarget用整个矩形做检测 | 关闭RaycastTarget,或自行重写命中检测 |
5.1 字符绕反和最左最右错位
这是最常见的问题,根源是极坐标方向容易混淆。Unity里角度是逆时针为正,从正右方的0度开始。要让文字从左到右经过顶部,实际上是从左边的180度逆时针减到右边的0度,所以StartAngle=180,EndAngle=0,角度差为负数,表示顺时针走过180度。
如果发现文字排列顺序反了,最快的判断方法是看弧线中间那个字符。它应该位于顶部(90度位置)。如果它跑到下方去了,说明起止角度弄反了,对调StartAngle和EndAngle即可。
5.2 RectTransform裁剪问题
UGUI本身不裁剪超出自身区域的网格,但一旦有Mask或RectMask2D在祖先节点上,圆弧翘出Rect范围的部分就会被裁掉。圆形环绕文字天然比普通文本占用的空间大,尤其是整圈模式。
我的做法是,把CircleText所在节点的RectTransform尺寸放大到能包住整个圆弧,同时把这个节点放在圆环中心。如果担心大Rect影响布局,可以把该节点放在一个自动缩放的容器里,或者干脆让CircleText不参与布局系统。
5.3 RichText带来的顶点错位
UGUI的Text支持RichText,比如 、 这类标签。开启RichText后,标签本身不生成可见字符,但Text类内部生成的顶点数量并不等于我们眼睛看到的字符数。我们的代码按“每4个顶点一个字符”去遍历,会把一个不连续的空隙也算成一个字符,导致角度映射和字符宽度的总和出现偏差。
如果项目里必须用RichText控制颜色或字号,建议改用TextMeshPro方案,TMP对富文本的字符索引处理得更规整。如果只是给整段文字一个颜色,不需要开启RichText,直接改Text.color即可,不会遇到这个问题。
5.4 命中检测区域不对
Text默认是RaycastTarget,但它的检测区域是原本的矩形文本区域。文字弯到圆弧上之后,点击区域依然是老位置,玩家点击绕在圆上的“保存”按钮文字时很可能点不中。
最简单的做法是把组件的RaycastTarget取消勾选,让UI事件穿透到背后的图形上。如果确实需要点击环形文字,可以重写Graphic的IsRaycastLocationValid方法,做一个“点到圆环带内才算命中”的自定义检测,或者把每个字符单独做成可点击的透明区域,配合环形菜单使用。
6. 扩展方向:旋转动画与TextMeshPro思路
组件本身已经能工作,但实际项目中往往还要加点动态效果。这里给两个扩展方向,都是我在其他需求里验证过的。
6.1 让文字自动旋转起来
最简单的方式是让整个CircleText挂载节点的RectTransform做Z轴旋转。因为文字顶点是相对于自身Rect生成的,旋转节点就相当于旋转整圈文字,适合做圆盘标签的旋转效果。
如果只想让文字在圆弧上滑动,不改节点旋转,而是每帧修改StartAngle和EndAngle。比如把两者同时加一个增量,就能看到整段文字沿圆环滑动。setter会触发SetVerticesDirty,每帧改会有顶点重建开销,但一个Text组件只有几十个顶点,性能完全可以接受。实测在低端移动设备上,10个左右字符的环形文字逐帧修改角度,没有明显卡顿。
6.2 TMP版本的核心思路
如果项目已经使用了TextMeshPro,那同样思路可以迁移到TMP上,而且迁移后能力更强。TMP的Text组件在生成后,可以通过textInfo.characterInfo获取到每个字符的顶点索引,通过meshInfo获取顶点集合。注意TMP的字符顶点是共享网格结构,需要先复制meshInfo.vertices,修改后再写回meshInfo,最后调用SetMesh刷新。
TMP比UGUI Text更容易处理换行、富文本和多种字体,所以如果需要更复杂的环绕效果,比如每个字符独立动画、混排不同字体,推荐使用TMP。不过TMP有一个细节要小心:它默认会生成额外的下划线、删除线等高亮顶点,字符索引并不是严格的“四个顶点一个字符”,必须通过characterInfo里的顶点引用而不是简单除以四。
我给出一段伪代码思路,供参考:
// 伪代码 TMP_TextInfo info = tmp.textInfo; Vector3[] vertices = info.meshInfo[0].vertices; for (int i = 0; i < info.characterCount; i++) { int index = info.characterInfo[i].materialReferenceIndex; int startIndex = info.characterInfo[i].vertexIndex; Vector3 center = 计算字符包围盒中心; float angle = 根据字符序号映射角度; Vector3 newCenter = circleCenter + radius * direction(angle); Quaternion rot = Quaternion.Euler(0, 0, angle - 90); for (int j = 0; j < 4; j++) { vertices[startIndex + j] = newCenter + rot * (vertices[startIndex + j] - center); } } tmp.meshInfo[0].mesh.vertices = vertices; tmp.UpdateGeometry();TMP的API在2019和2020版本略有差异,但核心思路一致。如果之后有机会,我会单独写一篇TMP版本逐字动画的文章,这里就不展开了。
最后分享一个实际操作中的小技巧:调圆弧参数时,先把文字数量填少一点,比如“ABC”三个字符,角度跨度调到60度左右,这样能迅速看清每个字符的朝向和排列关系。方向确认正确后,再改回正式文案、调大跨度。这个习惯能节省大量来回试错的时间,尤其在做环形菜单的多语言文案适配时,非常实用。