性能优化大纲
目录+

前提:先测量,再优化
没有数据支撑的优化都是猜。动手前先用 Profiler 定位瓶颈,CPU/GPU Profiler、Memory Profiler、Frame Debugger 分别覆盖不同维度。
下面列出的所有手段,都是针对已定位的具体问题的备选方案,而不是一份要逐条执行的清单。
判定性能瓶颈
一帧之内发生了什么
一帧由 CPU 和 GPU 分工完成。要判断瓶颈在哪一端,得先知道这一帧里两边各自在做什么、按什么顺序做。

CPU 侧
CPU 侧在主线程上按固定顺序推进:
- 物理 >> 脚本逻辑 >> 动画 >> UI 重建 >> 渲染数据准备
渲染准备就是 Profiler 里的 Camera.Render,它做三件事:
- 剔除(视锥剔除、遮挡剔除,筛掉看不见的物体);
- 排序(不透明物体从前往后、半透明从后往前);
- 合批
最后为每个要画的物体生成渲染命令。
Unity 默认开启多线程渲染,主线程生成渲染指令,渲染线程逐条取出指令,调用对应的图形 API(DX/GL),由驱动将命令写入 Command Buffer。——这是 CPU 和 GPU 之间的命令队列,剩下的由 GPU 异步消费。
GPU 侧
GPU 侧从 Command Buffer 取出命令执行,每条绘制命令走一遍渲染管线(完整拆解见 渲染流水线完全解析渲染流水线完全解析diagram.svg 完整的渲染流水线是一个高度并行、高度优化的过程。 这里按 数据流动 的顺序拆解:CPU 应用准备 → 驱动与命令提交 → GPU 前端几何处理 → GPU 后端像素处理 → 显示。 应用准备(CPU 端) 这一阶段完全在 CPU 上运行,是渲染的"备菜"阶段:把网格、纹理从慢速硬盘读进内存(RAM),解码成 GPU 能理解的原始数据结构(顶点数组、像素位图)。整个过程是串行的,受限于 I/O 和 CPU 解码速度。 数据从磁盘到 RAM 要走一条完整链路:应用程序(Unity/UE)向操作系统发出文件读取请求(如 ReadFile API),内核的文件系统驱动定位硬盘上的文件(如 Tank.fbx、Tank_Diffuse.png),通过磁盘控制器读取;数据先被读入系统文件缓存(RAM),再复制到应用进程的内存空间。 进入内存后还要解析(Parsing)成引擎可用的结构: 网格**:CPU 上的解析库(如 Assimp 或引擎自定义代码)读取 .fbx 的二进制内容,解码成顶点数组(float3 位置):从显存读取顶点数据 → Vertex Shader 做坐标变换 → 光栅化成片元 → Early-Z 深度剔除 → Pixel Shader 计算颜色 → 混合并写入 Back Buffer。GPU 不会直接画在正在显示的图像上,否则会看到"画了一半"的撕裂,所以它在后台缓冲区(Back Buffer)里画完整帧,再通过交换(Swap)变成前台缓冲区(Front Buffer)显示,即双缓冲或三缓冲。开启垂直同步(V-Sync)后,交换只能在显示器完成刷新时进行,可彻底消除撕裂,代价是帧率被限制为刷新率的整数分之一(60Hz 下为 60、30、20 FPS)。
关键在于两者是流水线并行的。 Command Buffer 把 CPU 和 GPU 解耦:CPU 提交完第 N 帧的命令后不等 GPU 画完,立刻开始准备第 N+1 帧;此刻 GPU 可能还在画第 N 帧甚至更早的帧。这个结构决定了瓶颈的表现形式是"谁在等谁":
- GPU 瓶颈:CPU 提交命令很快,但 GPU 执行太慢(复杂 Shader、阴影、Overdraw),命令在队列里越堆越多。队列写满后 CPU 想提交也提交不进去,只能停下来等——CPU 空转等 GPU。
- CPU 瓶颈:CPU 自己忙不过来——逻辑脚本太重,或 Draw Call 太多迟迟提交不完,队列被 GPU 消费到空——GPU 空转等 CPU。
Unity Profiler 的作用就是标出"到底是谁在等谁"。
解读三个关键标记
Gfx.WaitForPresent:出现在主线程上,表现为帧末尾一条长长的黄色标记。CPU 已经完成当前帧的全部计算和命令提交,准备开始下一帧,但 GPU 可能还在渲染更早的帧,显示器也还没完成 Present,操作系统会阻塞 CPU,直到显示器准备好接收新的一帧。这是最典型的 GPU 瓶颈信号,标记越长说明 GPU 越跟不上。
Gfx.WaitForCommands:出现在渲染线程(Render Thread)上。GPU 消耗指令太慢导致 Command Buffer 写满时,Render Thread 就得停下等待,这段等待就是 Gfx.WaitForCommands。同样是 GPU 瓶颈信号——CPU 生成指令的速度超过了 GPU 的消耗速度,常与 Gfx.WaitForPresent 同时出现。
PlayerLoop 及其子项:CPU 真正在干活的时间,包含所有脚本(Update、FixedUpdate)、物理、动画、UI 重建,以及为渲染做的准备(Camera.Render),表现为填满等待标记之外所有时间的各色工作块。如果一帧总耗时很长,却几乎看不到上面两个等待标记,说明时间都花在 PlayerLoop 上——这就是 CPU 瓶颈,CPU 忙到没空等 GPU。
更多容易混淆的等待类标记(WaitForTargetFPS、WaitForLastPresent 等)的成因,见 Unity ProfilerUnity ProfilerUnity 性能优化相关。 - 官方文档 - Common Profiler markers 下面梳理几个容易混淆的等待类 Profiler 标记,它们大多出现在帧的末端或渲染同步处,但成因不同——弄清成因才能判断瓶颈到底在 CPU、GPU 还是被 VSync 限制。瓶颈判定的整体思路和对应的优化手段见 [[性能优化大纲]]。 WaitForTargetFPS WaitForTargetFPS 是主线程在当前帧的所有逻辑处理(Update、Physics、Animation)和渲染提交完成后,为对齐设定的目标帧率(Application.targetFrameRate)而执行的一种主动节流(Throttling)/ 休眠。 Unity 每帧都会计算时间差: T_wait = T_target - T_actual - T_target:目标帧间隔(如 60 FPS ≈ 16.66 ms)。 - T_actual:当前帧实际消耗的 CPU 时间。 - 若 T_wait > 0,Unity 就执行 WaitForTargetFPS。 换句话说,它是"为了不跑太快而故意浪费的。
两个诊断示例
以 60FPS 为目标,每帧预算 16.67ms。
示例一:GPU 瓶颈。
Gfx.WaitForPresent 约 13ms,CPU 自身工作约 17ms。CPU 虽略微超标,但绝大部分时间都花在等 GPU 上,优化重心应放在 GPU:
- 切到 GPU Usage 模块(没有就手动添加)。
- 查看 GPU 耗时列表,定位最高项:是
Shadows.Draw、某个Post-Process,还是Render.OpaqueGeometry? - 针对性处理:阴影耗时高就降分辨率/距离;后处理耗时高就考虑禁用或换更轻的版本;不透明物体耗时高,多半是 Shader 太复杂或 Overdraw 严重,配合 Frame Debugger 逐个 Draw Call 排查。
示例二:CPU 瓶颈。
Gfx.WaitForPresent 几乎为零(< 1ms),CPU 总耗时却达 30ms:
- 停在 CPU Usage 模块的 Hierarchy 或 Timeline 视图。
- 展开
PlayerLoop,找耗时最高的子项。 - 针对性处理:
- 某个脚本
Update耗时高 → 优化算法,避免在 Update 里做重活或产生 GC。 Physics.FixedUpdate耗时高 → 检查碰撞体设置和层级碰撞矩阵。Camera.Render耗时高 → 通常是 Draw Call 瓶颈,检查合批情况,确认 SRP Batcher 生效,或对重复物体用 GPU Instancing。- 频繁出现红色
GC.Collect→ 垃圾回收导致卡顿,排查频繁产生堆内存的地方(new、字符串拼接等),用对象池等手段减少分配。
- 某个脚本
CPU 性能优化

CPU 的工作是准备数据和命令再提交给 GPU,瓶颈通常出在"准备"和"提交"太慢,让 GPU 空等。
剔除(Culling)
目标是减少提交给 GPU 的物体数量:屏幕上看不见的物体,CPU 不该处理,GPU 也不该渲染。
- 遮挡剔除:室内、多遮挡场景(迷宫、建筑内部)用
Window -> Rendering -> Occlusion Culling烘焙,合理设置 Occluder(遮挡物)与 Occludee(被遮挡物)。 - 细节剔除:用
Camera.layerCullDistances按层设置不同物体的最大可见距离,例如小石子、草只在近处显示。 - 自定义剔除:大量动态小物体(子弹、掉落物)可自己写简单剔除逻辑,按距离或可见性在
Update里禁用其MeshRenderer。
碰撞(Collision)
物理计算发生在 CPU 的物理循环(默认 FixedUpdate)。复杂碰撞体、碰撞体数量过多、不合理的物理设置都会大量消耗 CPU。
- 碰撞体选择:优先用基本碰撞体(Sphere、Capsule、Box),数学计算最简单。避免 Mesh Collider,尤其是非凸(non-convex)的,成本极高;必须用时尽量勾选 Convex。
- 层级碰撞矩阵:在
Project Settings -> Physics编辑 Layer Collision Matrix,取消不必要的层间碰撞检测,这是最有效的物理优化手段之一。 - 减少刚体:只有需要受力移动的物体才加
Rigidbody,静态场景物体不要挂。
合批(Batching)
提交一次 Draw Call 本身有开销(状态切换、数据准备)。合批就是把多个 Draw Call 并成一个,减少提交次数。
SRP Batcher 是 URP 下的核心手段。传统合批合并的是网格,SRP Batcher 合并的是 SetPass 调用:它把使用同一 Shader 变体的材质属性统一打包进一块大的 GPU 缓存,渲染时 CPU 只需指向缓存的某个偏移,无需频繁切换材质状态,大幅降低 Draw Call 的 CPU 开销。URP Asset 中默认开启,生效条件是物体使用兼容 SRP Batcher 的 Shader(URP 自带 Lit/Unlit 都兼容)且 Shader 变体相同;在 Profiler 和 Frame Debugger 里看到 SRP Batch 字样即生效。详细原理见 性能优化-SRP合批性能优化-SRP合批SetPass Call 为什么贵
CPU 提交一次绘制,真正贵的不是 Draw Call 本身,而是它前面的 SetPass Call——切换渲染状态的那一步。每次 SetPass,CPU 端要:
1. 选择要使用的 Shader 变体(Variant),绑定对应的管线状态;
2. 把这个材质的所有属性从 CPU 内存写入 GPU 的常量缓冲区(CBuffer)。
这一步之所以慢,是因为它走的是图形驱动的通用路径:驱动要校验状态合法性、重新组织资源绑定表,材质数据每次都要重新走一遍 CPU→GPU 的上传。场景里材质一多,即使共用同一个 Shader,传统管线也要为每个材质完整做一遍 SetPass。
SRP Batcher 要解决的就是这个:让使用相同 Shader 变体的多个材质共享一次 SetPass。注意它减少的是 SetPass Call,Draw Call 的次数并不变——只是每次 Draw 变得极其廉价。
核心思路:材质数据常驻显存
传统批处理(Static/Dynamic Batching,见 [[性能优化-静态合批]])的思路是合并网格,把多个物体拼成一。
GPU Instancing 针对大量完全相同的网格(草、树、子弹):CPU 一次性把各实例的位置、旋转、颜色等 Per-instance 属性数组发给 GPU,由 GPU 循环绘制,一个 Draw Call 完成。材质上勾选 Enable GPU Instancing,或用 Graphics.DrawMeshInstanced 等 API 绘制。详细原理见 GPU InstancingGPU InstancingGPU Instancing 做的事:一次 Draw Call + 一份 Mesh/Shader 状态,通过 InstanceID 在 GPU 端索引每个实例的差异数据(transform、color 等),复用顶点数据,省掉 CPU → GPU 的重复提交。
问题:CPU 循环发 Draw Call 的开销
传统流程每画一个物体,CPU 都要调用图形API 发 Draw Call ,给到图形驱动,由驱动翻译成 GPU 原生指令写进 Command Buffer,GPU 再异步取出执行(这条 CPU→驱动→GPU 的提交链路见 [[性能优化大纲]])。驱动每翻译一条 Draw Call 都有一笔固定的 CPU 耗时,场景里几千个物体,这笔翻译成本就付几千遍。
image.png
问题在于: 如果这几千个物体用的是同一份网格和同一个材质,CPU 其实在为同一件事反复发命令,每次唯一的差别只是变换矩阵。GPU Instancing 从这里切入:它不让每次提交更便宜(那是 [[性能优化-SRP合批|SRP Batcher]] 的活),而是直接把提交次数从 N 压到 1。
底。
Static Batching 把标记为 Static 的物体在构建或加载场景时按相同材质合并成大网格。合并后的大网格是原网格副本,会增加内存占用;在 SRP Batcher 高效运作的 URP 下优先级已降低。详细原理见 性能优化-静态合批性能优化-静态合批被标记为 Static、共享同一材质的物体,Unity 会在 Build 时把它们的网格预先合并成一套大的顶点/索引缓冲。运行时不再逐物体绑定缓冲、上传变换,而是一次绑定、连续绘制。 要理解它到底省在哪,得先看清 CPU 画一个物体要做几步。 画一个物体的三步 从图形 API(DX/GL)看,绘制一个物体分三步,开销依次递减: 设置渲染状态(SetPass)**:绑定 Shader、纹理、管线状态。走图形驱动的通用路径,要校验状态、重组资源绑定表,是三步里最贵的。 设置逐物体数据**:绑定这个物体的顶点/索引缓冲,把它的模型矩阵上传到 GPU 常量缓冲。 发起 Draw Call**:命令 GPU 按当前状态和缓冲画一次。 场景里成千上万个小物体,CPU 要为每个重复这套流程,提交本身就成了瓶颈。所有合批优化都是在削减这套重复流程里的某一步——分清砍的是哪一步,才知道每种合批解决的是什么问题。 静态合批砍掉的是第二步 前提要先说清:这批物体本来就共享同一材质。所以只要它们被连续绘制,渲染状态不变,SetPass 在合批前后都只有一次。静态合批省的不是 SetPass,而是。
Dynamic Batching 每帧动态合并符合条件的小网格,CPU 开销较高,对顶点属性限制严格。URP 下因 SRP Batcher 存在,通常禁用或效果不明显,不作为主要手段。
| 手段 | 合并的对象 | 适用场景 | 代价 |
|---|---|---|---|
| SRP Batcher | SetPass 调用 | URP 默认,同 Shader 变体即可 | 几乎无 |
| GPU Instancing | 同一网格的大量实例 | 草、树、子弹等重复物体 | 要求网格和材质相同 |
| Static Batching | 相同材质的静态网格 | 不动的场景物体 | 网格副本增加内存 |
| Dynamic Batching | 每帧动态合并的小网格 | URP 下基本不用 | CPU 开销高、限制严 |
GPU 性能优化
GPU 每帧做的所有工作,最终都能拆成两类动作——搬数据和算数据。哪一类先到极限,哪一类就是瓶颈:带宽(数据搬不过来)和计算(数据算不过来)。
先建立一个模型: GPU = 计算单元 + 显存,中间隔着一条"马路"
这个分类来自 GPU 的硬件结构:芯片内部是成千上万个 ALU 负责跑 Shader,而纹理、网格、帧缓冲都存在显存里,两者之间靠一条每秒传输字节数有硬上限的通道连接——这就是带宽。关键的物理事实是,算力的增长远快于搬运能力的增长:一次乘加运算的耗时和能耗,比从显存读一个字节小几个数量级。所以现代 GPU 天生"手快腿慢",ALU 经常算完了在等数据到货。
- 带宽的消耗:每个片元要采样纹理(一次双线性采样至少读 4 个纹素)、读顶点数据;写颜色进帧缓冲,开深度测试还要读写深度缓冲,开混合(Blend)必须先读回旧颜色再写新颜色——一读一写双倍流量。Overdraw 是放大器:同一像素被画 4 层半透明,上面的读写全部 ×4。移动端对此尤其敏感——手机带宽只有桌面显卡的几十分之一,且搬数据是耗电大户,带宽超标直接表现为发热降频。
- 计算的消耗:GPU 为每个顶点跑一遍 Vertex Shader、每个片元跑一遍 Pixel Shader,总计算量 ≈ 单次执行的指令数 × 执行次数。指令数由 Shader 复杂度决定(PBR 光照、pow/sin、分支);执行次数顶点侧看面数和蒙皮,片元侧看分辨率 × Overdraw——屏幕像素数就是 Pixel Shader 的"起跑次数"。
必须区分两者,因为药方互不相通:带宽瓶颈时去简化 Shader 数学没有效果——ALU 本来就闲着在等数据,该做的是压缩纹理、减少 RT 读写、砍半透明层数;计算瓶颈时压纹理同样没用,该简化 Shader、减面、上 LOD。快速判别法:只降低渲染分辨率测一下,帧率大幅改善说明瓶颈在片元侧(计算或帧缓冲带宽),几乎没变化则在顶点或 CPU;再单独调 Mipmap Bias(只影响带宽、不影响计算量)测试,就能把带宽和计算区分开。
下面各节就是沿这两条线展开:带宽对应纹理与顶点数据;计算对应 Overdraw(执行次数)与 Shader 复杂度(单次开销);特效和阴影则是两者的叠加场景。

带宽(Bandwidth)
纹理是带宽消耗大户,一张 2048×2048 未压缩 RGBA32 就占 16MB:
- 压缩:移动端首选 ASTC,PC/主机用 BC7。这类有损块压缩 GPU 硬件可直接处理——Texture Sampler 读一个像素时会把包含它的整个块(如 4×4)读入,在硬件里瞬间解压到 Cache,从 VRAM 到 Cache 的实际传输量只有未压缩纹理的 1/4 到 1/8。
- Mipmap:没有 Mipmap 时,用高分辨率纹理渲染远处只占几个像素的物体,Sampler 要在多个纹素间采样混合,加载多条 Cache Line,造成缓存颠簸(Cache Thrashing)并在远处产生闪烁噪点(Aliasing)。Mipmap 预生成一系列低分辨率版本,GPU 按距离自动选层采样,一次读取即可覆盖所需区域,缓存命中率和画质同时提升。
- Read/Write Enabled:开启后纹理在 CPU 内存和显存中各存一份,占用翻倍。仅在需要 CPU 脚本读写像素时才开,用完立即释放 CPU 侧内存;模型同理。
- 纹理图集(Atlas):把多张小图合成一张,主要为配合合批(减少 Draw Call),同时提高缓存命中率。
顶点数据(位置、法线、UV、切线等)同样要从显存读入 GPU,顶点和属性越多带宽消耗越大:
- 面数/顶点属性:导入设置里按需关闭用不到的属性——Shader 不用法线就关 Normals,没有法线贴图就关 Tangents。
- LOD Group:物体远离时切换低模,这是降低顶点处理和带宽消耗最有效的手段。
Overdraw 与像素填充率(Fillrate)
填充率是 GPU 每秒能渲染的像素数,是硬件的固定指标;Overdraw 指同一个像素在一帧内被绘制多次,最常见于半透明物体——5 层半透明窗口叠加,最底层像素就被画了 5 次。
现代 GPU 靠 Early-Z 硬件模块对抗 Overdraw:在片元光栅化之后、执行昂贵的 Pixel Shader 之前,先用片元深度与 Z-Buffer 比较,若被更近的物体遮挡就直接丢弃,省下 Pixel Shader。但半透明物体通常关闭深度写入(ZWrite Off)以保证正确混合,这会让 Early-Z 失效——它后面的物体无论是否被遮挡,片元都得先执行 Pixel Shader 再在混合阶段丢弃,产生大量浪费。所以优化思路分两条:让不透明物体用好 Early-Z,控制半透明本身。
用好 Early-Z:
- 渲染队列:URP 的 Opaque 队列默认从前往后排序,能最大化 Early-Z 效率。
- 深度预通道(Depth Priming):在
UniversalRendererData中开启,会加一个 DepthOnly Pass 用极简 Shader 先把不透明物体深度写入 Z-Buffer,随后的 Opaque Pass 就能近乎 100% 命中 Early-Z。复杂场景、复杂 Shader 下提升明显。
控制半透明:
- 减少面积:尽量缩小半透明特效的屏幕面积。
- Alpha Test(Cutout):用 Alpha Test(URP 里叫 Alpha Clipping)代替 Alpha Blend,它能利用 Early-Z,性能远好于混合,适合树叶、铁丝网等。
- UI:避免大量、大面积的半透明 UI 叠加。
- 简化 Shader:半透明物体的 Shader 尽量精简,减少纹理采样和复杂计算。
调试:Scene 窗口左上角渲染模式切到 Overdraw 视图,越亮(偏红/白)的区域 Overdraw 越严重,优先处理。
Shader 复杂度
Shader 的指令数、纹理采样次数、运算复杂度直接决定每个顶点/像素的处理耗时。Pixel Shader 的耗时主要来自两块:ALU 的算术指令(加减乘除、pow、sin)和 Texture Sampler 的访存。
- 光照模型:URP 的 Lit 是基于 PBR 的,计算重。不需要复杂物理光照就用 Simple Lit 或 Unlit。
- 采样:减少
tex2D采样次数,每次采样都是一次潜在的带宽消耗和延迟。 - 运算:少用 pow、sin、cos 等复杂数学函数;用
half代替float,移动端提升明显;避免分支和循环(if、for),它们会让 Warp/Wavefront 内线程执行路径不一致,降低并行效率。 - Shader 变体(Variants):
shader_featurevsmulti_compile:功能只在部分材质用到就用shader_feature(只编译材质实际用到的组合);multi_compile会把所有组合都编进包里。- Shader Stripping:在
Project Settings -> Graphics -> URP Global Settings配置,剥离项目用不到的变体(如确定不用 Light Cookies 就关掉),减小包体和内存。 ShaderVariantCollection:运行时通过代码动态开启的关键字,需把对应材质/Shader 加入ShaderVariantCollection并放进 Preloaded Shaders,否则打包时可能被剥离。
特效(VFX)
特效尤其是粒子,往往是带宽、Overdraw、Shader 复杂度三者叠加。
- 粒子数:严格控制同屏粒子数量和生命周期。
- 半透明:粒子多为半透明,按 Overdraw 一节优化,用 Alpha Clipping 会好很多。烟雾、火焰等尽量用更小、更稀疏的粒子模拟,别用一张又大又实的贴图。
- GPU Instancing:成千上万的相似粒子(火花等)用 VFX Graph 或
DrawMeshInstanced绘制,避免 CPU 瓶颈。
实时阴影(Real-time Shadows)
实时阴影很贵。它用 Shadow Mapping 实现(两趟绘制的完整流程见 ShadowMap阴影ShadowMap阴影阴影的原理 Shadow Map 是一种基于图像的技术,核心思想是用两趟绘制来模拟阴影: 1. Shadow Caster Pass(阴影投射通道):从光源视角绘制一遍场景,生成深度图。 2. Shadow Receiver Pass(阴影接收通道):从摄像机视角正常绘制,采样深度图判断阴影。 第一趟:Shadow Caster Pass——生成阴影图 这一阶段的目标是生成一张或多张深度图(Depth Map),即 Shadow Map。 视锥体确定。 引擎会为每个需要投射阴影的光源构建一个特殊的视锥体,不同光源类型用不同的投影: 方向光(Directional):用正交投影。视锥体是一个紧紧包住主摄像机视锥内所有潜在投影物体的包围盒;为处理动态物体和相机移动,这个包围盒会实时计算调整。大型场景通常用级联阴影贴图(Cascaded Shadow Maps, CSM)**——把相机视锥沿深度方向切成多个子视锥(Split),每个子视锥生成一张独立、分辨率更高的 Shadow Map,以解决单张贴图精度不足的问题。 聚光灯(Spot)**:用透视投影,视锥体就是灯光的锥形范围。):从每个光源视角把场景渲染一遍,只记录深度生成 Shadow Map;主摄像机渲染时,每个像素再采样 Shadow Map 判断是否处于阴影中。
- 减少投影光源:严格控制实时投影阴影的光源数,最好只有主方向光一个。
- 降低阴影质量:URP Asset 里降低 Shadow Resolution、减少 Cascade Count、缩短 Shadow Distance。
- 烘焙:静态物体优先用光照烘焙(Baked Lighting)生成静态阴影,运行时零开销。动态物体可用 Light Probes 接收静态物体的阴影,并向静态物体投射实时阴影。