卡顿是玩家的第一大流失原因。但"感觉卡"和"知道卡在哪"是两回事——前者靠猜,后者靠 Profiler 数据。本文建立一套完整的性能优化方法论:如何读 Profiler(Unity/Godot)、如何治理 Draw Call、如何驯服内存与 GC、如何用分辨率缩放换帧率、如何处理平台差异。它是本专题渲染与引擎两篇的收口:把 游戏渲染管线基础 的预算表和 游戏引擎架构:ECS 与资源管理 的帧循环,落实成一套可执行的调优工作流。
相关:性能数据的服务端侧版本见 Rust 游戏服务端性能优化;渲染侧的优化原理见 游戏渲染管线基础。
1. Profiler 使用:先测量,再优化
1.1 性能预算思维
一切优化从预算表开始。以 60FPS 为例:
| 模块 | 预算 | 60FPS 换算 |
|---|---|---|
| 渲染(Draw + 后处理) | 50-60% | 8-10 ms |
| 逻辑(AI/动画/物理) | 25-30% | 4-5 ms |
| 游戏脚本 | 10-15% | 2-3 ms |
| 余量(GC/IO 尖峰) | 5-10% | 1-2 ms |
铁律:不设预算的优化是盲目的。先量化"卡在哪个模块",再决定把预算从哪挪到哪。
1.2 Unity Profiler 关键窗口
Window > Analysis > Profiler
├── CPU Usage:按调用栈下钻,找耗时函数
├── Rendering:Draw Call 数、三角形数、SetPass 数
├── Memory:Managed Heap、纹理/网格显存、泄漏趋势
└── GPU Usage(需设备):真实 GPU 帧时间
// 代码标记:把自研逻辑块打上 Profiler 标签
UnityEngine.Profiling.Profiler.BeginSample("MyAI_Update");
UpdateAllAI();
UnityEngine.Profiling.Profiler.EndSample();
下钻顺序:先看总帧时间是否超预算 → 再看 CPU/GPU 各自占比(瓶颈在谁)→ 再看模块占比 → 最后下钻到函数级。
1.3 Godot Profiler
Debugger > Profiler(F12)
├── 各脚本函数耗时(每帧)
├── GPU 时间
└── 网络/内存监控
# Godot:性能监视器 API
Engine.get_frames_per_second()
Performance.get_monitor(Performance.TIME_PROCESS)
Performance.get_monitor(Performance.RENDER_OBJECTS_IN_FRAME)
移动端/真机测量:编辑器 Profiler 与真机差距很大(编辑器开销、GPU 不同)。务必用 Development Build + Profiler 连真机(Unity 的 Run in Background + Profiler 连接;Godot 的 Remote Debug)。
2. Draw Call 优化:渲染侧大头
2.1 测量 Draw Call
Unity 里看 Stats 面板 / Frame Debugger;Godot 里 Rendering > Debug > Show draw calls。目标值参考:
| 平台 | 建议 Draw Call 上限 |
|---|---|
| 低端移动 | 50-100 |
| 中端移动 | 100-200 |
| PC/主机 | 2000+(GPU 带宽为主) |
Draw Call 不是越低越好,而是和填充率、带宽一起看——过度合批可能把不必要的三角形也画了。
2.2 优化优先级清单
- GPU Instancing:大量重复物(草、石、子弹),首选。
- 静态合批:不动的场景物体。
- 纹理图集 + 材质合并:减少状态切换。
- SRP Batcher(Unity):Shader 兼容后自动降 SetPass。
- LOD(细节层级):远处用低模,减少顶点与批次数。
优化顺序(性价比从高到低):
Instancing > 静态合批 > 图集 > SRP Batcher > LOD > 动态合批(慎用)
2.3 填充率与过绘制(Overdraw)
Draw Call 管"提交次数",填充率管"像素工作量"。移动端像素填充率有限,过绘制(同一像素被画多次)是隐性杀手:
场景中透明粒子、后处理、多层 UI 叠加 → 同一像素被画 N 次 → 填充率爆炸
检测:Unity 的 Scene View > Shaded > Overdraw;优化:减少透明粒子层、后处理合并、UI 合层。
3. 内存与 GC 治理
3.1 托管内存(GC 停顿)
C#(Unity)与 GDScript 的 GC 会在回收时造成短暂停顿,高频分配(每帧 new)是元凶:
// 反模式:每帧分配临时对象
void Update() {
var list = new List<Vector3>(); // 每帧分配 → GC 每几分钟卡顿一次
// ...
}
// 优化:对象池 + 避免装箱
class BulletPool { /* 预分配,用后归还,见 ECS 篇 */ }
GC 治理清单:
- 对象池所有高频对象(子弹、粒子、伤害数字)。
- 避免装箱:
List<int>比List<object>好,foreach谨慎。 - 缓存引用:
GetComponent、FindObjectOfType缓存到字段。 - 字符串拼接用 StringBuilder:
$"HP:{hp}"每帧产生新字符串。 - Unity 的 GC 分配检测:Profiler 的
GC Alloc列,目标每帧 0 B。
3.2 显存与纹理内存
移动端显存预算通常 200-300MB。纹理是最大消耗:
| 维度 | 优化手段 |
|---|---|
| 压缩格式 | ASTC(移动)、BC7(PC)、ETC2(兼容) |
| 尺寸 | 不超过屏幕需求,关掉无关的 mipmap |
| 复用 | 同纹理共享,避免每个 UI 一张独立图 |
| 加载 | 异步加载 + 卸载(Addressables/Godot Resource) |
内存治理的铁律:先看分配来源,再动手。Profiler 的 Memory 页能按类别(Mesh/Texture/Script)看谁占了大头,90% 的场景问题出在"一张 4K 贴图 + 几十张未压缩图"。
4. 渲染分辨率缩放
4.1 动态分辨率(Dynamic Resolution)
移动端最简单有效的"帧率保险丝":GPU 过载时按比例降分辨率,帧率立刻回升,画质损失可接受。
// Unity:动态分辨率设置
void Update() {
if (GpuFrameTimeTooHigh()) {
float scale = Mathf.Max(0.5f, 1f - step); // 降到 50% 封底
UnityEngine.Rendering.DynamicResolutionHandler.SetDynamicResolutionScale(
scale, scale);
}
}
# Godot:视口缩放
var target = 0.8
get_viewport().scaling_3d_scale = target
实现原则:
- 只在 GPU 瓶颈时降分辨率;CPU 瓶颈降分辨率无效。
- 缩放要与分辨率配套调 mipmap 偏移,避免缩放后贴图闪烁。
- 分辨率降到 0.6x 以下画面明显糊,配合 TAA/FXAA 后处理可掩盖。
4.2 渲染特征分级(Quality Tiers)
用多档画质预设按设备性能分级:
| 档位 | 阴影 | 抗锯齿 | 后处理 | 粒子数 | 分辨率 |
|---|---|---|---|---|---|
| 低 | 关闭/低分辨率 | FXAA | 关 | 50% | 0.75x |
| 中 | 硬阴影 1024 | MSAA 2x | 轻度 | 100% | 1.0x |
| 高 | 软阴影 2048 | MSAA 4x/TAA | 全开 | 150% | 1.0x |
Unity 用 Quality Settings + QualitySettings.SetQualityLevel();Godot 用 Project Settings 的 Quality 预设 + 运行时切换。
4.3 逻辑层的性能治理:脚本、Job 与多线程
渲染优化后,CPU 逻辑常成为新瓶颈。三层治理手段:
| 手段 | 原理 | 适用 |
|---|---|---|
| 算法/复杂度 | 降低遍历量(空间索引、缓存查询结果) | 所有项目 |
| 数据布局 | SoA、对象池、避免缓存未命中 | 大量实体 |
| 并行化 | Job System / 多线程 / 异步 | 有独立可并行任务 |
// Unity Job System:把计算拆到工作线程
struct MoveJob : IJobParallelFor {
public NativeArray<float3> positions;
public float dt;
public void Execute(int i) {
positions[i] += new float3(dt, 0, 0); // 并行处理
}
}
# Godot:Thread 池做后台计算
var thread = Thread.new()
thread.start(func(): heavy_computation() )
法则:并行化的前提是任务可分解且无共享写。先做算法优化(O(n²)→O(n log n)),再做并行化——并行化复杂度高、收益上限受 Amdahl 定律约束,通常是最后手段。
5. 平台差异:移动 / PC / 主机
5.1 三类平台的性能特征
| 维度 | 移动端 | PC | 主机 |
|---|---|---|---|
| GPU 瓶颈 | 带宽/填充率(发热限频) | 多样 | 固定规格、可充分利用 |
| CPU | 弱、发热降频 | 强 | 中、固定 |
| 内存 | 2-6GB(可用更少) | 大 | 固定(如 16GB) |
| 目标帧率 | 30/60(低端 30) | 60/120+ | 固定 30/60 |
| 热管理 | 必须考虑降频 | 无 | 散热良好 |
5.2 移动端特有的坑
- 发热降频:GPU/CPU 温度过高自动降频,性能会"持续衰减"。优化目标不只是帧率,还是热耗(限制持续负载)。
- 内存杀手:后台驻留、大纹理、未释放资源 → 系统杀进程。必须做 内存预算 + 低内存回调(Unity
Application.lowMemory)。 - 触控与多线程:主线程别做 IO、网络解包放子线程(Job/Task)。
- HDR/AA 过度:移动端坚持 Forward + 轻后处理,别上 Deferred 的重 GBuffer。
5.3 PC/主机的差异
- PC:画质档位必须开放给玩家(分辨率、垂直同步、画质分级);帧率要锁 60/120 保持稳定(帧时间一致性 > 峰值)。
- 主机:规格固定,能精确优化到"每一毫秒都用足";利用 多线程 + 异步计算(GPU Async Compute)榨取固定硬件性能。
5.4 帧时间一致性
优化不只降低平均帧时间,还要消除尖峰:
稳定 16.7ms 每一帧 ← 理想(平滑)
平均 14ms 但偶发 40ms 尖峰 ← 卡顿感来自尖峰
尖峰来源:GC、IO 加载、资源创建、JIT 编译。对策:预加载(Preload)、后台加载、对象池、增量 GC。
5.5 平台专项检查清单
移动端发布前清单:
- 真机 Profiler 连续采集 30 分钟,确认无发热降频导致的持续衰减
- 内存峰值低于系统限制,
Application.lowMemory回调已实现降级 - 纹理全部 ASTC/ETC2 压缩,无未压缩大图
- Draw Call 低端机 < 100,GC 每帧分配 0 B
- 动态分辨率 + 画质分级已接入
PC 发布前清单:
- 分辨率、垂直同步、画质档位开放给玩家
- 锁帧并验证帧时间一致性(P99 < 2×P50)
- 无窗口模式、多屏、输入设备差异已适配
主机发布前清单:
- 固定规格下逐帧预算精确到 1ms
- 多线程 + GPU 异步计算已利用
- 存档、后台、待机唤醒路径无资源泄漏
6. 最佳实践与总结
性能优化完整工作流:
1. 设预算表(模块 ms + Draw Call 上限 + 内存上限)
2. 真机 Profiler 采集(编辑器数据仅参考)
3. 定位瓶颈(CPU/GPU/内存,找到最大的百分比)
4. 按性价比清单优化(渲染 → 内存 → 逻辑)
5. 回归测量 + 对比基线(同一设备、同一场景)
6. 平台适配(画质分级 + 动态分辨率 + 内存回调)
7. 持续集成:把性能门槛写进 CI(超预算即告警)
关键指标仪表盘:
- 帧时间:P50 / P95 / P99(别只看平均)
- Draw Call / SetPass 数
- GC 每帧分配(目标 0B)
- 显存与托管堆曲线(检测泄漏)
- 发热降频事件次数
性能优化不是一次性任务,而是持续的数据驱动工程。没有 Profiler 的"感觉卡"是猜测,有预算表的"知道卡"才是优化。记住:先测量、再优化、后验证——永远让数据说话。
相关阅读:游戏渲染管线基础 讲解 Draw Call 与填充率的原理;游戏引擎架构:ECS 与资源管理 讲解帧循环与对象池的工程基础;跨专题可参考 计算机图形学 的渲染细节。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。