UE5:深入理解游戏循环、Tick()、DeltaTime与时间膨胀的控制机制

相关源码的路径是:

1
2
Engine/Source/Runtime/Engine/Private/World.cpp
Engine/Source/Runtime/Engine/Private/LevelTick.cpp

UWorld::Tick() 函数在 LevelTick.cpp 中实现,而不是 World.cpp。

这篇文章讲得很好:

关于虚幻引擎“最佳做法”的真相

在游戏引擎开发的诸多核心要素中,游戏循环的设计无疑占据着基石般的地位。它不仅是驱动整个游戏世界运转的心脏,更是决定游戏逻辑、物理模拟和渲染管线如何与时间交互,以及如何在追求极致性能的同时保持绝对一致性的关键。本文将深入Unreal Engine 5的内部实现,详细剖析其游戏循环的控制流程、DeltaTime的产生与应用、时间膨胀这一强大机制,以及探讨为何以及如何选择非每帧更新的策略。

在传统的C++程序或早期的游戏开发中,开发者们习惯于使用一个简单的无限循环来驱动游戏。这个循环的运行速度完全取决于CPU的处理能力,就像一个不知疲倦但缺乏时间观念的工人,不停地执行着“更新逻辑”和“渲染画面”这两个任务。其伪代码大致如下:

1
2
3
4
5
while(true) 
{
UpdateGameLogic();
RenderFrame();
}

这种“竭尽全力”的循环模式在现代游戏开发中显然是不可取的。它无法适应不同性能的硬件,也无法精确控制游戏的进程。现代游戏引擎,如UE5,需要基于一个精确的、与现实世界同步的时钟来构建其循环。这个循环的核心不再是简单的迭代,而是对时间的精细管理。

UE5的游戏循环起始于一个高精度计时器的采样。在每一帧的开始,引擎会获取一个极其精确的当前时间戳,并与上一帧记录的时间戳相减,从而得出从上一帧到当前帧所经过的真实时间,这个时间差就是我们所说的 DeltaTime。这个小小的数值,成为了驱动整个游戏世界前进的标尺。其简化后的控制流可以用以下伪代码清晰展现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double LastTime = GetHighPrecisionTime();
while(GameRunning)
{
double CurrentTime = GetHighPrecisionTime();
double DeltaTime = CurrentTime - LastTime;
LastTime = CurrentTime;

// 应用时间膨胀效果,调整全局时间流速
DeltaTime *= GetEffectiveTimeDilation();

// 将经过的时间增量传递给世界进行更新
UWorld.Tick(DeltaTime);

RenderFrame();
ProcessInputAndNetworking();
}

这里,GetHighPrecisionTime() 函数扮演着至关重要的角色。它通常封装了平台层最底层的、最高精度的计时API,例如Windows平台上的QueryPerformanceCounter或macOS上的mach_absolute_time。这些API能够提供毫秒级甚至纳秒级的计时精度,确保DeltaTime计算的准确性。而 UWorld.Tick(DeltaTime) 则是整个游戏逻辑更新的总入口,它接收这个经过时间处理的DeltaTime,并将其分发给世界中的每一个Actor、每一个组件以及每一个子系统,让它们知道在这一帧里,时间前进了多少。

DeltaTime的本质,就是一把连接“现实时间”与“游戏时间”的标尺。它解决了游戏开发中最基础也最核心的挑战:如何让游戏在不同性能的硬件上运行时,都能保持一致的体验速度。

想象一个简单的场景:一个游戏角色需要以每秒10米的速度移动。在一台性能强悍、每秒能跑1000帧的顶级PC上,如果直接按帧移动,每帧需要移动的距离是10 / 1000 = 0.01米。而在另一台性能普通、每秒只能跑100帧的设备上,每帧则需要移动10 / 100 = 0.1米。(100帧也不普通)

通过引入DeltaTime,这个逻辑就变成了:每帧移动的距离 = 速度 * DeltaTime。在高帧率设备上,DeltaTime约为0.001秒,因此每帧移动0.01米;在低帧率设备上,DeltaTime约为0.01秒,每帧移动0.1米。两者相加,在一秒钟的真实时间内,角色都恰好移动了10米。正是这个精妙的计算,保证了无论玩家的硬件配置如何,游戏世界的节奏、角色的移动速度、动画的播放进度都是同步且一致的,从而奠定了公平性和流畅性的基础。

DeltaTime的另一个高阶应用,便是实现时间膨胀。这是一种对整个游戏世界时间流速进行全局缩放的能力。当游戏需要实现子弹时间、慢动作回放、加速效果或全局暂停时,时间膨胀机制便派上了用场。

在UE5中,AWorldSettings 类管理着一个全局的时间膨胀系数。GetEffectiveTimeDilation() 函数会返回这个系数,并将其与原始的DeltaTime相乘。其影响是立竿见影且全局性的:

  • 时间膨胀 = 1.0:游戏世界以正常速度运行。
  • 时间膨胀 = 0.5:时间流速减半,所有基于DeltaTime的更新,如角色移动、动画播放、物理模拟,都会以慢一倍的速度进行,营造出慢镜头效果。
  • 时间膨胀 = 2.0:时间流速加倍,世界中的一切动作都会加速。

一个游戏帧的更新远不止是“运行所有逻辑”那么简单。它涉及到游戏逻辑(如AI决策、角色移动)、物理模拟(碰撞检测、刚体运动)、渲染管线、输入处理和网络同步等多个复杂系统。这些系统之间存在天然的依赖关系,如果执行顺序不当,就会导致数据竞争和逻辑错误。

为了解决这个问题,UE5引入了 Tick Group 机制。它将一个Tick周期划分为多个阶段,如 TG_PrePhysics(物理前更新)、TG_DuringPhysics(物理中更新)、TG_PostPhysics(物理后更新)等。开发者可以为不同的Actor或组件指定它们所属的Tick Group,从而精确控制其更新时机。

例如,一个角色的移动逻辑可能需要在物理模拟之前执行,以便将最新的位置信息传递给物理引擎用于碰撞检测。而一个附着在角色身上的视觉效果,则可能在物理模拟之后、渲染之前进行更新。通过这种分阶段的执行方式,UE5确保了各系统间的数据依赖清晰、有序,避免了并发问题。

此外,UE5的架构还巧妙地利用了多线程。逻辑线程(Game Thread)负责驱动所有游戏逻辑的Tick,而渲染线程(Rendering Thread)则并行地处理上一帧的渲染指令。它们之间通过帧同步机制进行协作,确保渲染线程看到的是一份完整且一致的逻辑世界状态。

看一下Tick调用链:

主循环的启动

游戏循环的入口在 Launch.cppGuardedMain() 函数中。初始化完成后,程序进入一个简单的 while 循环:

1
2
3
4
while( !IsEngineExitRequested() )
{
EngineTick();
}

只要未请求退出,就会持续调用 EngineTick()。该函数将控制权交给全局的 GEngineLoop 对象,执行 FEngineLoop::Tick()

FEngineLoop::Tick 的职责

FEngineLoop::Tick() 是每帧的起点,负责帧级基础设施的初始化与协调。它首先进行诊断与监控,向诊断线程发送心跳,检测线程卡顿,并更新低级别内存追踪器的状态。随后处理热修复系统更新,确保运行时配置能及时生效。

在非多线程渲染模式下,FEngineLoop::Tick() 会同步渲染线程配置,确保游戏线程与渲染线程的配置一致。接着生成帧事件字符串,用于性能分析工具标记帧边界,并执行控制台变量的回调,处理运行时配置变更。

时间管理是 FEngineLoop::Tick() 的关键职责之一。它调用 GEngine->UpdateTimeAndHandleMaxTickRate() 来计算 DeltaTime 并控制帧率。该函数会获取当前真实时间,计算与上一帧的时间差,根据最大帧率设置计算是否需要等待,并在需要时通过 FPlatformProcess::SleepNoStats() 进行精确等待,确保帧率稳定。

1
GEngine->UpdateTimeAndHandleMaxTickRate();

时间计算完成后,FEngineLoop::Tick() 向渲染线程发送帧开始命令,通知渲染线程新帧开始。对于每个已分配的 Scene,它都会发送 StartFrame 命令,让渲染线程准备新帧的渲染工作。

输入处理在时间管理之后进行。FEngineLoop::Tick() 通过 FSlateApplication::Get().PollGameDeviceState() 轮询游戏设备状态,获取键盘、鼠标、手柄等输入,然后调用 FinishedInputThisFrame() 让 UI 组件处理累积的输入事件。

完成这些准备工作后,FEngineLoop::Tick() 调用 GEngine->Tick(FApp::GetDeltaTime(), bIdleMode),将控制权传递给游戏引擎层。这里传入的 DeltaTime 是经过帧率控制调整后的时间间隔,bIdleMode 表示是否处于空闲模式。

UGameEngine::Tick 的世界管理

UGameEngine::Tick() 负责管理所有 World 的更新。它首先进行非 World 相关的引擎级任务,包括验证 DeltaTime 的有效性、记录慢帧日志、清理已关闭的视口、检查退出条件等。

对于媒体框架,如果没有 World 会触发 MediaFramework 的更新,UGameEngine::Tick() 会提前调用 MediaModule->TickPreEngine(),确保媒体状态在游戏逻辑更新前已更新。异步加载处理通过 UObject::StaticTick() 进行,该方法会处理异步资源加载,确保资源在需要时可用。

UGameEngine::Tick() 的核心是遍历 WorldList,对每个有效的 World Context 执行更新。它首先检查 World 是否存在且需要更新,然后将当前 World 设置为全局的 GWorld,这样后续代码可以通过 GWorld 访问当前世界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (int32 WorldIdx = 0; WorldIdx < WorldList.Num(); ++WorldIdx)
{
FWorldContext &Context = WorldList[WorldIdx];
if (Context.World() == NULL || !Context.World()->ShouldTick())
{
continue;
}

GWorld = Context.World();

TickWorldTravel(Context, DeltaSeconds);

if (!bIdleMode)
{
Context.World()->Tick( LEVELTICK_All, DeltaSeconds );
}
}

在处理 World 更新前,UGameEngine::Tick() 会调用 TickWorldTravel() 处理无缝切换和服务器切换。这确保在切换地图时,World 状态能正确过渡。完成这些处理后,它调用 Context.World()->Tick(),将 DeltaTime 传递给 World 层,开始世界级别的更新。

UWorld::Tick 的游戏逻辑核心

UWorld::Tick() 是游戏逻辑更新的核心。它首先进行初始化与验证,更新性能追踪器,通知 XR 系统帧开始,并更新碰撞分析器状态。

网络更新是 UWorld::Tick() 的第一个重要阶段。它通过 BroadcastTickDispatch() 分发网络包,让网络系统处理接收到的数据包,然后通过 BroadcastPostTickDispatch() 处理网络包的后处理工作。如果是客户端,它会调用 TickNetClient() 处理与服务器的连接状态。

1
2
3
4
5
6
7
BroadcastTickDispatch(DeltaSeconds);
BroadcastPostTickDispatch();

if( NetDriver && NetDriver->ServerConnection )
{
TickNetClient( DeltaSeconds );
}

时间管理是 UWorld::Tick() 的另一个重要职责。它首先更新真实时间,将 DeltaSeconds 累加到 RealTimeSeconds。对于音频系统,它会单独更新 AudioTimeSeconds,因为音频播放不受时间膨胀影响,始终以真实时间播放。

接下来,UWorld::Tick() 应用时间膨胀。它从 WorldSettings 获取 TimeDilation 值,将 DeltaTime 乘以该系数。例如,如果 TimeDilation 为 0.5,游戏时间会以真实时间的一半速度流逝。应用时间膨胀后,它通过 FixupDeltaSeconds() 处理过大的 DeltaTime,防止单帧时间过长导致物理模拟不稳定。

1
2
3
4
5
6
7
8
9
10
RealTimeSeconds += DeltaSeconds;
if( !bIsPaused )
{
AudioTimeSeconds += DeltaSeconds;
}

float RealDeltaSeconds = DeltaSeconds;
DeltaSeconds *= Info->GetEffectiveTimeDilation();
const float GameDeltaSeconds = Info->FixupDeltaSeconds(DeltaSeconds, RealDeltaSeconds);
DeltaSeconds = GameDeltaSeconds;

子系统更新在时间管理之后进行。UWorld::Tick() 会更新导航系统,让 AI 系统能够进行路径查找。如果正在进行高优先级加载或无缝切换,它会处理异步资源加载,确保关键资源及时加载完成。

在开始 Actor Tick 前,UWorld::Tick() 会进行一系列准备工作。它重置延迟动作管理器,清理上一帧的处理记录,重置异步追踪系统,然后广播 OnWorldPreActorTick 委托,让订阅者有机会在 Actor Tick 前执行逻辑。对于 Sequencer 控制的序列,它会提前更新,确保动画和事件在正确的时机触发。

Tick Group 系统的执行机制

UWorld::Tick() 的核心是 Tick Group 系统的执行。它首先调用 SetupPhysicsTickFunctions() 设置物理 Tick 函数,然后调用 FTickTaskManagerInterface::Get().StartFrame() 初始化帧,准备所有需要执行的 Tick 函数列表。

1
2
3
SetupPhysicsTickFunctions(DeltaSeconds);
TickGroup = TG_PrePhysics;
FTickTaskManagerInterface::Get().StartFrame(this, DeltaSeconds, TickType, LevelsToTick);

Tick Group 系统将 Actor 和 Component 的 Tick 函数组织成不同的组,按顺序执行。第一个执行的是 TG_PrePhysics,包含物理模拟前的逻辑更新,如角色移动、输入处理等。UWorld::Tick() 调用 RunTickGroup(TG_PrePhysics) 执行该组的所有 Tick 函数。

1
RunTickGroup(TG_PrePhysics);

执行完 TG_PrePhysics 后,UWorld::Tick() 会确保碰撞树已构建,然后执行 TG_StartPhysics。该组包含物理模拟的准备工作,如设置物理约束、准备物理数据等。

接下来是 TG_DuringPhysics,这是物理模拟期间执行的组。该组支持并行执行,RunTickGroup() 的第二个参数为 false,表示不等待该组完成就继续执行。这是因为物理模拟是异步的,可以在后台进行,不需要阻塞游戏线程。

1
RunTickGroup(TG_DuringPhysics, false);

物理模拟完成后,UWorld::Tick() 执行 TG_EndPhysics,处理物理模拟的结果,如碰撞通知、物理事件等。最后执行 TG_PostPhysics,包含物理模拟后的逻辑更新,如摄像机更新、UI 更新等。

1
2
RunTickGroup(TG_EndPhysics);
RunTickGroup(TG_PostPhysics);

每个 Tick Group 的执行都通过 FTickTaskManager 进行调度。该管理器维护所有注册的 Tick 函数,根据它们的 Tick Group 和依赖关系进行排序,确保执行顺序正确。对于支持并行的 Tick Group,它会使用任务图系统将 Tick 函数分配到不同的线程执行,提高性能。

Actor 和 Component 的 Tick 执行

RunTickGroup() 执行某个 Tick Group 时,它会遍历该组中所有注册的 Tick 函数,依次调用它们的执行函数。对于 Actor,它会调用 AActor::Tick(),对于 Component,它会调用 UActorComponent::TickComponent()

每个 Tick 函数在执行前都会进行条件检查,包括是否启用 Tick、是否暂停、是否满足其他条件等。只有满足所有条件的 Tick 函数才会执行,这样可以避免不必要的计算,提高性能。

Tick 函数执行时,会接收 DeltaTime 作为参数。这个 DeltaTime 已经经过了时间膨胀的处理,所以 Actor 和 Component 可以直接使用它进行时间相关的计算,如移动、动画播放等。使用 DeltaTime 而不是固定值,可以确保游戏逻辑在不同帧率下表现一致。

引用自:https://dev.epicgames.com/community/learning/tutorials/qpZ3/unreal-engine-ca0ed0

Tick开销

Tick的开销指的是游戏中每帧调用对象Tick函数所消耗的CPU时间成本。这包括调度Tick函数的准备时间(排队、管理Tick顺序)和执行每个Tick函数本身的计算量。虽然单个对象的Tick开销通常很小(微秒级),但当大量对象同时Tick时,这些开销会累积,增加游戏线程负载。

UE5中,Tick开销主要分为两个部分:一是Tick函数调用的调度开销,二是Tick函数内部逻辑运算开销。使用Blueprint的Tick一般开销高于纯C++实现,因为Blueprint调用存在额外解释和桥接成本。相比之下,C++ Tick更高效且更低延迟。

不过,Tick本身的固定开销在正常范围内通常远小于Tick函数中处理的游戏逻辑(如角色移动、AI计算、物理驱动)。因此,优化重点一般在于缩减每个Tick执行的逻辑复杂度和合理控制Tick对象数量,而非Tick函数本身的调用机制。

Tick确实会产生轻微的开销,但微乎其微。只有存在大量执行Tick的Actor时,才会有明显的开销。但与Tick期间执行的实际工作量相比,这样开销通常可以忽略不计。

Tick的问题

人们常说:“Tick会拖慢游戏!”但真正的问题其实是“每个Actor每帧要做大量工作”拖慢了游戏。

这跟你用Tick、时间轴、计时器还是动画通知还是别的都没关系。通常开销不是问题,工作量才是症结。如果你需要Actor每帧都执行某些工作,那就尽管用Tick!如果你刻意去做每帧的时间轴,只是为了“避免使用Tick”,那反而是本末倒置了。

你的项目中依然要使用Tick。举个例子,《Fortnite》客户端会在许多不同的平台上运行,比如Switch和Android,并且平均会有80 Tick。而《Fortnite》服务器一次会话每帧平均会有700 Tick。

但不管你用什么方法,都不要在每帧积累太多工作量。你可以减少Tick Actor数量和/或优化单次Tick的工作量。

另外,也要避免不合理地给Actor加Tick。比如你要做一个弹幕游戏,屏幕里会出现1000个子弹,那这时候就不要让每个子弹作为独立的Actor,还让它们都有单独的Tick函数。因为这样的话,光这1000个带Tick的Actor就会产生3.5毫秒的开销。所以这时候不要滥用Tick,你可以尝试创建BP_BulletManager来计算子弹的位置,然后把子弹的图形放到实例化静态网格体组件里。

如果数据量非常大,那可以试试海量系统。但这样会比较复杂,所以还要酌情考虑。你可以尝试减少每帧的开销,去掉屏幕外Actor的Tick,或者降低远处Actor的Tick频率。

调用链的完整流程

FEngineLoop::Tick() 到 Actor/Component 的 Tick 函数,整个调用链形成了一个完整的更新流程。每一层都有明确的职责,通过参数传递和全局状态管理,确保信息能够正确传递。

时间信息从 FEngineLoop::Tick() 开始计算,经过 UGameEngine::Tick() 传递给 UWorld::Tick(),在 World 层应用时间膨胀后,最终传递给 Actor 和 Component 的 Tick 函数。这个过程确保了整个系统使用一致的时间基准,避免了时间不同步导致的问题。

执行顺序通过 Tick Group 系统保证。每个 Tick Group 都有明确的执行时机,Actor 和 Component 可以根据自己的需求选择合适的 Tick Group,确保在正确的时机执行更新。这种设计避免了竞态条件,保证了游戏逻辑的正确性。

性能优化贯穿整个调用链。每一层都使用性能统计系统监控执行时间,使用条件检查避免不必要的计算,使用并行执行提高性能。这些优化措施确保了系统在高负载下仍能保持稳定的性能。

在游戏引擎的主循环中,每一帧的执行时间由所需处理的逻辑复杂度直接决定。当游戏世界中有大量需要更新的对象时,Tick函数会被层层调用,逐一处理这些对象的状态、动画、物理以及各种子系统,这无疑增加了这一帧的计算开销。如果更新负载过重,当前帧的执行时间将显著拉长,从而导致下一帧计算出来的DeltaTime值变得异常巨大。

这种DeltaTime的异常增大,意味着游戏逻辑在下一帧需要跨越更长的时间跨度进行状态更新,游戏画面则表现为明显的卡顿和跳帧现象,俗称“卡成PPT”。本质上,这种现象是性能瓶颈的直接反映:帧时间被拖长导致游戏无法保持持续、平滑的更新速度。DeltaTime作为时间间隔的度量,本身只是客观反映了帧间经过的真实时间,并不会主动造成卡顿,卡顿的根因在于每帧重负载计算无法及时完成,导致渲染和逻辑错开步调。

仔细看一看关于Deltatime异常和相关优化的代码:

在 UWorld::Tick() 中,时间膨胀应用后会调用 AWorldSettings::FixupDeltaSeconds() 限制 DeltaTime 的范围。该函数将 DeltaTime 限制在 MinUndilatedFrameTime 和 MaxUndilatedFrameTime 之间,防止过小或过大的值。

const float GameDeltaSeconds = Info->FixupDeltaSeconds(DeltaSeconds, RealDeltaSeconds);

FixupDeltaSeconds() 的实现考虑了时间膨胀。它先获取有效时间膨胀系数,然后将最小和最大帧时间按该系数缩放,最后将 DeltaTime 限制在该范围内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float AWorldSettings::FixupDeltaSeconds(float *DeltaSeconds*, float *RealDeltaSeconds*)

{

float const Dilation = GetEffectiveTimeDilation();

float const MinFrameTime = MinUndilatedFrameTime * Dilation;

float const MaxFrameTime = MaxUndilatedFrameTime * Dilation;



return FMath::Clamp(DeltaSeconds, MinFrameTime, MaxFrameTime);

}

这样即使真实 DeltaTime 很大,传递给游戏逻辑的值也会被限制在合理范围内,避免物理模拟和动画系统因时间步长过大而失稳。

在 Unreal Engine 的游戏循环中,DeltaTime 表示两帧之间真实经过的时间,是驱动游戏逻辑和物理更新的核心参数。为了保障游戏运行的稳定性和连贯性,UE5在 UWorld::Tick() 中引入了对DeltaTime的上下限限制,即通过 AWorldSettings::FixupDeltaSeconds() 将DeltaTime约束在 MinUndilatedFrameTimeMaxUndilatedFrameTime 范围内。这样的设计初衷并非削弱时间的真实性,而是为了防止极端帧时间对游戏状态的破坏。

当游戏运行正常时,DeltaTime准确反映了每帧之间的时间差,世界的时间推进自然顺畅,游戏体验真实且连贯。然而,现实环境中可能会因各种原因出现极端帧时长,比如系统卡顿、加载阻塞或硬件性能突降,导致一帧耗时远远超出预期。直接将这一不正常的巨大DeltaTime用于游戏状态更新,会让游戏中的物体、动画或物理模拟在短时间内跳跃式前进,产生穿透、错乱和视觉突变等严重问题。这不仅使游戏逻辑变得不稳定,也极大地破坏了玩家的沉浸感和操作反馈。

为了避免这种现象,UE5使用限制机制对DeltaTime进行“折返”,将其限定在合理区间内,避免单帧时间过小或过大所带来的副作用。限制最大DeltaTime的存在,确保即便遇到卡顿,游戏也能以可控步长连贯地推进状态,减少瞬时跳跃所带来的不良体验。同样,限制最小DeltaTime避免因帧率过高或计时抖动导致的极短步长,使更新循环保持高效且稳定。

关于限制会否影响单位时间内游戏进度的问题,这种调整只在极端异常情况下介入,对于日常稳定运行毫无影响。游戏整体的时间尺度依然基于真实的时间流逝,玩家感知到的世界移动和事件发生保持自然和一致。DeltaTime限制正是为了在保证时间真实性的基础上,兼顾游戏的鲁棒性和用户体验,防止极端时间跳跃破坏游戏节奏和逻辑连续性,从而实现更平滑、更可靠的游戏运行效果。