2355 字
12 分钟
游戏循环与时间系统

一、什么是游戏循环#

GUI 应用通常是事件驱动的——按钮被点击了,就执行回调,其余时间什么都不做。而游戏不同:即使玩家什么都不按,画面也必须持续更新。敌人要移动、物理要模拟、动画要播放。

游戏循环(Game Loop)就是这条持续运转的流水线。每一帧,引擎要做的事大致如下:

while (游戏在运行) {
处理输入(); // 键盘、鼠标、手柄、触屏
更新世界(); // 移动、物理、AI、技能
渲染(); // 提交 GPU 命令、呈现画面
等待?(); // 要不要等,取决于帧率策略
}

这个循环看似简单,但它衍生出的所有设计决策,构成了引擎时间系统的骨架。UE 的 FTickTaskManager、Unity 的 MonoBehaviour.Update()、自研引擎里的 GameWorld::Tick(),本质上都是这个循环在不同抽象层次上的投影。


二、最简单的游戏循环#

while (true) {
ProcessInput();
Update();
Render();
}

没有任何时间控制。问题是:运行速度取决于硬件。在 60Hz 显示器上可能跑 300fps,在 144Hz 上可能跑 600fps,而且两台机器的游戏体验完全不同——快的机器上敌人移动更快、物理更不稳定。

游戏需要**帧率无关(Frame Rate Independent)**的行为。


三、DeltaTime:让逻辑与帧率脱钩#

1 可变帧率 + DeltaTime#

auto lastTime = Clock::now();
while (running) {
auto currentTime = Clock::now();
float deltaTime = (currentTime - lastTime).count(); // 单位:秒
lastTime = currentTime;
ProcessInput();
Update(deltaTime); // 所有逻辑乘以 deltaTime
Render();
}

每帧的实际耗时作为参数传入更新逻辑。物体移动公式变为:

// 旧:每帧移动 1 个单位(帧率越快,移动越快)
position.x += 1.0f;
// 新:每秒移动 60 个单位(与帧率无关)
position.x += 60.0f * deltaTime; // 0.0167 @ 60fps → 每帧约 1.0

60fps 时 deltaTime ≈ 0.0167 秒,每帧移动 60 × 0.0167 ≈ 1.0。120fps 时 deltaTime ≈ 0.0083 秒,每帧移动 60 × 0.0083 ≈ 0.5。一秒下来都是 60 个单位。

核心洞察deltaTime 不是可选项,它是引擎中最基本的不变量。你在 UE 里拿到的 GetWorld()->GetDeltaSeconds(),就是这个值。

2 螺旋式死亡(Spiral of Death)#

可变帧率的隐患:某帧 Update 特别耗时 → deltaTime 变大 → 下一帧的 Update 因为时间步长更大,物理计算更多 → 更耗时 → deltaTime 更大 → ……

最终帧率雪崩,游戏卡死。解决方案:

  • Clamp deltaTime:限制最大值,比如不超过 0.1 秒(10fps)。UE 默认 clamp 在 0.0 ~ 1.0 / MinDesiredFrameRate。
  • 固定时间步长:下一节的方法。

四、固定时间步长:物理模拟的基石#

1 为什么物理需要固定步长#

物理引擎本质上是数值积分。用欧拉方法更新位置:

v(t+Δt) = v(t) + a(t) × Δt
x(t+Δt) = x(t) + v(t+Δt) × Δt

如果 Δt 每帧不同,两次模拟同一个物理场景会得到不同的结果。这对网络同步、回放、录像来说是灾难——确定性(Determinism)是很多系统的硬需求

2 经典解法:Decouple Update & Render#

const float FIXED_DT = 1.0f / 60.0f; // 固定 60Hz 物理步长
float accumulator = 0.0f;
auto lastTime = Clock::now();
while (running) {
auto currentTime = Clock::now();
float frameTime = (currentTime - lastTime).count();
lastTime = currentTime;
accumulator += frameTime;
// 固定步长更新——可能执行 0 次、1 次、或多次
while (accumulator >= FIXED_DT) {
FixedUpdate(FIXED_DT); // 物理、AI 决策等
accumulator -= FIXED_DT; // 消耗掉固定步长
}
// 渲染用剩余时间的比例做插值
float alpha = accumulator / FIXED_DT;
Render(alpha); // 传入插值因子,平滑显示
}

关键点

  • FixedUpdate 始终以 1/60 秒的步长执行,保证了确定性。
  • 渲染帧率仍然可变——60fps 时每帧大概跑一次 FixedUpdate,120fps 时可能隔帧跑一次。
  • alpha 用于渲染插值:物理位置是离散的(每 1/60 秒一个快照),但渲染可以在两个快照之间做线性插值,画面看起来更流畅。

UE 里你看到的 Sub-stepping(物理子步)就是这个机制的体现。UWorld::TickFPhysScene::Tick 的调用次数取决于 accumulative time。

3 “死亡螺旋”在这个模型下怎么解决#

如果某一帧耗时太长导致 accumulator 累积了巨大值(比如 0.5 秒),内层 while 循环会疯狂执行 FixedUpdate(0.5 / 0.0167 ≈ 30 次),进一步拖慢帧率。

解决方案:设置 accumulator 的上限。超过阈值(如 0.1 秒)直接截断:

accumulator = std::min(accumulator, 0.1f); // 最多积攒 0.1 秒

代价是如果卡顿太久,物理会”跳帧”(丢失中间状态),但总比螺旋死亡好。这个策略在 UE 的 AWorldSettings::FixupDeltaSecondsMaxPhysicsDeltaTime 中有对应。


五、UE 中的 Tick 系统#

1 Three-Tick Architecture#

UE 的 Tick 分为三层,每一层由不同的子系统驱动:

频率职责对应函数
Frame Tick可变(渲染帧率)输入、相机、UI、Blueprint EventTickUWorld::Tick
Physics Tick固定(通常与帧率解耦)碰撞、刚体运动、布料FPhysScene::Tick
Net Tick固定(由 NetUpdateFrequency 决定)属性复制、RPCUNetDriver::TickFlush

一个完整的帧大致是:

UEngine::Tick() → UWorld::Tick() → TickGroup 分发
→ 物理子步循环
→ 网络刷新
→ 渲染提交

2 TickGroup:谁先谁后#

不是所有 Actor 在同一时刻 Tick。UE 定义了 ETickingGroup 来排序:

enum ETickingGroup {
TG_PrePhysics, // 物理之前——输入处理、动画更新
TG_DuringPhysics, // 与物理并行(特殊用途)
TG_PostPhysics, // 物理之后——大部分 Gameplay 逻辑
TG_PostUpdateWork, // 相机、特效——依赖最终位置的逻辑
};

这个顺序保证了一个基本约定:PostPhysics 里拿到的物理位置是已经更新过的。如果你在 PrePhysics 里读刚体的位置,读到的是上一帧的。

3 FTickFunction 与 FTickTaskManager#

每个需要 Tick 的对象(Actor、Component)都持有一个 FTickFunction。它不是虚函数调用,而是一个预先注册的任务图

FTickTaskManager 的核心工作:

  1. 收集:遍历所有 FTickFunction,按 TickGroup 分桶
  2. 排序:按依赖关系做拓扑排序(AddPrerequisite 可以指定”我先、你后”)
  3. 并行执行FTickTaskLevel::QueueAllTicks 后,FTaskGraphInterface 把无依赖的 Tick 分发到工作线程
  4. 屏障等待:每个 TickGroup 结束后有一个同步点,保证顺序

这意味着 UE 的 Actor Tick 已经是多线程的——TG_PostPhysics 里的 200 个 Actor 可能同时在 8 个线程上执行。前提是它们之间没有通过 AddPrerequisite 建立依赖。

4 TickInterval:不是每个 Actor 每帧都 Tick#

Actor 有一个 PrimaryActorTick.TickInterval 属性。设为 0.1 意味着这个 Actor 每 0.1 秒才 Tick 一次,即 60fps 下每 6 帧 Tick 一次。

实现不是用定时器,而是用 Tick 预算累积FTickFunction 内部有一个 TickVisited 计数器和 TickInterval 预算,FTickTaskManager 在决定是否调用时检查”上次 Tick 距今是否超过 TickInterval”。

这个机制极大地节省了 CPU——场景里有 1000 个 Actor,但只有玩家附近的 50 个每帧 Tick,远处的可能每 1 秒 Tick 一次。

5 你平时在 UE 里的体感#

你在蓝图里写的 Event Tick,本质就是在 AActor::Tick 里被 FTickTaskManager 调到。你在 C++ 里写的:

PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.TickGroup = TG_PostPhysics;

就是在告诉 FTickTaskManager:“请把我注册进 PostPhysics 组,每帧叫我。“


六、时间管理的几个特殊场景#

1 暂停与时间膨胀(Time Dilation)#

UE 的 AWorldSettings::SetTimeDilation(float) 可以缩放整个世界的 deltaTime:

// 时间膨胀 0.5 → 所有东西以半速运行
// 时间膨胀 2.0 → 两倍速
// 时间膨胀 0.0 → 暂停
ActualDeltaTime = RawDeltaTime * TimeDilation;

子弹时间的实现就是这么简单——不修改任何游戏逻辑,只改时间流速。

CustomTimeDilation 可以让单个 Actor 的时间与全局不同。UE 在计算每个 Actor 的 deltaTime 时会乘上两级 dilation:

ActorDeltaTime = WorldDeltaTime × GlobalDilation × CustomDilation

2 最大帧率限制#

UE 通过 t.MaxFPS 控制。实现不是在游戏循环里 sleep,而是在 Render 之后、下一帧 Tick 开始之前用 FPlatformProcess::Sleep 或更精准的等待机制。帧率平滑(Smooth Frame Rate)涉及:

  • VSync:硬件级等待,卡在 SwapChain Present 上。保证不撕裂,但可能引入输入延迟。
  • 手动限帧:用 std::this_thread::sleep_until 等待到下一个目标帧的时间点。精度取决于 OS 的调度粒度(Windows 默认约 15ms)。

UE 的 FEngineLoop 在每帧结束时调用 FPlatformProcess::Sleep 控制帧率上限。

3 断点调试时的时间#

你调试时暂停了 30 秒,恢复后游戏不应该瞬移 30 秒的距离。UE 处理这件事的方式是:

// 在计算 deltaTime 之前
RealDeltaTime = FMath::Min(RealDeltaTime, MaxDeltaTime);
// 如果调试暂停超过 MaxDeltaTime(比如 0.1 秒),直接截断

这样你可以放心打断点,不用担心恢复后一个 deltaTime = 30.0 把物理炸飞。


七、总结#

概念一句话
游戏循环连续的”读输入→更新→渲染”流水线
DeltaTime让逻辑与帧率无关,引擎最基础的不变量
固定时间步长物理模拟确定性的前提,与渲染帧率解耦
死亡螺旋大帧导致大 dt → 大 dt 导致更大帧 → 截断 rescue
UE Tick 系统三层 Tick(Frame / Physics / Net)+ TickGroup 排序 + 任务图并行
时间膨胀游戏慢动作/暂停的本质是缩放 deltaTime

游戏循环是引擎最基础也最容易被忽略的模块。它决定了所有其他子系统的时间基准——物理、动画、AI、网络——全部跑在这条节拍线上。


延伸阅读#

如果你在 UE 源码里追:

  • FEngineLoop::Tick() — 入口,Engine/Source/Runtime/Launch/Private/LaunchEngineLoop.cpp
  • UWorld::Tick() — World 层的 Tick,包含 TickGroup 分发
  • FTickTaskManager::StartFrame() — Tick 任务的收集与调度
  • FTickFunction — 单个 Tick 任务的基类
  • FPhysScene::Tick() — 物理子步循环
游戏循环与时间系统
https://www.m4doka.xyz/posts/engine/engine-1-game-loop/
作者
m4doka
发布于
2026-06-20
许可协议
CC BY-NC-SA 4.0