Unity XR性能优化核心知识完全指南
前言
XR(VR/AR/MR)应用对性能要求极为严苛——需要同时保证高帧率渲染、物理计算、交互系统稳定运行。稍有卡顿就会严重影响用户体验。
渲染批次基础
Draw Call 与 SetPass
理解渲染批次,先要搞清楚 Draw Call 和 SetPass 的区别。
| 概念 | 说明 |
|---|---|
| Draw Call | CPU 向 GPU 发送的一条命令,告诉 GPU “画这个网格(Mesh)“ |
| SetPass Call | 统计 GPU 准备渲染状态(切换 Shader、贴图、Blend 模式等)的次数 |
简单比喻:
- SetPass = 工厂里工人为下一个生产任务更换工具、调整机器设置、准备好原材料
- Draw Call = 工人开始用这套设置实际组装产品
一个 SetPass Call 可以为一次或多次 Draw Call 服务,只要这些 Draw Call 使用完全相同的渲染状态。这就是合批(Batching)的基础。
四大批次技术
动态批次(Dynamic Batching)
Unity 在运行时由 CPU 自动合并满足限制条件的多个动态物体网格,合成一个大网格一次性提交给 GPU。
// 动态批次自动生效,无需手动开启// 适用场景:大量小型相同材质物体(如场景草地、碎石)重要:动态批次是 内置渲染管线(Build-in) 的重要优化手段。在 SRP(URP/HDRP)管线中主要采用 SRP Batcher,URP 渲染管线可以使用动态批次。
静态批次(Static Batching)
对不移动的物体标记 Batching Static,Unity 在构建时合并相同材质的物体。
// 在 Inspector 中勾选 Batching Static// 或使用 API 强制合并StaticBatchingUtility.Combine(gameObject);静态批次规则:
- 必须勾选 Batching Static
- 必须相同材质、相同渲染状态(Blend/ZTest/Pass)
- 每批次最多 64,000 个顶点
- 运行时不能改变 Transform(位置、旋转、缩放)
- 物体不能改变网格数据
内存警告:静态批次会占用额外内存,因为合并后的网格是唯一存在的。合并物体越多,顶点越多,内存越大。
释放静态批次内存:
// 对象不再使用时,手动释放Resources.UnloadAsset(meshData);AssetBundle.Unload(true);GPU Instancing
GPU Instancing 允许 CPU 一次 Draw Call 通知 GPU 渲染 N 个相同网格但不同变换/颜色的物体。
原理对比:
| 技术 | CPU 端 | GPU 端 |
|---|---|---|
| 动态/静态批次 | 合并网格,有计算和内存开销 | 收到一个大网格 |
| GPU Instancing | 只传递数据,开销小 | 承担更多矩阵运算 |
// Shader 中添加支持#pragma multi_compile_instancing
// 材质球上勾选 Enable GPU Instancing适用场景:
- 大量相同物体(树木、森林、岩石、子弹等)
- 实例之间只有 Transform、颜色、UV 偏移等微小变化
- 动态物体,但网格和材质固定,性能瓶颈在 CPU 时
不适用场景:
- 每个物体材质/贴图不同
- 实例数量很少(几十个以内,开销可能反而更大)
- 大量独立网格和材质
- SkinnedMeshRenderer(不支持)
SRP Batcher
SRP Batcher 是 SRP(URP/HDRP) 下的 CPU 优化技术,通过将物体和材质数据缓存到 GPU,减少计算和上传开销。
核心特点:
- 不直接减少 Draw Call 数量,但减少 CPU 到 GPU 的数据传输
- 让同一 Shader 的不同材质对象共用已缓存的 GPU 常量缓冲
- Draw Call 数量不变,但 CPU 渲染线程时间显著下降
注意:Frame Debugger 中显示的是 SRP Batch,而不是每个物体的单独 Draw Call。实际上 GPU 仍在分别绘制,CPU 端提交被优化了。
SRP Batcher 使用要求:
- Shader 必须声明 UnityPerDraw 和 UnityPerMaterial 的 Constant Buffer
- 对象不能使用 MaterialPropertyBlocks
- 对象需要是 MeshRenderer 或 SkinnedMeshRenderer
四种技术优先级
| 渲染管线 | 优先级顺序 |
|---|---|
| 内置渲染管线 | 静态批次 > GPU Instancing > 动态批次 > 单独绘制 |
| SRP 管线 | 静态批次 > SRP Batcher > GPU Instancing > 单独绘制 |
注意:静态批次执行后就不会再执行 GPU Instancing,但会执行 SRP Batcher。
GPU 性能优化
Overdraw(过度绘制)
Overdraw 指同一像素在一帧中被重复绘制多次,上一次计算结果会被覆盖或叠加。
优化思路一:针对不透明物体
1. 从前向后渲染
- Unity 内部处理不透明物体已采用这种方式
- 使用 Early-Z(提前深度测试)在片元着色器运行之前丢弃被遮挡的片元
2. 遮挡剔除(Occlusion Culling)
// Window > Rendering > Occlusion Culling// 避免完全不可见的物体进入渲染优化思路二:针对半透明物体
1. 减少透明层数
- UI 背景、HUD 尽量合并图层,少用大面积透明混合
2. Alpha Test 替代 Alpha Blend
- 树叶、栅栏等使用 Alpha Test(Clip)替代 Alpha Blend,让深度测试能提前丢弃片元
3. 排序优化
- 透明物体按从远到近绘制,减少不必要的重复混合
4. 特殊替代方案
- 粒子系统远处改用广告牌效果,降低粒子数量和透明叠加
优化思路三:针对 UI 系统
1. 图集 + 批次 — 减少 Draw Call,但避免大图集带来过多 Overdraw
2. 减少叠层 — UI 设计时避免多层全屏半透明面板
3. RectMask2D 替代 Mask
- 普通 Mask 相当于多了一层全屏绘制,浪费填充率
- RectMask2D 是更轻量级的矩形裁剪,不会产生额外的全屏 OverDraw
4. Stencil 遮罩
- 复杂形状裁剪使用 Stencil(模板缓冲),需要在自定义 Shader 中实现
5. UI Renderer 独立渲染
- 避免与 3D 场景互相叠加 OverDraw
内存带宽优化
内存带宽 = 单位时间内,显存(VRAM)和 GPU 内部之间能传输的数据量。
GPU 架构比喻:
- ALU = 只做加减乘除、逻辑运算的工人
- Shader Core = 负责跑一条着色器指令的一组工人
- SM(流式多处理器)= 一个小工厂,内有成百上千个工人
- 显存 = 存储原料的大仓库
- 内存带宽 = 从大仓库运原料到小工厂的输送带
优化思路:减少显存读写量,让 GPU 少搬运,多干活
1. 减少分辨率和像素相关开销
- 降低渲染分辨率(动态分辨率、注视点渲染)
- 降低抗锯齿采样
- 减少 OverDraw
2. 优化纹理相关开销
- 使用 Mipmap:远处物体采样低分辨率贴图,减少带宽浪费
- 压缩纹理格式:ASTC/ETC2 等压缩格式显著减少带宽占用
- 贴图合并:把粗糙度、金属度纹理合并,减少采样次数
- 合理分辨率:角色贴图、UI 纹理尽量使用满足需求的最小分辨率
3. 提高缓存利用率
- 避免 Shader 中随机纹理采样,尽量连续访问
- 减少冗余读取,能一次采样合并的就合并
- 前向渲染排序让 Early-Z 提前丢掉被挡片元
4. 减少不必要的数据存储
- 避免冗余拷贝(同一贴图拷贝到多个 RT)
- 利用 GPU 共享内存缓存重复计算的数据
- 压缩 G-Buffer(延迟渲染阶段存储几何信息的缓冲区)
5. 优化 RenderTarget
- 使用低精度格式
- 屏幕后处理效果用 1/2 或 1/4 分辨率处理后放大
- 多个后处理效果合并到一个 Pass,避免重复拷贝
网格优化
顶点与片元基础
- 顶点 = 3D 模型的”钢筋骨架”,决定模型形状
- 片元 = 光栅化阶段产生的候选像素点,决定屏幕上每个像素的最终颜色
GPU 渲染流程:先有骨架(顶点),再填满砖块(片元),最后变成完整画面。
顶点优化思路
从本质上减少顶点以及顶点相关的计算
-
降低顶点数量
- LOD(Level of Detail)
- 网格简化
- 使用 Impostor(公告牌替代品)优化远距离物体
-
Billboard(广告牌)
- 多角度烘焙到球面或半球面纹理图集
- Octahedral Impostor 优化
-
蒙皮优化
- 减少骨骼数量、减少顶点权重、合并骨骼等
-
顶点动画优化
- 把复杂的程序形变改为 VAT(顶点动画纹理)
-
顶点着色器优化
- 减少 v2f 里插值数量(TEXCOORD 通道)
- 移动端着色器中数值类型尽量用 half
- 避免大量 sin/cos/pow,用 x*x 替代 pow(x,2.0)
- 预计算复杂函数结果到纹理
-
合批处理
-
剔除先于变化 — 使用遮挡剔除、视锥剔除
片元优化思路
从本质上减少片元以及片元相关的计算
- 降低分辨率(动态分辨率)
- 减少全屏 Pass
- 避免 Early-Z 失效(透明物体会导致失效)
- Shader 中避免 if/else 分支,用 step/lerp 替代
内存堆栈优化
常见内存分配问题与解决方案
// ❌ 错误:每帧分配字符串void Update() { string name = gameObject.name; // 每次都分配新字符串}
// ✅ 正确:使用 String.Empty 或缓存private string tempName = String.Empty;优化建议清单:
| 序号 | 优化项 | 说明 |
|---|---|---|
| 1 | 定期清理资源 | Resources.UnloadUnusedAssets() |
| 2 | 字符串拼接 | 使用 StringBuilder |
| 3 | 配置数据 | 使用 Scriptable Object |
| 4 | 标签比较 | 使用 Gameobject.CompareTag |
| 5 | 协程缓存 | 避免 new WaitForSeconds |
| 6 | LINQ | 避免使用 LINQ 和正则表达式 |
| 7 | 分帧执行 | Update 逻辑改为每 N 帧执行一次 |
| 8 | 禁用调试日志 | 正式发布时关闭 Debug.Log |
| 9 | 删除空函数 | 删除空的 Update/Start 等 |
| 10 | 缓存组件 | 不要在 Update 里 GetComponent |
| 11 | 字符串 | 使用 String.Empty 替代 "" |
| 12 | 数据结构 | 单纯传递数据用 struct 而非 class |
| 13 | 对象池 | 频繁创建对象时用缓存池 |
分帧执行示例
private int interval = 3;
void Update(){ if (Time.frameCount % interval == 0) { ExampleExpensiveFunction(); }}禁用调试日志
public class GameLog : MonoBehaviour{ [SerializeField] private bool logEnable = true;
private void Awake() { Debug.unityLogger.logEnabled = logEnable; }}Shader 参数优化
// ❌ 一般做法material.SetFloat("_DissolveAmount", 1);
// ✅ 优化做法:使用 PropertyToIDprivate int dissolveAmountProperty = Shader.PropertyToID("_DissolveAmount");private MaterialPropertyBlock propertyBlock;
void Update(){ targetRenderer.GetPropertyBlock(propertyBlock); propertyBlock.SetFloat(dissolveAmountProperty, 1); targetRenderer.SetPropertyBlock(propertyBlock);}CPU-Slicing
将一些脚本的 Update 逻辑放在一个统一的 Update 管理器,分帧执行:
// 第一帧执行 A 脚本 Update// 第二帧执行 B 脚本 Update// ...贴图与物理优化
贴图优化
XR 一体机建议贴图 Max Size 设为 1024
Mipmap 设置
| 参数 | 推荐值 | 说明 |
|---|---|---|
| MipMap | ON | 远处使用低分辨率 |
| Filter Mode | Trilinear | 在 mip 级别间平滑过渡 |
| MipMap Filter | Kaiser | 过滤更清晰 |
| Aniso Level | 4~8 | 各项异性,角度越大越清晰 |
| Wrap Mode | Repeat | 重复模式 |
VR 头显推荐配置:
- MipMap: ON
- Filter Mode: Trilinear
- MipMap Filter: Kaiser
- Aniso Level: 4~8
- Wrap Mode: Repeat
UI 贴图设置:Clamp + 不需要 MipMap
注意:开启 Mipmap 的贴图会多占用 33% 显存。
物理优化
| 优化项 | 说明 |
|---|---|
| 减少 MeshCollider | 少用复杂网格碰撞体 |
| 删除无用碰撞体 | 删除没用的碰撞器和刚体 |
| Prebake Collision Meshes | Player > Optimation 勾选 |
| Reuse Collision Callbacks | Project Settings > Physics 勾选 |
| 关闭可读写 | 模型导入设置中关闭 Read/Write |
UI 优化深度解析
Canvas 重建机制
Canvas 的本质作用:
- 收集子节点的 UI 元素(Graphic)信息
- 处理渲染顺序(Sorting、Camera、RenderMode)
- 管理重建,监听子物体的变动
- 批量生成 UI 顶点网格、更新材质信息
- 最终在渲染阶段合批并提交给 GPU
核心问题:一个 Canvas 统一管理它下面所有子 UI 元素,若更新其中一个子元素会让整个 Canvas 重建。
导致 Canvas 重建的操作:
- 修改 RectTransform(位置、尺寸、角度)
- 动态布局变化(LayoutGroup、ContentSizeFitter)
- 频繁改变父子关系
- 透明度变化
- 修改材质
- 脚本修改 Shader
动静分离策略
| 方案 | 效果 |
|---|---|
| 大 Canvas | 500 个 UI + 血条 + 按钮… → 血条变化 → 重建 500 个 UI |
| 分离 Canvas | 450 个静态 UI + 小 Canvas(50 个动态血条) → 血条变化只重建 50 个 |
注意:Canvas 不是越大越好,合并后可能产生更多 DrawCall。
UI 优化清单
- 不需要交互的取消勾选 Raycast target
- 移除默认的 Graphic caster
- 手动指定 Canvas 的 event camera
- 少用自适应布局组件
- 使用 SpriteAtlas 图集
- 用
canvas.enabled = false替代SetActive(避免 UI 重建) - 避免用 Animator 控制 UI,优先使用:
- DoTween 插件做缓动效果
- 协程或 Lerp 实现插值动画
- Shader 实现动态效果(进度条流光、动态血条等)
音频优化
音频加载类型选择
| Load Type | 说明 | 适用场景 |
|---|---|---|
| Decompress On Load | 加载即解压,占用内存多 | 短音效 |
| Compressed In Memory | 内存中压缩,占 CPU | 中等长度音效 |
| Streaming | 边读边播,占用内存最少 | BGM 等长音频 |
音频优化建议
- Preload Audio Data:短音效勾选,长音效关闭
- 使用 WAV 格式(未压缩,解码快)
- 推荐压缩格式:OGG/MP3
- 音频压缩可节省存储空间
总结:性能优化检查清单
| 优化领域 | 核心要点 |
|---|---|
| 渲染批次 | 减少 Draw Call,根据管线选择合适的批次技术 |
| GPU 优化 | 减少 Overdraw,使用遮挡剔除,优化 Shader |
| 内存带宽 | 使用 Mipmap,压缩纹理格式,减少不必要的数据传输 |
| 网格优化 | LOD,减少三角面,使用合适的公告牌技术 |
| 内存堆栈 | 减少堆分配,缓存组件,使用对象池,分帧执行 |
| UI 优化 | 动静分离,避免 Canvas 重建,使用图集 |
| 音频优化 | 选择合适的 Load Type,使用压缩格式 |
| 贴图优化 | 合理分辨率,启用 Mipmap,选择压缩格式 |
| 物理优化 | 简化碰撞体,启用回调复用 |
工具建议:使用 Unity Profiler 和 Frame Debugger 定期检查性能瓶颈,针对性优化。
参考资源
本文整合自 Unity XR 训练营学习资料,结合 PDF 与 Word 两份文档编写。如有疑问欢迎交流!