适合阶段:入门
适合读者:刚入行或准备入行的学生、转行者,以及第一次接触性能优化、需要看懂性能分析工具的客户端程序员
说明:文中的数值,如帧时间预算和 Draw Call 数量,仅为便于理解的示意。实际预算取决于目标平台、引擎、渲染管线和项目类型,请以项目实测为准
引子
性能相关的讨论,新人最容易听不懂。项目组里经常有这样的对话:
- “这个场景掉到 40 帧了,帧时间看看是 CPU 还是 GPU。”
- “Draw Call 太多了,这些物件能不能合批?”
- “远处的树换成低 LOD,再远一点直接剔掉。”
- “Overdraw 太严重了,半透明特效叠了七八层。”
- “玩十分钟就发烫降频,帧率从 60 掉到 45。”
- “平均帧率没问题,但 1% Low 很难看,有卡顿。”
这些词背后是同一个问题:在有限的时间和硬件资源内,把一帧画面算完、画完。
这篇文章先讲清楚“一帧里发生了什么”,再按 CPU、GPU、内存、移动端特有问题依次介绍常见术语,最后落到程序员的日常工作:如何测量、如何定位瓶颈、如何避免常见误区。
一、全景:一帧里发生了什么
1. 一帧的大致流程
以常见的游戏引擎为例,一帧的工作大致分为三段:
CPU:主线程(游戏线程) CPU:渲染线程 GPU ┌──────────────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │ 输入处理 │ │ 剔除(可见性判断) │ │ 顶点处理 │ │ 游戏逻辑、AI、脚本 │→ │ 排序、合批 │→ │ 光栅化 │ │ 物理、动画 │ │ 提交 Draw Call │ │ 像素着色、后处理 │ │ UI 更新 │ │ │ │ 输出到屏幕 │ └──────────────────────────┘ └──────────────────┘ └─────────────────────┘现代引擎通常让这三段流水线并行:当 GPU 在画第 N−2 帧时,渲染线程在准备第 N−1 帧,主线程在计算第 N 帧。
时间 → 主线程 │ 帧1 │ 帧2 │ 帧3 │ 帧4 │ 渲染线程 │ 帧1 │ 帧2 │ 帧3 │ GPU │ 帧1 │ 帧2 │这带来一个关键结论:最终帧率由最慢的那一段决定。主线程花 10ms、GPU 花 20ms,帧率就由 GPU 决定,优化主线程不会让帧率提高。
2. 术语速查
| 类别 | 术语 | 一句话含义 |
|---|---|---|
| 帧 | 帧率(FPS)、帧时间(Frame Time) | 每秒画多少帧 / 每帧花多少毫秒 |
| 卡顿(Hitch / Stutter)、1% Low | 偶发的长帧 / 最差帧的表现 | |
| 垂直同步(VSync)、帧节奏(Frame Pacing) | 与屏幕刷新的配合 | |
| 瓶颈 | CPU 瓶颈(CPU Bound)、GPU 瓶颈(GPU Bound) | 谁拖慢了这一帧 |
| CPU 端 | Draw Call、SetPass Call、合批(Batching)、实例化(Instancing) | 向 GPU 提交绘制命令的开销 |
| GC(垃圾回收) | 托管内存回收造成的停顿 | |
| GPU 端 | 面数、顶点数 | 几何复杂度 |
| 填充率(Fill Rate)、Overdraw | 像素处理压力 | |
| 带宽(Bandwidth) | 读写显存的数据量 | |
| Shader 复杂度、Shader 变体 | 着色程序的计算量与数量 | |
| 按需降级 | LOD、Mipmap、HLOD | 远处用更简单的版本 |
| 剔除(Culling) | 看不见就不画 | |
| 内存与加载 | 内存预算、纹理压缩、流式加载 | 放不放得下、加载快不快 |
| 移动端 | 发热、降频(Thermal Throttling)、功耗 | 能不能持续跑得动 |
二、帧率与帧时间
1. FPS 与帧时间
帧率(FPS,Frames Per Second):每秒渲染的帧数。
帧时间(Frame Time):渲染一帧所用的时间,通常以毫秒(ms)为单位。
两者互为倒数:帧时间(ms)= 1000 / FPS。
| 目标帧率 | 每帧时间预算 | 常见场景 |
|---|---|---|
| 30 FPS | 33.33 ms | 主机画质模式、部分移动端重度游戏 |
| 60 FPS | 16.67 ms | 主流动作、射击、竞技游戏 |
| 90 FPS | 11.11 ms | 部分高刷手机、VR 常见下限 |
| 120 FPS | 8.33 ms | 高刷手机、主机性能模式、竞技 PC 游戏 |
| 144 FPS | 6.94 ms | 竞技 PC 显示器 |
2. 为什么程序员更喜欢说“帧时间”
FPS 不是线性的,帧时间是:
| 变化 | FPS 看起来 | 帧时间实际 |
|---|---|---|
| 30 → 25 FPS | 掉了 5 帧 | 增加约 6.67 ms |
| 60 → 55 FPS | 掉了 5 帧 | 增加约 1.52 ms |
| 120 → 115 FPS | 掉了 5 帧 | 增加约 0.36 ms |
同样“掉 5 帧”,代价相差近 20 倍。所以:
- 优化以毫秒计:“这个改动节省了 2ms”比“提升了 8 帧”更准确;
- 预算以毫秒分配:例如 60 FPS 时,16.67ms 可以拆成逻辑 4ms、物理 2ms、动画 2ms、UI 1ms……
在 Unreal 中,控制台命令
stat unit会分别显示 Frame、Game(游戏线程)、Draw(渲染线程)、GPU 的耗时,这正是按帧时间思考的典型工具。
3. 平均帧率会骗人:卡顿与 1% Low
平均帧率 60 的游戏,玩起来可能依然很卡。原因是偶发的长帧:
帧时间(ms) 80 │ █ 60 │ █ 40 │ █ 16 │ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ └──────────────────────────────────→ 帧 ↑ 一次 80ms 的卡顿(Hitch)这一帧对平均值的影响很小,但玩家能明显感觉到“顿了一下”。
| 术语 | 含义 |
|---|---|
| 卡顿(Hitch / Spike) | 偶发的、明显超出预算的长帧 |
| 抖动(Stutter) | 帧时间忽长忽短,不稳定 |
| 1% Low / 0.1% Low | 衡量最差帧的指标。常见计算方式有两种:最慢 1% 帧的平均帧率,或第 99 百分位帧时间换算的帧率。不同工具口径不同,对比时要统一 |
常见卡顿来源:
- 资源同步加载、Shader 首次编译;
- GC(垃圾回收);
- 大量对象同时创建或销毁;
- 一次性的大计算,如寻路网格重建、大范围物理查询。
一个计算帧时间统计的示意:
defframe_stats(frame_times_ms:list[float])->dict:"""根据帧时间序列计算平均帧率与 1% Low(按最慢 1% 帧的平均值计算)。"""n=len(frame_times_ms)avg_ms=sum(frame_times_ms)/n worst=sorted(frame_times_ms,reverse=True)count=max(1,n//100)# 最慢的 1% 帧worst_avg_ms=sum(worst[:count])/countreturn{"avg_fps":1000/avg_ms,"one_percent_low_fps":1000/worst_avg_ms,"max_frame_ms":worst[0],}4. 垂直同步、撕裂与帧节奏
| 术语 | 含义 |
|---|---|
| 刷新率(Refresh Rate) | 显示器每秒刷新的次数,单位 Hz,如 60Hz、120Hz |
| 画面撕裂(Tearing) | 显示器刷新到一半时帧缓冲被替换,同一画面出现上下错位 |
| 垂直同步(VSync) | 等显示器刷新完成再交换帧缓冲,消除撕裂,但可能增加输入延迟 |
| 三重缓冲(Triple Buffering) | 多一个缓冲区,减少 VSync 下的等待,代价是更多内存和可能的延迟 |
| 可变刷新率(VRR) | 显示器配合游戏的实际帧率刷新,如 G-SYNC、FreeSync、HDMI 2.1 VRR |
| 帧节奏(Frame Pacing) | 每帧在屏幕上停留的时间是否均匀 |
帧节奏常被忽视。在 60Hz 屏幕上以 30 FPS 运行,理想情况是每帧显示 2 次刷新(33.3ms)。如果节奏不稳定,实际可能是 16.7ms、50ms 交替出现,平均仍是 30 FPS,但玩家会感到明显的抖动。
Android 提供了 Android Frame Pacing 库(Swappy)帮助处理这一问题,Unity、Unreal 在 Android 平台已集成相关支持。
5. 锁帧与输入延迟
- 锁帧(Frame Rate Cap):主动限制最高帧率。移动端常见 30 / 60 档位,主机常见“画质模式 30 帧 / 性能模式 60 帧”。锁帧能降低功耗与发热,使帧率更稳定。
- 输入延迟(Input Latency):从玩家按键到画面响应的时间。帧率越低、流水线越深、缓冲越多,延迟越高。竞技游戏对此尤为敏感。
三、CPU 瓶颈与 GPU 瓶颈
优化的第一步不是“找哪里可以优化”,而是判断瓶颈在哪一端。
1. 两种瓶颈
| 类型 | 含义 | 常见原因 |
|---|---|---|
| CPU 瓶颈(CPU Bound) | CPU 端处理一帧的时间超过预算,GPU 在等 CPU | 游戏逻辑复杂、对象过多、Draw Call 过多、GC、物理计算 |
| GPU 瓶颈(GPU Bound) | GPU 处理一帧的时间超过预算,CPU 在等 GPU | 分辨率高、Overdraw 严重、Shader 复杂、后处理过多、面数过高 |
CPU 瓶颈还可以细分为主线程(游戏线程)瓶颈和渲染线程瓶颈,二者的优化方向完全不同。
2. 快速判断方法
| 方法 | 看什么 |
|---|---|
| 看分析工具 | 直接比较主线程、渲染线程、GPU 各自的耗时,最长的就是瓶颈 |
| 降低渲染分辨率 | 帧率明显提升 → 很可能是 GPU 像素处理瓶颈(填充率、Shader、后处理) |
| 分辨率降低但帧率不变 | 瓶颈在 CPU,或 GPU 的顶点处理端 |
| 关闭部分逻辑系统 | 如暂停 AI、关闭物理,帧率提升说明主线程是瓶颈 |
| 观察等待标记 | 分析工具中主线程大量时间在“等待 GPU / 等待渲染线程”,说明瓶颈在后者 |
不同引擎中的等待标记名称不同,例如 Unity Profiler 中常见
Gfx.WaitForPresent等标记。看到这类标记占用大量时间时,不要误以为是主线程本身慢。
一个关键原则:在 GPU 瓶颈时优化 CPU,或在 CPU 瓶颈时优化 GPU,帧率都不会变化。先定位,再优化。
四、Draw Call 与合批
Draw Call 是新人听到最多、也最容易误解的性能术语。
1. 什么是 Draw Call
Draw Call:CPU 向 GPU 发出的一次“绘制指令”,告诉 GPU 用哪些顶点数据、哪个 Shader、哪些纹理和参数画一批图元。
CPU GPU │ 设置 Shader、纹理、参数(状态切换) │ │ ───────────────────────────────────────→ │ │ Draw Call:画这个网格 │ │ ───────────────────────────────────────→ │ │ 切换材质…… │ │ Draw Call:画下一个网格 │ │ ───────────────────────────────────────→ │真正贵的往往不是“画”本身,而是 CPU 侧的准备工作:
- 切换渲染状态(Shader、纹理、混合模式等);
- 驱动层的校验与命令转换;
- 引擎侧为每个物体收集数据、设置参数。
因此,Draw Call 过多通常表现为CPU 瓶颈(渲染线程),而不是 GPU 瓶颈。
2. 相关术语
| 术语 | 含义 |
|---|---|
| Draw Call | 一次绘制命令 |
| Batch | 引擎统计的“绘制批次”,在 Unity 统计面板中显示为 Batches |
| SetPass Call | Unity 术语,表示切换 Shader Pass 等渲染状态的次数,通常比 Draw Call 本身更能反映开销 |
| 状态切换(State Change) | 更换 Shader、纹理、混合模式等,开销较大 |
在 Vulkan、Metal、DirectX 12 等现代图形 API 下,驱动开销显著降低,但 Draw Call 数量和状态切换仍然是需要控制的指标,尤其在移动端。
3. 降低 Draw Call 的常见手段
| 手段 | 原理 | 适用场景 | 代价 |
|---|---|---|---|
| 静态合批(Static Batching) | 把不会移动的物体在构建或加载时合并成大网格 | 场景中的静态建筑、地面物件 | 增加内存和包体,合并后无法单独移动 |
| 动态合批(Dynamic Batching) | 每帧在 CPU 上合并顶点数较少的小物体 | 小型、共享材质的动态物体 | 合并本身消耗 CPU,有顶点数限制 |
| GPU 实例化(GPU Instancing) | 同一网格、同一材质,一次 Draw Call 绘制多份,每份传入不同参数 | 草、树、石头、大量相同单位 | 要求网格和材质相同 |
| 纹理图集(Texture Atlas) | 多张小图合成一张大图,使多个物体可以共享材质 | UI、2D 精灵、小型道具 | 图集管理复杂,可能浪费空间 |
| 材质合并 | 减少材质种类,让更多物体能合批 | 场景美术规范 | 需要美术配合 |
| 引擎的批处理器 | 例如 Unity 的 SRP Batcher | Unity URP / HDRP 项目 | 依赖 Shader 写法兼容 |
注意区分:Unity 的SRP Batcher并不减少 Draw Call 的数量,而是降低相邻 Draw Call 之间的设置开销:使用同一 Shader 变体的物体,其材质数据常驻 GPU,切换时开销更小。所以开启后 Batches 数字可能变化不大,但 CPU 耗时会下降。
Unreal 方面,自 4.22 起重构的网格绘制管线支持对相同网格与材质的自动实例化;UE5 的Nanite虚拟几何体则从根本上改变了几何体的提交方式。
4. UI 的合批
UI 同样产生 Draw Call,而且常被忽视:
| 问题 | 说明 |
|---|---|
| 图集打散 | 同一界面的图片来自不同图集,无法合批 |
| 层级穿插 | 图片、文字交替叠放,打断合批 |
| 频繁重建 | 以 Unity UGUI 为例,Canvas 中任一元素变化会导致整个 Canvas 重建网格。常见做法是把频繁变化的元素(如倒计时、血条)拆到独立的 Canvas |
| 隐藏方式 | 通过频繁激活 / 禁用对象来隐藏 UI,可能触发重建,具体开销因引擎和 UI 框架而异 |
5. Draw Call 多少算多
没有通用答案。它取决于平台、图形 API、引擎和每个 Draw Call 的状态切换量。业界经验上,移动端通常比 PC 和主机严格得多。
正确做法是:
- 在目标设备上测量渲染线程耗时;
- 以耗时而不是数字本身作为判断依据;
- 为项目制定自己的预算,并在每日构建中跟踪趋势。
五、GPU 端:面数、填充率、带宽
当瓶颈在 GPU 时,需要区分是“顶点太多”还是“像素太忙”。
1. 面数与顶点数
| 术语 | 含义 |
|---|---|
| 三角面(Triangle / Tris) | GPU 渲染的基本图元,模型由大量三角面组成 |
| 顶点(Vertex) | 三角面的角点。因为法线、UV 不同而拆分的顶点,在 GPU 侧会计为多个 |
| 顶点着色器开销 | 每个顶点都要经过变换、蒙皮等计算 |
常见误区:只看建模软件里的面数。实际进入 GPU 的顶点数,会因为硬边、UV 接缝而增加;骨骼蒙皮也会显著增加顶点计算量。
2. 填充率与 Overdraw
| 术语 | 含义 |
|---|---|
| 填充率(Fill Rate) | GPU 每秒能处理(写入)的像素数量 |
| Overdraw(过度绘制) | 同一个像素在一帧中被绘制多次 |
从侧面看一个像素: 眼睛 ──→ [半透明特效1] [半透明特效2] [半透明特效3] [角色] [地面] ↑ ↑ ↑ ↑ ↑ 这一个像素被着色了 5 次 → Overdraw = 5不透明物体:GPU 可以通过深度测试(尤其是Early-Z,在像素着色之前做深度测试)提前丢弃被遮挡的像素,因此通常建议从前往后绘制不透明物体。
半透明物体:需要从后往前绘制并混合,无法利用深度测试提前丢弃,每一层都要完整着色。这就是为什么大面积半透明特效、多层 UI 叠加是 Overdraw 的重灾区。
常见优化:
- 减少大面积半透明粒子,缩小粒子贴图中的透明区域(使用贴合形状的网格而非方形面片);
- 全屏 UI 打开时,关闭被完全遮挡的 3D 场景渲染;
- 多数引擎提供 Overdraw 可视化视图,用于定位问题区域。
3. 分辨率:最直接的 GPU 开关
像素数量直接决定像素处理压力:
| 分辨率 | 像素数(约) | 相对 1080p |
|---|---|---|
| 1280 × 720 | 92 万 | 0.44 倍 |
| 1920 × 1080 | 207 万 | 1 倍 |
| 2560 × 1440 | 369 万 | 1.78 倍 |
| 3840 × 2160 | 829 万 | 4 倍 |
相关技术:
| 术语 | 含义 |
|---|---|
| 渲染分辨率缩放(Render Scale) | 以低于屏幕的分辨率渲染 3D 场景,再放大显示;UI 通常仍以原生分辨率绘制 |
| 动态分辨率(Dynamic Resolution) | 根据 GPU 负载实时调整渲染分辨率,保持帧率稳定 |
| 超分辨率(Upscaling) | 用算法把低分辨率画面重建到高分辨率,如 NVIDIA DLSS、AMD FSR、Intel XeSS、Apple MetalFX |
移动设备的屏幕分辨率往往很高,以原生分辨率渲染 3D 场景通常没有必要。降低渲染分辨率是移动端最常用、也最有效的 GPU 优化手段之一。