1. 从一帧的旅程说起:为什么帧计时值得单独拎出来讲
如果你做过一段时间虚幻引擎的性能优化,大概率经历过这种场景:美术跑过来说“场景里加了点东西,你帮我看看掉不掉帧”,你打开stat unit,发现 Game 线程 12ms、Draw 线程 8ms、GPU 14ms,帧率卡在 60 上下晃。你盯着这几个数字,心里大概知道是 GPU 那边有点紧,但具体紧在哪、是哪个 Pass 拖的、下一帧会不会更糟,光看这几个数其实说不清楚。
这就是帧计时这件事的麻烦之处:它看起来只是几个毫秒数,背后却是一整条从输入采样、游戏逻辑、渲染提交、GPU 执行到最终上屏的流水线。任何一个环节的节奏没对上,表现出来就是卡顿、延迟、手感发飘。而“A Frame's Life”这个分享主题,讲的正是这一整条链路——一帧从诞生到显示,中间经历了什么,时间是怎么被切分的,同步点在哪里,延迟又是怎么一层层累积起来的。
我先把这篇内容的定位说清楚:它不是给完全没碰过引擎的人看的入门教程,也不是纯理论推导。它更适合那些已经能看懂stat unit、知道 Game/Render/Draw 三条线程大概在干什么,但想进一步搞清楚“为什么我的帧时间分布是这样”“为什么加了同步点反而更卡”“为什么 GPU 明明没满但延迟就是下不来”的从业者。无论你是做主机、PC 还是移动端,只要你在意帧的节奏和手感,这些内容都用得上。
在展开之前,先给一个最朴素的框架,方便后面所有讨论都有个锚点。一帧的生命周期,粗略可以拆成这么几段:输入在某个时刻被采样,游戏逻辑基于这份输入推进世界状态,渲染线程把这一帧要画的东西整理成命令,GPU 按命令执行并输出到后台缓冲,最后在垂直同步的节拍上交换到前台显示。每一段之间都可能存在等待、缓冲和同步,而延迟就是这些等待和缓冲的总和。理解帧计时,本质上就是理解这些等待发生在哪、为什么发生、能不能减少。
2. 帧计时到底在计什么:Game、Render、GPU 三条线的真实关系
2.1 三条线程不是并行跑完就完事
很多人第一次看stat unit会有一个误解,以为 Frame Time 是三条线里最大的那个,优化就是把这个最大的压下去。这个理解只对了一半。Frame Time 确实约等于三者中的最大值,但三条线之间的关系不是“各跑各的、取最慢”,而是有明确的依赖和同步。
Game 线程负责游戏逻辑、物理、动画求值、蓝图执行这些。它跑完一帧的逻辑后,会把这一帧需要渲染的数据(也就是场景代理、可见性信息等)交给 Render 线程。Render 线程做可见性剔除、生成渲染命令、准备 RHI 层的调用。Draw 线程(在很多版本里和 Render 线程合并讨论,但概念上要分开)负责把命令提交给 GPU。GPU 则真正执行绘制。
关键在于:Game 线程跑第 N+1 帧的时候,Render 线程可能还在处理第 N 帧,GPU 可能还在画第 N-1 帧。这种流水线式的重叠是性能的来源,也是延迟的来源。你看到的 Frame Time 是这条流水线稳定后的产出节奏,但单帧的延迟其实是好几个帧周期叠加的结果。
2.2 用一张表把三条线的职责和常见瓶颈对齐
| 线程 | 主要职责 | 典型瓶颈来源 | 观察命令 |
|---|---|---|---|
| Game | 逻辑、物理、动画、蓝图、网络同步 | 复杂蓝图、大量 Actor Tick、物理模拟 | stat game、stat physics |
| Render | 可见性剔除、渲染命令生成 | 大量图元、复杂材质、阴影投射 | stat scenerendering |
| Draw/RHI | 命令提交、状态切换 | Draw Call 过多、状态频繁切换 | stat rhi |
| GPU | 实际像素和几何处理 | 高分辨率、复杂光照、后处理 | ProfileGPU、stat gpu |
这张表不是让你背,而是让你在遇到问题时能快速定位该看哪个命令。我自己的习惯是,先看stat unit确定是哪条线冒头,再进对应的细分命令里找具体原因。比如 Game 线程高,先看stat game里哪一项占比大;GPU 高,直接开ProfileGPU看哪个 Pass 最贵。
2.3 帧时间的“稳定”比“平均”更重要
这里要插一个很多人踩过的坑:只盯着平均帧时间优化,结果 1% low 帧一塌糊涂。平均 60fps 听起来不错,但如果每隔几帧就有一个 30ms 的尖峰,玩家感受到的就是持续的小卡顿。帧计时的核心价值,恰恰在于帮你找到这些尖峰的来源。
尖峰的常见来源有几类:一是资源加载或流送导致的 hitch,比如纹理流送、Level Streaming;二是垃圾回收或内存分配抖动;三是同步点上的等待被放大,比如某个线程偶尔多跑了几毫秒,导致整条流水线等它。这些在平均值里往往被稀释掉,但在帧时间曲线上非常明显。所以我在实际项目里,除了看平均值,一定会看帧时间的分布和最大值。
3. 同步机制:为什么加了同步反而可能更卡
3.1 同步的本质是“等”,而等待是有代价的
同步这个词听起来很正面,好像加了同步就“稳”了。但在帧计时语境下,同步的本质是让某条线程停下来等另一条线程。等待本身不产生任何有效工作,纯粹是时间开销。所以每一次同步都是一次权衡:用等待换取数据一致性或资源安全。
虚幻引擎里常见的同步点包括:Game 线程等待 Render 线程释放上一帧的资源、Render 线程等待 GPU 完成某些操作、以及各种 fence 和 event 的使用。这些同步点在正常情况下开销很小,但在帧时间紧张的时候会被放大。
3.2 三种典型同步场景的拆解
第一种是帧末的同步。Game 线程跑完一帧后,通常需要等 Render 线程把这一帧的数据消费掉,才能安全地修改某些共享状态。如果 Render 线程这一帧特别慢,Game 线程就会在这里卡住,表现出来就是 Game 线程时间被拉长,但实际逻辑并没变复杂。
第二种是资源生命周期的同步。比如一帧里创建的临时渲染资源,必须等 GPU 用完才能释放。如果释放时机没安排好,就会出现“等 GPU”的情况。这类问题在stat unit里往往表现为 GPU 时间不高但帧率上不去,因为瓶颈其实在等待上。
第三种是跨帧的同步。比如某些效果需要读取上一帧的结果,这就天然引入了一帧的延迟。延迟本身不一定是问题,但如果同步点设计得不好,可能引入两帧甚至更多帧的延迟。
3.3 一个实操判断:同步点是不是元凶
我常用的判断方法是:先看stat unit里三条线的时间,如果某条线的时间明显高于它实际的工作量(比如 Game 线程逻辑很简单但时间很长),那大概率是在等同步。这时候可以进一步用stat sync或者引擎自带的同步统计来看具体等在哪里。
注意:不要一看到同步就想着去掉。有些同步是正确性必需的,去掉会引入更难查的 bug。正确的做法是判断这个同步是否发生在关键路径上,能不能通过调整缓冲策略来减少等待。
4. 延迟:从输入到上屏,每一段都在偷偷加时间
4.1 延迟不是单一数字,而是一条链
很多人问“我的游戏延迟多少”,其实这个问题没有单一答案。从玩家按下按键到屏幕上看到反应,中间至少经过:输入采样延迟、游戏逻辑处理延迟、渲染命令生成延迟、GPU 执行延迟、显示扫描延迟。每一段都有自己的时间,加起来才是端到端延迟。
在 60fps 下,一个帧周期是 16.67ms。如果整条链路刚好卡在一个帧周期内,那端到端延迟大概就是 16.67ms 加上显示本身的延迟。但实际情况往往更复杂,因为流水线重叠意味着你这一帧的输入可能要等下一帧甚至下下帧才被处理。
4.2 输入采样时机决定了下限
输入采样发生在帧的哪个时刻,直接决定了输入延迟的下限。如果输入在帧的最开始采样,那这一帧就能用上;如果采样发生在帧的末尾,那这份输入只能等下一帧。虚幻引擎里输入采样和 Game 线程的调度关系,是延迟优化里非常关键但容易被忽略的一环。
我实测过一个简单的对比:在同样的逻辑下,把输入采样点提前,端到端延迟能减少接近一个帧周期。这个收益在快节奏游戏里非常明显,因为玩家对输入响应的感知阈值其实很低,几十毫秒的差别就能感觉到“跟手”和“不跟手”。
4.3 缓冲和队列是延迟的隐形推手
渲染管线里有很多缓冲和队列,比如交换链的缓冲数量、命令队列的深度。这些缓冲的存在是为了平滑帧时间的波动,让 GPU 不至于因为偶尔的 CPU 卡顿而空转。但代价就是延迟增加:缓冲越多,你这一帧的画面越晚才显示。
这里有个经典的权衡:缓冲少,延迟低,但帧时间波动容易导致撕裂或卡顿;缓冲多,帧时间平滑,但延迟高。虚幻引擎默认的交换链配置是一个折中,但在竞技类项目里,往往需要手动调整来压低延迟。
| 缓冲策略 | 延迟表现 | 帧时间平滑度 | 适用场景 |
|---|---|---|---|
| 单缓冲 | 最低 | 最差 | 几乎不用 |
| 双缓冲 | 较低 | 一般 | 竞技类、VR |
| 三缓冲 | 较高 | 较好 | 单机、画面优先 |
4.4 用滑动窗口的思路理解延迟波动
延迟不是一个固定值,它会随帧时间波动。如果某一帧特别慢,这一帧的延迟就会拉长,而且可能影响后续几帧。用滑动窗口的视角来看,你关心的不是某一帧的延迟,而是一段时间窗口内的延迟分布。这也是为什么 1% low 帧和延迟是强相关的:那些慢帧就是延迟尖峰的来源。
5. 实操:怎么在项目里定位帧计时和延迟问题
5.1 第一步永远是建立基线
在动任何优化之前,先在一个固定场景、固定视角、固定操作下录一段帧时间数据。这个基线是你后续所有对比的依据。我习惯用stat unit配合引擎的 CSV Profiler,把 Game、Render、GPU 三条线的时间都记下来,同时记录帧时间的最大值和 1% low。
没有基线就优化,等于闭着眼睛调参数,你根本不知道改动是变好了还是变差了。
5.2 用 ProfileGPU 拆解 GPU 时间
GPU 时间高的时候,stat gpu只能告诉你大概哪个大类贵,真正要定位到具体 Pass,得用ProfileGPU。它会输出每个渲染 Pass 的耗时,包括 Base Pass、Lighting、Shadow、PostProcess 等等。我一般会重点看几个地方:阴影相关 Pass 是不是异常大、后处理链是不是太长、有没有意外的全屏 Pass。
提示:ProfileGPU 本身有开销,测出来的绝对值会比实际略高,但相对占比是可信的。用它来定位瓶颈,不要用它来报最终帧率。
5.3 用 Unreal Insights 看跨帧的节奏
单帧的 stat 命令看的是瞬时状态,要看跨帧的节奏和同步关系,Unreal Insights 是更好的工具。它能把 Game、Render、GPU 的时间线铺开,让你看到哪一帧在等哪一帧、同步点在哪里、有没有长尾的 hitch。我排查偶发卡顿的时候,基本都靠 Insights 的时间线来定位。
具体操作上,先开 Insights 抓一段包含卡顿的 trace,然后在 Timing 视图里找那些明显凸起的帧,放大看它前后几条线程的状态。很多时候你会发现,卡顿的根源不在卡顿那一帧本身,而在前面几帧的某个同步等待。
5.4 延迟的实测方法
延迟很难靠 stat 命令直接测出来,通常需要外部手段。简单一点的做法是用高速摄像机拍按键和屏幕反应,粗略估算端到端延迟。更精细的做法是在引擎里打时间戳,从输入事件开始,到这一帧上屏的 Present 事件结束,中间的时间差就是这一帧的延迟。
我在项目里会做一个简单的延迟统计:在输入处理处记一个时间,在 Present 处记一个时间,两者相减,输出到日志或屏幕。这样能直观看到延迟随帧时间的变化。
6. 常见问题与排查速查
6.1 帧率够但手感不对
这种情况往往是延迟问题,而不是帧率问题。帧率稳定在 60 但感觉不跟手,通常是输入采样时机太晚,或者缓冲队列太长。先检查输入采样点,再看交换链配置。
6.2 GPU 没满但帧率上不去
大概率是同步等待。用 Insights 看 GPU 时间线,如果 GPU 有空闲但帧率受限,说明 CPU 侧在等某个同步点,或者命令提交节奏有问题。
6.3 偶发卡顿查不到来源
先确认是不是资源加载导致的。用stat streaming看纹理流送,用 Insights 看有没有长耗时的加载事件。如果排除了加载,再看是不是 GC 或内存分配抖动。
| 现象 | 可能原因 | 排查工具 |
|---|---|---|
| 帧率够但延迟高 | 输入采样晚、缓冲多 | 输入时间戳、交换链配置 |
| GPU 空闲但帧率低 | 同步等待、提交瓶颈 | Unreal Insights |
| 偶发卡顿 | 资源加载、GC | stat streaming、Insights |
| 1% low 差 | 帧时间尖峰 | CSV Profiler、帧时间曲线 |
6.4 一个容易被忽略的点:垂直同步的影响
垂直同步在消除撕裂的同时会引入额外延迟,尤其是在帧时间接近帧周期的时候。如果项目对延迟敏感,可以考虑关闭垂直同步配合帧率限制,或者使用更现代的呈现模式。这个取舍没有标准答案,得根据项目类型来定。
7. 我在实际项目里踩过的坑和几条经验
第一条经验是:不要孤立地看单帧数据。帧计时的问题往往是跨帧的,某一帧的异常可能是前面几帧积累的结果。养成看时间线的习惯,比盯着单个数字有用得多。
第二条是:同步点的优化要谨慎。我见过为了压帧时间把某个同步去掉,结果引入了偶发的资源竞争,查了好几天。同步的存在通常有它的理由,动之前先搞清楚它在保护什么。
第三条是:延迟优化和帧率优化有时候是冲突的。压低延迟可能需要减少缓冲,但减少缓冲会让帧时间波动更明显。这时候要明确项目的优先级,是手感优先还是画面稳定优先,然后围绕这个优先级做取舍。
第四条是:工具要用熟。stat unit、ProfileGPU、Unreal Insights 这三个是我日常用得最多的,每个的脾气都不一样。比如 ProfileGPU 有开销、Insights 抓 trace 会影响性能,知道这些才能正确解读数据。
最后分享一个小技巧:在项目里加一个简单的帧时间和延迟的屏幕叠加显示,开发期一直开着。这样任何改动带来的帧时间变化都能立刻看到,比事后专门去测要高效得多。这个叠加显示不用很复杂,几个数字加一条帧时间曲线就够了,但它能帮你建立起对帧节奏的直觉,这种直觉在优化时非常值钱。