Deep Read

性能优化大纲

diagram (1).png

前提:先测量,再优化

没有数据支撑的优化都是猜。动手前先用 Profiler 定位瓶颈,CPU/GPU Profiler、Memory Profiler、Frame Debugger 分别覆盖不同维度。

下面列出的所有手段,都是针对已定位的具体问题的备选方案,而不是一份要逐条执行的清单。

判定性能瓶颈

一帧之内发生了什么

一帧由 CPU 和 GPU 分工完成。要判断瓶颈在哪一端,得先知道这一帧里两边各自在做什么、按什么顺序做。 image.png

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 真正在干活的时间,包含所有脚本(UpdateFixedUpdate)、物理、动画、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:

  1. 切到 GPU Usage 模块(没有就手动添加)。
  2. 查看 GPU 耗时列表,定位最高项:是 Shadows.Draw、某个 Post-Process,还是 Render.OpaqueGeometry
  3. 针对性处理:阴影耗时高就降分辨率/距离;后处理耗时高就考虑禁用或换更轻的版本;不透明物体耗时高,多半是 Shader 太复杂或 Overdraw 严重,配合 Frame Debugger 逐个 Draw Call 排查。

示例二:CPU 瓶颈。

Gfx.WaitForPresent 几乎为零(< 1ms),CPU 总耗时却达 30ms:

  1. 停在 CPU Usage 模块的 Hierarchy 或 Timeline 视图。
  2. 展开 PlayerLoop,找耗时最高的子项。
  3. 针对性处理:
    • 某个脚本 Update 耗时高 → 优化算法,避免在 Update 里做重活或产生 GC。
    • Physics.FixedUpdate 耗时高 → 检查碰撞体设置和层级碰撞矩阵。
    • Camera.Render 耗时高 → 通常是 Draw Call 瓶颈,检查合批情况,确认 SRP Batcher 生效,或对重复物体用 GPU Instancing。
    • 频繁出现红色 GC.Collect → 垃圾回收导致卡顿,排查频繁产生堆内存的地方(new、字符串拼接等),用对象池等手段减少分配。

CPU 性能优化

image.png

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 BatcherSetPass 调用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 复杂度(单次开销);特效和阴影则是两者的叠加场景。

image.png

带宽(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,移动端提升明显;避免分支和循环(iffor),它们会让 Warp/Wavefront 内线程执行路径不一致,降低并行效率。
  • Shader 变体(Variants)
    • shader_feature vs multi_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 接收静态物体的阴影,并向静态物体投射实时阴影。