1. UI渲染排序的底层逻辑,搞清楚“谁在谁上面”
做Unity项目这几年,我最常被问到的渲染问题就是“为什么我的UI被3D物体挡住了”“为什么这个按钮点不到”“粒子特效到底怎么才能显示在UI上面”。这些问题十有八九都能归到同一个源头:UI渲染排序。排序没梳理清楚,就会出现各种看起来像是“引擎bug”的灵异现象,其实底层规则非常固定。
先讲一个最重要的背景。Unity每一帧绘制画面的顺序大致是:清屏、绘制不透明物体、绘制半透明物体、最后绘制UI。UI的默认Shader基本都走透明渲染队列,但在Screen Space Overlay模式下,Canvas并不是某个相机里的三维物体,而是引擎在最后阶段用一个独立的“覆盖层”逻辑提交到屏幕。这就是UI默认能盖住绝大多数3D物体的根本原因。只要你还在用Overlay模式,就别幻想一个普通3D粒子能天然显示在UI前面,除非你专门做处理。
我把UGUI实际参与排序的维度整理成下面的优先级关系,排查问题时从头到尾对照一遍,基本都能定位:
- Camera的Depth,也就是相机深度,决定多个相机的渲染先后。
- Canvas组件的Sorting Layer,在不同Canvas之间排序时最先被比较。
- Canvas组件的Sorting Order,同Sorting Layer下数值更大者后绘制,显示在上层。
- 渲染模式相关的深度信息,Screen Space Camera看Plane Distance,World Space看世界坐标Z。
- Hierarchy层级顺序,同一个Canvas内部同级子物体越靠下越后绘制。
这里有个高频误区:很多人以为给UI元素设置Sorting Layer或者Order in Layer就能调整它和另一个UI元素的上下关系,结果改了半天没用。原因很简单,单个Image、Text本身并没有Sorting Layer和Order in Layer这两个字段,它们属于Canvas的一部分,只有Canvas组件才有这些排序参数。同一个Canvas内部,元素的绘制顺序完全由Hierarchy的层级顺序决定,Canvas之间的排序规则管不到Canvas内部的具体元素。这句话值得记下来,能省掉你大量Debug时间。
2. Canvas三种渲染模式,排序方式完全不同
Canvas的Render Mode有三种:Screen Space Overlay、Screen Space Camera、World Space。很多项目从头到尾只用默认的Overlay,遇到特殊需求就开始硬调,其实换一种模式反而更干净。
2.1 Screen Space Overlay:最省事但也最“粗暴”
Overlay模式不需要指定相机,UI直接覆盖在屏幕最上层。这种模式的优势是接入成本极低,性能也相对好,因为它不参与相机矩阵计算。劣势在于它没有“深度”概念,很难把某个3D对象插到UI层之间。
在Overlay模式下,多个Canvas之间的排序逻辑是:先比Sorting Layer,再比Sorting Order,如果两者都相同,就比Hierarchy顺序,后创建或者层级靠后的Canvas会绘制在上层。所以同一个场景里所有Overlay Canvas,最好养成设置Sorting Order的习惯,不要都默认写0,否则哪天多加了几个弹窗Canvas,谁上谁下就只能看Inspector里的顺序了。
另外,Overlay模式下Canvas的缩放和屏幕分辨率强相关,UI在不同分辨率下会出现各种适配问题,但那是另一个话题。这里只需要记住:Overlay就是“永远在最上面”,别再为3D物体和UI的穿插问题去调它的渲染队列,方向不对。
2.2 Screen Space Camera:用Plane Distance控制前后
Screen Space Camera模式下,UI会被放置到指定相机前方的一个平面上,整块UI像一个“画板”悬浮在三维空间中。这个模式最大的好处是支持真正的深度关系,UI可以进入3D场景,也可以被其他物体遮挡。
这个模式下的排序规则稍微复杂一点。首先还是比Sorting Layer和Sorting Order,如果Canvas之间的这两个值一致,则根据Canvas到相机的距离排序,离相机更近的Canvas后绘制,所以会显示在上层。这个距离由Canvas组件上的Plane Distance控制,数值越小离相机越近。
我自己的项目中,主界面常用的配置是:UICamera用正交模式,挂在主相机之外,专门负责渲染UI。这样UI既能拥有深度信息,又不会和场景里的3D物体混在一起穿帮。如果你要让某个UI弹窗和主UI插在中间层级,只需要单独建一个Canvas,调高Sort Order,再把Plane Distance设置在合适的位置即可。
2.3 World Space:把UI当3D物体摆放
World Space模式最符合直觉:UI就是一个3D物体,可以旋转、缩放,也可以和场景里其他物体发生真实的前后遮挡。数字孪生、AR、VR项目里大量使用这种模式,比如设备标签、场景中的浮动面板、角色头顶血条。
World Space Canvas的排序,本质上是Unity通用渲染器之间的排序。先看Sorting Layer和Order in Layer,如果两个Canvas相同,则通过物体距离相机的远近来决定谁先谁后,离相机更近的绘制在后,遮挡住远处的UI。这个规则和普通3D物体的近大远小、近处遮挡远处的直觉是一致的。
所以World Space下如果出现UI被墙壁挡住、被其他模型吃掉之类的现象,先不要急着改Shader,优先去检查Canvas的Sorting Layer、Order in Layer,还有物体本身和相机之间的距离。很多时候只是Order in Layer没拉开,或者Sorting Layer没有规划好层级。
3. 同一个Canvas内部,如何精确控制单个UI的上下
跨Canvas的排序规则很清晰,真正让新人头疼的是同一个Canvas内部多个UI元素之间的遮挡控制。比如一个战斗飘字突然被头像UI遮住,或者弹窗上的关闭按钮点不到。这时候需要的是Canvas内部的排序知识。
3.1 Hierarchy顺序就是大部分情况的答案
UGUI在同一个Canvas内部,通过Hierarchy深度和同级顺序决定每个Graphic的绘制先后。这个顺序可以理解为深度优先遍历:Unity先递归进入第一个子对象,把它和它的所有子UI元素依次提交,然后再处理下一个兄弟节点。后提交的元素会绘制在前面,也就是说,Hierarchy里靠下的同级UI显示在靠上的同级UI之上。
所以想让某个Image显示在另一个Image前面,最直接的办法就是把前者的父节点顺序往下调。运行时用代码操作,常见的是transform.SetAsLastSibling()和transform.SetAsFirstSibling(),一个置底一个置顶。很多新手去买各种UI排序插件,其实大部分需求用这两个API就能解决。
值得注意,Canvas内部的这个顺序和CanvasRenderer的渲染批次强相关。调整层级顺序会改变绘制顺序,同时也可能破坏Unity现有的批处理组合,带来额外的DrawCall。后面讲性能时会专门展开。
3.2 用“同一级下置顶”实现弹窗、飘字、提示框
弹窗、飘字、新手引导这类需要“压在其他UI上面”的元素,我建议不要直接把它们塞进主UI的Hierarchy里靠调顺序实现置顶。那样会让主UI的层级结构彻底变成一团乱麻,每加一个弹窗都要小心翼翼。
我更推荐两种做法。简单场景下,在弹窗物体根节点上单独挂一个Canvas组件,同时关掉Graphic Raycaster之外的其他组件,把这个Canvas的Sorting Order设成远高于主界面的值。这样弹窗虽然逻辑上仍然属于同一个父物体,但渲染时Unity会把它当作一个独立Canvas来处理,自动排在主UI上方,Hierarchy顺序就不会再影响它。
另一种做法是准备一个名叫UIRootTop的节点,专门用于挂载飘字、提示条、新手引导箭头。这个节点本身挂Canvas,Sorting Order设定为项目里最大值。所有置顶元素全部放在这个节点下,逻辑和渲染都清晰。
3.3 跨Canvas排序的实战配置举例
我在项目里通常把整个UI体系划分成几层,每一层都用独立的Canvas承载:
| 层名 | Sorting Layer | Sort Order | 主要用途 |
|---|---|---|---|
| WorldBack | UI | 0 | 3D场景内嵌UI,如地面标记 |
| UIBack | UI | 100 | 背景装饰、远古UI底图 |
| UIMain | UI | 200 | 主界面、功能面板 |
| UIPopup | UI | 300 | 弹窗、对话框 |
| UITop | UI | 400 | 飘字、提示、新手引导、特效 |
这套分层的核心思路是:Sorting Layer统一叫UI,Sort Order从100到400拉开间隔,给未来扩展留出余地。每个Canvas内部再有复杂遮挡时,用SetAsLastSibling和子Canvas处理。只要各层之间的Sort Order不重叠,弹窗永远在功能面板上方,飘字永远在弹窗上方,不需要每次做新界面都重新理一遍层级。
4. 特效与UI混合的正确姿势
UI和特效的混合排序,是项目里最容易被问爆的问题。核心矛盾在于:默认Overlay模式下UI永远在最上层,普通3D粒子根本不可能显示在UI前面,但美术又经常要求“按钮特效盖住按钮”“角色脚下光环穿透UI”。
4.1 场景一:3D粒子特效想在UI上方显示
想在UI上面显示粒子,有几个常见方案,我按踩坑程度从低到高推荐。
如果你的UI还没大量铺开,建议直接换成Screen Space Camera模式,给UI单独挂一个相机,粒子系统放在UI相机和主相机之间的一个专用特效相机里,通过相机Depth和Canvas的Plane Distance互相配合。这套方案能保留真实的粒子深度,效果最自然,但要改动整个UI框架,适合在项目早期规划。
如果项目已经用了Overlay模式,不想大改,最省力的做法是用RawImage加Render Texture。把粒子特效用另一个相机单独渲染到一张RenderTexture上,再当成普通UI图片显示在需要的位置。优点是思路简单,UI层级完全可控;缺点是每个特效都要多一个相机和纹理,内存和DrawCall成本偏高,动态特效多了以后会明显拖慢帧率。
还有一种偏门的做法是给粒子Shader改写深度写入和渲染队列,让粒子强行插到UI队列之间。这个方案我不推荐,因为它需要Shader层面的精细控制,而且不同平台表现可能不一致。我见过团队为了把一个命中特效显示在UI上方去改Shader,最后在部分Android机型上出现花屏,排查了很久。能用结构解决的问题,尽量不要靠Shader去硬钻。
4.2 场景二:UI上播放特效,但需要限制在某个子区域
很多时候我们要求特效只在某个按钮范围内播放,超出部分不显示,比如按钮高亮光晕、卡牌升级闪耀。这个需求看起来是渲染排序,本质上是裁剪。
解决方案最常用的是Mask组件,也就是在特效UI父节点上挂一个Image加Mask,把子区域之外的粒子裁剪掉。但这里有个坑:Mask会打断UI批处理,而且如果粒子数量很大,裁剪本身也会增加额外开销。UI界面卡顿问题有相当一部分就是这种不限次数叠加Mask造成的。
另一个做法是使用RectMask2D,它比Mask轻量,因为它没有额外的模板缓冲操作,更适合裁剪UI元素。但是粒子系统能不能被RectMask2D正确裁剪,需要实际测试,不同粒子Shader的表现有差异。我的经验是优先把粒子渲染到RenderTexture里,再对这个RawImage做RectMask2D裁剪,控制力最强,也不会受到粒子系统内部状态干扰。
4.3 场景三:角色头顶血条在VR/Pico4项目里的排序处理
做Pico等VR设备的Unity项目时,UI排序比手机更敏感。VR里如果继续用Overlay UI,很容易出现眩晕感,因为UI没有真实深度,眼睛聚焦和大脑预期不一致。所以VR项目的角色血条、面板、提示文字一律建议用World Space Canvas,挂在角色上方固定位置。
World Space血条会被角色模型遮挡这个问题很常见,我的处理方法是给血条单独设一个Sorting Layer叫UIWorld,Order in Layer设为明显高于普通3D物体的值,然后关闭血条Canvas的遮挡交互。这里要注意,World Space Canvas的Sorting Layer如果和3D的Sorting Layer相同,同样取Order in Layer比较;如果Sorting Layer不同,则提前把自定义Layer顺序设好,确保UIWorld在Default之上。
另外,VR项目里血条最好不要直接放在角色骨骼正上方,因为人物转头或抽搐时,血条会跟着晃动。实践中可以把血条挂在角色根节点上,只做位置偏移,不要挂到具体骨骼节点。这点虽然不属于渲染排序,但和“血条显示层级”耦合很深,我也一并写在这里。
5. 排序做不对,很容易拖累性能
排序从来不只是“谁看见谁”的问题,排序结构直接决定Canvas如何重建、如何批处理,进而决定UI跑起来卡不卡。
5.1 Overdraw与透明区域重绘
UI界面上大量全屏半透明黑色遮罩、复杂圆角图片叠在一起时,同一个像素可能被画了四五遍,这就是Overdraw。Overdraw在移动端尤其致命,因为GPU的填充率是有限度的。UI卡顿如果发生在打开某个功能面板时,第一步就去检查这个面板是否引入了大面积的透明叠加层。
可以用Unity编辑器右上角的Overdraw模式查看屏幕重绘情况,颜色越亮代表重绘越多。正常的UI主界面应该保持中低Overdraw,弹窗可以稍微高一点,但大面积亮白区域意味着UI层级叠了太多透明物料。从排序层面能做的优化是:能并排的不要重叠,能隐藏的不要半透明,透明区域尽量不做。
5.2 Canvas重建:为什么频繁改排序会让UI卡顿
UGUI一个特点是,Canvas内容发生变化时需要重建网格。重建的本质是重新执行一次布局计算和顶点计算,然后把结果提交给GPU。如果每一帧都有人调用SetAsLastSibling、修改UI的alpha、或者频繁显示隐藏弹窗,这些操作都会触发Canvas的一部分重建。
注意“Canvas重建”以Canvas为单位,同一个Canvas下任何一个UI元素的改动都可能牵连整块Canvas的批处理数据。所以我之前在3.2节推荐的“置顶独立Canvas”方案,不只为了排序清晰,更是为了让动效UI的频繁更新不会拖累静态主UI。
实际项目中要把频繁变动的飘字、加载圈、滚动列表放在单独的Canvas中,静态背景、按钮、大段文字放在另一个Canvas中,让Canvas在帧间只重建它需要重建的那部分。Unity的UI Profiler面板里可以看到每个Canvas的重建耗时,排序做久了以后,打开性能面板第一件事不是看DrawCall,而是看有哪些Canvas在疯狂重建。
5.3 动静态UI分离与批处理优化
批处理的意思是Unity将相邻的UI元素合并到一个DrawCall里绘制。相邻的前提有两个:绘制顺序连续,而且材质兼容。所以调整Hierarchy顺序不只是改遮挡,还会直接影响批处理。
很多团队会在搭建界面时把所有Image放在一起、Text放在一起、按钮放在一起,理由是图集分类方便。但从批处理角度看,这反而打断了“同一材质相邻绘制”的条件,让引擎频繁切换材质状态,产生更多DrawCall。更合理的组合是,将同一个图集里的Image、Text、Button按视觉遮挡关系排布,让它们尽可能连续绘制。
这里还要提一下图集的作用。DrawCall优化的最大前提是共享图集,如果每张图片都来自不同Texture,Unity再怎么批处理也没用。项目里应当规划好UI图集,把同一个面板的素材打进同一张图集里。然后再通过Hierarchy顺序控制UI排序,而不是为了排版美观把相同类型的组件强行归类到一起。
6. 常见问题与排查技巧实录
每次讲完排序原理,还是会有人在群里发一堆各种奇怪截图来问。我这边把这些年高频踩到的几个问题汇总一下,直接给排查路径。
6.1 UI被3D物体遮挡,或3D物体穿透UI
如果出现3D物体盖在Overlay模式的UI上面,首先确认UICanvas的Render Mode是不是还在Overlay,别不小心把它改成了World Space或者Screen Space Camera。Second,检查相机列表,看看是不是有第二台相机Depth值很大,并且Clear Flags和Culling Mask设置得不合理。另外如果项目里用过URP或HDRP,还要确认Render Objects的覆盖设置有没有把UI相机覆盖掉。
3D物体穿透UI的另一个常见来源是某些特效Shader里加了深度写入。这些特效虽然跑在透明渲染队列,但因为写了深度缓冲,导致后续UI绘制时深度测试失败,UI就被“穿透”了。排查方法是逐个关闭特效,找到肇事特效后,去掉Shader里的ZWrite阶段,或者在特效材质上关闭深度写入。
6.2 粒子特效在UI之间“消失”或乱序
粒子消失通常是渲染队列问题。UI材质用的是透明队列3000,粒子材质也大多在透明队列,如果两边的RenderQueue一致,Unity在这个队列里再去比较深度和排序。但UGUI Canvas和粒子系统分属两套排序系统,同一个Sorting Layer下偶尔会冒出奇怪结果。
我总结的做法是:UI粒子必须给独立的Sorting Layer,放在一个专用特效层中,让它和普通UI、普通3D物体彻底分离。再配合透明队列的深度测试,就能稳定控制。如果粒子还乱序,查看粒子系统里的Render Alignment、Sorting Fudge参数,把Order in Layer和Sorting Fudge配合设定,效果会比较稳定。
6.3 按钮点击区域与实际视觉不一致
排序问题和点击区域在GraphicRaycaster里是同一套顺序逻辑。GraphicRaycaster在对UI元素做命中测试时,会按Canvas内的绘制顺序从后往前遍历,后绘制的元素优先被判断命中。也就是说,一个看不见的透明Image如果在排序上位于按钮上方,它会先拦截这次点击,按钮就变得“点了没反应”。
这时可以从两个方向处理:一是调整透明遮罩的层级,让它别挡住按钮,或者不要让它参与射线检测,在Image上关闭Raycast Target;二是通过代码扩大按钮的点击范围,给按钮的Image单独写一个自定义类,重写OnRectTransformDimensionsChange后扩展一个点击判定矩形,而不是在上层叠一个大而无形的碰撞体。
6.4 用Frame Debugger和UI Profiler定位排序问题
最后还是要推荐两个工具,它们比人肉猜原因可靠得多。
Frame Debugger能看到每一条渲染命令,包括当前DrawCall使用的是哪个材质、哪个网格、排序顺序是多少。定位“一个UI为什么被另一个UI盖住”时,直接在Frame Debugger里找到这两个元素的DrawCall,对比它们的渲染顺序,立马知道谁先谁后。
UI Profiler则重点看Canvas重建情况,它能列出每个Canvas的Batch、DrawCall、重建耗时。如果某个Canvas的重建耗时特别高,就说明这个Canvas内部动得太频繁,需要按第5节的方法做动静分离。
这两个工具搭配使用,大部分UI渲染问题都能在几分钟内锁定根因。我平时排查流程是:先看UI Profiler确认卡顿和重建集中在哪个Canvas,再用Frame Debugger精确定位DrawCall顺序,最后根据排序规则调整Hierarchy或Sort Order。
最后提一个工程习惯:所有UI排序相关的配置,统一由项目里一两个人维护,不要今天这个同事建一个Canvas设个Sort Order,明天另一个同事再改一下Sorting Layer。散乱的排序配置虽然短时间能解决局部遮挡,但会为后续埋下大量坑。把Canvas分层、Sort Order范围、特效层规则都写成一份简短文档,让团队都按这套规范来,比任何高级技巧都管用。