ARTICLE DETAIL

资讯详情

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

虚幻引擎帧计时与同步机制:从原理到延迟优化实战

虚幻引擎帧计时与同步机制:从原理到延迟优化实战 1. 为什么帧计时值得单独拿出来讲1.1 一个被低估的底层问题做虚幻引擎开发的人几乎都遇到过这样的场景编辑器里跑得好好的项目打包出来帧率就是不稳明明GPU占用没满但画面就是一顿一顿的多人联机时本地看着流畅对面玩家却像在瞬移。这些问题追到根上十有八九都跟帧计时和同步机制有关。A Frames Life这个议题名字起得很妙它把一帧的生命周期当成一个完整的故事来讲。从游戏线程发起Tick到渲染线程提交命令再到GPU实际执行、画面呈现到显示器上这一整条链路里每一环都有时间成本每一环都可能成为延迟的来源。很多人只盯着最后的FPS数字看但FPS只是一个平均值它掩盖了太多真相。我刚开始做UE项目性能优化的时候也犯过这个毛病——看到stat fps显示60就以为万事大吉。后来用stat unit一看Game线程28ms、Draw线程15ms、GPU 22ms三个数字对不上帧率自然稳不住。这就是典型的平均值骗人。这篇文章适合谁看如果你正在做UE项目的性能调优或者被多人同步的延迟问题折磨过又或者你只是想知道引擎内部到底怎么算一帧的时间那接下来的内容应该对你有用。我会从帧计时的基本原理讲起一路拆到同步机制和延迟优化尽量把每个为什么都说清楚。1.2 帧计时到底在计什么先把这个概念掰开。一帧的时间不等于你看到的FPS的倒数。FPS是每秒帧数60FPS意味着平均每帧16.67ms但实际每一帧的耗时可能从12ms到25ms不等波动越大体感越差。这就是为什么现在大家越来越关注1% low帧——它反映的是最差那1%帧的表现比平均帧率更能说明流畅度。虚幻引擎内部有一套完整的计时体系。核心的几个时间源包括平台级的硬件计时器比如Windows上的QueryPerformanceCounter、引擎的FPlatformTime::Seconds()、以及游戏线程和渲染线程各自的帧时间统计。这些时间源精度不同、更新频率不同用错了地方就会得到误导性的数据。举个实际例子。FPlatformTime::Seconds()返回的是双精度浮点数看起来精度很高但它的实际分辨率取决于底层硬件计时器。在某些平台上这个值的有效精度可能只有微秒级甚至更低。如果你用它来测量一个耗时几十微秒的操作误差可能比被测对象本身还大。这种时候就得上周期计数cycle counter用FPlatformTime::Cycles64()来测。提示测短耗时操作时别用秒为单位的时间函数用周期计数再换算。换算公式是 时间(秒) 周期数 / 频率频率通过FPlatformTime::GetCyclesPerSecond()获取。2. 引擎的帧循环与线程模型拆解2.1 游戏线程、渲染线程与RHI线程的分工虚幻引擎的帧循环不是单线程跑到底的它是一条流水线。理解这条流水线是理解帧计时的前提。游戏线程Game Thread负责跑蓝图、C逻辑、物理模拟、动画更新这些。它每个Tick结束时会产出一批渲染命令交给渲染线程。渲染线程Render Thread负责把这些高层命令翻译成RHIRender Hardware Interface层的调用做可见性剔除、排序、构建渲染批次。RHI线程再把最终的命令提交给GPU驱动。这三个线程是并行工作的但又不是完全独立。它们之间通过帧同步机制协调保证游戏线程不会跑得太超前导致渲染线程积压太多帧。这个协调机制就是所谓的帧流水线深度控制。默认情况下UE允许游戏线程领先渲染线程一帧。也就是说当渲染线程在渲染第N帧时游戏线程已经在准备第N1帧了。这个设计是为了让CPU和GPU尽量并行提高吞吐。但代价是引入了额外的延迟——你这一帧的输入要到下一帧甚至下下帧才能反映到画面上。我实测过一个极端案例一个输入响应要求很高的项目默认流水线配置下从按键到画面反馈的延迟大约在3到4帧。按60FPS算就是50到66ms。对于格斗游戏或者音游来说这个延迟是致命的。后来把流水线深度调成0游戏线程和渲染线程严格同步延迟降到了2帧左右但吞吐也降了帧率从稳定60掉到了偶尔55。这就是典型的延迟和吞吐的权衡。2.2 帧同步点的作用与代价引擎在每帧的特定位置会设置同步点sync point强制各个线程在这里对齐。最常见的同步点是游戏线程等待渲染线程完成上一帧的RHI提交。这个等待保证了GPU命令缓冲区不会无限增长也保证了渲染资源的生命周期安全。但同步点是有代价的。每次同步都意味着一个线程要空转等待另一个线程这段时间CPU是浪费的。如果同步点设置得太频繁CPU利用率上不去设置得太少延迟又会累积。UE提供了几个控制这个行为的CVar。r.OneFrameThreadLag控制游戏线程是否允许领先渲染线程一帧默认是1。设成0就是严格同步延迟最低但吞吐最差。rhi.SyncInterval控制的是呈现间隔影响的是GPU侧的帧节奏。这里有个经验不要盲目追求低延迟就把所有同步都关掉。我曾经把r.OneFrameThreadLag设成0之后发现某些场景下帧率反而更不稳了因为游戏线程和渲染线程互相等待任何一个抖动都会直接传导到另一个。后来改成在关键输入帧才做严格同步其他时候保持流水线效果反而更好。2.3 帧时间的统计口径stat unit命令输出的三个数字——Frame、Game、Draw——很多人搞不清楚它们的含义。Frame是整帧的墙上时间wall clock time也就是从这一帧开始到下一帧开始的实际间隔。Game是游戏线程这一帧实际工作的时间。Draw是渲染线程这一帧实际工作的时间。注意Frame不等于Game加Draw因为它们是并行执行的Frame取决于最慢的那个线程加上同步等待的时间。如果Frame明显大于Game和Draw中的较大者说明有同步等待或者GPU瓶颈。如果Game远大于Draw说明游戏逻辑是瓶颈。如果Draw远大于Game说明渲染线程或者GPU是瓶颈。还有一个容易被忽略的指标是GPU时间。stat gpu可以看到GPU各阶段的耗时。但GPU时间的测量本身有延迟——你看到的GPU时间通常是几帧之前的因为GPU是异步执行的。这一点在分析瞬时卡顿时特别容易误判。3. 延迟的构成与测量方法3.1 从输入到显示的完整延迟链路延迟不是一个单一的数字它是一条链路上所有环节的累加。从玩家按下按键到画面上出现对应的变化中间至少经过这些环节输入采集延迟外设本身的轮询率和驱动处理、游戏线程处理延迟输入事件排队到被Tick处理、渲染命令生成延迟游戏线程到渲染线程的传递、GPU执行延迟命令提交到实际绘制完成、显示延迟帧缓冲区到屏幕像素点亮。每一环都有优化空间但每一环的优化手段不同。外设层面高刷新率鼠标和键盘能降低输入采集延迟。引擎层面减少流水线深度能降低处理延迟。渲染层面减少GPU工作量能降低执行延迟。显示层面高刷新率显示器能降低显示延迟。我做过一个粗略的实测对比。同一台机器同一个UE项目用60Hz显示器和144Hz显示器分别测输入延迟。用高速摄像机拍按键和画面变化的帧差60Hz下平均延迟约85ms144Hz下约55ms。差了30ms其中大部分来自显示刷新率的提升少部分来自引擎在高帧率下流水线延迟的绝对值变小。3.2 用引擎自带工具测延迟UE提供了一些内置的延迟测量手段。最直接的是stat latency它会显示从输入采样到画面呈现的延迟帧数。但这个数字是估算的精度有限。更精确的做法是打时间戳。在输入处理的最早位置记录一个时间戳在渲染完成回调里再记录一个两者相减就是引擎内部的延迟。这个方法的难点在于跨线程传递时间戳需要保证时间基准一致。还有一个土办法但很有效在屏幕上画一个随输入变化的标记然后用高速摄像机拍。虽然听起来原始但它测的是端到端的真实延迟包含了所有环节比任何引擎内部统计都可靠。我认识的一个做VR项目的团队就是靠这个方法发现他们的延迟比预期高了20ms最后定位到是显示驱动的某个后处理环节引入的。3.3 滑动窗口滤波器在延迟统计中的应用说到延迟统计就不得不提滑动窗口滤波器。原始的帧时间数据抖动很大直接看会眼花缭乱。滑动窗口滤波器的作用是在保持趋势的同时平滑掉噪声。最简单的滑动平均就是取最近N帧的算术平均。但它有个缺点对突变响应慢而且会引入相位延迟。比如你突然遇到一次卡顿滑动平均要好几帧之后才能反映出来。稍微好一点的是指数加权移动平均EWMA它给近期数据更高的权重。UE内部的一些统计就用了类似的方法。参数α控制平滑程度α越大响应越快但噪声越大α越小越平滑但越迟钝。经验值是α取0.1到0.2之间比较平衡。还有一种叫中值滤波取窗口内的中位数而不是平均值。它对脉冲噪声特别有效比如偶尔一次的超长帧不会污染整体统计。但计算量比平均大而且对渐变趋势的跟踪不如平均。注意做性能统计时别只报平均值。至少要有平均值、最大值、以及1% low或0.1% low。平均值好看但体验差的例子太多了。4. 同步机制的核心原理与实操4.1 帧同步与状态同步的本质区别多人游戏里的同步机制绕不开帧同步和状态同步这两个概念。它们不是非此即彼很多项目是混合使用的。帧同步lockstep的核心思想是所有客户端跑相同的逻辑只同步输入不同步状态。只要输入一致、逻辑确定性一致各客户端算出来的结果就一致。它的优点是同步数据量极小一个输入指令几个字节就够了。缺点是要求逻辑必须确定性任何浮点误差、随机数不一致、遍历顺序不同都会导致状态分歧desync。状态同步的核心思想是服务器算权威状态定期把状态广播给客户端。客户端收到状态后做插值或者预测。它的优点是逻辑不需要确定性服务器说了算。缺点是同步数据量大状态越复杂带宽压力越大。UE的网络同步默认是状态同步路线通过属性复制property replication和RPC来实现。但UE也支持帧同步式的做法只是需要自己保证确定性引擎不帮你兜底。我踩过的一个坑早期做一个物理交互比较多的项目用了状态同步但物理模拟在客户端和服务器上跑出了不同结果导致物体位置对不上。后来改成服务器权威物理客户端只做表现才解决。这个教训是涉及物理的同步要么服务器权威要么保证确定性没有第三条路。4.2 网络同步中的时间对齐状态同步里最麻烦的是时间对齐。服务器和客户端的时钟不可能完全一致网络延迟也在波动。如果直接把服务器状态套用到客户端画面会一跳一跳的。UE的解决方案是网络预测和插值。客户端维护一个略滞后于服务器的时间轴把收到的状态按时间戳排好然后在自己的时间轴上插值。这样画面就平滑了代价是引入了额外的延迟——你看到的是过去的状态。这个滞后量是可以调的。调小了延迟低但容易抖动调大了平滑但延迟高。默认值通常在100ms左右。对于快节奏游戏可以压到50ms甚至更低但要求网络质量好。对于网络波动大的场景可能得放到150ms以上。还有一个关键参数是NetUpdateFrequency控制每个Actor状态同步的频率。设太高浪费带宽设太低状态更新不及时。经验做法是按Actor的重要性分级玩家角色和关键交互物高频同步背景装饰物低频甚至不同步。4.3 硬件同步与多设备协同硬件同步这个词在多个领域都有出现在游戏和多媒体领域它通常指的是多个设备或采集源之间的时间对齐。比如多相机同步采集如果每个相机各跑各的时钟采集到的画面时间戳就对不上后期做立体匹配或者多视角重建时会出问题。解决办法是用统一的硬件触发信号让所有相机在同一时刻曝光。这就是硬件同步的典型应用。在VR领域硬件同步更重要。头显的显示刷新、手柄的追踪采样、基站的定位信号这些如果不同步就会导致追踪抖动或者画面撕裂。VR头显通常有专门的同步信号线保证显示和追踪的节奏一致。UE对硬件同步的支持主要体现在VR和AR模块里。如果你做的是多屏输出或者多机协同的项目可能需要自己处理同步信号。一个常见的做法是用Genlock信号同步多台机器的GPU输出保证多屏画面的帧对齐。4.4 同步机制的性能开销同步不是免费的。每一次同步操作都有开销包括锁竞争、内存屏障、缓存失效等。在UE里游戏线程和渲染线程之间的同步主要通过FEvent和FRenderCommandFence来实现。FEvent用于线程间的信号通知FRenderCommandFence用于等待渲染命令执行完成。这些机制底层都涉及原子操作和内存屏障开销虽然不大但高频调用时会累积。我做过一个测试在一个每帧调用几千次同步等待的场景里把不必要的同步去掉之后游戏线程耗时从18ms降到了14ms。省下来的4ms就是同步开销。这个案例说明同步要用在刀刃上不能滥用。另一个容易忽略的点是锁的粒度。UE的很多容器和系统内部有锁保护如果你在多线程环境里频繁访问共享数据锁竞争会成为瓶颈。解决办法是尽量用无锁数据结构或者把数据按线程分区减少共享。5. 延迟优化的实战策略5.1 降低输入延迟的具体手段输入延迟是体感最明显的延迟。优化它收益立竿见影。第一招是提高输入采样率。UE默认的输入采样是在游戏线程Tick时进行的如果帧率是60采样率就是60Hz。但很多外设的轮询率远高于此比如鼠标可以到1000Hz。把输入采样独立出来用更高的频率采集能降低输入事件的等待时间。第二招是减少流水线深度。前面提过r.OneFrameThreadLag设成0能降低一帧延迟。但要注意副作用前面也分析过了。第三招是输入预测。对于移动类输入可以根据历史输入预测下一帧的位置提前渲染。如果预测准确体感延迟会大幅降低。但预测错误时会有回弹需要做好平滑处理。第四招是减少输入处理链路。有些项目的输入事件要经过好几层蓝图和组件才到达最终处理逻辑每一层都有开销。把关键输入的处理路径缩短能省下不少时间。我实测过一个优化良好的项目从按键到画面反馈可以做到2帧以内。按120FPS算就是16ms左右这个水平已经很难察觉到延迟了。5.2 渲染侧的延迟优化渲染侧的延迟主要来自GPU工作量和呈现机制。减少GPU工作量的手段很多降低渲染分辨率、简化材质、减少Draw Call、优化光照。这些是常规操作不多展开。重点说一下呈现机制。UE默认的呈现模式是带缓冲的GPU渲染完一帧后先放到缓冲区等下一个垂直同步信号再呈现。这个缓冲引入了至少一帧的延迟。如果关掉垂直同步延迟会降低但会有画面撕裂。还有一种模式叫即时呈现immediate presentGPU渲染完立刻呈现延迟最低。但它对帧率的稳定性要求极高帧率波动会导致明显的抖动。通常只在专业场景下使用。另一个影响延迟的因素是后处理链的长度。每个后处理效果都会增加GPU工作量有些效果还会引入额外的缓冲。如果延迟敏感可以精简后处理链去掉不必要的效果。5.3 网络延迟的对抗思路网络延迟是多人游戏绕不开的问题。物理上光速限制了延迟的下限。但在这个下限之上还有很多优化空间。第一是减少同步数据量。只同步必要的信息用压缩和量化降低带宽。比如位置可以用定点数而不是浮点数旋转可以用四元数压缩。第二是提高同步频率。在带宽允许的前提下提高关键Actor的同步频率让状态更新更及时。第三是客户端预测。客户端不等服务器确认就先行执行服务器确认后再校正。预测准确时体感延迟极低预测错误时会有校正回弹。UE的CharacterMovement组件内置了预测机制可以参考它的实现。第四是服务器端回滚。服务器收到客户端输入时回滚到输入对应的时刻重新模拟再广播结果。这样能保证公平性但服务器计算量大。适合格斗游戏这类对帧精确要求高的场景。5.4 1% low帧的工程实践1% low帧是衡量流畅度的重要指标。它反映的是最差那1%帧的表现。平均帧率60但1% low只有30的项目体感会比平均帧率50但1% low有45的项目差很多。优化1% low帧核心是消除卡顿源。常见的卡顿源包括垃圾回收、资源加载、着色器编译、物理模拟峰值、网络同步峰值。垃圾回收在UE里主要是UObject的GC。GC触发时会暂停游戏线程造成明显卡顿。减少GC卡顿的手段包括减少临时对象创建、用对象池、调整GC参数让它更频繁但每次更短。资源加载卡顿通常来自同步加载。解决办法是异步加载加流式加载把大资源拆成小块按需加载。着色器编译卡顿在首次遇到新材质时出现。解决办法是预热在加载界面就把常用着色器编译好。UE的PSO缓存机制就是干这个的。物理模拟峰值通常来自大量物体同时激活。解决办法是限制同时激活的物理体数量或者用简化的物理表示。网络同步峰值来自大量Actor同时更新。解决办法是错峰更新把Actor的更新分散到不同帧。我做过一个项目1% low帧一直上不去最后定位到是每帧都有几个蓝图在做字符串拼接产生了大量临时对象GC频繁触发。把字符串拼接改成缓存之后1% low帧从35提升到了52。这个案例说明卡顿源往往藏在不起眼的地方。6. 常见问题与排查技巧实录6.1 帧率不稳的排查思路帧率不稳是最常见的问题。排查时按这个顺序来先看stat unit确定是Game、Draw还是GPU瓶颈。如果Frame远大于Game和Draw看是不是同步等待或者垂直同步的问题。如果Game是瓶颈用stat game看具体哪个系统耗时多。如果Draw是瓶颈用stat render看渲染各阶段。如果GPU是瓶颈用stat gpu看GPU各阶段。然后看stat scenerendering检查Draw Call数量。Draw Call过多通常是渲染瓶颈的主因。再看stat memory检查内存是否吃紧内存不足会导致频繁换页引起卡顿。最后看stat gc检查垃圾回收频率和耗时。GC卡顿往往表现为周期性的帧率下降。6.2 多人同步异常的定位方法多人同步异常的表现形式很多位置不同步、状态不一致、延迟忽高忽低。定位时先用net.Ping看网络延迟排除网络本身的问题。然后用net.DebugDraw看同步的可视化检查哪些Actor在同步、同步频率如何。再用stat net看网络带宽和同步数据量判断是否带宽不足。如果是个别客户端异常检查该客户端的网络质量和硬件性能。如果是所有客户端都异常检查服务器端的同步逻辑和带宽。一个常见的坑是Actor的NetUpdateFrequency设得太低导致状态更新不及时。另一个坑是同步的属性太多带宽被占满。还有一个坑是RPC调用过于频繁把网络通道堵住了。6.3 延迟测量的常见误区测延迟时容易犯几个错误。第一个错误是只测平均值。前面说过平均值会掩盖波动。要测分布至少看P50、P95、P99。第二个错误是测量环境不真实。在编辑器里测和在打包版本里测结果可能差很多。编辑器有额外的开销打包版本更接近真实表现。第三个错误是忽略测量本身的开销。打时间戳、写日志这些操作本身有开销如果测量代码太重会污染测量结果。测量代码要尽量轻量。第四个错误是时间基准不一致。跨线程、跨设备的时间戳要对齐基准否则相减没有意义。6.4 常见问题速查表问题现象可能原因排查手段解决方向帧率周期性下降垃圾回收stat gc减少临时对象调整GC参数首次遇到某效果卡顿着色器编译stat shaders预热PSO缓存移动时卡顿资源流式加载stat streaming优化流式加载策略多人时延迟高同步数据量大stat net减少同步属性降低频率输入响应慢流水线深度大stat latency降低流水线深度输入预测GPU占用高但帧率低Draw Call过多stat scenerendering合并批次减少Draw Call画面撕裂垂直同步关闭目视检查开启垂直同步或自适应同步1% low帧低卡顿源未消除逐项排查定位并消除卡顿源6.5 几个容易被忽略的细节第一个细节是编辑器的性能开销。编辑器本身占用大量CPU和GPU资源在编辑器里测性能往往比打包版本差。做性能测试一定要用打包版本。第二个细节是后台进程的干扰。杀毒软件、系统更新、其他应用都可能抢占资源。测试时尽量关闭无关进程。第三个细节是温度降频。长时间高负载运行会导致CPU和GPU降频性能下降。测试时注意散热。第四个细节是驱动版本。显卡驱动对性能影响很大不同版本可能差10%以上。测试时记录驱动版本保持一致性。第五个细节是电源模式。笔记本在电池模式下会限制性能测试时要插电并设置为高性能模式。7. 一些个人经验和收尾做帧计时和延迟优化这些年最大的体会是不要迷信工具给出的数字要相信自己的眼睛和手感。工具能告诉你哪里有问题但最终判断标准是玩家的体验。另一个体会是优化要有优先级。不是所有延迟都值得优化也不是所有卡顿都能消除。把精力放在玩家最能感知的地方比如输入响应、画面流畅度、多人同步的公平性。那些玩家感知不到的微秒级优化投入产出比很低。还有一个反直觉的经验有时候增加一点延迟反而能提升体验。比如网络同步里适当增加插值缓冲虽然延迟高了但画面平滑了玩家反而觉得更流畅。这就是延迟和稳定性的权衡没有绝对的最优解只有适合当前场景的平衡点。最后分享一个排查卡顿的小技巧用stat startfile和stat stopfile抓取性能分析文件然后用Unreal Insights打开。Insights能看到每一帧每个线程每个任务的耗时比stat命令详细得多。很多隐藏的卡顿源用stat看不出来用Insights一抓一个准。这个工具值得花时间学。
返回列表