拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity显示层级完全解析:UGUI与Sprite跨体系排序方案

Unity显示层级完全解析:UGUI与Sprite跨体系排序方案

1. 层级问题绕不开:UGUI和Sprite是两套完全独立的排序系统

刚接触Unity的开发者,十有八九会在显示层级上栽跟头。最常见的一幕是:游戏里明明把血条UI挂在了角色头顶,运行起来却被地面上的草、或者其他Sprite给盖住了;又或者做了一个弹窗,结果怎么调都盖不住角色脚下的特效。查了半天代码没毛病,最后发现是层级控制方式的锅。

要理解Unity的显示层级,第一步必须明白一件事:UI(UGUI)和Sprite(图片资源)在Unity里走的是两套完全不相关的渲染排序逻辑。你可以在Inspector面板里把某个Sprite的Order in Layer调到1000,但只要这个UI是用默认的Screen Space - Overlay方式渲染的,它永远在所有Sprite之上。反之,把UI挂到World Space之后,它又变成了一个可以参与世界坐标排序的普通物体,会被3D模型挡住。

这两套体系分别是:

排序体系核心组件主要控制手段
UGUICanvas, GraphicRaycasterCanvas的sortingOrder、Hierarchy中的兄弟节点顺序
SpriteSprite RendererSorting Layer、Order in Layer、物体Z轴位置
3D物体Mesh RendererRender Queue、深度缓冲、渲染顺序(与本文主题弱相关)

很多做UI的同事只盯着Canvas里的层级看,很多做2D游戏的同事只盯着Sorting Layer看,但一旦两者出现在同一个画面里——比如AR游戏、2D角色扮演游戏、有大量技能特效和浮动战斗数字的游戏——就会立刻发现:"我明明把UI的层级调对了,为什么还是被遮住?"答案就在这里:你调的是UGUI体系里的层级,Sprite根本不认这套。

此外还需要理解一个底层概念:渲染队列(Render Queue)。Shader里的RenderQueue大致分为Background(1000)、Geometry(2000)、AlphaTest(2450)、Transparent(3000)、Overlay(4000)这几档。3D场景中的不透明物体走Geometry,遵循深度缓冲判定"谁挡谁";而UI、Sprite、粒子这类透明物体走Transparent队列,深度写入默认关闭,主要依赖CPU端的排序来决定先画谁后画谁,后画的自然会盖住先画的。

所以整篇博文的核心逻辑就是:UGUI里排UGUI的顺序,Sprite里排Sprite的顺序,当需要跨体系控制遮挡时,再通过Sorting Layer、sortingOrder和相机距离这三个公共维度把它们拉到同一把尺子上来比较。理清这一条主线,后面所有的问题都有解。

2. UGUI层级的三把钥匙:Canvas sortingOrder、Hierarchy顺序、嵌套Canvas

2.1 同一Canvas内:Hierarchy中的先后顺序就是渲染顺序

在同一个Canvas下面,你不需要设置任何额外的数值,Hierarchy面板中从上到下的顺序,就决定了渲染从底到顶的顺序。也就是:越靠下的物体,显示的时候越靠上层,越能遮挡别人。

这个规则很多新手会弄反,因为做UI设计的时候,设计稿里的"底层背景"在图层面板里通常在最下方,到了Unity里你想让背景垫底,就得把它的GameObject放在Hierarchy的第一位,而弹窗、按钮、提示文字依次往下排,越重要的、越需要弹出的组件越靠后。

举一个最直观的例子,一个登录界面:

Canvas ├── Background (背景图,Hierarchy最上面) ├── Logo (Logo图) ├── InputField (账号输入框) ├── Button (登录按钮) └── TipsText (提示文字,Hierarchy最下面,永远最上)

如果运行后发现登录按钮被背景图盖住点不到,第一反应不用去查代码,直接看Hierarchy里Button是否在Background上面。很多时候"UI显示不出来"的根本原因不是代码逻辑,而是新实例化的UI被SetParent之后,默认挂到了父节点列表的末尾——如果不小心把弹窗挂到了背景下面,它自然就"隐身"了。

这种顺序规则在代码里也有对应的API:transform.SetSiblingIndex(int index)、transform.SetAsLastSibling()、transform.SetAsFirstSibling(),以及查询用的transform.GetSiblingIndex()。后面第五节会专门讲动态排序时会用到,这里先记住SetAsLastSibling()=把当前节点移到父节点列表末尾=变成最上层显示。

2.2 多个Canvas之间:sortingOrder决定谁压谁

如果整个微信小游戏或手游只有一个Canvas,那日子会好过很多。实际项目里几乎不可能只有一个Canvas:主界面一个Canvas、弹窗一个Canvas、Loading一个Canvas、Joystick一个Canvas,甚至每个UI模块独立一个Canvas来方便管理。

这时候同一Canvas内部的Hierarchy顺序,对跨Canvas的情况就不起作用了。多个Canvas同时存在时,Unity先比较的是Canvas组件上的Sorting Order(在Canvas组件上的"Sorting Order"字段),数值大的显示在上层。如果多个Canvas的Sorting Order相同,再回头看Hierarchy中这些Canvas的先后顺序。

这是我踩过的一个典型坑:主界面Canvas的Sorting Order是0,弹窗Canvas的Sorting Order也设置成了0。逻辑上弹窗应该盖住主界面,但在不同Canvas下层级相同得靠Hierarchy顺序,而弹窗如果在主界面之前创建的话,弹窗就不会显示在最上层。后面我统一规定:凡是弹窗类Canvas,Sorting Order必须≥100,Loading类≥1000,Toast类≥2000,才彻底根治了这类问题。

2.3 Canvas的三种Render Mode各自影响什么

Canvas组件上有一个Render Mode,它决定的是Canvas本身跟相机的关系,也直接决定了UI能不能被3D物体遮挡。

  • Screen Space - Overlay:完全贴在屏幕上,永远渲染在所有东西的最前面。不需要关联相机,不管你的3D模型离相机多近,都遮不住它。方便,但是物理上"不真实",而且UI上无法做任何被遮挡的效果。
  • Screen Space - Camera:UI会摆在指定的相机前方某个平面上,跟3D物体处于同一个渲染空间中。此时UI是会被3D物体遮住的,你能通过调整Canvas的Plane Distance来控制UI离相机多远。这是AR、FPS等需求下最常见的模式。
  • World Space:Canvas变成一个世界空间里的普通矩形平面,可以在任意位置旋转和缩放,像一面屏幕挂在3D场景里。此时UI再也不是"Special"的东西了,它的渲染顺序完全取决于Sorting Layer、Order in Layer、到相机的距离等,跟Sprite完全处在同一个坐标系里比较优先级。

这里直接回答很多人的疑问:"我想让一个角色从UI后面走过去,怎么做?" 答案很简单:把UI从Overlay改成Screen Space - Camera(或者World Space),再让角色的渲染层级在UI之上即可。如果你是Overlay模式,对不起,物理上它永远在最前面,角色无论在哪都只能从UI下面走。

2.4 嵌套Canvas:子Canvas是一个独立的"子排序空间"

还有一种情况比较隐蔽:Canvas下面挂着另一个带Canvas组件的子物体。Unity处理嵌套Canvas的规则是——子Canvas作为一个整体参与父Canvas的排序,但子Canvas内部元素之间的顺序,由子Canvas内的Hierarchy顺序决定,不会再跟父Canvas的兄弟节点混排。

举一个实际案例:一个主界面Canvas下,有一个"弹出窗口"子物体,它带一个自己的Canvas组件,Sorting Order保持默认0。同时这个弹窗里又挂了很多子物体。你想通过修改主Canvas下其他物体的顺序,来让弹窗显示到某些物体之上、某些物体之下——这是做不到的。因为子Canvas一旦存在,它内部就是一个独立的渲染单元。你唯一能控制的就是:把弹窗这个子物体的位置,放在主Canvas兄弟节点的某个位置,以及设置它自己Canvas的Sorting Order。

所以如果设计良好,弹窗完全不应该做成嵌套Canvas,而应该独立出一个顶级Canvas,用Sorting Order统一控制。嵌套Canvas更多是用来做局部特殊处理,比如某个面板整体要加Mask、整体要做一个特殊shader效果,才考虑用子Canvas包一层。

3. Sprite的显示层级:Sorting Layer、Order in Layer和Y轴排序

3.1 Sprite Renderer的优先级顺序

说完了UI体系,再看Sprite体系。2D游戏里所有图片元件都靠Sprite Renderer组件显示,它的排序规则相对清晰,优先级从高到低是:

  1. Sorting Layer(在Tags & Layers里配置)
  2. Order in Layer(同一个Layer内比较,数值大的遮住数值小的)
  3. Z轴到相机的距离(如果Layer和Order都相同,离相机近的渲染在前,会盖住后面的)
  4. Y轴位置(2D游戏的"伪3D"排序):需要通过Sprite Renderer的Sprite Sort Point + 相机设置配合开启

这里最容易犯的一个错误是:把Order in Layer当成了全局优先级,两个角色的Order都设置了5,却忘了这两个物体可能不在同一个Sorting Layer。如果A角色在"Character"层、B角色在"Effect"层,而"Effect"这个Layer在Tag列表中排得比"Character"靠后,那B角色哪怕Order in Layer设置为10,也会被排序靠前的Layer里的物体压住。

Sorting Layer的排序规则就是在Tags & Layers窗口中,从上到下依次优先级从低到高:最上面的Layer优先级最低,越往下越靠前。这就意味着,如果单位、特效、场景背景分别放到三个不同的Sorting Layer,那么背景在最上面(最低优先级),单位在中间,特效在最下面(最高优先级)。实际操作中记得这点就行:上面=垫底,下面=显示在最顶。

3.2 Order in Layer的规则与代码控制

同一个Sorting Layer下面,Order in Layer是Int数值类型。数值越大显示越靠前。默认都是0。我见过很多团队,为了省事直接把所有Sprite都放在同一个Default Layer,靠修改Order in Layer来控制单位前后关系,这种做法虽然简单,但也会埋下隐患——因为Unity对Sprite的绘制顺序不是按Hierarchy来的,而是先把所有物体按Sorting Layer排序,再按Order in Layer排序,再按Z/Y排序。所以如果你在一个"角色A"的脚下放了一个阴影Sprite,阴影的Order设成了-1,角色A本身是0,阴影就永远垫底,但如果地面的Order也是0且绘制顺序排在角色A之后,地面就会跑上来盖住角色,产生穿帮。

代码控制很简单:

SpriteRenderer renderer = GetComponent<SpriteRenderer>(); renderer.sortingLayerName = "Character"; // 按名字切换Layer renderer.sortingOrder = 10; // 同Layer内排序

还有一个开发技巧:把不同的游戏对象按重要程度预设Order区间,比如:

对象类型Sorting LayerOrder in Layer 范围
地形/背景Ground-100 ~ -1
常规单位Character0 ~ 50
弹道/技能特效Effect50 ~ 200
漂浮文字/血条UI_Like1000 ~ 2000

这样写入一个规范后,所有美术和程序员都按统一区间来,就很少出现层级打架的情况。

3.3 2D横版游戏常见的Y轴"伪深度"排序

2D游戏里还有一个区别于普通UI排序的特殊场景:两个角色在地图上的不同Y轴位置,靠下的角色理论上应该遮挡靠上的角色(因为更接近画面下方,视觉上更靠前)。这是小时候玩《仙剑奇侠传》这种俯视角/45度视角游戏时最常见的遮挡逻辑。

Unity的Sprite Renderer支持这个:把Sprite Renderer的Sprite Sort Point属性改成Pivot(或者Center,视素材锚点而定),然后在Camera上勾选一个自定义的"Transparency Sort Axis"。默认轴方向是(0, 0, 1),也就是按Z轴排序,当两个Sprite距离相机不同时做Z排序用的。改成(0, 1, 0)之后,Unity就会按Y轴坐标去排序,Y值越小(越靠下)的Sprite越优先渲染、越遮挡别人。

我在项目里遇到过一个情况:一个角色站在墙后面,墙的Y轴和角色的Y轴正好重叠,导致角色的半身从墙里浮了出来。后来处理方式就是把墙的Sorting Layer设为"Obstacle",并且所有Unit的Sprite与墙的Y轴排序解析方式改成像素级Pivot对齐,解决了一大批类似的问题。这类"Y轴排序"的经验很多2D游戏项目都会遇到,建议独立做一个排序脚本,监听单位Y坐标变化,动态更新List里的渲染先后顺序。

3.4 Sprite的包围盒与剔除对层级的间接影响

热搜词里反复出现了"Unity Renderer的包围盒",其实这个跟层级控制也有间接关系。Sprite Renderer渲染时,Unity会计算包围盒(Bounds)来做视锥剔除,如果包围盒被裁掉,这个Sprite就不会被渲染,也就谈不上层级了。在层级调试中如果发现某个Sprite"偶尔消失",不一定是排序问题,有可能是它的Bounds被相机裁剪逻辑误判了——尤其是动态生成的Sprite、从图集动态换Sprite时,Bounds往往需要手动刷新。核心调试方法就是:选中物体查看Scene视图右下角的Bounds轮廓线,如果轮廓线跟实际显示区域不匹配,就是Bounds计算有错。这个跟"遮挡关系"看似无关,但在排查显示异常时经常是同一个现象的两个不同原因。

4. UI与Sprite的混合遮挡:跨体系排序的关键打通方案

4.1 为什么"UI放到最后"依然被Sprite盖住

前面反复强调:UGUI和Sprite是两套体系。但实际场景里它们往往需要互相遮挡,比如角色从UI背后走过、血条UI的半截被城墙挡住、技能特效在UI文字前面飞。要实现跨体系的遮挡控制,需要找到两者共用的排序维度。

最大的关键点在于:不管哪个体系,渲染时最终都汇入同一个渲染队列,排序依据都可以归结为三个公共维度:Sorting Layer(优先级最高)、Order in Layer(次之)、到相机的距离(最后)。UGUI的Canvas组件上也有Sorting Order字段,这个字段实质上就是Canvas的"Order in Layer";同时也支持给Canvas设置Sorting Layer(在Canvas组件最下方)。

所以打通方案就出来了:

  1. 把UI从Screen Space - Overlay改成Screen Space - Camera,并指定一个专门渲染UI的相机(比如叫UICamera)。
  2. 在Tags & Layers里创建公共的Sorting Layer,比如"3DWorld"、“UI",让3D/Sprite的Sorting Layer和UI Canvas的Sorting Layer统一在一张排序表里。
  3. 想让Sprite遮挡UI,就把Sprite的Sorting Layer设置在UI之上;想让UI永远最上,就把UI Canvas的Sorting Layer排在所有Sprite Layer的下面(最上面),并把Sorting Order设大。

4.2 粒子特效和3D模型如何参与排序

很多项目里真正的痛点不是Sprite,而是粒子特效和UI的遮挡。比如技能大招的特效要盖住UI的按钮,或者UI文字要浮在特效之上。粒子特效的Renderer模块上有和Sprite Renderer一模一样的Sorting Layer和Order in Layer属性。如果你使用的是默认Screen Space - Overlay的UI,那粒子特效无论如何都盖不住UI。如果你使用的是Screen Space - Camera的UI,那么粒子的Sorting Layer一旦排到UI之上,粒子就会盖住UI。

对3D模型(Mesh Renderer)来说情况稍复杂一点:不透明的3D模型不参与Sorting Layer排序,它走深度缓冲。这意味着哪怕你把一个不透明箱子的Sorting Layer设得再高,只要UI平面在空间上离相机近,箱子还是会被UI挡住(深度写入和深度测试的逻辑)。但如果你把箱子的Shader换成Transparent类型(部分半透明、粒子这类),它就会转入Transparent渲染队列,重新参与Sorting Layer排序。

实战建议:如果只是想让某个3D模型盖住UI,简单粗暴的方法是给这个模型单独建一个透明材质版本,设置它的Sorting Layer高于UI;如果这个模型必须是不透明材质,则用Screen Space - Camera + 调整UI相机的深度/裁剪距离来配合。

4.3 跨体系排序的实际工程配置

一个典型的跨体系项目(比如2.5D卡牌游戏)的Sorting Layer配置可以参考这样:

Tags & Layers 中的 Sorting Layers(从上到下优先级递增): 1. Background (地图背景) 2. Obstacle (墙体、树) 3. Character (角色) 4. Effect (技能特效) 5. UI (所有UGUI Canvas) 6. UI_AboveEverything (Toast、Loading等UI)

然后在每个Canvas上设置:

  • 主界面Canvas:Sorting Layer = UI, Sorting Order = 0
  • 弹窗Canvas:Sorting Layer = UI, Sorting Order = 300
  • Loading/Toast Canvas:Sorting Layer = UI_AboveEverything, Sorting Order = 2000

这样整个项目的层级体系就统一在一套Sorting Layer之下了,UGUI和Sprite之间的遮挡只剩"看谁的Layer更靠后"这一个判断标准,再也不会有那种"明明调了Order却无效"的玄学问题。

5. 动态控制层级:实例化、弹窗、拖拽时最常用的代码方案

5.1 动态修改UI的兄弟顺序

实战项目中UI往往不是静态摆好的,而是代码动态创建、动态切换的。最常见的需求是"点击某个按钮弹出一个面板,这个面板要出现在最上层"。用上前面讲的原理,最靠谱的写法是在Show弹窗时:

public static void ShowPanel(GameObject panel) { panel.SetActive(true); panel.transform.SetAsLastSibling(); // 放到兄弟列表最末尾 = 渲染最上面 }

因为同一个Canvas内部,Hierarchy顺序直接等于渲染顺序,SetAsLastSibling()就能把面板提到这个Canvas内所有UI之上。同理,如果要做一个"新创建的角色血条永远显示在其它血条之上",可以:

healthBar.transform.SetSiblingIndex(transform.parent.childCount - 1);

这里有一个很容易犯的错:如果你把UI放在同一个Canvas下,但项目的Canvas用了多个Sorting Order不同的Canvas,SetAsLastSibling()只对同一Canvas内的兄弟节点有效,跨Canvas无效。之前有个同事没注意到"主界面Canvas"和"弹窗Canvas"是父子平级的关系,在弹窗里调SetAsLastSibling(),结果弹窗始终比主界面低一层,最后排查半天才发现是Canvas的Sorting Order没设值。

5.2 动态修改Sprite的Order

Sprite的排序比UI更灵活,因为它直接暴露了数值属性,动态排序的代码写起来也直观:

// 单位被击杀后,尸体要显示在普通单位之下 GetComponent<SpriteRenderer>().sortingOrder = -10; // 单位死亡掉落道具,要显示在角色脚下、地面之上 itemRenderer.sortingLayerName = "Character"; itemRenderer.sortingOrder = 1;

另一个常见场景是玩家操控的角色移动时,脚下阴影会跟着地图位置变化改变Y轴排序。如果多个游戏角色都快走到同一条线上,Y轴重叠会导致排序来回跳变,画面会闪。我的经验是做一个专门的排序管理器(SortingManager),把所有需要动态排序的单位注册进去,每个单位维护一个SortingOrder权重值(基础值+Y偏移),由管理器统一每帧更新一次sortingOrder,而不是每个单位的脚本自己改自己的,这样能避免多单位并发修改时谁覆盖谁的问题。

5.3 一个自动处理弹窗层级的UI管理器封装

分享一个我在项目里实际使用的UI层级管理器简化版思路。这个管理器负责所有动态面板的排序注册,思路很简单:

public class UILayerManager : MonoBehaviour { public static UILayerManager Instance; [Header("Canvas层级配置")] public Canvas mainCanvas; // 主界面 public Canvas popupRoot; // 弹窗根Canvas public Canvas toastRoot; // Toast提示根Canvas public Canvas loadingRoot; // Loading根Canvas private Dictionary<UILevel, Canvas> levelCanvasMap; public enum UILevel { Main = 0, // 主界面 Popup = 300, // 弹窗 Toast = 1000, // 提示 Loading = 2000 // 加载 } public void ShowPanel(GameObject panel, UILevel level) { if (panel == null) return; Canvas targetRoot = levelCanvasMap[level]; panel.transform.SetParent(targetRoot.transform, false); panel.transform.SetAsLastSibling(); panel.SetActive(true); } }

核心思想就是把UI按照层级类型拆到不同的根Canvas下,每个根Canvas的Sorting Order各不相同,并且留好了数值余量。这样无论以后加多少新面板,只要指定一个UILevel,就不会出现弹窗压在Toast上面、Loading盖不住弹窗这类乱七八糟的顺序问题。

5.4 处理拖拽后的层级变化

拖拽功能有个专门的需求:拖拽中的物体必须显示在所有物体之上。处理办法有两种。第一种:在OnBeginDrag时调用transform.SetAsLastSibling(),拖拽结束再恢复;第二种:拖拽过程中临时修改Canvas的sortingOrder至一个高位值。

推荐优先用第二种,原因是如果Canvas内部有滚动列表、滑动区域,你把拖拽Item在兄弟节点里移到最末尾可能会影响其它UI的布局计算(比如ScrollRect对子Node的遍历)。改成临时提高sortingOrder则完全不影响布局逻辑。

public class DragSortingFix : MonoBehaviour, IBeginDragHandler, IEndDragHandler { private Canvas canvas; private void Awake() { canvas = GetComponentInParent<Canvas>(); } public void OnBeginDrag(PointerEventData eventData) { if (canvas != null) canvas.sortingOrder = 9999; } public void OnEndDrag(PointerEventData eventData) { if (canvas != null) canvas.sortingOrder = 0; // 恢复原值,实际项目要记录原始值 } }

注意,这里恢复排序值的时候要记录的是这个Canvas的原始sortingOrder,而不是无脑写死0。尤其是全局UI层级管理器统一管理Canvas时,写死0会把这个Canvas从"弹窗Canvas"直接降级成"主界面Canvas"级别,引发一串连锁显示问题。

6. 层级排查实战:几个典型“显示不上来/盖不住”的谜之现象

6.1 场景一:新弹出的UI明明代码没问题,却显示不出来

现象是:点击按钮后,一个面板Show了,SetActive(true)了,console里也没报错,但界面上就是看不到这个面板。排查思路:

  1. 先看Hierarchy里这个面板挂在哪个父节点下。如果挂到了主界面的某个被Mask裁剪的节点下,可能直接被父级Mask裁掉了,层级根本没机会参与。
  2. 再看坐标,如果面板的RectTransform跑到了屏幕外面,照明条件正常也不会显示。
  3. 再查Canvas的sortingOrder。如果这个面板属于弹窗Canvas,但弹窗Canvas的sortingOrder=0,主界面的Canvas也是0,而面板在Hierarchy顺序上排在主界面之前,那它就被主界面压住了。
  4. 最后查Raycast Target。如果面板上一个透明Image把射线全部挡住了,视觉上可能显示但按钮点不到,这其实是事件系统的层级问题,和渲染层级无关但表现很像。

6.2 场景二:3D角色走到UI面前,UI反而被角色遮住了

之前遇到一个AR项目,UI是Screen Space - Camera模式挂在AR相机前面,3D角色一旦走到相机和UI平面之间,角色就会把UI遮住。这是正常的物理遮挡,不是bug。处理方式看需求而定:如果要求UI永远不被3D物体遮住,就别用Screen Space - Camera,切回Overlay;如果要求"角色被UI半遮挡"的视觉效果,就把UI平面调整到离相机更近的距离(调Plane Distance),并给角色材质或者UI平面使用半透明Shader来动态混合。

另外还有一个常见坑:用了Screen Space - Camera后,UI Canvas的Sorting Layer如果没设置,默认是Default,而3D角色可能也设了Default且渲染顺序靠前,导致UI整体被角色压住。解决办法就是给UI Canvas单独设置一个高优先级的Sorting Layer,如"UI"层,保证在跨体系排序时UI优先。

6.3 场景三:粒子特效要么永远被UI盖住,要么盖住所有UI

粒子特效在UI前后的层级问题,根源几乎都是粒子系统的Renderer模块的Sorting Order没跟UI的Canvas排序统一。

最直接的做法:第一,确认UI Canvas是Screen Space - Camera模式,不能用Overlay;第二,给粒子特效设置一个独立的高Sorting Layer;第三,如果同一个Sorting Layer里还要继续细致分级,就用Order in Layer。粒子系统排序最终生效的其实是特效所在GameObject上的ParticleSystemRenderer组件的排序属性,而不是特效下面某个Sprite的排序属性,注意区分。

6.4 场景四:Hierarchy顺序明明调整了,UI就是不动

这通常发生在嵌套Canvas场景下。如果你的UI元素被一个子Canvas包裹着,你修改父Canvas下的兄弟顺序是不会影响到子Canvas内部UI元素的显示顺序的。排查方式:选中目标UI元素,看它的祖先链上有没有带Canvas组件的节点。找到一个就得重新评估它的sortingOrder,因为子Canvas会"插队"。

这种嵌套设计我在游戏里见最多的就是"让一整个聊天窗口可以整体旋转缩放",然后把聊天窗口单独做成了一个带Canvas的节点。一旦出现弹窗和聊天窗口同时存在的情景,就得额外小心它们的sortingOrder设置。

6.5 从"显示层级"延伸到性能调优

热搜词里还有一条"UI界面卡顿",其实层级设置和卡顿之间也有直接关系。UI的Overlay模式虽然方便,但有一个隐藏代价:Overlay模式下UGUI会为整个UI生成一张巨大的合并网格,任何一个小改动都可能触发大范围的Rebuild,造成卡顿。而Screen Space - Camera模式的UI在局部动态内容较多时性能通常更好。另一个常见问题是"为了排序,恨不得一个UI一个Canvas",这样做非常消耗Draw Call,UI界面卡顿往往就是这么来的。控制层级不等于无限拆分Canvas,正确做法是:静态UI放同一个Canvas,动态面板用层级管理器分配到少数几个固定Canvas上,每个Canvas的sortingOrder只做固定区段划分,不要每实例化一个UI就新建一个Canvas。

我见过有项目为了改一个按钮的显示顺序,就给这个按钮单独挂一个Canvas并动态调整sortingOrder,结果UI整体性能直线下降。移动端上Unity UI的Rebuild开销和Overdraw都要严格控制,排序设计、Canvas拆分、粒子特效的Sorting Layer都要同时考虑进去,才能做到既正确又流畅。

6.6 特殊环境:微信小游戏里的UI层级注意事项

针对"Unity微信小游戏(小程序)视频播放方案"这类热度很高的需求,顺带提一句跟层级相关的坑:在微信小游戏环境里,如果要播放全屏视频,视频播放器本质是盖在Unity渲染画面之上的原生控件,Unity里的层级控制完全管不到它。所以如果你在Unity里做了"视频上要显示一个CloseButton"的UI,按钮无论如何也显示不到视频上面——因为视频是原生层。业内常规套路是:不要在Unity里放关闭按钮,而是视频全屏播放完之后自动关闭,或者用榜单/分享等小游戏原生接口来做交互。这算是"层级控制"在小游戏环境下的一种特殊边界。

7. 一套可控的层级设计方案:从项目初始化时就定好规矩

7.1 项目启动时先配好Sorting Layer清单

层级问题最怕的不是不会调,而是没有统一规范,每个开发者各调各的,最后一团乱麻。所以我的强烈建议是:项目初始化阶段就建立一个Sorting Layer清单,把整个项目用到的层级全部列出来,后续所有开发都按这个清单执行,不允许临时新增。一旦进入开发中期再去乱加Sorting Layer,那就会引发连锁灾难——新增的Layer可能会把原本辛苦调好的各种显示关系全部打乱。

一个通用模板:

Layer名称说明典型对象
Background场景背景地图、天空盒贴图
Ground地面层地面、地板
Obstacle障碍物墙、柱子、树
Shadow阴影层角色脚下阴影
Character角色层玩家、NPC、怪物
Effect特效层攻击特效、Buff特效
Water水面层水面半透明区域
UIUI层所有UGUI Canvas
UIPopupUI弹窗层弹窗、Modal
UITopUI顶层Toast、Loading、全屏过渡

7.2 与美术/策划约定层级区间

光有Layer名还不够,还要跟美术和策划约定一个Order in Layer区段规范。比如:

区间含义示例
-1000 ~ -101背景装饰远处的山、云
-100 ~ -1地表草地、路面
0 ~ 50普通单位小怪、玩家
51 ~ 200高优先级单位BOSS、飞行单位
201 ~ 500技能特效大招、暴击特效
501 ~ 1000特殊表现战斗飘字、伤害数字

这样策划在配置数值时心里也有数,不会随手写一个很大的数字,把别的层级的显示顺序搞乱。特别是伤害数字类UI,既不能盖住角色脸,又要在所有特效之上,区间设在1000+比较安全。

7.3 实现一个全局排序检查工具

最后分享一个"提升幸福感"的开发小工具思路:因为层级问题排查非常费眼,最好在编辑器下写一个临时检查脚本,一键统计当前场景内所有Canvas和Sprite Renderer的排序配置,输出成对照表。

#if UNITY_EDITOR using UnityEngine; using UnityEditor; public class SortingDebugTool : EditorWindow { [MenuItem("Tools/Sorting Debugger")] public static void ShowWindow() { GetWindow<SortingDebugTool>("Sorting Debugger"); } private void OnGUI() { if (GUILayout.Button("列出场景内所有Canvas")) { var allCanvases = FindObjectsOfType<Canvas>(); foreach (var c in allCanvases) { Debug.Log($"Canvas路径: {GetPath(c.transform)}, " + $"RenderMode: {c.renderMode}, " + $"SortingLayer: {c.sortingLayerName}, " + $"SortingOrder: {c.sortingOrder}"); } } if (GUILayout.Button("列出场景内所有SpriteRenderer")) { var allSprites = FindObjectsOfType<SpriteRenderer>(); foreach (var s in allSprites) { Debug.Log($"Sprite路径: {GetPath(s.transform)}, " + $"Layer: {s.sortingLayerName}, Order: {s.sortingOrder}, " + $"PosY: {s.transform.position.y}"); } } } private string GetPath(Transform target) { string path = target.name; while (target.parent != null) { target = target.parent; path = target.name + "/" + path; } return path; } } #endif

排查层级问题时,最花时间的不是改代码,而是"找到那个视图里被挡住但Hierarchy里不显眼的物体"。有这样一个工具,把所有排序信息统一打印出来,一眼就能看出哪个Canvas的Sorting Order比预期的低、哪个Sprite的Layer压住了另一个Layer。

8. 一个综合案例:2.5D卡牌战斗中所有元素如何排层级

为了把前面所有的原理揉到一起,最后用一个综合场景收尾:做一个2.5D卡牌战斗游戏,画面里有3D场景、2D卡牌Sprite、UI按钮、血条、技能特效、伤害飘字、战斗结算弹窗。我们按责任划分来一步步设计层级。

  • 3D场景(地形、建筑)用Geometry渲染队列的不透明材质,深度缓冲负责普通遮挡,不需要参与排序。
  • 卡牌单位用Sprite Renderer,挂"Character"Sorting Layer,Order in Layer = 单位在阵营中的序号(前排单位Order小,后排单位Order大)。这样两排单位重叠时,后排显示在前排之上,符合斜45度视角的视觉逻辑。
  • 脚下的攻击范围辅助线、范围提示器,挂"Effect"层,Order in Layer = 100,保证能盖住单位地面投影但又不会挡住单位本体。
  • 伤害飘字、战斗数字,挂在专用的World Space Canvas上,Sorting Layer = UIImage(或UI_Like),并且这个Canvas使用World Space模式,让它在3D空间中跟随角色头顶位置,这样一旦角色走到建筑后面,飘字也会跟着被建筑遮住,表现非常自然。
  • 左上角返回按钮、英雄头像、能量条,属于常驻UI,用Screen Space - Camera模式,挂"UI"层,Sorting Order = 100。
  • 战斗胜利/失败结算弹窗,属于Modal弹窗,挂在独立Canvas,Sorting Layer = UI,Sorting Order = 300。
  • Loading界面、Toast提示,挂在Sorting Layer = UITop的Canvas,Order = 1000。

这个方案跑起来后的实际效果是:3D场景在最底下,2D角色其次,角色脚下特效再往上一层,头像UI紧接着盖住角色,弹窗压在头像UI之上,而Toast永远在最顶——所有元素的遮挡关系一目了然,任何一个新来的程序员看到这个配置也基本能立刻定位问题。

这个方案里最关键的一个设计决策是:伤害飘字、血条这类需要"参与世界遮挡"的UI,必须用World Space Canvas挂到Sorting Layer的最上层;而常驻按钮这类"永远不能遮挡"的UI,用Screen Space - Camera但Sorting Layer设置更高。如果你把血条做成了Overlay模式的Canvas,那角色走到障碍物后面时,血条反而会从障碍物前面飘过,穿帮效果非常明显。

我在实际项目里把这个方案做成了一套预制体模板,初始化项目时直接复制修改,后面几乎再也没有出现过大面积的层级bug。这套方法的本质并不是某个单一技巧,而是把"层级"从一种运行时临时修改的东西,变成了一种项目初始设计时就要明确规划好的基础架构。多数人把层级问题当bug来修,修一次坏一次;真正稳妥的做法是把它当架构来设计,让所有元素从出生那一刻就知道自己在哪个层级、跟谁是兄弟、要被谁遮挡。

返回列表