ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

虚幻引擎帧计时与延迟优化:从一帧生命周期到性能调优实战

虚幻引擎帧计时与延迟优化:从一帧生命周期到性能调优实战 1. 为什么帧的一生值得单独拿出来讲如果你做过一段时间的虚幻引擎开发大概率经历过这种场景编辑器里跑得好好的项目打包出来在真机上就是感觉不跟手或者美术同学跑过来问你为什么他做的特效在低端机上会一顿一顿的而你在编辑器里怎么看都顺。这类问题最后十有八九会落到同一个地方——帧计时、同步与延迟。A Frames 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.7ms144Hz是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是个好东西建议每个做虚幻的人都花时间学一下怎么用它能帮你省下大量瞎猜的时间。最后分享一个小技巧如果你怀疑某个功能导致了延迟但又说不清是哪一段可以试试在输入处理的地方打一个时间戳在画面上出现反应的地方再打一个两个时间戳一减就是端到端的延迟。这个方法很土但非常有效尤其是在你还没有建立起完整性能分析流程的时候。
返回列表