做 Unity 优化的第一步,不是学会点开 Profiler,而是先把渲染管线从 CPU 提交到 GPU 画屏这条链路彻底串起来。我见过太多人对着 Profiler 里几千个 draw call 发愣,把模型面数从几百万压到几万,阴影依旧忽闪忽闪;也见过有人在 URP 和内置管线之间反复横跳,换完管线之后一堆 shader 报错。这些问题的源头,大多藏在“渲染管线与阶段”这几个字里。这篇内容会从应用阶段、几何阶段、光栅化阶段讲起,落到 Unity 实际调度一帧画面的完整流程,再把 URP/HDRP 选型、批处理、阴影问题、透明排序这些高频痛点梳理一遍。适合刚开始接触 U3D 渲染、或者已经开始做性能优化但总觉得知识不成体系的同学。
1. 渲染管线到底管了什么
1.1 先记住“三个大阶段”这条主干
渲染管线不是 Unity 发明的概念,它本质上是把三维场景变成二维像素的一套流水线工序。行业里最常见的分法是“应用阶段、几何阶段、光栅化阶段”三大段,有些资料会把几何阶段再拆细一点,说成“几何处理、光栅化、合并”,但主干不变。
- 应用阶段:CPU 决定“画什么”,把相机位置、可见物体列表、灯光列表、阴影范围整理好,生成一批渲染指令。
- 几何阶段:GPU 决定“三角形在哪”,把每个顶点的坐标从模型空间一路变换到屏幕空间,做投影、裁剪、背面剔除。
- 光栅化阶段:GPU 决定“哪些像素被填充”,把三角形转换成片元,执行逐像素着色、深度测试、颜色混合。
这条链其实特别像餐厅后厨的流程。CPU 是前台,负责接单、看菜单、给订单分类;GPU 是后厨,按传过来的单子切菜、配菜、炒菜、装盘。如果前台记菜太慢,后厨速度再快也出不了菜;如果后厨锅小火力差,前台下再多单也没用。你后续做的所有渲染优化,本质上都是在找这条流水线上“堵车”的位置。
1.2 理解阶段之间的关系,比背术语更重要
很多人会把 draw call 和 GPU 直接划等号,其实 draw call 的发起方是 CPU。画面上每一帧都由 CPU 把“用这个网格、这个材质、绘制到这个位置”的命令提交给图形 API,再由驱动交给 GPU。减少 draw call 不只是让 GPU 少干活,更重要的是降低 CPU 的提交成本和 GPU 的状态切换成本。
还有一点很容易被忽略:不同阶段的优化策略是互相牵制的。比如动态合批,它是 CPU 在提交前临时把小网格拼成大网格,看似 draw call 低了,但 CPU 要额外做顶点拼合计算。如果你对一堆不停变形的高面数物体开了动态合批,优化效果反而可能是负的。只有理解阶段之间的这些成本转移,才不会被网上那些“一刀切”的优化口诀带偏。
1.3 从 Profiler 里的 Render 时间说起
我早期做项目也干过这种傻事:看到 Profiler 里 Render 项占了几百毫秒,第一反应是场景面数太多,于是拼命减面。后来发现掉帧的根源其实是阴影贴图分辨率开太高,GPU 填充压力过大。从那时起我养成一个习惯——凡是渲染性能问题,先拆“是 CPU 提交慢,还是 GPU 填充慢”,再沿 Unity 的实际渲染顺序逐段排查。后面第 4 节写的内容,就是这个排查习惯的浓缩版。
2. 应用阶段:CPU 每帧都不轻松
2.1 剔除:把看不见的东西挡在渲染队列之外
Unity 每帧会对场景中的渲染器做一次视锥体剔除,只要物体包围盒和相机视锥体完全不沾边,就直接不发给 GPU。在此基础上还有按距离剔除、按图层剔除,以及更精细的遮挡剔除。遮挡剔除的收益通常比视锥体剔除更大,因为它能挡掉“在视锥体内但被墙完全挡住”的物体。
遮挡剔除不能只看开关,它的数据依赖烘焙结果,而且最好配合大量静态几何体使用。如果你的关卡全是动态可破坏物体,或者角色频繁瞬移,遮挡数据就会失效,甚至出现物体莫名被剪掉的诡异现象。我自己的做法是:只有大规模使用墙体、道路、固定建筑的项目才开遮挡剔除,动态对象交给视锥体剔除和 draw call 控制,别强求。
2.2 排序:渲染性能的隐藏大动脉
经过剔除之后,CPU 会把可见物体按绘制顺序排队。Unity 内部主要依赖 RenderQueue 来管理先后,比如背景 1000、不透明几何体 2000、透明物体 3000、覆盖层 4000。同队列的物体再根据材质、shader、深度等信息做进一步排序。
排序的核心目标之一是减少状态切换。你可以把渲染状态切换想象成“切菜换刀”:同一把刀连续切十个菜,效率当然高;每切一个菜就换一把刀,后厨就要不断取刀、洗刀、换位,CPU 和 GPU 都要额外停顿。在渲染里,换材质、换 shader、换纹理都可能触发这类重状态配置,所以不透明和透明分开排序,不只是为了混合效果,也是性能需要。
2.3 渲染指令的打包:Draw Call 与 SetPass Call
CPU 最终生成的渲染指令列表里,常见的有 Draw Call 和 SetPass Call 两类。Draw Call 负责绘制网格,SetPass Call 负责切换 shader 渲染状态。查看 URP 项目帧数据时经常能看到 SRP Batcher 的统计,它其实是对“shader 状态设置”和“材质常量上传”做了缓存优化。这一点在第 4 节讲批处理时会详细展开。
那 draw call 多了会卡吗?分平台。PC 上几千个 draw call 通常还能扛住,因为 CPU 和驱动都更强;手机上几百上千就很可能成为瓶颈。更麻烦的是,一个物体用了三个 Pass 的 shader,它可能产生不止一个 draw call。最直接的办法是打开 Frame Debugger,逐帧看实际执行了多少个绘制命令,而不是靠猜。
3. 几何阶段与光栅化阶段:GPU 开始发力
3.1 顶点着色器到底做了什么
当渲染指令进入 GPU,第一步是顶点着色器。每个网格顶点都会执行这段代码,把顶点从模型本地坐标依次转换到世界坐标、相机观察坐标、裁剪坐标。最终得到的裁剪坐标会被硬件用来判断该顶点投影到屏幕后的位置。
这个阶段是很多 shader 效果的分水岭。比如你想做顶点动画、模型沿法线膨胀、风吹草动之类的效果,都是在模型空间或世界空间改完顶点位置再走后续矩阵。想做溶解、描边、卡通渲染那种逐像素效果,就必须依赖片元着色器。很多用 Shader Graph 做“假室内”或 NPR 卡通渲染的朋友,容易把顶点阶段和片元阶段搞混,导致效果只能在静态模型上正常、一有动画就穿帮。
3.2 三角形装配、裁剪和光栅化
顶点处理完之后,硬件会把顶点装配成三角形图元,接着进行裁剪:完全在视野外的三角形直接丢弃,跨边界的三角形会被切成若干个小三角形。这个阶段不用我们自己写逻辑,但要清楚它是性能分界点——一旦进入光栅化,讨论的就是像素量而不是顶点量了。
光栅化阶段本质上是把三角形“切”成屏幕上的像素网格。每个像素位置会得到一个片元,片元里携带插值后的坐标、深度、法线、UV、顶点色等信息。之后片元着色器才会对这些信息做光照、纹理采样、颜色计算。同一场景里,如果相机不动但 Game 视口分辨率提高,GPU 的光栅化和片元处理压力会明显上升,这就是常说的 fill rate 瓶颈。遇到 fill rate 卡顿,光减面数没用,优先降渲染分辨率、减少半透明 overdraw、关掉不必要的后处理。
3.3 深度测试、模板测试与混合
片元着色器输出颜色之前,还要经过一系列测试。模板测试常用于描边、遮罩、团队高亮这类玩法,深度测试则决定片元和当前深度缓冲的遮挡关系。常见默认方式是小于等于当前深度就通过,被挡住的部分直接丢片元。这就是为什么不透明物体即使绘制顺序乱一点,最终显示也不容易穿帮——因为深度测试会把被遮挡的像素干掉。
透明物体不一样,默认不写入深度,且依赖 alpha 混合。如果你把一个半透明物体 A 放到半透明物体 B 后面,却因为排序问题先画了 B,那么 B 的颜色不会正确透过 A,画面就会出现透明闪烁。这一条在排查 UI、粒子、玻璃材质时特别常见。想解决,除了调 RenderQueue,还可以给透明物体加一个只写深度的 Pass,或者按渲染距离分层处理。
4. Unity 实际调度:一帧画面从相机到屏幕的完整旅程
4.1 Camera.Render 的顺序是排查问题的第一现场
Unity 的场景渲染由相机驱动,通常不需要手写。内建管线的顺序大致是:先处理相机背景,然后渲染阴影贴图,再渲染深度纹理(如果某些效果需要),接着绘制不透明物体,再绘制天空盒或背景,随后绘制透明物体,最后执行后处理。URP 里的 ScriptableRenderer 顺序也类似,但它把流程拆得更开,还允许你用 Renderer Feature 在任意位置插入自定义绘制命令。
很多人看到这里会说:我知道这个顺序能干什么?作用非常大。比如某个特效必须在主相机渲染后叠加,你就要知道它插在哪个环节;某个半透明物体一直闪,先开 Frame Debugger 看它的绘制顺序,确认是否在透明队列内部被插到前面。我处理渲染异常的第一动作永远是打开 Frame Debugger,先确认“顺序对不对”,再去看“参数对不对”。
4.2 阴影贴图阶段常被低估的开销
先于主相机渲染之前,Unity 需要把带实时阴影的灯光从灯光视角渲染出深度图。这个深度图就是阴影贴图。它也是一次完整渲染,也要消耗 draw call 和 GPU 时间。如果一个场景有四盏投影实时灯,就可能多渲染四张阴影贴图;平行光开级联阴影后,还会按距离分成多块,成本更高。很多项目的卡顿不是主场景 shader 太贵,而是阴影阶段耗掉了大量填充率。
排查方式很简单:在 Profiler 里看 Shadow 相关阶段占了多少毫秒。常用的减负手段包括:缩小阴影距离、减少实时灯光数量、适当降低阴影贴图分辨率、用更紧凑的阴影采样范围。很多人执着于调 shadow bias 去修边缘抖动,却忘了真正的坑是阴影距离拉太远导致 GPU 压力爆发。
4.3 批处理、合批与 SRP Batcher 分别是什么
为了降低 draw call 和状态切换,Unity 提供了几个不同层次的工具:
| 方案 | 本质 | 适合场景 | 注意点 |
|---|---|---|---|
| 动态合批 | CPU 在提交前把小网格临时拼成一个大网格 | 移动端、数量多但顶点少的物体 | 材质属性、shader 必须兼容,CPU 会额外计算 |
| 静态合批 | 构建时把标记 Static 的物体合并进共享网格 | 场景里固定的建筑、地形、装饰 | 增加内存占用,对动态物体无效 |
| SRP Batcher | 缓存渲染状态,减少 SetPass Call | URP/HDRP 下的多数项目 | 需要 shader 与 SRP Batcher 兼容,不能盲目合批 |
很早以前大家都迷信“所有物体都开 Static 就能快”,结果项目迭代时发现内存暴涨。静态合批本质上是拿内存换 draw call,不是免费午餐。真正省心的是 SRP Batcher 这套思路,它不是物理合并网格,而是把材质常量上传和渲染状态设置做缓存,让同一个 shader 下不同材质多次绘制的附加成本大幅降低。这也是我建议新项目直接用 URP 的理由之一——你几乎不需要做太多动态合批,就能获得很可观的 CPU 侧优化。
4.4 透明物体的绘制队列
Unity 的透明队列默认从后往前绘制。先画远的半透明物体,再画近的半透明物体,这样近处物体的 alpha 混合结果会把后面的颜色叠上来。理想情况下,所有半透明物体按中心点距离排序就行,但现实里物体 A 和 B 互相穿插,甚至一个物体自身分了好几块网格,你要找到一个对所有像素都成立的顺序几乎不可能。于是画面就会出现透明排序错乱、物体若隐若现的闪烁。
我曾经处理过一个玻璃材质的案例:玻璃罩和内部物品都是半透明,默认 RenderQueue 都是 3000,绘制顺序完全随机,稍微转一下相机就闪。后来我给了玻璃罩一个单独的“深度写入 Pass”,让它在透明队列里先占住深度缓冲,再渲染内部物品和另一侧的玻璃层,效果立刻稳了。这类做法在粒子、水、UI 特效里都适用,核心思想是:能写深度的先写深度,避免多个半透明物体交叉混合。
5. 渲染路径选型和管线选型:Forward 还是 Deferred,URP 还是 HDRP
5.1 前向、延迟和 Forward+ 的区别
渲染路径决定了光照计算发生在哪里、发生在几次 draw call 里。前向渲染先画物体再逐灯计算,物体数量乘以灯光数量的代价很高;延迟渲染先把场景的材质属性写进 G-Buffer,再统一做光照计算,适合大量实时灯光的场景,但 G-Buffer 对显存带宽的压力大,在移动端尤其敏感。
Forward+ 则是把屏幕分块,对每块做灯光剔除,然后把有效的灯光列表传给 shader,既保留前向的透明处理能力,又能扛住较多灯光。对做 URP 移动端项目的朋友来说,如果灯光特别多,可以考虑开 Forward+,但多数中小体量玩法用普通 Forward 就足够。看到很多人一上来就选 Deferred,结果花在调 G-Buffer 带宽、做移动端兼容上的时间远超省下来的那点 draw call,不划算。
5.2 内置管线、URP、HDRP 到底怎么选
“渲染管线”这个词在不同场景下有不同含义:它既可以指前文说的通用渲染阶段,也可以指 Unity 里具体的 Scriptable Render Pipeline。这里必须把选型问题单独拎出来说。
| 管线 | 定位 | 适合平台 | 性价比 |
|---|---|---|---|
| Built-in | 传统管线,资料多,支持停更 | 老项目、重度自研 shader 项目 | 迁移成本高,但稳定 |
| URP | 移动优先,SRP 封装,支持前后向 | 手机、Pico4、微信小游戏、PC 轻量项目 | 新项目首选 |
| HDRP | 高画质物理渲染,光追、体积雾 | PC、主机、高端项目 | 对硬件要求高 |
我见过不少团队在立项时纠结,我的建议很直接:面向手机和一体机的项目,无脑 URP;面向 PC 且画质是核心卖点的项目,可以考虑 HDRP,但一定要算清硬件下限;老项目里已经写了大量 Built-in shader 的,别为了“新技术”强行迁移,那是一个无底洞。URP 本身也是可编程渲染管线,大多数效果都能通过 Renderer Feature 实现,没必要一上来就上最高端配置。
5.3 Renderer Feature 和 CommandBuffer:可编程渲染的精髓
URP/HDRP 最方便的地方,是允许你在渲染流程中插入自定义 Pass。比如描边、扫描线、屏幕空间贴花、边缘光,都可以用 Renderer Feature + ScriptableRenderPass 写进去。内置管线里想干类似的事,通常用 CommandBuffer 往相机渲染命令里塞东西,但顺序控制不如 SRP 直观。
想玩转这套,先搞懂 Renderer 的 Render 方法里各阶段的顺序,再用 Frame Debugger 确认自己的 pass 被插到了第几位。很多人踩坑是因为只写了 pass 却忘了给 Renderer Feature 设置合适的 Event 位置,结果自定义效果要么被后处理盖掉,要么在透明物体之前被 opaque 遮挡。先把插入时机搞明白,比自己闷头调 shader 参数重要得多。
6. 高频问题排查与避坑记录
6.1 阴影闪动和边缘漏光的常见原因
阴影闪动最常见的原因是 shadow bias 不够。当自阴影发生 self-shadow 时,深度差值过小,采样结果抖动,看起来就是像素在闪。通常的做法是适当调大 Shadow Depth Bias 和 Normal Bias,但不能调过头,否则会出现假漏光。
还有一种情况是平行光开级联阴影后,级联衔接处出现阴影跳动。这通常是级联数、分割比例和 shadow distance 配合不好。我的建议是先用 Frame Debugger 看阴影贴图的四个级联边界,把分割比例调到主要游戏区域覆盖最密的位置,再配合小范围阴影距离,比盲目把 bias 调大更治本。
6.2 半透明排序错乱怎么修
半透明物体排序不是单靠一个值就能解决的。常见修法优先级如下:
- 先确认物体的 RenderQueue 是否都落在正确队列,有没有被 UI 或粒子特效的队列干扰。
- 再看是否能拆开几何穿插,从物理层面消除“互相穿过”的结构。
- 对于难以拆分的物体,尝试加深度写入 Pass,把复杂混合变成“不透明占位 + 透明叠加”。
- 最后才考虑调脚本里的排序偏移值,那是顺应项目情况的兜底方案。
这套流程我在粒子特效和玻璃材质上反复用过,基本能覆盖 90% 的排序闪烁问题。
6.3 Z-fighting 导致的模型闪烁
两个面靠得太近、深度缓冲精度又不够时,画面会出现交叉闪烁,这个叫 Z-fighting。常见于大面积贴片叠加、路面贴花、墙面装饰物做双重建模的场景。处理手段有几种:把两个面拉开足够距离、给材质加 Polygon Offset、调整相机远近裁剪面的精度比。特别注意,移动端深度缓冲精度通常更低,所以这种问题在手机上一旦出现会更明显。
6.4 Profiler 数据最容易带偏的几个地方
有次做 URP 优化,Profiler 里 “Rendering > Render Opaques” 的 GPU 时间特别长,我以为是对象 shader 太贵,花了半天调采样。后来发现罪魁祸首是全局 Bloom 后处理,在屏幕分辨率极高的情况下把填充率吃满了。从那以后我习惯同时打开 Frame Debugger 和 Profiler 对照:先确认是哪个阶段耗时,再决定是调 shader 还是关后处理。
常见误区还有把“draw call 数量”当唯一指标。真正影响帧率的往往是 SetPass Call、顶点数、填充率和纹理带宽。解决 draw call 是用合批;解决填充率是降分辨率和 overdraw;解决带宽是压缩纹理和减少 RT 采样。别拿一套方案去解所有问题。
6.5 Pico4 这类一体机项目的管线注意事项
一体机和手机类似,GPU 能力有限,选 URP 是基本盘。需要注意的点包括:开启 MSAA 会显著增加带宽压力,别又把平台优化和抗锯齿同时拉满;投影、多光源、软粒子这类特性要按需开,不能只为追求画质把 URP 的默认特性全打开。做“数字孪生”和“假室内”类项目时,Shader Graph 虽然方便,但每个节点都是运行时开销,节点少一个是一个。
做优化这么久,我最深的感触是:渲染管线不是需要背下来的名词,而是一张定位“堵点”的地图。只要你知道这一帧的 CPU 在提交哪个阶段、GPU 在处理哪个阶段,大部分性能异常都能在半小时内找到方向。最后分享一个我自己一直在用的检查习惯:遇到渲染异常,先开一次 Frame Debugger,看渲染顺序或者某个 Pass 是不是被重复执行;顺序都不对的话,调参数就没什么意义。这个习惯帮我躲过不少坑,也希望它能帮你看清 Unity 渲染过程中那些原先觉得神秘的阶段。