尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

UGUI交互数学:从坐标变换到命中测试与性能优化

UGUI交互数学:从坐标变换到命中测试与性能优化 上一节我们聊到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、高为HanchorMin.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.GetComponentRectTransform(); 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单一四边形四顶点普通图标、按钮4Sliced九宫格分段九四边形对话框、可变尺寸背景36Tiled按照图元大小重复平铺边框、纹理重复背景动态增加Filled按填充角度生成扇形网格技能冷却、进度环6-603.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的depthdepth越大代表绘制顺序越靠后也就是视觉上越靠上越优先接收事件。排好序之后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在频繁重建。定位到具体类别之后再回到本文前半部分的数学框架里逐层检查锚点设置、布局嵌套和合批顺序问题通常都能找到。
返回列表