news 2026/10/17 1:34:05

UGUI圆形环绕文字:继承Text重写OnPopulateMesh实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
UGUI圆形环绕文字:继承Text重写OnPopulateMesh实现

做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度左右,这样能迅速看清每个字符的朝向和排列关系。方向确认正确后,再改回正式文案、调大跨度。这个习惯能节省大量来回试错的时间,尤其在做环形菜单的多语言文案适配时,非常实用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 1:33:49

双节锂电池主动均衡方案:MP2642GR-Z与MK20DX128VFM5协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:33:48

Unity类红警单位移动:从NavMesh烘焙到点击寻路全拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:33:47

基于GD32VF103VBT6与MP2642GR-Z的两串锂电池均衡方案设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:33:35

从说话到写作:掌握口头记录转书面成文的完整路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华