做Unity项目,只要画面里同时出现两个以上对象,就迟早要碰“谁的绘制优先级更高”这个问题:2D角色的箭头是不是被技能特效挡住了?全屏UI里的伤害数字为什么突然被模型穿透?3D角色站在灌木丛后面,头发到底该不该被树叶边缘罩住?这些看着都是美术或表现问题,实际上全是Unity渲染顺序在幕后定生死。我早期做消除类小游戏时,就因为多加了一层级联UI,导致伤害数字层级错乱,排查了一整晚,最后发现是RenderQueue、Sorting Order和深度缓冲三个维度一起夹击的结果。
这篇内容我打算把Unity渲染顺序彻底讲透,包括RenderQueue、Sorting Layer、深度缓冲、Camera Depth的优先级划分,并穿插一些实战踩坑过程。适合正在做2D、UI、3D混合项目,经常被“谁盖谁”困扰的中初级Unity开发者,也适合想优化绘制批次、减少状态切换的性能控。搞懂这套机制,你调层级、调穿插、调特效顺序时就不需要再靠试错,一次就能定位问题。
1. 渲染顺序到底由什么决定
先说结论:Unity里最终绘制顺序不是靠场景面板里的Hierarchy上下位置决定的,也不是靠脚本执行先后决定的,而是由一套独立于游戏对象逻辑的规则来判定。很多新手习惯在Hierarchy里把想显示在后面的物体往下拖,拖完之后发现画面纹丝不动,就是这个原因——你不小心在跟引擎的排序机制较劲。
1.1 三个排序维度及其优先级
我把Unity判定“谁先画谁后画”的规则拆成三个维度,按优先级从高到低排列:
| 维度 | 作用范围 | 判定方式 | 常见使用场景 |
|---|---|---|---|
| RenderQueue(渲染队列) | Shader/Material级别 | ShaderLab里Tags { "Queue" = "..." }的值从小到大 | 区分不透明、透明、背景、Overlay |
| Sorting Layer + Order in Layer | SpriteRenderer/CanvasRenderer等2D渲染器 | 每层有全局编号,同层内Order数字大的后画 | 2D角色、UI、技能指示器、特效 |
| 深度缓冲(ZTest/ZWrite) | 像素级 | 当前片元深度与缓冲已有深度比较 | 3D物体的前后遮挡关系 |
这里要特别注意:深度缓冲并不决定“谁先画谁后画”,它决定的是“后画的能不能覆盖先画的”。真正控制绘制顺序的第一优先级是RenderQueue,第二优先级是2D相关的Sorting Layer和Order。如果同一个物体既不是Sprite也不是Canvas,那它基本只靠RenderQueue和深度缓冲来工作。
1.2 为什么游戏引擎不做“从上到下扫描”
搜索热词里有一条“dom从上到下顺序渲染是不是更快”,这个疑问我在Web开发者朋友那边也听过。浏览器渲染DOM树时确实有明确的文档顺序,但Unity这类实时3D引擎完全不同。图形API本质上是“提交一批绘制命令,GPU按命令顺序处理”,引擎为了减少状态切换,会对命令重新排列。比如同一批物体,材质相同就尽量挨在一起画;不透明物体大多不排序,反正深度缓冲会兜底;透明物体才需要严格按距离从远到近。
打个比方:餐厅打饭不是按客人进门顺序叫号,而是按窗口分类排队——拿包子的人去包子窗口,拿饮料的人去饮料窗口,最终每个窗口内部再按先后顺序出餐。游戏引擎里的“窗口”就是RenderQueue、Sorting Layer、材质状态这些维度。所以你手动调整Hierarchy上下顺序,本质上是想通过“进门顺序”控制“出餐顺序”,当然经常失效。
2. RenderQueue才是全局总开关
任何一个可渲染物体,最终都要经过Shader里的Queue标签来分拣。这个标签决定了物体被划进哪个大组,大组之间的优先级是“一刀切”的:Background组永远先画,接着是Geometry(不透明几何体)、AlphaTest、Transparent(透明组)、Overlay(最后画的覆盖层)。
2.1 内置队列的数值体系
ShaderLab里最常见的写法是这样的:
Shader "Custom/MyShader" { SubShader { Tags { "Queue" = "Transparent" } // ... } }Unity内置的队列数值大致如下:
| 队列名称 | 数值 | 典型用途 |
|---|---|---|
| Background | 1000 | 天空盒、远景背景 |
| Geometry | 2000 | 默认不透明物体、场景静态网格 |
| AlphaTest | 2450 | 带Alpha Test的植物、头发 |
| Transparent | 3000 | 半透明物体、粒子、UI元素 |
| Overlay | 4000 | 镜头光晕、最终覆盖层 |
你可以用Renderer.material.renderQueue = 3000;在脚本里动态修改单个物体的实际队列值,也可以写成Tags { "Queue" = "Geometry+1" }做到微调。这个“+1”的做法很实用,比如角色身上有些装饰物想排在不透明主体的后面一点,又不想完全丢进透明队列,就可以用Geometry+1。
2.2 不透明物体不排序,透明物体必须从后往前
默认情况下,不透明物体会被归到Geometry队列,这个队列内部的绘制顺序基本不按距离排序,而是按材质、纹理、渲染状态做合批优化。你可能会疑惑:如果不排序,那后画的大楼会不会把先画的大楼盖住?不会。因为每个不透明片元写入前都会做深度测试,深度值更远的片元直接丢弃,这样一来即使后画,只要几何位置在后面,也无法覆盖前面的物体。这是深度缓冲的功劳,不是排序的功劳。
透明物体就不一样。透明材质默认会关闭深度写入(ZWrite Off),因为如果每个半透明片元都写深度,那后续需要覆盖的半透明物体全被堵死了。没有深度写入,就必须靠CPU端对物体中心到相机的距离排序,从远到近依次绘制,否则就会出现“后面的玻璃挡住了前面的玻璃”这种错乱。但这里有个很大的坑:距离排序用的是物体包围盒中心点,不是物体表面。当一个长条形的半透明物体横跨场景,两端距离远近差异较大时,中心点排序可能完全判断错。
2.3 半透明穿插的实战解法
遇到透明物体相互穿插、排序错误,我常用的处理方案有三个:
- 把大的透明物体拆成多个小Renderer,让每个小物体的中心点都更接近实际表面,远近距离判断更准确。
- 在Project Settings > Graphics里调整Transparency Sort Mode,可以把它改成Orthographic或自定义轴,让透明排序按某个固定轴而不是相机方向,尤其适合2D风格战斗场景。
- 干脆用两个Pass做深度占位:先用一个不透明的、不写颜色的Pass把深度写入,再用透明Pass绘制半透明部分,后续物体就无法穿透上来。
方案3在写水面、冰面、能量罩时特别常用。我给一个简化的Shader伪代码:
Shader "Custom/WaterWithDepth" { SubShader { // 深度占位 Pass,不写颜色 Pass { Tags { "Queue" = "Geometry+2" } ColorMask 0 ZWrite On } // 透明主体 Pass { Tags { "Queue" = "Transparent" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off } } }这样水面既能挡住它后面的物体,自己又是半透明的,表现稳定很多。
2.4 渐隐、水墨、二次元特效里的Queue坑
热词里有一条“unity脚本控制逐渐消失”,很多人在脚本里直接改material.color.a,结果物体一点反应没有。原因很简单:材质当前还是不透明模式,不透明材质的Alpha值在默认光照模型里基本不影响渲染,只有把Shader的Surface Type改成Transparent(也就是Queue变成Transparent),Alpha才会参与混合。在URP里操作更直观:Shader面板上把Surface从Opaque切到Transparent即可,但背后引擎还会同时改掉RenderQueue和ZWrite状态。
水墨晕开特效、二次元角色描边这类需求就更讲究顺序了。水墨特效通常是粒子系统,粒子材质必须放在Transparent队列,且关闭深度写入,不然它会把后面的角色妆容给“挖空”。二次元描边我习惯用两个Pass:第一个Pass专门沿法线方向扩一圈写描边,第二个Pass画正常身体,两个Pass写在同一个Shader里,执行顺序按代码从上到下,这样描边会被身体自然覆盖区域遮挡,不会出现描边穿透到身体前面的奇怪结果。
3. 2D与UI的排序控制:Sorting Layer和Order in Layer
如果项目里只有3D模型,排序规则相对简单;一旦混入Sprite、UI、粒子,问题就来了:Sprite本身也是Renderer,它到底怎么跟3D模型比优先级?答案是看Sorting Layer和Order in Layer,这两个字段在2D/UI体系里优先级极高,可以被视为“RenderQueue之外的第二个排序维度”。
3.1 Sorting Layer的全局定义
在Project Settings > Graphics里有一个Sorting Layers列表,从上到下依次由底层往顶层排列,你在列表里新增的层靠下就越靠后渲染。实际操作时,层名尽量不要乱起,我用过一套固定命名:BackGround、Ground、Character、Effect、Indicator、UI。这样做的好处是所有团队成员写代码时都能准确选择层名,不会出现 “我想让特效显示在角色前面,但不知道该选哪层”的纠结。
技能攻击指示器(热词里那条“unity skill attack indicators”)就是个典型场景:角色脚下有圆形攻击范围,头顶有蓄力条,攻击特效在命中瞬间爆发。如果指示器、蓄力条、特效都在默认的Default层,就只能靠Order in Layer硬调,稍微加一个特效就可能被盖住。更合理的做法是把指示器单独放在Indicator层,这个层排在Effect下面、UI上面,既保证指示器显示在角色和战斗特效之上,又不会被血条、面板等UI遮挡。
3.2 Order in Layer与SpriteRenderer的Z坐标
同一层内的Sprite,按Order in Layer数字从小到大绘制,数字越大越靠前。如果Order完全相同,Unity会拿Sprite距离相机的远近来比,离相机更近的优先显示。在正交相机下这就是Z轴数值的比较。
这里要特别提醒:不要为了方便,把所有Sprite都设成同一个Order然后再去拖Z轴。Z轴参与的是“相机距离排序”,如果两个Sprite在空间上交错,结果可能很不直观。我踩过的坑是做一个2D俯视角物品拾取提示,提示箭头和金币堆叠在同一个位置,Z轴微调了一晚上也没稳定,最后老老实实把箭头设成Order 10、金币设成Order 5,世界立刻清净了。2D项目里,能用Order解决的就别依赖Z轴,Z轴留给真正的空间位置判断。
3.3 Canvas渲染顺序的特殊规则与常见坑
Canvas有三种渲染模式,对层级的影响差异巨大:
- Screen Space - Overlay:永远最后绘制,无视深度缓冲和相机裁剪,适合全屏UI、对话框、广告位。缺点是无法被3D物体遮挡,也不参与后处理。
- Screen Space - Camera:Canvas被放到指定相机的裁剪空间里,通过Plane Distance控制离相机远近,参与相机的深度排序。
- World Space:Canvas像一块世界里的面板,可以被模型遮挡,也受光照影响,常用于HUD、血条跟随。
多个Canvas同时存在时,先按Camera Depth排序,再按sortingOrder和sortingLayer。但有个经典坑:如果多个Canvas使用同一套图集和材质,动态合批可能把它们的绘制顺序强行合并,导致你改Canvas上的sortingOrder不生效。我遇到过一次,两个血条面板,一个在后一个在前,改Order后画面纹丝不动,最后发现是两个Canvas的CanvasRenderer被批处理合并了,给其中一个Canvas换了个材质或关掉动态批处理才解决。
从Figma导出的UI进Unity后层级乱,也多半是这个道理:你不是把素材拖错顺序了,而是Canvas自身的渲染模式和sortingOrder没有规划好。导入后统一在Canvas根节点把Sorting Order设成固定值,再在内部用RectTransform的上下关系控制显示,会比层层嵌套Canvas靠谱得多。
4. 3D深度缓冲与遮挡关系
到了3D场景,决定“谁被谁挡住”的主角已经不再是排序指令,而是深度缓冲。如果前面半透明问题让很多人头疼,那不透明物体的深度管理相对简单,但也需要理解几个致命细节。
4.1 ZWrite与ZTest的关系
每个片元在写入屏幕前,GPU都会做一次深度测试,只有测试通过的片元才会执行颜色混合和深度写入。两者可以通过ShaderLab来控制:
Pass { ZWrite On ZTest LEqual // ... }常见组合的用途如下:
| 组合 | 用途 |
|---|---|
| ZWrite On / ZTest LEqual | 默认不透明物体,能正常遮挡 |
| ZWrite Off / ZTest LEqual | 透明物体,需要从后往前排序 |
| ZWrite Off / ZTest Greater | 描边或体积光扩边,显示在物体表面外侧 |
| ZWrite On / ZTest Always | 特效后处理,永远绘制,但也会污染深度 |
一个常见的误区是:给透明材质开启ZWrite On,想让透明物体被遮挡得更“干净”。但这样会导致后续透明物体在深度测试时全军覆没,半透明遮挡链直接断裂。正确的做法是用前面提到的深度占位Pass,而不是简单打开ZWrite。
4.2 多相机叠加的排序方式
游戏里一个场景往往不止一个相机:主相机负责世界,UI相机负责面板,小地图相机负责俯视。相机的执行顺序取决于Camera组件上的Depth值,数值小的先渲染,大的后渲染。后渲染的相机会默认覆盖整个屏幕,除非Clear Flags设置为Depth Only。
热词里那条“unity摄像机跟随”,跟这个也有关系。如果你的跟随相机挂在角色下面,而场景里还有一个小地图相机,切记别把小地图相机的Depth值调到比UI相机还大,否则小地图会盖住主UI。我见过的惯用做法是:世界主相机Depth=0,小地图Depth=1但Clear Flags设为Depth Only,UI相机Depth=2。记住,Clear Flags里Solid Color会直接拿底色盖掉之前相机的画面,多个相机叠加时优先用Skybox或Depth Only。
4.3 阴影与渲染顺序是两套独立Pass
热词里有“unity阴影问题”,不少人以为阴影错乱是渲染顺序引起的。其实阴影用的是ShadowMap流程,它在正式绘制前会把场景按光源视角渲染一遍深度,这个阶段只跑所有物体的ShadowCaster Pass。如果材质的Shader没有包含ShadowCaster Pass,物体就不会出现在阴影图上,哪怕它本身绘制顺序再正常也白搭。
排查“模型没有阴影”的顺序应该是:先看光源是否开启阴影;再查Shader里有没有"LightMode" = "ShadowCaster"的Pass;最后看Renderer的Cast Shadows属性是不是设成了Off。URP里如果用了自定义Shader又没接阴影,直接在材质面板开启Receive Shadows也无效,因为你缺少那个Pass。
至于“unity模型遮挡剔除插件”,Unity自带的Occlusion Culling往往就被很多人忽略了。这套机制在做遮挡剔除烘焙后,会在渲染前剔除被建筑挡住的整个网格。它和深度缓冲不冲突,一个是粗粒度剔除,一个是像素级精确遮挡。实际项目中优先开内置的,不用急着买第三方插件。
4.4 Z-fighting闪烁的根源
两个面完全重合时,深度缓冲无法判断谁前谁后,同一像素反复被两个面交替写入,画面就会闪烁。这种“打架”在数字孪生、贴花、路面白线上特别常见。解决办法首先是避免面重合,其次可以给Shader加上深度偏移:
Pass { Offset -1, -1 }Offset的作用是把片元深度往相机方向拉一点,让重合的面有一个明确的先后。这个参数不是越大越好,太大会导致物体被错误地拉穿其他遮挡物,我通常从-0.5、-1开始试。
5. 常见渲染顺序问题的排查与实战修正
这里我整理了一张速查表,覆盖我这些年遇到的最常见的排序问题。遇到问题时可以照着表里从前往后排查,大部分情况能在十分钟内定位。
5.1 问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| Sprite被3D模型挡住 | Sprite与模型都在Geometry队列,模型深度更近 | 给Sprite设更高的Sorting Layer |
| 半透明物体穿插错乱 | 透明排序用中心点距离,长物体判断不准 | 拆分Mesh、改Transparency Sort Mode |
| UI盖不住特效 | Canvas RenderMode不是Overlay,或相机Depth低于特效相机 | UI相机Depth调到最大,Overlay优先 |
| 多个Canvas的sortingOrder不生效 | 动态合批把渲染状态合并了 | 调整材质或关闭动态批处理 |
| 淡出效果没反应 | 材质还在Opaque模式,Alpha不参与混合 | 切到Transparent/Fade模式 |
| 物体没有阴影 | Shader缺少ShadowCaster Pass | 给材质补上ShadowCaster Pass |
| 两个重叠面闪烁 | 深度精度冲突,Z-fighting | 拉开间距或加Offset |
5.2 实战:让Sprite稳定显示在模型前面
热词里那句“sprite renderer在模型前渲染”,我提供一个完整的实操流程。
- 在场景中创建一个带Sprite的物体,位置移动到3D模型附近。
- 初始状态下,Sprite很可能被模型遮挡,因为两者都在Geometry队列,模型表面的深度更近。
- 选中Sprite物体,在SpriteRenderer组件里找到Sorting Layer,把它从Default调整为更高的一层,比如Character层之上的Effect层。
- 如果想让Sprite盖在所有3D物体之上,可以给它单独建一个层叫AlwaysOnTop,什么都不放,只给这个Sprite用。
- 如果Sprite是UI图标,更稳的做法是直接用UI Canvas的Overlay模式,彻底脱离3D空间。
需要注意:如果你给Sprite设了很高的Sorting Layer,但它的材质仍是Transparent,那它还是参与透明绘制逻辑,可能被别的透明物体穿插。这种情况下要检查材质队列和ZWrite设置,确保它跟透明批次里的其他对象处于合适的关系。
5.3 顺带说清LayerMask与RenderingLayerMask
热词里有人问“unity中的layermask与renderinglayermask的区别是什么”。这两个东西名字很像,但完全不是一回事。LayerMask是传统Unity的层遮罩,用来做物理射线检测、相机Culling Mask过滤,它决定“哪些物体参与物理检测、哪些物体被相机看到”,本身不参与渲染顺序。RenderingLayerMask是SRP时代引入的渲染层遮罩,主要用来控制Decal、Light Layer这类渲染特性,判断“这个贴花是否投射到这个物体上”“这个灯光会照亮哪些物体”。
它们跟渲染顺序都没有直接关系。如果你遇到模型不接收贴花,想的是“是不是被其他物体挡住了”,其实是RenderingLayerMask里的Decal Layer不匹配。而Camera的Culling Mask用的还是传统LayerMask,两套遮罩混着用就会出现“物体明明被相机看到了,特效却就是贴不上”的怪象。
5.4 实战:处理“半透明遮挡但又被深度正确挡住”
这个需求常见于游戏里的大型能量护罩:它应该是半透明的,能看到后面的建筑,但建筑边缘又要明确地被护罩边缘压住,不能整面穿透。
我的做法是给护罩材质加两个Pass。第一个Pass单独负责“把ZTest打开、ZWrite打开、ColorMask 0”,相当于在深度缓冲里画了一个不透明的护罩轮廓。第二个Pass正常绘制半透明护罩本身,ZWrite Off。这样GPU先知道护罩在哪里,再绘制护罩颜色,结果建筑被护罩的边缘部分正确遮蔽,而透过护罩中央看建筑时又会呈现半透明的混合效果。这个方案在URP、内置管线下都能跑,只是URP里要注意RenderObjects Feature不要把这个depth pass二次排序。
6. 优化渲染顺序的一些个人经验
6.1 排序方式直接影响性能,不只是表现
很多人只关心排序对不对,不关心排序多不多。渲染顺序一旦频繁变化,引擎的合批和状态切换就会爆炸。同一个场景里,如果把大量Sprite的sortingOrder在运行时来回改,或者动态把物体的renderQueue从Geometry改到Transparent,Unity会不断拆批、重新上传顶点数据,帧率立刻给你颜色看。
我有一个习惯:能在编辑器里定好的顺序绝不让脚本动态改,能在材质里写好的Queue绝不在代码里赋值。运行时做排序的代价远高于看似“灵活”的收益。限制物体排序变化的次数,比你去优化Shader效率更见效,尤其是在移动端。热词里那条“dom从上到下顺序渲染是不是更快”的疑惑,放在Unity里就变成:只要渲染状态切换少、批次合并好,顺序本身快不快不是关键,关键是别为排序付出额外的重组代价。
6.2 SortingGroup让组合排序变得可控
一堆Sprite需要作为一个整体跟周围物体比较优先级时,单独调每个Sprite的Order会很痛苦。比如一个2D角色由身体、武器、披风三个Sprite组成,你想让整个角色比地面物品层高,又比天气雨效低。如果只调每个子Sprite,加一个新特效就得全盘重调。
这时可以用Sorting Group组件挂在角色根节点上。Sorting Group会给整组一个统一的Sorting Layer和Order值,组内子Sprite的相对顺序还在,但跟外部物体比较时,整组被视为一个单位。最典型的应用是2D战斗阵营里的整支小队编队,以及纸娃娃系统。注意SortingGroup不要嵌套太深,两层以内足够,嵌套太多容易让人搞不清优先级来源。
6.3 我现在的默认配置
最后分享一套目前我起新项目时最先定下来的默认规则。场景Sorting Layers从上到下固定为:BackGround、Ground、Character、Effect、Indicator、UI。所有透明特效材质统一走Transparent队列并且ZWrite Off。UI主Canvas固定为Screen Space - Overlay,任何小地图、血条、指示器的Canvas按Camera Depth排好,然后sortingOrder由根节点统一控制。
这个配置我用过好几个项目,表现稳定,排查问题也快。以后只要有人跑来问“为什么我的Sprite跑到模型前面了”或“为什么半透明水墙把后面的角色切掉了”,我先问一句他的Sorting Layer和Queue设置,问题基本就能解开一半。渲染顺序不是玄学,它只是三个维度叠加后的确定性结果,理解透之后,调起来真的很快。