上一节我们聊到,UGUI的画面生成很像一台透镜成像系统:布局约束是入射光,顶点数据是光在介质里的折射路径,屏幕上的最终像素是像面。当时我留了个尾巴——光线到达像面之后,还有半程没走完,那就是用户手指或鼠标落在某个控件上时,系统如何反推回去,判断这一下到底点到了谁。这半程就是交互数学。这一节我们专门把它补上,顺带把网格成形阶段的几何细节、以及玩UGUI时最让人头疼的“重建风暴”背后的数学账本一起讲清楚。
这篇内容适合三类人:想真正读懂ugui源码的人,排查UI卡顿查到Canvas.SendWillRenderCanvases就无从下手的开发者,以及准备自己写扩展控件、需要精确控制顶点和命中区域的进阶用户。
1. 光路继续:从布局约束求解到屏幕成像的最后半程
1.1 前文坐标系的回顾
在继续往下走之前,先把上一节建立的模型快速回顾一遍。我们当时把UGUI渲染比作了一套几何光学系统,但先说清楚,这里的光学不是物理光学,而是一个抽象模型:把UI的每一层数据变换当成光线在介质中传播,光路每一段都是一次确定的数学运算。
整套系统分三段。第一段是入射光,业务数据进入UI树,经过LayoutGroup、ContentSizeFitter、LayoutElement等组件把约束条件换算成每个RectTransform的矩形尺寸与位置,这个过程本质上是约束求解,不是简单赋值。第二段是折射,也就是网格生成,Graphic组件根据矩形数据、图片切片信息、文本排版结果生成顶点数据,交给CanvasRenderer。第三段是成像,GPU把顶点光栅化成屏幕像素。
上一节我们把前三段讲得比较细,重点放在入射光这一段:为什么anchor和offset组合起来能描述UI适配,为什么不同Canvas的缩放系数会导致像素模糊,这些问题的答案都在坐标变换里。这一节要补的是第二段的几何细节,以及新加入的第四段——接收器。
1.2 续篇要处理的问题清单
几何光学里,透镜系统本身能不能成像是一回事,能不能在像面上被看见是另一回事。对应到UGUI,网格能不能生成、合批是第一步,用户产生的触摸或点击事件能不能准确地落到正确的控件上,是第二步。很多人把这两件事割裂开看,觉得渲染管线和事件系统是两套东西,实际上它们在数学上共享同一个坐标空间,只是方向相反。
渲染是把控件坐标推向屏幕像素,事件是把屏幕坐标拉回控件本地坐标。一个正向投影,一个反向求交,中间的变换矩阵完全一致。这就是为什么这篇续篇要把几何光学和交互数学放在一起讲——它们本来就是同一台机器的两个侧面。
从这节开始,我按这样一条线走:先解决坐标空间里的仿射变换问题,这是所有计算的地基;然后看顶点网格怎么从Rect长出来,包括九宫格切片和合批判定的几何前提;接着进入事件路由,看射线命中测试和结果排序的算法细节;最后用数学方式算一笔UGUI性能账,解释为什么某些操作会从一帧秒变一秒。每一段我都会给出可以直接抄走的代码或者排查套路。
2. 锚点与枢轴背后的仿射空间:UGUI的坐标数学手册
2.1 锚点:归一化区间语义
RectTransform上最容易被误解的就是anchorMin和anchorMax这两个Vector2。很多人记了一堆“左上对齐、右上对齐”的口诀,但遇到复杂UI布局时依然摆弄不明白。我建议你把它们理解成父矩形内的两个归一化点,而不是“位置”。
假设父Rect宽为W、高为H,anchorMin.x乘以W就是锚点区间左端在父坐标中的横坐标,anchorMax.x乘以W是右端。两个值相等时,锚点退化为一个固定点;两个值不等时,它们定义了一条参考线——更准确地说,定义了一个区间,这个区间会被后面的pivot插值成一个唯一的参考点。
这个设计妙在它把“相对位置”和“相对大小”统一到了一个模型里。要让按钮始终贴在屏幕右边缘,就把anchorMin.x和anchorMax.x都设成1,宽高用固定像素值,这样无论屏幕多宽,按钮离右边距始终不变。要让一个面板跟着父容器等比例缩放,就把四个锚点值都设成0到1区间里的对应比例。锚点的本质是归一化区间,理解了这个,UI适配就不需要背任何表格。
2.2 anchoredPosition、sizeDelta与offset的换算关系
锚点定义了参考系,anchoredPosition、offsetMin、offsetMax、sizeDelta这几个字段就是在这个参考系下的度量值。先说最常见的那对:offsetMin表示矩形左下角相对anchorMin这个点的偏移,offsetMax表示矩形右上角相对anchorMax这个点的偏移。说得更精确一点,用像素单位计算的话:
offsetMin = rect.min - anchorMin * (W, H)
offsetMax = rect.max - anchorMax * (W, H)
这里rect是RectTransform在父节点本地坐标系中的轴对齐矩形。接下来有一个经常被忽视的公式:
sizeDelta = offsetMax - offsetMin
= rect.size - (anchorMax - anchorMin) * (W, H)
当锚点完全重合时,(anchorMax - anchorMin)为零,sizeDelta就直接等于rect的宽高。这就是为什么很多教程说“锚点重合时,sizeDelta就是控件大小”。严格来说,这句话只在锚点重合时成立;锚点区间越大,sizeDelta和实际尺寸的偏差越大。这个偏差恰恰是“拉伸模式”占位用的值——父矩形变大时,控件尺寸跟着变大,sizeDelta则保持矩形相对锚定区间的差值不变。
anchoredPosition的定义更绕一点,它等于pivot点在本地坐标中的位置与锚点参考点之间的差值。锚点参考点怎么算?把anchorMin到anchorMax之间的区间按pivot的比例做一次插值:
anchorRef = anchorMin * (W, H) + (anchorMax - anchorMin) * (W, H) * pivot
可以看到,pivot在这里同时干了三件事:定义自身矩形里的缩放/旋转中心、参与锚点参考点的插值计算、参与anchoredPosition的换算。很多人只知道pivot影响旋转中心,不晓得它还参与锚点插值,结果出现旋转位置怪异、对不齐的情况,怎么查都查不到原因。
| 场景 | 推荐锚点设置 | 理由 |
|---|---|---|
| 按钮贴屏幕边缘 | min/max同值,取0或1 | 退化为固定点,距离边缘恒定 |
| 面板随父容器等比缩放 | min=(0,0), max=(1,1) | 矩形跟随父矩形变化 |
| 头像固定大小、位置随窗口移动 | min/max同值,偏移由anchoredPosition控制 | 尺寸不随适配变化 |
| 分割条拖拽 | min/max取边界,修改offset调整 | 只改一侧,另一侧固定 |
2.3 pivot的几何意义与踩坑实录
说一个我实际踩过的坑。之前做一个背包界面,底部有个物品详情面板,需求是面板从底部滑出,滑出动画过程中要带着一个关闭按钮一起平移。动画直接改anchoredPosition,初版效果正常,但美术要求面板做一点弹性形变,于是给根节点加了Scale动画,问题立刻出现:整个面板从右下角而不是底部中心往外扩张。
原因就是该节点的pivot当时为了让旋转支点落在底部而设成了(0.5, 0)。位置动画和缩放动画共用同一个pivot,改Scale时引擎以pivot为原点做缩放,pivot不在几何中心,缩放时矩形向一个方向膨胀,视觉上就像“从右下角长出”一样。最后解决方式是保留pivot为(0.5, 0.5)负责缩放形变,用一个子节点专门负责滑出动画,避免一套坐标参数同时服务两种需求。
这类问题在UGUI里很常见,因为pivot同时参与缩放、旋转、锚点参考点插值、anchoredPosition换算,你很难只让它影响其中一项而不影响其他项。所以我的建议是:能用子节点隔离动画就尽量隔离,不要在一个RectTransform上同时搞多种变换和动画。
2.4 屏幕点与本地矩形的转换
不管做拖拽、右键菜单、还是自定义碰撞判定,你最终都会遇到同一个需求:把一个屏幕坐标转换到某个UI控件的本地坐标里,然后判断它是否落在矩形内。这个转换的标准写法是这样:
RectTransform rt = target.GetComponent<RectTransform>(); bool inside = RectTransformUtility.ScreenPointToLocalPointInRectangle( rt, screenPosition, canvas.worldCamera, out Vector2 localPoint ) && rt.rect.Contains(localPoint);这里最容易被忽略的是第三个参数canvas.worldCamera。在Screen Space - Overlay模式下传null即可;在Screen Space - Camera以及World Space模式下必须传对应的事件相机,否则坐标换算会直接漂移。我曾经遇到过一个问题:UI场景里有一个世界空间的小地图,点击地图区域时,用Overlay模式下的逻辑去换算坐标,结果判定区域整体偏移了几十个像素,排查了半天发现是事件相机没传对。
这套换算的数学本质不复杂:屏幕坐标经过相机的逆投影,变成UI所在平面上的世界坐标,再经过RectTransform的世界矩阵逆变换,落到本地坐标空间,最后与rect做一次轴对齐矩形包含测试。整个过程就是渲染流程的逆过程,这也是我为什么坚持说交互数学和渲染数学是同一台机器的两个方向。
3. 顶点网格如何沿着光路成形:从Rect到合批
3.1 一个Image的顶点是怎么长出来的
很多人以为Image显示一张图就是把贴图往屏幕上一贴,实际上引擎在CPU侧先要生成顶点数据。默认的Simple模式下,一张Image的网格极其朴素:取RectTransform.rect的四个角,作为本地坐标下的四个顶点;再取Sprite的outer UV矩形,作为四个顶点对应的UV;颜色用Image.color填充到顶点色里。最后把四个顶点拆成两个三角形,索引顺序通常是(0,1,2)和(2,3,0),这样一个矩形网格就成形了。
四个顶点要变换到屏幕上的正确位置,靠的是CanvasRenderer从上级继承来的世界矩阵。网格生成本身只负责产出本地坐标,真正决定它被画在哪里的,是RectTransform的localToWorldMatrix。理解这一点很重要——排查顶点位置异常时,不必怀疑网格顶点数据,先检查父链上有没有异常缩放或旋转。
如果只是这个程度,UGUI的网格系统其实并不复杂,真正的复杂度来自九宫格切片和文本排版。
3.2 Sliced模式的九宫格几何
九宫格切片模式本质上是把一个矩形分成九块子矩形,每一块的UV区域来自Sprite的不同区域。Sprite的border属性定义了四条边距,把源图划分成九个部分:四个角保持原始像素比例,四条边只在单一方向拉伸,中心区域双向拉伸。
这种设计在数学上是把“整体缩放”替换成了“分段仿射映射”。角部不变、边部单向缩放、中心双向缩放,这样既不会让圆角变形,也不会让图标比例失真。生成网格的时候,四角各一个独立四边形,四条边各一个四边形,中心一个四边形,一共九个四边形,对应九个源图区域。
做优化时我经常检查Sliced图片的网格数量。如果一个列表Item里用了大量Sliced背景,而且Item数量有几十个,顶点总数会达到上千级别,再加上UI重建时的CPU消耗,很容易成为卡顿来源。这时候可以考虑用预烘焙的图集和固定尺寸模板,减少网格生成的频率。
| 模式 | 网格策略 | 典型场景 | 顶点数量 |
|---|---|---|---|
| Simple | 单一四边形,四顶点 | 普通图标、按钮 | 4 |
| Sliced | 九宫格分段,九四边形 | 对话框、可变尺寸背景 | 36 |
| Tiled | 按照图元大小重复平铺 | 边框、纹理重复背景 | 动态增加 |
| Filled | 按填充角度生成扇形网格 | 技能冷却、进度环 | 6-60+ |
3.3 合批判定的几何学前提
顶点生成之后,Canvas会把同一Canvas下的所有Graphic按深度排序,再送入UI批处理系统。这里有一个经常被误解的点:合批不只是看材质和贴图,还要看渲染顺序是否允许。
假设有三个相邻的Image,中间一个带透明区域的镂空图,两边的都是不透明图标。如果只是材质相同就能合批,那GPU可以先画两边再画中间,但这样透明混合会出错——中间镂空处的上半部分本应被后面内容遮挡或叠加,绘制顺序错误会导致透明区域表现异常。所以UGUI遇到透明穿插元素时,会选择强制拆分合批,宁可多几个DrawCall也要保证绘制顺序正确。
把合批理解成几何学分组就清晰多了:同一批次的元素在深度上必须是连续的,中间不能插着打破状态的元素。这解释了为什么把动态数字Text从静态图标的子节点里拆出去,放到独立的Canvas中,不仅不影响最终显示效果,还能显著减少整体合批被打断的频率。
另外,Mask组件的模板裁剪也可以从几何上理解。Mask本质上是往模板缓冲里画一个裁剪形状,所有子Graphic只有在模板通过的区域才被写入颜色。这就像光学系统里在光路中间插了一块遮光片,只有光路重叠部分的射线才能到达像面。凡是用到Stencil的地方,合批都会被强制断开,所以Mask用多了DrawCall暴涨是完全正常的,不必惊慌,真正要警惕的是无意识的Mask嵌套。
4. 命中判定与事件路由:交互数学的完整链路
4.1 RectangleContainsScreenPoint的判定真相
UGUI的射线判定入口是GraphicRaycaster,它在扫描所有Graphic时,会调用RectTransformUtility.RectangleContainsScreenPoint来判断一个屏幕点是否落在控件范围内。这个方法看似是个黑盒,实际上内部的数学过程就是2.4节那套转换:先根据Canvas渲染模式取到事件相机,把屏幕坐标投影到UI平面,再转换到目标Graphic的本地坐标,最后用Rect.Contains做包含测试。
这里我强烈建议你自己把这套逻辑写一遍,哪怕只是用System.Numerics模拟一次,也会发现几个有意思的边界问题。比如pivot的位置不会影响Contains结果,因为本地矩形的坐标始终以pivot为原点;再比如如果某个父节点带了旋转,包含测试使用的矩形是本地的轴对齐矩形,旋转效果是在裁剪阶段体现的,不会影响命中的矩形覆盖范围。
4.2 Raycast结果排序规则
屏幕上重叠的控件可能不止一个,GraphicRaycaster会把所有通过包含测试的Graphic都收集起来,返回给EventSystem,再由EventSystem统一排序决定谁先响应。这个排序规则是理解“为什么按钮被挡住”的关键。
排序分两级:先看Canvas的sortingOrder,数字大的在上,优先响应;同一个Canvas内再看Graphic的depth,depth越大代表绘制顺序越靠后,也就是视觉上越靠上,越优先接收事件。排好序之后,EventSystem从列表末尾往前遍历,遇到第一个能处理事件的对象就停下来。
这个规则藏着一个大家经常踩的坑:一个全屏透明的Image只要raycastTarget为true,就会覆盖在它下面所有控件之上,点击事件全部被它截胡,而你看到的却是一张“什么都没有”的空白图。原因是透明Image同样参与包含测试,而且它的深度大于下层按钮。解决办法是把它当作UI遮罩时显式关闭raycastTarget,或者把它的层级调整到不需要拦截事件的元素之后。
4.3 自定义命中区域的几何实现
默认的矩形包含测试满足大多数场景,但偶尔会遇到不规则热区的需求,比如圆形头像按钮、扇形技能按钮、或者带透明镂空的异形奖励图标。直接改默认判定不值得,UGUI留给你的正规接口是Graphic的IsRaycastLocationValid虚方法。
这个方法的输入参数是屏幕坐标和事件相机,返回是否命中。默认实现是4.1节里的矩形包含测试;重写它就像替换光路上的一个光阑,你可以把它换成自己的形状数学。
public class CircularRaycast : Graphic { public float radiusPercent = 0.5f; public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera) { if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( rectTransform, screenPoint, eventCamera, out Vector2 local)) return false; Rect rect = rectTransform.rect; float radius = rect.width * 0.5f * radiusPercent; return local.magnitude <= radius; } }这个示例做了两件事:先把屏幕点转换成控件本地坐标,再用向量长度判定是否落在以原点为圆心的圆内。相比默认实现,多了一次向量距离计算,成本几乎可以忽略。我在技能按钮和头像组件上用过这个方案,比堆叠多个透明子控件的做法干净得多。你要做扇形热区的话,可以在此基础上加入角度判断,奇偶射线法、叉积符号判断都是成熟的几何算法,往本地坐标空间里套即可。
5. 一帧卡成一秒:重建风暴的数学账本
5.1 Layout重建的复杂度推导
UI卡顿的原因有很多,但UGUI里最典型的性能杀手是布局重建风暴。要理解它,得先知道一个事实:当某个控件的尺寸或位置需要重新计算时,UGUI会把这条脏标记一路往上传递到Canvas层,并在下一帧渲染前执行一次布局重建。
LayoutGroup的嵌套会让这个重建呈指数级放大。假设有N层VerticalLayoutGroup嵌套,每层有M个子节点,最内层一个元素尺寸发生变化。最坏情况下,每个父节点尺寸变化都会触发所有子节点的重新布局,而每个子节点的尺寸变化又会继续向下触发更深一层的重建。总开销的量级接近M的N次方。看着只是三层嵌套、每层六个子项,实际可能触发两百多次布局计算,而且这些计算都发生在主线程渲染前的那一刻。
我优化过一个背包界面,所有格子都用LayoutGroup自动排布,数据刷新时整个列表重算坐标,打开背包瞬间顿一下。用Profiler一看,Canvas.SendWillRenderCanvases占用了主线程20多毫秒。解决办法是把自动布局改成固定格子尺寸加手动计算偏移,数据刷新时只更新可见的十几个格子,复杂度从O(n)降到常数级别,打开背包瞬间流畅了。
5.2 Graphic重建的触发面
除了布局重建,还有一类重建是图形网格和材质的重建。修改Text的文本内容、改变Image的Sprite、修改顶点色、切换材质或贴图,都会触发对应的Graphic重建。Layout重建跑在Canvas的布局阶段,图形重建跑在后续的图形阶段,二者是串联关系,一次完整的数据更新可能两种重建都做一遍。
这里有个隐藏成本容易被忽略:当你修改Text文本时,UGUI不仅要重新生成文本网格,还要重新计算文本的排版。这个排版计算是纯CPU的,字符串越长、字体越大、换行越多,耗时越高。如果你在列表滚动时每帧刷新大量Text的文本,比如数字跳动、时间刷新,就会看到稳定持续的主线程耗时。
想从根源上控制这部分开销,思路有两个方向。一方面减少每次重建的工作量,比如把频繁变化的文本单独放一个Canvas,避免影响同Canvas里的静态图集合批。另一方面减少触发次数,不要每帧都赋值Text.text,只有当数值真正变化时才赋值;哪怕是微小的字符串变化,也会让整个Text的网格全部重算。
5.3 从数学上规划可扩展的UI结构
把这两类账本合在一起看,UI结构的优劣其实是可以通过公式预估的。一个屏上静态元素占多数、动态元素集中在少数区域,那么被标记重建的元素就少;LayoutGroup嵌套层级越浅、节点数越少,布局计算的规模就越小;相邻Graphic的材质和贴图越一致、穿插越少,合批保留下来的DrawCall就越少。
从设计阶段就带着这些数学约束去搭UI结构,比事后挨个找卡点效率高得多。我的经验是:静态背景和底部图标放一个Canvas,按钮等中等动态内容放一个Canvas,高频刷新的文本单独放一个Canvas。这样即便高频区域每帧重建,影响范围也被限制在一个小集合里,不会波及全屏。
最后再分享一个排查工具层面的心得。遇到UI卡顿,别只看DrawCall数字,DrawCall低不代表主线程不忙。打开Profiler,盯住Canvas.SendWillRenderCanvases这个入口,它会把你带向真正的重灾区:要么是LayoutRebuilder在疯狂重排,要么是TextGenerator在反复排版,要么是Mesh在频繁重建。定位到具体类别之后再回到本文前半部分的数学框架里,逐层检查锚点设置、布局嵌套和合批顺序,问题通常都能找到。