ARTICLE DETAIL

资讯详情

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

UE一帧的生命周期:从帧计时、Tick调度到同步延迟的完整拆解

UE一帧的生命周期:从帧计时、Tick调度到同步延迟的完整拆解 做过一段时间Unreal项目的人大概都逃不开这几个问题为什么stat fps显示的帧率很漂亮游戏玩起来却一卡一卡为什么我在GameThread里改了Actor位置屏幕上好像晚了两帧才动为什么两个人的帧率不同打协同玩法时同步总出鬼这些问题的答案全都藏在一帧的生命周期里。这篇文章我不打算从文档讲起而是把帧计时、同步与延迟这三根线串在一起结合我这些年在UE里砸过的调试经历把从主循环到屏幕像素、从服务器到客户端的所有关键节点都过一遍。适合正在被手感、卡顿、联机同步折磨的同行参考也适合刚入行想搞明白一帧到底是什么的开发者。1. 一帧从哪里来主循环、DeltaTime与帧率上限的真实关系1.1 引擎里每一帧究竟是谁在喊开始UE整个引擎的脉搏是FEngineLoop::Tick()在跳。这个函数在目标平台的入口WinMain、Main等被初始化之后会进入一个while循环每一轮循环就是游戏的一帧。它做的事情概括起来三步先Flush所有挂起的窗口消息和输入再推进World的Tick也就是游戏逻辑最后把渲染命令推给渲染线程等GPU消费。这里有个关键点UE是典型的三线程架构——GameThread游戏逻辑、RenderThread渲染命令生成、RHIThread/GPU实际执行。游戏帧率指标通常挂在GameThread上但一帧真正结束要等GPU把上一帧画完。所以帧与帧之间并不是简单串行而是交错的流水线。打个比方GameThread在画第N2帧时RenderThread可能正在处理第N1帧GPU正在输出第N帧。这种流水线是好东西它让三个部门都在干活但也让延迟这个概念变得复杂——你看到的画面永远是好多帧之前的逻辑结果。1.2 DeltaTime的算法与它的小把戏DeltaTime就是这帧和上一帧之间经过了多少秒。UE在FApp里维护了一个全局DeltaTime每一轮FEngineLoop::Tick开始时会用当前平台时间减去上次记录的时间得到RawDeltaTime再经过一系列处理Clamp上限、可选的平滑处理、不同端的微调。Clamp上限值得留意引擎默认把DeltaTime限制在0.25秒以内目的是防止游戏暂停回来后物理瞬间爆炸。如果你在编辑器里断点停很久再继续物理不会飞出去就是这个Clamp在兜底。但如果你在做录像回放、帧步进调试记得这个Clamp会改变你记录的原始时间。平滑DeltaTime是另一个坑。引擎有个r.SmoothFrameRate开关默认开启目的是让DeltaTime不要忽大忽小否则物理和移动会因为帧时间抖动产生视觉上的碎步。它会把当前帧时间向目标帧率默认60FPS做指数平滑。代价是你的DeltaTime不再等于真实经过的时间。如果你在单机游戏里做速通计时、或者在网络对战里做精确同步必须清楚这个默认行为正在污染你的时间基准。我在项目里会把全局计时统一改成读FPlatformTime::Seconds()的真实时间戳DeltaTime只用于引擎内部的步进两套时间各司其职。1.3 帧率上限和固定帧率你限制的不只是FPS很多人以为限制帧率就是t.MaxFPS一个命令的事。在UE里受控帧率是Project Settings - General Settings - Framerate下面的一组设置Frame Rate Cap上限、Use Fixed Frame Rate是否用固定帧率、Fixed Frame Rate固定帧率目标值、Smooth Frame Rate平滑开关。它们对应UEngine里的TargetFrameRate、bUseFixedFrameRate、FixedFrameRate等字段。这几个设置千万别混t.MaxFPS 60只是上限如果一帧实际耗时12ms你能跑到83FPS它不锁死帧率bUseFixedFrameRatetrue配合FixedFrameRate60则是强制每帧按16.67ms步进不管真实时间走了多少。这种模式适合帧同步玩法、回放录制和确定性复现SmoothFrameRate必须配合Frame Rate Cap才有意义单独打开时它会向着60FPS去平滑如果你目标是120Hz会发现它反而帮倒忙我自己最常用的组合对外发布时把帧率上限设到显示器刷新率的整数倍调试时用固定帧率跑物理确保不同机器上模拟出来的结果一致。移动端上尤其如此——iOS的ProMotion屏幕会动态变刷新率如果引擎帧率上限写死60显示端却跑到120Hz帧间隔和渲染帧会错位体感延迟反而升高。2. Tick调度与帧内同步为什么同一个帧里的先后顺序能毁掉一个玩法2.1 TickGroup把一帧切成车间流水线一帧的GameThread逻辑不是所有Actor横着扫一遍那么简单。UE按ETickingGroup把一帧切成了几段TG_PrePhysics、TG_StartPhysics、TG_DuringPhysics、TG_EndPhysics、TG_PostPhysics、TG_PostUpdateWork、TG_LastDemotable不同引擎版本名单有微调但骨架一样。你可以把这一帧想象成一条传送带先跑PrePhysics逻辑触发物理模拟等物理跑完再跑PostPhysics和PostUpdateWork。为什么这么设计因为物理步进是需要锁存输入的而摄像机必须拿到物理结果后最后更新否则你会看到相机抖动或者角色插进墙里。实际项目里最常见的坑就是把移动逻辑放在PostPhysics里而目标位置更新放在PrePhysics里于是你一帧读到的永远是上一帧更新的值。做动作游戏时我建议把所有会被物理读取的状态放在TG_PrePhysics把依赖物理结果的反馈放在TG_PostPhysics中间不要混。2.2 显式依赖别赌Actor的创建顺序TickGroup只是粗粒度排序Actor之间谁先谁后还取决于Ticking顺序。UE默认的Actor tick顺序是最早创建的Actor先tick——对就是这么朴素连哈希排序都没有。如果你有一堆角色依赖同一个管理器先更新靠创建顺序去赌迟早翻车。我见过一个项目地图加载顺序微调了一下整个战斗AI的决策链就乱了排查了三天最后发现是Tick顺序问题。正确做法是显式依赖PrimaryActorTick.AddPrerequisite(ManagerActor, ManagerActor-PrimaryActorTick); // 或 ActorA-PrimaryActorTick.AddTickPrerequisiteActor(ActorB);AddPrerequisite会动态建立拓扑序保证B在A之前。这个API无论C还是蓝图里都有。我的习惯是所有服务型Actor数据管理器、输入调度器、摄像机秒准目标都作为依赖源业务Actor在初始化时统一注册依赖而不是依赖创建顺序。代码里写清楚依赖别人改起来也安全不容易踩到隐式顺序的雷。2.3 物理子步进看似同一帧其实模拟走了好几步还有一个让不少新手困惑的事引擎显示一帧但PT物理线程可能已经把这一帧切成了多个子步进。比如你的游戏是60帧但物理的固定子步长如果设置得偏小帧时间只要略超物理就会自动拆成2步、3步跑完。换句话说物理模拟的帧和游戏逻辑的帧不是同一个坐标系。这个问题在物理驱动型玩法里特别明显。协同运动、状态机驱动骨骼这类需求如果逻辑里直接用DeltaTime累加里程或角度而物理用的是自己的子步进你在高负载机器上会看到位移偏差。我处理这类问题的方式读取物理对象位置时不要只看一次快照要看它当前的子步进状态更省事的办法是直接开固定帧率模式让物理步进和逻辑步进严格对齐。取舍很简单——如果你做的是格斗、平台跳跃这种需要精确物理反馈的游戏固定帧率是必选项不是可选项。2.4 定时器与Latent Action帧精度原来这么粗糙FTimerManager的SetTimer、蓝图里的Delay、SetTimerByEvent对时间精度的处理都远没有你想的精确。FTimerManager基于Tick推进其内部以帧为单位结算一帧内只能处理到本帧时刻已经到期的定时器所以一个0.1秒的Timer实际触发时刻是第N帧结束时而不是0.1秒精确点。帧率越低定时器抖动越明显。蓝图Delay属于Latent Action同样是挂在World的Tick上。帧率45和144两种环境下同一个Delay(0.1)的触发先后差异就能被高速玩家感知。做连招窗口、节拍判定、精确输入这类玩法时千万不要用Timer和Delay做判定改用Tick内累加真实DeltaTime再比较阈值或者直接采样输入时间戳。具体的坑我踩过一个音游打点项目判定窗口用Delay回调实现低帧率手机上误差能到2帧以上整个游戏手感全毁换成纯时间戳累加后立刻正常。3. 多人项目的帧同步状态同步、帧同步与帧率不一致的修罗场3.1 服务器和客户端各有各的帧多人游戏里服务器跑的是权威模拟每个客户端跑的是本地预测。两者的帧率、步长完全独立。服务器有NetServerMaxTickRate控制网络计算节奏常见30~120客户端则按本地显示帧率跑。所以帧同步在在线游戏里天然就是伪命题除非你用固定帧率并强制双方步调一致否则没有任何逻辑意义上的同一帧。Actor的网络同步按NetUpdateFrequency进行默认100也就是说服务器每秒钟只向客户端推送若干次属性快照但客户端渲染是连续的中间的过渡全靠客户端猜测和插值。理解这一点很重要你看到的其他角色本质上是服务器历史状态客户端插值的产物从来都不是实时真身。3.2 状态同步 vs 帧同步两种思路的本质差异UE的内置网络模型是状态同步。如果你要做格斗游戏那种帧级判定的玩法必须先搞清楚这两种路线的区别否则就会在错误的方向上疯狂堆工程量。维度状态同步UE默认帧同步Lockstep/回滚同步内容属性/状态快照玩家输入指令典型应用FPS、TPS、MMO格斗、RTS、体育游戏对帧率要求客户端可各自变帧双方必须同一时间基准回放确定性差好抗丢包成本较低高需要回滚缓冲UE内置支持完善几乎没有需要自己搭UE的内置网络模型是状态同步CharacterMovementComponent负责移动的客户端预测与服务器矫正RPC和DOREPLIFETIME负责属性同步。如果你要做格斗游戏的帧级判定UE默认这套并不合适需要把bUseFixedFrameRate打开、把输入打包成带帧号的ReplicatedInput、再用回滚缓冲区缓存每帧状态。工程量不小但这是唯一能保证判定确定性的路径。我自己评估过如果是5分钟一局的格斗玩法用UE硬做帧同步的成本往往比换一个自带回滚机制的自研同步层还要高。3.3 预测与插值用延迟换平滑同步的代价是延迟。客户端要发出移动请求服务器回执这个来回就是RTT。为了不让玩家感觉肉角色移动用了预测——本地立即执行输入产生位移同时发给服务器服务器权威移动后再矫正。而其他玩家角色因为是远端状态客户端必须靠插值平滑过去。这两者造成的延迟观感差异就是为什么你在自己屏幕上觉得不卡看别人总像在飘。插值缓冲越大越平滑但延迟越高越小越跟手但越容易抖动。UE里CharacterMovement默认的插值时间窗口约为100ms你可以通过p.NetSmoothingMode和相关插值参数调整。我的经验是竞技射击项目里把他人角色插值窗口压到50~80ms配合服务器TickRate拉到60以上观感最接近真实。低于50ms时网络抖动带来的瞬移会被玩家看到反而更糟糕。3.4 高低帧率混战120帧玩家遇上了30Hz服务器当服务器30Hz、客户端120Hz时客户端每帧都在产生输入采样但服务器可能每好几帧才处理一次移动输入。UE的方式是把多帧输入累积、在下一个网络tick里合并处理。这本身没问题但要小心客户端本地的DeltaTime是120Hz的细腻时间服务器上却只能看到30Hz的粗粒度移动高帧率玩家的移动轨迹在别人眼里会明显一顿一顿——除非客户端预测和服务器权威在输入采样的时间戳上严格对齐。这个问题没有魔法解。我能给的建议是服务器NetServerMaxTickRate最好不低于客户端预期帧率的一半如果游戏需要极致公平考虑所有客户端统一用固定帧率比如竞技模式锁定120并且把输入命令带上客户端帧序号在服务器回滚缓冲里按帧序处理。这里的核心思想是别让帧率成为玩家之间天然不公平的因素要么统一要么用时间戳对齐。4. 延迟的一生从手指按下到像素点亮要穿过哪些关卡4.1 输入采集延迟的第一道门延迟从物理世界就开始算。键鼠和手柄的回报率多数鼠标125/500/1000Hz决定了物理事件进入系统的频率操作系统处理窗口消息有调度延迟然后引擎的输入处理在每一帧GameThread上轮询一次。一个1000Hz回报率的鼠标在60帧游戏里也只每帧取一个最新状态也就是说输入采样频率是60Hz信息到达的相位延迟平均约半帧8.3ms再加上系统排队几十毫秒就没了。UE里处理输入的顺序大致是平台鼠标/键盘回调 - Application/Slate层 - PlayerController的输入处理 - PlayerInput - 绑定逻辑。绑定逻辑在PlayerTick里执行。这里有个容易被忽略的坑如果你把开枪逻辑放在Actor的Tick里而不是InputAction回调里就会额外多出0到1帧输入延迟。别小看这一帧60Hz下就是16.7ms足以让射击手感从跟手变成肉。4.2 三线程与帧队列为什么多缓冲会增加延迟渲染端有一个长期存在的平衡问题。GameThread提交渲染命令给RenderThreadRenderThread生成RHI命令给GPU。为了不让两个线程互相等待引擎默认会让RenderThread落后GameThread一帧r.OneFrameThreadLag1。这意味着渲染看到的游戏状态天然是上一帧的而显示器后面可能还在排队。垂直同步开启时显卡还会在present环节缓冲0~3帧避免画面撕裂但引入等待。整条链路下来输入采样最多半帧 GameThread执行半帧 OneFrameLag一帧 GPU排队若干帧总延迟轻松超过两三个帧周期。144Hz下约20~40ms60Hz下会到30~80ms。这就是为什么很多人觉得60帧响应慢——它不是画面不流畅是逻辑到像素的链路太长。4.3 UE里的低延迟开关与硬同步UE里能直接控制这些点的CVar不多但很关键r.OneFrameThreadLag 0去掉渲染线程落后的那一帧立省一帧延迟但可能因为线程抢占导致部分平台上的帧率波动t.MaxFPS配合显示刷新率合理设置避免帧率无限高导致GPU排队过多r.VSync 0加上可变刷新率显示器G-Sync/FreeSync让present不阻塞帧间隔由显示器垂直刷新同步这是目前PC上兼顾画质和延迟的最好方案集成的NVIDIA Reflex不同引擎版本的CVar可能是r.Reflex.Enable或r.NVIDIAReflex.Enable它会动态调整CPU/GPU的运行节奏主动削减渲染队列深度是竞技游戏压延迟最有效的手段之一注意Reflex不是开个CVar就好。它要求CPU不是瓶颈且帧率高过目标否则效果有限。我实测过一组数据60Hz屏幕Reflex60帧上限End to End延迟大概能从60ms降到40ms120HzReflex120帧上限能压到30ms以内。如果你的项目是3A单机这条链路优先级不高如果是竞技类从立项第一天就该把低延迟链路纳入渲染架构。4.4 不依赖昂贵设备的手测延迟方法没有专业设备也能测个大概相对延迟。方法把屏幕调成白底黑块的简单画面用240FPS或480FPS慢动作手机拍一段角色移动瞬间数帧算出屏幕运动开始与按下输入那一刻之间的帧数差。测三次取平均不同配置之间的差值基本可信。想更精确就找带LatencyMarkers的引擎构建或者用支持延迟分析的显示器方案后者需要有专门硬件一般团队用不到。这个手测方法不需要多少成本但能把感觉变成数据。我自己给项目做过一次录像对比发现某个版本更新后延迟涨了接近一帧最终定位到是输入处理逻辑被一个多余的Delegate链拖慢了。没有这个手测基线这种回归可能要等玩家反馈才会暴露。5. 帧率波动与Frame Pacing为什么平均60帧玩起来还是卡5.1 平均值骗人的地方用帧时间而不是帧率看问题平均帧率60和体验60是两回事。玩家感知的卡顿主要是帧时间的分布而不是均值。业界常用的指标是1% Low最慢的1%帧的平均帧时间以及0.1% Low。举例一秒钟60帧里如果有2帧掉到100ms其余58帧都在15ms以内平均帧率显示可能还有50多但玩家体感是明显的卡顿。1% Low则会把这种极端帧暴露出来。所以我现在看性能只看帧时间分布stat unitgraph波形、Unreal Insights的帧时间图先看有没有尖刺再看中位数。尖刺比均值可怕得多。团队汇报性能的时候我只接受帧时间P95、P99、1% Low这类指标不要给我平均60帧这种神仙数字。5.2 卡顿追踪的常见罪魁祸首按我在项目里踩坑的频率排序资源加载与Level Streaming新关卡、新材质、纹理流送MipMap触发加载GameThread或IO线程卡住着色器编译首次运行时的Shader编译和PSO生成垃圾回收GCUObject的GC会暂停WorldAI/NavMesh请求大范围寻路、动态遮挡查询后台线程争抢物理、音频、网络线程把GameThread卡在锁上每种都有典型波形。流送卡顿是周期性尖刺GC是多帧内的一次长停顿Shader编译是第一次到某个新区域就卡寻路卡顿时常伴随CPU占用突然飙升。定位的第一步永远是分清谁超了预算而不是笼统地去优化CPU不够快。5.3 从预算到预算表一帧的时间到底该怎么花做帧率规划时要按预算表来做而不是只定一个目标帧率目标帧率单帧预算实际安全预算留10%余量30 FPS33.3 ms约30 ms60 FPS16.7 ms约15 ms90 FPS11.1 ms约10 ms120 FPS8.3 ms约7.5 ms144 FPS6.9 ms约6.2 ms为什么留余量因为任何一帧超出预算就会让后面几帧跟着排队感知上的卡顿不是超出的那一帧而是后面两帧的空窗。我之前把一个项目的GameThread从17.2ms压到15ms平均帧率几乎没变但1% Low直接从40帧升到55帧体感已经完全不一样了。这就是预算表的价值——它在给稳定留空间。5.4 帧间隔均匀化垂直同步、可变刷新率与滑动窗口帧率稳定和帧间隔稳定是两个概念。同一帧时间20ms但两帧之间间隔如果是5ms35ms交替显示器上就是快慢不均的节奏——哪怕平均帧率是50也像抖动。VSync会让GPU等垂直回扫帧间隔均匀但延迟增加G-Sync/FreeSync则是让显示器刷新跟随GPU输出均匀性和延迟都照顾到代价是需要支持硬件。在工程层面我维护帧节奏还有一个土办法用滑动窗口记录最近N帧比如32帧的帧时间对异常值做一次过滤或平滑再显示给UI和统计系统避免一两个尖刺就把仪表盘抖成波浪。这个思路同样可以用于输入采样滤波对原始输入时间戳做滑窗中值能滤掉USB回报的微小抖动让每次判定更稳定。注意是稳定而不是更快——平滑一定带来延迟只是滑动窗口够小的话延迟可以忽略不计。6. 没有数据就没有优化帧计时调试工具与一次真实排查6.1 控制台Stat命令速查表UE控制台自带的统计工具覆盖了90%的日常性能分析需求。最常用的几个命令看的指标stat fps帧率 帧时间stat unitGameThread/RenderThread/GPU各自的单帧耗时stat unitgraph帧时间随时间的波形图stat scenerenderingDrawCall、三角形数stat streaming流送/IO状态stat memory内存状态stat startfile/stat stopfile记录二进制性能文件排错流程的第一步永远是stat unit先把三个线程的耗时看清楚GameThread超预算说明逻辑、寻路或AI问题RenderThread超预算说明网格体、材质问题GPU超预算说明Shader和Overdraw问题。这三个方向几乎能分流所有卡顿。我见过太多人一上来就打开材质球逐个看GPU结果最后发现是GameThread上一个死循环式的查表逻辑——先看stat unit真的能省三天。6.2 Unreal Insights从帧的二维视角看卡顿新版引擎里我强烈建议学会Unreal Insights。它能Trace整个帧内部每个TickGroup、每条渲染命令、GC、加载事件用时间-帧的二维视图让你看到卡顿发生在哪一秒、哪一帧、哪个函数里。使用方法编辑器里Trace窗口配置Channel或者运行时用-Trace...启动参数。Insights对我最大的价值不是看平均值而是看火山图——尖刺发生在哪一帧、持续多长、触发的Trace Event是什么一目了然。排查周期性卡顿和偶发卡顿它比任何printf都高效。6.3 在业务代码里埋自己的计时器引擎的Profiler不能覆盖所有业务逻辑。我会在关键路径上插自己的统计DECLARE_CYCLE_STAT(TEXT(MyTickLogic), STAT_MyTickLogic, STATGROUP_Game); void AMyActor::Tick(float DeltaTime) { SCOPE_CYCLE_COUNTER(STAT_MyTickLogic); // 业务逻辑... }编译后在stat命令的分组里就能看到自己函数的耗时。更简单粗暴的办法在Tick里记录FPlatformTime::Seconds()把耗时超过阈值的帧时间加调用栈打进Log做一个超时熔断日志。这样线上玩家遇到偶发卡顿时日志里就自动留下了现场证据不需要抓玩家重现。这个习惯帮我定位过好几个只在特定机器上出现的性能问题比如某款AMD笔记本上特定驱动导致的GPU尖刺。6.4 一次真实的周期性卡顿排查全流程分享一个最近处理过的案例。项目症状进入新场景后每4到6秒固定卡顿一次stat fps从60掉到25附近时间很短但极其规律。第一反应看stat unit发现是GameThread尖刺RenderThread和GPU曲线都很平。再开stat streaming发现Level Streaming在周期性触发检查Levels面板后发现有几个Level被设成了定期卸载再加载。根因不是业务逻辑而是关卡流送的MinTimeBetweenTimeUnload设置过短导致关卡频繁流进流出。流送卸载本身是异步的但触发流送时引擎需要在GameThread上处理一批资源代理正好形成周期性尖刺。修复方案很简单把无需卸载的Level设为AlwaysLoaded或者把MinTimeBetweenTimeUnload调大让加载分摊到切场景或过场阶段。改完之后周期性尖刺消失1% Low从37升到56玩家反馈的每隔几秒就顿一下彻底消失。整个过程大概三个小时。如果不用stat unit先分流直接去优化Shader或角色逻辑可能几天都找不到点。这类问题在团队项目里非常典型——它往往不是哪段代码写得差而是哪个配置在周期性拖后腿。先看帧时间分布再追根因永远比拍脑袋优化高效。最后分享一点个人体会。帧计时、同步与延迟这三件事表面上是技术参数本质上是时间感的管理。玩家的手指是一个高精度时钟屏幕像素是另一个两者之间隔了多少个缓冲、多少个帧、多少次网络往返直接决定了游戏的手感。不要被stat fps那个数字迷惑去看帧时间分布、去量端到端延迟、去理清楚每一帧里的执行顺序。你在UE里被帧率不低但手感不对折磨的时候先把这三份数据打出来——帧时间分布、Tick顺序、渲染队列状态。答案基本就在里面。
返回列表