Unity 游戏开发性能优化全景手册
覆盖:特效 / 模型 / UI / 编程与内存 / 渲染 / 包体 / 开发技巧
定位:面试速查 + 工程落地清单。每个结论都尽量给出原因 、做法 、验收指标 。
面试答题时优先说"我怎么定位、怎么量化、怎么验证",而不是背一堆零散技巧。
第 0 章 优化方法论:先测量,再优化
0.1 三条铁律
- 不要凭感觉优化。Editor 里的帧率不等于真机;瓶颈往往和你猜的不一样。
- 优化 = 找到瓶颈 + 消除瓶颈 + 验证收益。没有 Profiler 数据的优化都是自嗨。
- 优化有代价。合批吃内存、LOD 吃包体、对象池吃常驻内存、降分辨率吃画质。每次优化都要说清"拿什么换了什么"。
0.2 瓶颈的四象限定位
| 瓶颈类型 | 典型症状 | 验证手段 |
|---|---|---|
| CPU 逻辑 | PlayerLoop/脚本函数耗时长,帧时间高但 GPU 空闲 |
Profiler CPU 面板、Deep Profile 定位具体函数 |
| CPU 渲染提交 | Camera.Render 下 RenderLoop.Draw、BatchRenderer 耗时长;DrawCall 数高 |
Profiler Rendering 面板看 Batches / SetPass Calls |
| GPU 填充率 / Overdraw | 降分辨率后帧率明显回升;GPU ms 高 |
强行降 Render Scale 到 0.5 → 若帧率大涨,瓶颈在填充率 |
| GPU 顶点 / 带宽 | 降低模型面数后帧率回升;纹理大、未压缩 | Profiler 看 Tris/Verts、纹理内存 |
| GC 卡顿 | 每隔几秒一次尖刺(锯齿状帧时间) | Profiler CPU 面板的 GC Alloc 列、GC.Collect 标记 |
| 内存 | 闪退、OOM、被系统杀后台 | Memory Profiler、Profiler Memory 面板 |
| 加载 / IO | 切场景、首次播放特效卡顿 | Profiler 看 Loading、Shader.CreateGpuProgram、Texture.Awake |
一行判定法:把分辨率降到 50%,帧率暴涨 → GPU 填充率瓶颈;帧率几乎不变 → CPU 或顶点瓶颈。
0.3 移动端参考预算(中端机,60fps = 16.67ms)
| 指标 | 参考值 | 说明 |
|---|---|---|
| DrawCall(Batches) | 100 ~ 250 | 超过 300 要重点治理 |
| SetPass Calls | < 60 | 材质切换成本,比 DrawCall 更致命 |
| 三角面 / 帧 | 10 万 ~ 30 万 | 视机型浮动 |
| GC Alloc / 帧 | 0 B(理想),< 1KB(可接受) | 这是最容易量化也最容易拿分的指标 |
| 同屏 SkinnedMesh | 10 ~ 30 个 | SLG 大量士兵必须走 GPU Skinning / Instancing |
| 纹理内存 | < 150 ~ 250 MB | 视机型 |
| 特效同屏数量 | < 20 个系统(不含合批) | 配合上限控制 |
0.4 优化闭环
Measure(Profiler 取基线)
↓
Locate(定位到具体函数/资源/模块)
↓
Fix(改一个点,只改一个)
↓
Verify(同一场景、同一机型复测,对比前后数据)
↓
Guard(防回归:CI 自动化拍照 + 资源导入规范)
0.5 面试速答模板:方法论
"我优化的第一步永远是测量,不凭感觉。先连真机 Profiler 取基线,分四象限判断瓶颈在 CPU 逻辑、渲染提交、GPU 填充率还是 GC------我常用的快速判定是把 Render Scale 降到 50%,如果帧率暴涨说明瓶颈在填充率,如果几乎不变说明在 CPU 或顶点。然后针对单点改,改完复测对比数据。每帧 GC 分配我会盯到 0,因为这是最容易量化也最容易在低端机放大的指标。优化一定有代价,所以我会同时说明这次拿内存还是画质换了帧率。"
第一章 特效优化
1.1 特效的四个开销来源(按重要性排序)
- Overdraw(填充率) ------ 特效的头号杀手。半透明粒子层层叠加,一个像素被反复着色十几次,移动 GPU 直接跪。
- 粒子更新计算 ------ CPU 端逐粒子模拟(位置、速度、颜色、大小),粒子越多越贵。
- DrawCall 与材质切换 ------ 每个粒子系统至少 1 个 DrawCall,材质不同还会 SetPass。
- 内存与加载 ------ 贴图、Shader 变体、预制体常驻;首次播放还要编译 Shader、上传纹理 → 卡顿。
1.2 ParticleSystem 模块级优化
| 设置项 | 优化做法 | 原因 |
|---|---|---|
| Max Particles | 严格设上限,能小就小 | 直接决定 CPU 模拟量和顶点数 |
| Emission | 优先用 Bursts(一次性爆发),少用持续高 Rate over Time |
同样视觉冲击力下粒子总数更少 |
| Collision / Triggers | 能关就关 | 最贵的模块,逐粒子做物理查询 |
| Sub Emitters | 少用,层级不超过 1~2 层 | 会指数级增加粒子数 |
| Shape | 用简单形状(Cone/Box/Circle),避免复杂 Mesh | 发射计算成本 |
| Renderer Mode | 优先 Billboard;Mesh 模式成本高 |
Mesh 粒子 = 顶点 + 可能的阴影 |
| Cast/Receive Shadows | 关闭 | 阴影 = 再来一遍渲染 |
| Lights | 关闭(不用光照粒子) | 每粒子光照极贵 |
| Simulation Space | 非必要用 Local |
World 模式每帧要额外变换 |
| Play On Awake / Looping | 池化对象手动控制 | 便于对象池统一管理 |
| 未使用的模块 | 把 Size over Lifetime、Rotation by Speed 等不用的模块整个关掉 |
每个启用模块都会参与逐粒子计算 |
| Sorting Fudge / Order in Layer | 同材质同层级的粒子可合批 | 减少 DrawCall |
重点 :模块关掉比参数调小更有效。一个
Collision模块的开销可能顶得上几百个粒子。
1.3 粒子材质与贴图
- 贴图尺寸:粒子贴图 128×128 / 256×256 足够,绝不用 512+。
- 关闭 Mipmap 、关闭
Read/Write Enabled(否则内存 ×2)。 - 压缩格式:Android ASTC,iOS ASTC / PVRTC。
- 合并粒子图集:把常用粒子贴图打到一张 Atlas,多个特效共享一个材质 → 可合批。
- Shader :用 Mobile 系列(如
Mobile/Particles/Additive)或 URP 的Universal Render Pipeline/Particles/Unlit。- 不要给粒子用 PBR/Standard Shader(采样次数多、计算重)。
- Additive 材质关 ZWrite、关深度测试写入。
- 避免过度使用
Alpha Blend:能做成Additive的(火焰、光效)就别用混合,Additive 不需要排序。
1.4 Overdraw 治理(面试高频)
判断方法:
- Unity 场景视图 →
Shading Mode→Overdraw(越亮/越白说明叠加层数越多,理想是深蓝/黑)。 - 真机用 RenderDoc / Xcode GPU Frame Capture / Mali Graphics Debugger 看实际填充。
治理手段:
- 减少粒子体积:同样的覆盖面积,用更少、更大的粒子 vs 很多小粒子 ------ 后者 Overdraw 更分散但总面积更大,需要权衡;通常减小粒子尺寸收益明显。
- 减少层数:一个特效从"核心光 + 外圈光 + 火花 + 烟雾 + 拖尾"5 层砍到 3 层。
- 缩短生命周期:粒子存活越久,同时在屏的越多。
- 避免全屏特效叠加:全屏闪白、全屏泛光不要同屏多个。
- 画质分级(见下)。
1.5 特效画质分级(必须有)
csharp
// ScriptableObject 配置驱动(架构视角:美术/策划可配,不改代码)
[CreateAssetMenu(menuName = "Config/FXQualitySettings")]
public class FXQualitySettings : ScriptableObject
{
public float ParticleScale = 1.0f; // 粒子数量缩放
public int MaxSameScreenFX = 20; // 同屏特效上限
public bool EnableSecondaryFX = true; // 次级特效(击中火花、地面痕迹)
public bool EnablePostProcess = true; // 全屏后处理
}
| 档位 | 策略 |
|---|---|
| 高 | 全量粒子、次级特效开、后处理开 |
| 中 | 粒子数 ×0.6、次级特效只留主要的、后处理降级 |
| 低 | 粒子数 ×0.3、关次级特效、关后处理、全屏特效禁用 |
1.6 特效对象池 + 预加载(架构加分项)
问题 :运行时 Instantiate 粒子预制体 + 首次播放时 Shader 编译 / 纹理上传 → 明显掉帧尖刺。
方案:
csharp
public class FXPool : MonoBehaviour
{
[SerializeField] private FXDatabase _database; // SO: id -> prefab + poolSize
private readonly Dictionary<int, Queue<ParticleSystem>> _pools = new();
public ParticleSystem Play(int fxId, Vector3 pos, Quaternion rot)
{
if (!_pools.TryGetValue(fxId, out var q)) { q = new Queue<ParticleSystem>(); _pools[fxId] = q; }
ParticleSystem ps;
if (q.Count > 0) ps = q.Dequeue();
else ps = Instantiate(_database.Get(fxId).Prefab, transform);
var t = ps.transform;
t.SetPositionAndRotation(pos, rot);
ps.gameObject.SetActive(true);
ps.Play(true);
StartCoroutine(Recycle(fxId, ps)); // 或用时间片管理器,避免大量协程
return ps;
}
private IEnumerator Recycle(int fxId, ParticleSystem ps)
{
yield return new WaitForSeconds(ps.main.duration + ps.main.startLifetime.constantMax);
ps.Stop(true);
ps.gameObject.SetActive(false);
_pools[fxId].Enqueue(ps);
}
}
关键实践:
- 启动时预热:进游戏前对常用特效各实例化 1 次并播放到不可见处,触发 Shader 编译与纹理上传。
- ShaderVariantCollection.WarmUp():打包时收集变体,启动时一次性预热,避免运行时编译卡顿。
- 同屏数量上限:超过上限时按优先级丢弃(主角大招 > 小怪受击)。
- 距离剔除:远处/屏幕外的特效不播或降级。
- 回收用统一 Tick 管理,避免每个特效起一个协程(协程本身有分配)。
1.7 其他特效形态
| 类型 | 优化点 |
|---|---|
| TrailRenderer | 调大 Min Vertex Distance(减少顶点)、设 Time 不要过长、关阴影 |
| LineRenderer | 同理,顶点数控制 |
| 全屏后处理(Bloom / Radial Blur / 屏幕扭曲) | 移动端慎用;降分辨率执行 RT;缩短持续时间;多个合并成一个 Pass |
| URP Render Feature | 不用的一律关掉(每个 Feature = 一次全屏 Pass) |
| Shader 特效(溶解、流光、边缘光) | 优先用 Shader 做,比堆粒子省得多 |
| VFX Graph | 计算在 GPU,适合超大量粒子,但需 GPU 支持 Compute,移动端要谨慎评估 |
1.8 特效优化 Checklist
- 所有粒子系统关闭
Collision、Lights、Shadows - 关掉所有未使用的模块
- Max Particles 有明确上限并归档
- 粒子贴图 ≤ 256,关 Mipmap、关 Read/Write、ASTC 压缩
- 常用粒子贴图合并图集,共享材质
- 用 Mobile/Unlit 粒子 Shader,不用 Standard/PBR
- 特效走对象池 + 启动预热 + ShaderVariant WarmUp
- 有同屏数量上限 + 画质分级
- Overdraw 视图检查过(无大面积亮白区域)
- 全屏特效数量受控,不叠加
第二章 模型优化
2.1 面数预算与减面
| 对象 | 移动端参考面数(三角面) |
|---|---|
| 主角 | 3,000 ~ 8,000 |
| 精英怪 / NPC | 2,000 ~ 5,000 |
| 小怪(会成群) | 800 ~ 2,000 |
| 武器 / 道具 | 200 ~ 800 |
| 建筑(SLG) | 500 ~ 3,000 |
| 同屏总面数 | 10 万 ~ 30 万 |
减面手段:
- 删除看不见的面(模型内部、底面、被遮挡部分)。
- 用 Normal Map 表现细节,代替真实高模。
- 远景模型用低模 + Imposter / Billboard。
- 导出前做 Mesh 简化(Blender Decimate / Simplygon)。
2.2 LOD(Level of Detail)
- 用 LOD Group 组件,3~4 级(
LOD0高模 →LOD1中 →LOD2低 →Culled)。 - 切换阈值按屏幕占比设置(不是距离),通常 60% / 30% / 10%。
- LOD 生成工具:Unity 内置
LOD Generator、第三方(Mesh Baker、Simplygon)。 - 注意:LOD 会增加包体和内存,是"用空间换时间"。
- 大量远景重复物件:LOD 最后一级用 Billboard / Imposter(一张贴图 + 两个三角面)。
2.3 材质与合批(DrawCall 治理核心)
一个模型 = 一个材质是黄金准则。多材质 = 多 DrawCall(SkinnedMesh 尤其明显,每个材质一次蒙皮绘制)。
| 合批方式 | 适用场景 | 代价 |
|---|---|---|
| 静态合批 Static Batching | 静态场景(建筑、地形装饰) | 内存 + 包体增大(网格被合并复制一份) |
| 动态合批 Dynamic Batching | 大量小网格(< 300 顶点 / 900 顶点属性) | CPU 每帧变换顶点,有开销;限制多 |
| GPU Instancing | 大量相同网格 + 相同材质(草、石头、士兵) | 需要 Shader 支持 INSTANCING 宏 |
| SRP Batcher(URP/HDRP 默认开) | 通用,按 Shader 变体合批 | 要求 Shader 的 CBUFFER 结构一致 |
降级顺序建议:优先 SRP Batcher + GPU Instancing,其次静态合批,动态合批只在小物件上用。
其他:
- 合并 Mesh:静态装饰物在 DCC 或运行时用
Mesh.CombineMeshes合并成一个(配合图集)。 - 材质数量控制:一个场景材质种类越少越好。
2.4 骨骼与蒙皮(SLG / ARPG 重点)
SkinnedMeshRenderer 是 CPU + GPU 双重开销大户:
| 优化点 | 做法 |
|---|---|
| 骨骼数量 | 移动端控制在 30~60 根以内(人形通常 40~55) |
| Skin Weights | Quality Settings 里设 2 Bones(默认 4),顶点混合计算减半 |
| Update When Offscreen | 关闭(或手动扩大 Bounds 保证不被错误剔除) |
| Skinned Motion Vectors | 关闭(除非需要运动模糊/TAA) |
| Optimize Game Objects | 导入时勾选,把骨骼节点从 Transform 层级里剥离,减少层级遍历开销 |
| Mesh Compression | 导入设置里开(有精度损失,权衡) |
| 关掉不需要的顶点属性 | 不需要切线/第二 UV 就设为 None,减少带宽 |
大量同屏角色(SLG 军团战)方案:
- GPU Skinning / Bake Mesh:把蒙皮烘焙到贴图,实例化渲染(如 GPU Instancing + Animation Texture)。
- Animation Instancing:官方/社区方案,动画数据烘焙成纹理,支持 Instancing。
- DOTS Animation + Entities Graphics:ECS 驱动,适合上万单位。
- 降级:远处士兵用 Billboard / 简化模型 / 只显示旗帜。
2.5 贴图优化(内存大头)
| 项目 | 建议 |
|---|---|
| 尺寸 | 主角 512~1024;小物件 / 场景道具 256;地面 512~1024 tiling;UI 见第三章 |
| 必须是 2 的幂(POT) | 才能用最好的压缩格式 |
| 压缩格式 | Android:ASTC(6×6 或 8×8);iOS:ASTC(PVRTC 作兼容降级);PC:DXT/BC7 |
| 绝不用 RGBA32 / RGB24 未压缩 | 一张 1024 RGBA32 = 4MB,ASTC 6×6 约 0.37MB,差 10 倍 |
| 关闭 Mipmap | UI、2D、正交相机物件、始终同距离的贴图(省 33% 内存 + 带宽) |
| 关闭 Read/Write Enabled | 开启后 CPU 内存里会多留一份副本 → 内存 ×2 |
| 通道复用 | Mask 放 Alpha;金属度/粗糙度/AO 合并到一张 ORM 贴图(3 张变 1 张) |
| 图集 | 模型贴图也可以打 Atlas(尤其同种建筑/小怪) |
| Aniso Level | 只在地面/斜视角贴图开(1~4),其余设 0 |
| Filter Mode | 像素风用 Point,其余 Bilinear;Trilinear 更贵 |
| Max Size 覆盖 | 按平台覆盖(iOS/Android 分别设),避免美术给 2048 直接用 |
2.6 导入设置规范(用 AssetPostprocessor 强制)
csharp
// 工程化做法:资源导入时自动修正,不靠人自觉
public class ModelImportRule : AssetPostprocessor
{
private void OnPreprocessModel()
{
var importer = assetImporter as ModelImporter;
importer.meshCompression = ModelImporterMeshCompression.Medium;
importer.optimizeMeshVertices = true;
importer.isReadable = false; // 关 Read/Write,省一半内存
importer.importLights = false;
importer.importCameras = false;
importer.animationCompression = ModelImporterAnimationCompression.Optimal;
if (!assetPath.Contains("Characters")) // 静态模型不导动画
importer.importAnimation = false;
}
private void OnPreprocessTexture()
{
var importer = assetImporter as TextureImporter;
importer.isReadable = false;
importer.mipmapEnabled = assetPath.Contains("UI") ? false : true;
importer.maxTextureSize = 1024;
// 按平台覆盖 ASTC
var android = importer.GetPlatformTextureSettings("Android");
android.overridden = true; android.format = TextureImporterFormat.ASTC_6x6;
importer.SetPlatformTextureSettings(android);
}
}
2.7 场景与剔除
- 视锥剔除:自动,但如果模型 Bounds 过大(带动画的角色)会失效 → 手动调 Bounds。
- 遮挡剔除 Occlusion Culling:室内、城市场景收益极大;开放大地图收益低(烘焙成本高、内存占用大)。
- 手动分层剔除 / 分块加载:SLG 大地图按 Chunk 加载卸载(见 SLG 算法文档)。
- 摄像机远裁剪面:调小 Far Clip Plane(雾效配合遮掩)。
- Layer 剔除 :
Camera.cullingMask关闭不需要渲染的层。
2.8 模型优化 Checklist
- 面数符合预算,删除不可见面
- 主角/重要 NPC 有 LOD,远景有 Imposter
- 单模型材质数 ≤ 2(理想 1)
- 骨骼数 ≤ 60,Skin Weights 设 2
- 蒙皮网格关闭 Update When Offscreen / Motion Vectors
- 贴图 2 的幂 + ASTC 压缩 + 关 Read/Write + UI 关 Mipmap
- 用 AssetPostprocessor 强制导入规范
- 静态物件走静态合批 / SRP Batcher,重复物件走 GPU Instancing
- Occlusion Culling(适用场景)已烘焙
- 大量同屏角色有 GPU Skinning / Instancing / DOTS 方案
第三章 UI 优化(UGUI)
3.1 Canvas 拆分:按更新频率分组(不是按 prefab)
Canvas 是 UGUI 合批与重建的基本单位:
- Canvas 内任一元素变化 → 整个 Canvas 标记 dirty →
Canvas.BuildBatch重建所有元素几何。 - 不同 Canvas 之间不能合批 → Canvas 越多 DrawCall 越多。
正确分组:
Canvas_Static 背景、框架、固定按钮 ← 几乎不重建
Canvas_Dynamic 血条、倒计时、飘字、CD ← 高频变化,隔离重建
Canvas_Panel 需要整体开关的整屏面板 ← 只在整屏面板才独立
禁止:每个 UI prefab 都自带一个 Canvas(DrawCall 随 prefab 数线性暴涨)。详见《面试技术文档》第十七章。
3.2 合批规则(打断合批的因素)
同一 Canvas 下能合批的条件:同材质 + 同图集(纹理)+ 同裁剪状态 + 层级连续不被打断。
打断合批的常见原因:
| 打断源 | 说明 |
|---|---|
| 不同图集 / 不同纹理 | 最常见。Text 用字体图集,Image 用 UI 图集 → 交错就打断 |
Mask(Stencil) |
打断 + 额外 2 个 DrawCall |
| 不同材质(自定义 UI Shader) | 每个材质一个批次 |
| 层级穿插 | A(图集1) - B(图集2) - A(图集1) → 无法合成一批,必须调层级 |
不同 Rect Clipping 状态 |
Mask 内外不能合批 |
验证 :Window > Analysis > Frame Debugger,逐个看 DrawCall 与合批情况。
3.3 Mask vs RectMask2D
| Mask | RectMask2D | |
|---|---|---|
| 原理 | Stencil 缓冲 + 模板测试 | 矩形裁剪(Shader 里做 clip) |
| 额外 DrawCall | +2(写 stencil + 恢复) | 0 |
| 合批 | 打断合批 | 不额外打断 |
| 形状 | 任意(配合 Image 做圆形/异形头像) | 仅矩形 |
| 适用 | 头像、异形裁剪 | ScrollView、列表、面板裁剪 |
结论 :能用 RectMask2D 就用;只有异形裁剪才用 Mask,且避免嵌套 Mask。
3.4 图集(Sprite Atlas)
- 按同屏功能模块分图集(主界面一套、战斗 HUD 一套、背包一套),同屏内尽量同图集 → 合批率最高。
- 避免同一张图被打进多个图集(内存翻倍)。
- 图集尺寸 ≤ 2048(超过会有兼容问题 + 内存大)。
Padding设 2~4(防止压缩采样溢出)。- 不用的图集勾选
Include in Build关闭 / 走 Addressables 按需加载。 - 图集
Variant(SD/HD)按机型切换。
3.5 Text 优化
- 用 TextMeshPro(TMP) :SDF 渲染,缩放不糊,合批好,功能强。旧
Text已不推荐。 - 避免频繁改动文本内容 :改 Text 会触发重新布局 + 几何重建。
- 数字变化(伤害、金币)→ 拆成独立的 Text 组件,只重建那一个。
- 值没变就不要赋值 (
if (v != old) text.text = v)。
- 关闭 Best Fit(每次要计算多次字号,很贵)。
- 关闭 Rich Text(不需要富文本时)。
- 字体图集缺字会触发重建 → 预热常用字符集,或用静态字体资源。
- 避免用 Text 做纯装饰(用 Image + 图集)。
Raycast Target关闭(Text 不需要点击时)。
3.6 ScrollRect / 长列表
- 虚拟列表(对象池):只实例化可见区域 + 上下各缓冲 2~3 个,滚动时复用。这是长列表最关键的优化。
- 避免
Content Size Fitter+Layout Group嵌套 :布局重建成本极高(LayoutRebuild),尤其是Horizontal/Vertical Layout Group+Content Size Fitter组合。- 替代:手动计算尺寸,或只在初始化时算一次。
- 滚动时暂停列表项的复杂更新(如加载头像)。
- 用
RectMask2D而非Mask。 - 列表项 prefab 不带自己的 Canvas。
3.7 事件与射线检测
- 关闭所有不需要的
Raycast Target:Image、Text默认开启,每一帧的点击检测都会遍历它们。这是 UI 最简单也最容易忘的优化。 - 用
CanvasGroup.blocksRaycasts一次性控制整组。 - 减少
GraphicRaycaster数量(每个 Canvas 一个)。 - 用 Layer 剔除:
EventSystem只处理 UI 层。 - 3D 物体不需要点击就关掉 Collider 或设到不参与射线的层。
3.8 UI 动效
- 改
anchoredPosition(位置)比改sizeDelta/布局便宜(后者触发布局重建)。 - 频繁变化的 UI 单独放一个 Canvas(动静分离)。
- 用 DoTween / 缓动库,避免在 Update 里手动插值。
- 粒子特效在 UI 上:见第十七章(RenderTexture 方案 / 多 Canvas 排序)。
3.9 全屏 UI 时的降级
- 全屏 UI 打开时关闭或降频 3D 相机 (
Camera.enabled = false或降低渲染频率)。 - 背包/商城等纯 UI 界面,背景场景可以不渲染(或用截图/模糊贴图代替)。
- 半透明遮罩叠加层数控制(Overdraw)。
3.10 UI 优化 Checklist
- Canvas 按更新频率分组,数量受控(几十个以内)
- 同屏 UI 尽量同图集,层级排好不穿插
- 用 RectMask2D 替 Mask
- 所有非交互 Image/Text 关闭 Raycast Target
- 用 TMP,关闭 Best Fit / Rich Text
- 文本变化做"值变了才赋值"
- 长列表用虚拟列表 + 对象池
- 避免 LayoutGroup + ContentSizeFitter 嵌套
- Frame Debugger 验证过 DrawCall
第四章 编程与内存优化
4.1 Unity GC 机制要点
- Unity 传统使用 Boehm GC :非分代、非压缩 → 分配越多碎片越严重,堆只增不减。
- Unity 2019+ 支持 Incremental GC(增量 GC):把一次大的 GC 分摊到多帧,消除尖刺(但总量不变)。
- GC 触发时机:托管堆分配达到阈值时自动触发,或手动
GC.Collect()。 - 目标不是"少触发 GC",而是"每帧零分配"(Profiler 的 GC Alloc 列恒为 0)。
4.2 每帧 GC 分配的常见来源(面试必背)
| 来源 | 说明 | 解法 |
|---|---|---|
| 闭包 / lambda 捕获 | 捕获外部变量 → 生成显示类(class,堆) | 用静态本地函数 / 显式传参 / 缓存委托 |
| 装箱 | struct → object / 接口 / 非泛型集合 | 泛型集合、实现 IEquatable<T>、避免 ArrayList/object[] |
| 字符串拼接 | +、$""、string.Format 都产生临时字符串 |
StringBuilder(缓存复用)、避免每帧拼;值不变就别重拼 |
| LINQ | 迭代器对象 + 委托分配 | 热路径改用 for 循环 |
new class / 数组 |
每次 new 都分配 | 对象池、缓存数组、ArrayPool<T> |
| 协程 | StartCoroutine 有分配;yield return new WaitForSeconds() 每次 new |
缓存 WaitForSeconds 实例;或改用统一 Tick 管理器 |
| Unity API 返回数组 | GetComponents<T>()、mesh.vertices、FindObjectsOfType 都返回新数组 |
用 GetComponentsInChildren(list) 复用 List;缓存结果 |
foreach 老集合 |
部分老版本对 Dictionary 等有分配(新版已优化) |
关键路径用 for / struct 枚举器 |
| enum 作 Dictionary key | 默认 comparer 装箱 | 自定义 IEqualityComparer<T> |
| 委托/事件频繁增删 | +=/-= 会产生新委托对象 |
在 OnEnable/OnDisable 成对处理,不在 Update 里做 |
renderer.material |
每次访问都会 clone 一份新材质! | 用 sharedMaterial(共享),或只取一次缓存 |
| 物理查询返回数组 | Physics.RaycastAll / OverlapSphere |
用 RaycastNonAlloc / OverlapSphereNonAlloc + 预分配数组 |
4.3 具体编码优化手法
A. 缓存一切
csharp
// ❌ 每帧都做
void Update() {
GetComponent<Rigidbody>().velocity = v;
Camera.main.transform.position = p; // Camera.main 内部是 FindObjectWithTag!
transform.position = p; // transform 属性有开销,缓存一下更稳
}
// ✅ 缓存
private Rigidbody _rb; private Transform _tf; private Camera _cam;
void Awake() { _rb = GetComponent<Rigidbody>(); _tf = transform; _cam = Camera.main; }
void Update() { _rb.velocity = v; _tf.position = p; _cam.transform.position = p; }
B. 避免 Update:事件驱动
csharp
// ❌ 每帧轮询
void Update() { if (_hp <= 0) Die(); }
// ✅ 事件驱动(SO 事件通道 / C# event)
_health.OnValueChanged += OnHealthChanged;
void OnHealthChanged(float v) { if (v <= 0) Die(); }
C. 合并 Update(大量对象时)
csharp
// 1000 个单位各自 Update → 1000 次跨语言调用开销 + cache miss
// ✅ 统一由管理器 Tick
public class UnitManager : MonoBehaviour {
private readonly List<Unit> _units = new();
private void Update() { for (int i = 0; i < _units.Count; i++) _units[i].Tick(Time.deltaTime); }
}
D. 对象池
csharp
public class ObjectPool<T> where T : Component
{
private readonly Stack<T> _pool = new();
private readonly T _prefab;
private readonly Transform _root;
public T Get() {
T item = _pool.Count > 0 ? _pool.Pop() : Object.Instantiate(_prefab, _root);
item.gameObject.SetActive(true);
return item;
}
public void Return(T item) {
item.gameObject.SetActive(false);
_pool.Push(item);
}
}
用于:子弹、特效、飘字、列表项、怪物、网络包。
E. 字符串与哈希
Animator.SetTrigger("Attack")→ 先用Animator.StringToHash("Attack")缓存 int。- 比较用
gameObject.CompareTag("Enemy")而不是tag == "Enemy"(后者分配)。 - Shader 属性用
Shader.PropertyToID缓存。
F. 物理优化
- 简化 Collider:Box / Sphere / Capsule > MeshCollider(MeshCollider 极贵,静态场景除外)。
Layer Collision Matrix关闭不需要的层间碰撞。- 调大
Fixed Timestep(0.02 → 0.025/0.033),减少物理步数。 - 关闭
Auto Sync Transforms(Unity 2018+ 默认关,开了会每次查询都同步所有变换)。 - 用
NonAlloc系列 API。 - 不需要物理就别加
Rigidbody。
G. 计算密集型任务
- Job System + Burst:寻路(A*)、视野计算、大量单位位置更新、骨骼计算。零 GC、SIMD 加速。
NativeArray/NativeList(Allocator.Persistent长驻,避免频繁分配)。- 分帧:大循环拆到多帧执行(如一次生成 500 个单位,分 10 帧)。
H. 避免反射与消息
SendMessage/BroadcastMessage/Invoke(string)走反射,又慢又有分配 → 用委托 / 事件 / 接口 / SO 事件通道。
4.4 内存占用与泄漏
内存构成:
| 类别 | 大头 |
|---|---|
| Native(引擎) | 纹理、Mesh、AudioClip、AnimationClip、Shader 变体、AssetBundle |
| Managed(托管堆) | C# 对象,只增不减 |
| Lua/ILRuntime/HybridCLR | 热更层内存(SLG 常见) |
| 其他 | RenderTexture、GraphicsBuffer、字体 |
常见泄漏源:
- 事件未退订 :
publisher.OnX += Handler后没-=→ 发布者一直持有订阅者 → 对象无法回收。 - 静态引用:静态字段 / 单例缓存持有对象 → 永不释放。
- 协程未停止 :对象销毁但协程还在跑(协程随 MonoBehaviour 停止,但
StopAllCoroutines要主动调)。 renderer.material克隆:每次访问生成新材质,不 Destroy 就泄漏。new RenderTexture不释放。- AssetBundle / Addressables 只加载不释放(句柄没 Release)。
- 未使用的资源常驻 :
Resources目录全部打包且不卸载。 - 重复资源:同一张图被多个 AB / 图集各打一份。
排查工具:
- Memory Profiler(Package):看托管堆快照、引用链(谁持有谁)、找泄漏根。
- Profiler Memory 面板:Simple / Detailed 模式看各资源占用。
- 关键做法 :对比两次快照的 Diff(进战斗前 / 出战斗后),多出来的就是泄漏。
卸载策略:
Resources.UnloadAsset(asset):卸载单个资源(纹理等)。Resources.UnloadUnusedAssets():会卡顿,只在切场景 / Loading 界面调用。- Addressables:
Addressables.Release(handle),引用计数管理。 - AssetBundle:
bundle.Unload(true)(连资源一起卸)/Unload(false)(只卸 bundle 头,资源手动管)。
音频内存(常忽略):
| Load Type | 特点 |
|---|---|
Decompressed on Load |
解压到内存,播放快,内存大(适合短音效) |
Compressed in Memory |
压缩驻留,播放时解压(CPU 换内存) |
Streaming |
流式读取,内存最小(适合 BGM) |
- 勾选
Force To Mono(音效一般不需要立体声,省一半)。 - 降
Sample Rate(44100 → 22050)。 - BGM 用 Streaming + 压缩。
动画内存:
- AnimationClip 压缩(
Optimal),误差阈值调大(0.5 → 1.5 肉眼基本无感)。 Animator Controller状态过多会增加内存和求值成本。- 不需要的 clip 不打包。
4.5 编程与内存 Checklist
- Profiler 的 GC Alloc 列每帧为 0
- 无 LINQ / 无每帧字符串拼接 / 无闭包捕获
- 所有 GetComponent、Camera.main、Transform 已缓存
- 协程的 WaitForSeconds 已缓存,或用统一 Tick
- 子弹/特效/飘字/列表项全部对象池化
- 物理用 NonAlloc API + 简化 Collider + 层碰撞矩阵已裁剪
- 事件在 OnEnable/OnDisable 成对订阅退订
- 无
renderer.material滥用,改用 sharedMaterial 或缓存 - Memory Profiler 做过进/出战斗快照 Diff,无泄漏
- 音频 Force To Mono + 合理 Load Type
- AnimationClip 已压缩
第五章 渲染与图形优化
5.1 渲染管线设置(URP Asset)
| 设置 | 建议 |
|---|---|
| Render Scale | 0.8 ~ 1.0(低端机动态降到 0.6) |
| MSAA | 移动端关闭(用 FXAA / SMAA 或直接不用),MSAA 带宽高 |
| HDR | 移动端关闭 |
| Depth Texture / Opaque Texture | 不需要(无软粒子、无屏幕扭曲)就关 |
| Shadow Distance | 20 ~ 50,越短越好 |
| Shadow Cascades | 2 级(不要 4 级) |
| Shadow Resolution | 1024 / 2048,按需 |
| Soft Shadows | 尽量用 Hard(Soft 多一次采样) |
| Render Features | 不用的全部删除 |
| LOD Bias / Maximum LOD Level | 低端机降低 LOD Bias,提前切低模 |
5.2 光照
- 烘焙优先:静态场景用 Baked GI / Lightmap,运行时零成本。
- 动态物体 用 Light Probe 取间接光。
- 移动端实时光:1 个方向光(主光),点光/聚光灯尽量不用或极少。
- 关闭不需要的
Realtime Global Illumination。 - 假阴影:角色脚下用一个圆形贴片(Blob Shadow)代替实时阴影,成本几乎为零 ------ 移动端常规做法。
5.3 Shader 优化
| 要点 | 说明 |
|---|---|
| 变体控制 | Shader 变体数爆炸 → 包体大、内存大、加载慢。剔除无用变体(URP Stripping 设置) |
| 变体预热 | ShaderVariantCollection.WarmUp() 在启动时调用,避免运行时编译卡顿 |
| 精度 | 移动端用 half / fixed,少用 float(省带宽和 ALU) |
| 纹理采样次数 | 合并贴图通道,减少 sampler |
| 避免 discard / clip | 破坏 Early-Z,移动 GPU(TBDR)代价大。Alpha Test 有时比 Alpha Blend 还慢 |
| 避免 GrabPass | 会强制同步等待,移动端极贵 |
| 简化光照模型 | 移动端用 Lambert / 简化 PBR,不用完整 PBR + IBL |
| 避免复杂分支 | GPU 分支代价高,用 lerp/step 代替 if |
| Shader 编译 | 打包时预编译(Preloaded Shaders) |
5.4 DrawCall 与填充率
DrawCall 高 :合批(SRP Batcher / GPU Instancing / 静态合批)、合并材质、减少物体数。
SetPass 高 :材质种类太多、Shader 变体多 → 统一材质、减少变体。
填充率高(GPU 瓶颈):减少 Overdraw(透明层、粒子、后处理)、降低分辨率(Render Scale / Dynamic Resolution)、减少全屏 Pass。
排序规则:
- 不透明:从前到后(Early-Z 剔除被遮挡像素)。
- 透明:从后到前(保证混合正确),且不能写 ZWrite。
5.5 耗电与发热控制
- 锁帧 :
Application.targetFrameRate = 30(非竞技类),比跑满 60 省一半电。 - 动态降帧:检测到帧率持续低于阈值或温度升高 → 降帧 + 降分辨率 + 降画质档。
- 动态分辨率 (URP
ScalableBufferManager/ Dynamic Resolution)。 - 减少持续高负载:粒子、后处理、实时阴影在低端机降级。
- 菜单/静置时降帧:无操作时降到 15~30 帧。
5.6 渲染优化 Checklist
- URP Asset:关 MSAA/HDR/无用 Depth Texture、Shadow Distance 与 Cascade 收敛
- 无用的 Render Feature 已删除
- 静态光照已烘焙,实时光仅 1 盏主光
- Shader 变体已裁剪 + 启动预热
- 移动端 Shader 用 half 精度、无 discard、无 GrabPass
- DrawCall / SetPass 在预算内
- 有动态分辨率 / 动态降帧策略
- 低端机有独立画质档
第六章 包体优化
| 方向 | 做法 |
|---|---|
| 纹理 | ASTC 压缩、Max Size 平台覆盖、关 Mipmap、关 Read/Write、合并图集 |
| 音频 | 压缩格式(Vorbis/ADPCM)、Force Mono、降采样、BGM 走 Streaming 或下载 |
| 模型 | Mesh Compression、关 Read/Write、删无用顶点属性、动画压缩 |
| 代码 | IL2CPP + Managed Stripping Level(High),用 link.xml 保留反射用到的类型 |
| 资源分包 | Addressables:首包只放必要内容,其余热更下载 |
| 剔除重复 | 用 Build Report / AssetBundle 分析工具找重复资源(同一张图进多个 AB) |
| 字体 | 裁剪字库(只保留常用字 + 游戏用字),避免完整中文字体(几 MB ~ 十几 MB) |
| 视频 | 走 StreamingAssets 或远端,不进首包 |
| 场景 | 精简场景数量,复用 prefab |
| Lua/热更 | HybridCLR + AB 热更,减少首包体积 |
第七章 开发技巧总结
7.1 架构技巧(解耦 / 可扩展)
| 技巧 | 说明 |
|---|---|
| ScriptableObject 驱动配置 | 数值、事件、配置全在 SO,策划/美术在 Inspector 改,不改代码、不重编译 |
| SO 事件通道 | 跨系统通信不依赖引用(GameEvent + GameEventListener),消灭 FindObjectOfType 和单例 |
| RuntimeSet | 追踪场上实体(所有敌人、所有友军),替代 FindObjectsOfType 和单例列表 |
| 单职责组件 | 一个 MonoBehaviour 只干一件事;超过 150 行就该拆 |
| 自包含 prefab | 拖到空场景就能跑,不依赖外部层级 |
| 状态机 | 角色状态、UI 面板、游戏流程(Procedure)都用状态机,逻辑清晰可扩展 |
| 对象池统一管理 | 一个 PoolManager 管所有池,统一预加载、统一上限、统一回收 |
| 命令模式 | 撤销/重做、战斗回放 |
| 配置表 + 代码生成 | Excel → 自动生成 C# 数据类 + 索引字典,避免手写差错 |
| 模块化热更 | HybridCLR 分程序集(GameLogic / UI / Config),按域热更 |
7.2 性能技巧(口诀版)
池化一切、预加载、分帧做、能合批就合批、能剔除就剔除、能烘焙就烘焙、能降级就降级。
- 池化:子弹、特效、飘字、列表项、怪物、网络包。
- 预加载:特效预热、Shader 变体预热、常用资源进场景前加载。
- 分帧:大循环、大资源加载、批量实例化分到多帧。
- 合批:SRP Batcher + GPU Instancing + 静态合批 + 图集。
- 剔除:视锥、遮挡、距离、Layer、手动分块。
- 烘焙:光照、阴影、导航网格。
- 降级:LOD、画质档、动态分辨率、动态降帧。
7.3 避坑清单(血泪经验)
- Editor 流畅 ≠ 真机流畅。所有性能结论必须真机验证。
renderer.material会克隆 ------ 用sharedMaterial(但注意:改 sharedMaterial 会影响所有共用者)。- 静态字段不随场景切换清空 ------ 跨场景残留是常见 bug 源。
- 事件必须成对订阅/退订 ------
OnEnable/OnDisable,不在Awake/Start里订阅后不管。 - 不要在
Update里new任何东西。 Camera.main是FindObjectWithTag------ 每帧调用就是在查找。- 不要在
Update里用GetComponent。 - 协程不随
SetActive(false)停止(随 Destroy 停止),隐藏对象时协程仍在跑。 - 改变父节点会触发完整重建(Transform 层级变化)。
- Destroy 是延迟的 ------ 不是立即销毁,本帧内对象还在。
7.4 工具链
| 工具 | 用途 |
|---|---|
| Profiler(CPU / GPU / Memory / Rendering) | 全能主力,真机连接 |
| Profile Analyzer | 对比两帧/两段时间的数据,量化优化收益 |
| Frame Debugger | 逐 DrawCall 看渲染流程、找合批失败原因 |
| Memory Profiler | 内存快照、引用链、泄漏定位、Diff 对比 |
| Overdraw 视图 / RenderDoc / Xcode GPU Capture | 填充率分析 |
| 自建 Debug 面板 | 实时显示 FPS、DrawCall、GC Alloc/帧、对象池占用、内存 ------ 开发期必备 |
| CI 性能基线 | 每次构建自动跑固定场景录性能数据,超标报警 |
| AssetPostprocessor | 强制资源规范(尺寸/压缩/ReadWrite),从源头防劣化 |
7.5 面试速答模板:完整版"你怎么做性能优化?"
"我做优化分四步:测量、定位、修改、验证。第一,先连真机 Profiler 取基线,我会分四象限判断瓶颈------CPU 逻辑、渲染提交、GPU 填充率还是 GC。常用的快速判定是把 Render Scale 降到 50%,帧率暴涨说明瓶颈在填充率,几乎不变说明在 CPU 或顶点。
第二,针对瓶颈逐项治理。GC 方面 ,我的目标是每帧零分配------避免闭包捕获、装箱、字符串拼接、LINQ,缓存 GetComponent 和 Camera.main,协程的 WaitForSeconds 缓存复用,物理用 NonAlloc 接口,子弹特效飘字全部对象池化。渲染方面 ,控制 DrawCall 和 SetPass:静态物件走静态合批或 SRP Batcher,重复物件走 GPU Instancing,材质和图集合并,UI 按更新频率拆 Canvas 做动静分离,Mask 换 RectMask2D。内存方面 ,纹理用 ASTC 压缩、关 Read/Write 和不必要的 Mipmap,动画压缩,音频 Force Mono,用 Memory Profiler 做进/出战斗的快照 Diff 查泄漏,特别注意事件没退订、静态引用、renderer.material 克隆这几个泄漏源。特效方面重点是 Overdraw,关掉 Collision 和阴影、限制 Max Particles、贴图图集化、走对象池和启动预热,配合画质分级和同屏数量上限。
第三,改完必须复测对比数据,我会用 Profile Analyzer 量化前后收益。
最后一步是防回归:资源规范用 AssetPostprocessor 在导入时强制,性能数据进 CI 做基线对比。
我还想强调一点:优化都有代价------合批吃内存、LOD 吃包体、降分辨率吃画质,所以每次我都会说清楚这次是拿什么换了什么。"
附录:一页纸总清单(打印用)
特效
关 Collision/Shadow/Light · 限制 Max Particles · 关无用模块 · 贴图 ≤256 + ASTC + 关 Mipmap/ReadWrite · Mobile/Unlit Shader · 粒子图集 · 对象池 + 预热 + WarmUp · 同屏上限 + 画质分级 · Overdraw 视图验收
模型
面数按预算 · LOD + Imposter · 单模型 ≤2 材质 · 骨骼 ≤60 + SkinWeights 2 · 关 UpdateWhenOffscreen/MotionVectors · POT + ASTC + 关 ReadWrite · 通道复用 ORM · AssetPostprocessor 强制规范 · 静态合批/Instancing/SRP Batcher · Occlusion Culling · 大量角色 GPU Skinning / DOTS
UI
Canvas 按频率分组(非按 prefab) · 同图集不穿插 · RectMask2D 替 Mask · 关 Raycast Target · TMP + 关 BestFit/RichText · 值变了才赋值 · 虚拟列表对象池 · 避免 LayoutGroup+ContentSizeFitter 嵌套 · Frame Debugger 验收
编程内存
GC Alloc/帧 = 0 · 缓存 GetComponent/Camera.main/Transform · 无 LINQ/闭包/拼接/装箱 · 对象池 · NonAlloc 物理 · 简化 Collider + 层碰撞矩阵 · 事件成对退订 · sharedMaterial 不克隆 · Memory Profiler Diff 查泄漏 · 音频 ForceMono · 动画压缩
渲染
URP:关 MSAA/HDR/无用 DepthTexture · Shadow Distance+Cascade 收敛 · 删无用 RenderFeature · 烘焙光照 + LightProbe · 1 盏实时主光 · Blob 假阴影 · Shader 变体裁剪+预热 · half 精度 · 无 discard/GrabPass · 动态分辨率 + 动态降帧
方法
先测量后优化 · 降分辨率判 GPU 瓶颈 · 真机验证 · 改一处复测一次 · Profile Analyzer 量化 · AssetPostprocessor 防劣化 · CI 性能基线