1. 为什么"帧的一生"值得单独拿出来讲
如果你做过一段时间的虚幻引擎开发,大概率经历过这种场景:编辑器里跑得好好的项目,打包出来在真机上就是感觉"不跟手";或者美术同学跑过来问你,为什么他做的特效在低端机上会一顿一顿的,而你在编辑器里怎么看都顺。这类问题最后十有八九会落到同一个地方——帧计时、同步与延迟。
"A Frame's Life"这个议题名字起得很妙,它把一帧当成一个有生命周期的对象来看待:从游戏线程决定"这一帧要干什么",到渲染线程把它翻译成GPU指令,再到GPU真正把像素画到屏幕上,最后到玩家按下按键、这个输入又反过来影响下一帧——这一整条链路里,任何一环拖后腿,玩家感知到的就是延迟、卡顿、撕裂。
我先把这篇要讲的东西说清楚:它适合已经能写Gameplay逻辑、但对引擎底层时序机制只有模糊概念的开发者;也适合做性能优化、做主机/移动端移植、做VR这类对延迟极度敏感项目的同学。看完之后你应该能做到几件事:看懂stat unit、stat fps、stat game这些命令背后到底在量什么;理解GameThread、RenderThread、RHIThread、GPU这几条流水线是怎么并行又怎么互相等待的;知道帧率上限、垂直同步、帧队列这些设置对延迟的实际影响;以及最关键的——当有人跟你说"这游戏延迟高"的时候,你能拆出到底是哪一段延迟。
我不会一上来就甩一堆引擎源码路径,而是按"一帧从生到死"的顺序来讲,中间穿插我自己踩过的坑。有些结论可能和你直觉相反,比如"帧率越高延迟越低"这句话在特定配置下并不成立,后面会展开。
2. 一帧的生命周期:从输入采样到像素上屏
2.1 游戏线程这一帧到底在忙什么
虚幻引擎的主循环,核心是一个叫FEngineLoop::Tick的东西。每一帧它做的事情可以粗略拆成几块:处理平台层发来的消息(输入、窗口事件)、Tick世界(Actor的Tick、组件Tick、蓝图Tick)、跑网络同步、最后把这一帧的渲染数据交给渲染线程。
这里有个很多人忽略的点:输入并不是"实时"被处理的。平台层的输入事件会被缓存,然后在下一帧的Tick里被消费。也就是说,你按下按键的那一刻,到游戏逻辑真正读到这个输入,中间至少隔了"当前帧剩余时间 + 下一帧的游戏线程耗时"。在60帧下,这一来一回轻松就是十几毫秒。这就是为什么做格斗游戏、音游、VR的时候,输入延迟是头号敌人。
游戏线程的耗时,用stat game能看到。但要注意,这个数字是"游戏线程这一帧花了多久",它不包括渲染线程和GPU的时间。很多人看到stat game只有5ms就以为万事大吉,结果GPU那边已经30ms了,帧率照样上不去。
2.2 渲染线程与RHI线程的分工
游戏线程把场景数据准备好之后,会通过一个叫FSceneRenderer的机制把渲染命令入队,渲染线程(RenderThread)从队列里取出来,做可见性剔除、生成绘制调用(Draw Call),再交给RHI线程(RHIThread)翻译成具体图形API的调用,最后提交给GPU。
这几条线程是并行的,但又不是完全独立。它们之间靠帧同步机制协调,引擎会限制渲染线程最多领先游戏线程多少帧(这个由r.RHICmdBypass、r.OneFrameThreadLag之类的CVar控制)。默认情况下,渲染线程可以领先游戏线程一帧,这样能提高吞吐,但代价是增加了一帧的延迟。
我实测过一个很典型的例子:一个场景在编辑器里stat unit显示Game 8ms、Draw 6ms、GPU 10ms,看起来GPU是瓶颈。但把r.OneFrameThreadLag设成0之后,帧率反而掉了一点,因为线程间的并行度降低了。所以这个参数不是"越小越好",它是在吞吐和延迟之间做权衡。
2.3 GPU这一帧的结束才是真的结束
GPU的工作是异步的。当你看到stat unit里的GPU时间时,那其实是上一帧甚至上上帧的GPU耗时,因为CPU提交完命令就继续往下跑了,不会等GPU画完。这就是为什么GPU瓶颈有时候很难定位——CPU看起来闲得很,但帧率就是上不去。
要准确测量GPU时间,得用平台相关的工具,比如PC上的PIX、RenderDoc,主机平台各自的profiler。虚幻自己的stat GPU在部分平台上能给出比较准的数字,但跨平台一致性一般。
这里有个经验:当你怀疑是GPU瓶颈时,先把分辨率降一半看帧率有没有明显提升。如果帧率翻倍,那基本就是像素填充或者带宽的问题;如果几乎没变,那瓶颈大概率在CPU侧或者Draw Call数量上。
3. 帧率、帧队列与垂直同步:延迟的三个隐形推手
3.1 帧率上限不等于延迟上限
很多人有个误区:把帧率上限设得越高,延迟就越低。这话在GPU没跑满的前提下成立,但一旦GPU成为瓶颈,情况就变了。
假设你的GPU渲染一帧需要20ms,那么无论你把帧率上限设成多少,实际帧率都上不去50帧。这时候如果你还开着三重缓冲,GPU的队列里会堆积2-3帧待渲染的画面,玩家看到的画面可能已经是几十毫秒前的状态了。这就是所谓的"队列延迟"。
虚幻里控制这个的是t.MaxFPS和平台层的交换链配置。在PC上,r.VSync控制垂直同步,r.FrameRateLimit或者t.MaxFPS控制帧率上限。我的建议是:竞技类项目关垂直同步、帧率上限设成显示器刷新率的整数倍附近;单机剧情类项目可以开垂直同步防撕裂,但要接受额外延迟。
3.2 垂直同步到底在等什么
垂直同步的本质是让GPU的呈现(Present)操作和显示器的刷新周期对齐。不开垂直同步的时候,GPU画完一帧就直接Present,可能出现"撕裂"——屏幕上半部分是上一帧,下半部分是这一帧。开了垂直同步,Present会等到显示器下一次刷新才开始,避免了撕裂,但引入了等待。
这个等待时间最长可以接近一个刷新周期。60Hz显示器就是16.7ms,144Hz是6.9ms。听起来不多,但在VR里这就是能不能让人不晕车的区别。
虚幻在移动端和主机上对垂直同步的处理和PC不太一样,移动端很多平台是强制开垂直同步的,因为移动GPU的Present机制和桌面不同。做移动项目的时候,别指望靠关垂直同步来降延迟,得从别的地方想办法。
3.3 三重缓冲是朋友还是敌人
三重缓冲的初衷是解决"开了垂直同步之后帧率被腰斩"的问题。不开三重缓冲时,如果GPU渲染一帧的时间略长于刷新周期,帧率会直接从60掉到30。三重缓冲让GPU可以继续渲染下一帧,等下一个刷新周期再Present,帧率更平滑。
但代价就是延迟。三重缓冲意味着最多有2帧在队列里等着,加上正在渲染的那一帧,玩家看到的画面可能滞后2-3帧。在60帧下就是33-50ms的额外延迟。
虚幻默认在PC上是不开三重缓冲的,需要手动在项目设置或者平台配置里开。我的经验是:除非你的项目对帧率稳定性要求极高、且对延迟不敏感(比如某些策略游戏、模拟经营),否则不要轻易开三重缓冲。
4. 同步机制:线程之间到底在等什么
4.1 帧同步与任务图
虚幻的任务系统(Task Graph)是理解帧同步的关键。引擎把一帧内可以并行的工作拆成一个个Task,丢进任务图里,由工作线程池去执行。游戏线程和渲染线程之间的同步,很大程度上就是靠任务图的依赖关系来保证的。
比如渲染线程要等游戏线程把这一帧的场景数据准备好才能开始剔除,这个"等"就是通过任务图的依赖实现的。如果游戏线程这一帧特别慢,渲染线程就得干等,GPU也就跟着饿肚子。
这里有个优化点:把不依赖游戏线程状态的工作提前。比如一些后处理、UI渲染,其实可以在游戏线程还在跑逻辑的时候就先开始准备。虚幻的FParallelCommandList和相关的并行渲染机制就是干这个的。
4.2 渲染命令的入队与出队
游戏线程往渲染线程传数据,走的是ENQUEUE_RENDER_COMMAND这套机制。每一条命令都会被塞进一个队列,渲染线程按顺序取出来执行。这个队列是有容量限制的,如果游戏线程生产得太快,队列满了,游戏线程就会被阻塞,直到渲染线程消费掉一些。
这个阻塞点很容易被忽略。你在游戏线程里看到某个函数莫名其妙变慢了,有可能不是它本身慢,而是它在等渲染线程腾出队列空间。用Unreal Insights抓一下,能看到WaitForTasksToComplete或者类似的等待标记。
我踩过的一个坑:在一个大量使用动态材质实例的项目里,每帧都在游戏线程创建新的材质实例并传给渲染线程,结果渲染命令队列频繁打满,游戏线程被反复阻塞。后来改成复用材质实例、只在必要时更新参数,帧时间直接降了3ms。
4.3 跨平台同步的差异
不同平台的图形API对同步的处理差别很大。PC上的DX12和Vulkan给了开发者更多控制权,但也更容易写出同步问题;主机平台通常有更严格的验证层,问题暴露得更早;移动端的Vulkan和Metal在同步上的行为又不一样。
虚幻的RHI层做了很多封装来抹平这些差异,但不是万能的。做跨平台项目的时候,同步相关的bug往往只在某一个平台上出现,而且很难复现。我的建议是尽早建立每个平台的性能基线,用自动化测试定期跑,别等到快上线了才发现某个平台帧时间莫名其妙比别人高。
5. 延迟的拆解:从按下按键到看到反应
5.1 输入延迟的组成
玩家感知到的"延迟",其实是好几段延迟的叠加:
| 延迟来源 | 典型耗时(60帧) | 说明 |
|---|---|---|
| 输入设备采样 | 1-8ms | 鼠标、手柄的轮询率不同 |
| 平台层到游戏线程 | 0-16ms | 取决于输入在帧内什么时候被处理 |
| 游戏逻辑处理 | 1-5ms | 取决于逻辑复杂度 |
| 渲染线程准备 | 1-5ms | 剔除、生成绘制调用 |
| GPU渲染 | 5-30ms | 取决于场景复杂度 |
| 显示呈现 | 0-16ms | 垂直同步等待 |
| 显示器响应 | 1-10ms | 面板本身的响应时间 |
把这些加起来,60帧下轻松就是50-100ms。这就是为什么"感觉不跟手"是很多项目的通病。
5.2 用Unreal Insights定位延迟
Unreal Insights是虚幻自带的性能分析工具,能同时抓CPU和GPU的时序。用它看延迟,重点看几个东西:输入事件的时间戳、游戏线程处理输入的时间、渲染线程提交的时间、GPU完成的时间。
我一般会先抓一段包含"按下按键"到"画面上出现反应"的完整trace,然后在时间轴上量这几个点之间的间隔。如果发现游戏线程处理输入的时间点离输入事件的时间戳很远,那说明输入在队列里等太久了,可能是帧率太低或者输入处理逻辑太重。
5.3 降低延迟的实操手段
几个我实际用过、效果比较明显的手段:
- 提高帧率:这是最直接的。帧率翻倍,帧内延迟基本减半。
- 减少帧队列深度:把
r.OneFrameThreadLag设成0,牺牲一点吞吐换延迟。 - 输入在帧内尽早处理:虚幻的输入处理默认在Tick之前,但如果你的项目有自定义的输入系统,确保它别排在Tick后面。
- 关垂直同步、关三重缓冲:前提是能接受撕裂。
- 用低延迟模式:NVIDIA Reflex之类的技术,虚幻有对应的插件支持,能显著降低从输入到显示的延迟。
注意:这些手段之间会互相影响。比如你关了垂直同步,帧率上去了,但GPU可能跑满,导致帧时间波动变大,反而感觉更卡。调优的时候要一项一项来,每改一项都实测。
6. 那些年我踩过的帧计时坑
6.1 stat unit的数字为什么会骗人
stat unit显示的是Game、Draw、GPU三个时间,取最大值作为帧时间的估计。但这个估计在有些情况下是不准的。
比如当游戏线程和渲染线程的负载很不均衡时,stat unit可能显示Game 10ms、Draw 5ms、GPU 5ms,你以为帧时间就是10ms,但实际上因为线程间的同步等待,真实帧时间可能是15ms。这时候得看stat unit下面的Frame数字,那个才是真实的帧时间。
还有一个坑:stat unit在编辑器里和打包后的行为不完全一样。编辑器里有很多额外的开销,而且编辑器的渲染路径和游戏模式不同。性能测试一定要在打包版本里做,编辑器里的数字只能作为参考。
6.2 帧率上限设了但没生效
t.MaxFPS这个命令,在有些平台上会被平台层的设置覆盖。比如在移动端,很多设备有自己的帧率控制机制,t.MaxFPS设了也不一定生效。PC上如果开了垂直同步,t.MaxFPS设得比刷新率高也没用。
我遇到过一个案例:项目在PC上设了t.MaxFPS 120,但实测帧率一直在60左右。查了半天发现是显卡驱动里开了"垂直同步"的全局设置,把引擎的设置覆盖了。这种问题很难从引擎侧发现,得从平台侧排查。
6.3 GPU时间测不准的几种情况
前面说过GPU时间是异步的,stat GPU给出的数字有时候是上一帧的。更麻烦的是,在某些平台上,GPU时间根本拿不到准确的数字,只能靠平台工具。
还有一个常见问题:GPU时间在帧与帧之间波动很大。这时候看平均值没意义,得看分布。虚幻的stat GPU不给分布,得用平台工具或者自己写统计。我一般会抓一段trace,把GPU时间导出来,看P50、P95、P99,这样能看出卡顿到底有多严重。
7. 把帧计时知识用到实际优化里
7.1 先定位瓶颈在哪条线程
优化的第一步永远是定位。用stat unit看三个时间哪个最大:
- Game最大:瓶颈在游戏逻辑,去看
stat game的细分,找耗时最长的Actor或系统。 - Draw最大:瓶颈在渲染线程,去看
stat rendering,重点看Draw Call数量和剔除效率。 - GPU最大:瓶颈在GPU,去看
stat gpu或者平台工具,重点看像素填充、带宽、Shader复杂度。
但别忘了前面说的,stat unit可能骗人。如果三个数字都不大但帧率就是上不去,那大概率是同步等待的问题,得用Unreal Insights看线程间的等待关系。
7.2 针对性的优化手段
定位到瓶颈之后,优化手段就相对明确了:
游戏线程瓶颈:
- 减少每帧Tick的Actor数量,用事件驱动代替轮询
- 把重逻辑拆到多帧执行,或者用异步任务
- 检查蓝图,蓝图Tick的开销比C++大很多
渲染线程瓶颈:
- 减少Draw Call,用实例化、合并网格
- 优化剔除,减少不必要的可见性计算
- 检查有没有每帧创建渲染资源的情况
GPU瓶颈:
- 降低分辨率或者用动态分辨率
- 简化Shader,减少过度绘制
- 优化纹理,减少带宽压力
7.3 优化之后怎么验证
优化做完一定要验证,而且要在目标平台上验证。PC上跑得好不代表主机上跑得好,编辑器里跑得好不代表打包后跑得好。
我一般会建一个自动化测试流程:固定场景、固定相机路径、固定时长,每次优化后跑一遍,记录帧时间的P50、P95、P99。这样能看出优化是不是真的有效,还是只是把问题挪到了别的地方。
还有一个经验:优化不要一次改太多。一次改一个点,测一次,确认有效再改下一个。否则出了问题很难定位是哪个改动导致的。
8. 关于帧计时,我最后想说的几件事
帧计时这个东西,入门容易精通难。引擎给了你一堆工具和参数,但每个参数背后都是权衡。帧率、延迟、吞吐、稳定性,这四个东西很难同时最优,你得根据项目类型决定优先级。
竞技类项目,延迟优先,帧率尽量高,可以接受一定的帧率波动。单机剧情类项目,稳定性优先,帧率可以低一点但要稳。VR项目,延迟是生死线,其他都可以让步。移动项目,功耗和发热是硬约束,帧率上限往往不是你能决定的。
我个人的习惯是,项目早期就把性能基线建起来,别等到快上线了才想起来优化。帧计时的问题越早发现越好改,越往后改代价越大。Unreal Insights是个好东西,建议每个做虚幻的人都花时间学一下怎么用,它能帮你省下大量瞎猜的时间。
最后分享一个小技巧:如果你怀疑某个功能导致了延迟,但又说不清是哪一段,可以试试在输入处理的地方打一个时间戳,在画面上出现反应的地方再打一个,两个时间戳一减,就是端到端的延迟。这个方法很土,但非常有效,尤其是在你还没有建立起完整性能分析流程的时候。