一、什么是游戏循环
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.060fps 时 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) × Δtx(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::Tick 里 FPhysScene::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::FixupDeltaSeconds 和 MaxPhysicsDeltaTime 中有对应。
五、UE 中的 Tick 系统
1 Three-Tick Architecture
UE 的 Tick 分为三层,每一层由不同的子系统驱动:
| 层 | 频率 | 职责 | 对应函数 |
|---|---|---|---|
| Frame Tick | 可变(渲染帧率) | 输入、相机、UI、Blueprint EventTick | UWorld::Tick |
| Physics Tick | 固定(通常与帧率解耦) | 碰撞、刚体运动、布料 | FPhysScene::Tick |
| Net Tick | 固定(由 NetUpdateFrequency 决定) | 属性复制、RPC | UNetDriver::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 的核心工作:
- 收集:遍历所有
FTickFunction,按 TickGroup 分桶 - 排序:按依赖关系做拓扑排序(
AddPrerequisite可以指定”我先、你后”) - 并行执行:
FTickTaskLevel::QueueAllTicks后,FTaskGraphInterface把无依赖的 Tick 分发到工作线程 - 屏障等待:每个 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 × CustomDilation2 最大帧率限制
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.cppUWorld::Tick()— World 层的 Tick,包含 TickGroup 分发FTickTaskManager::StartFrame()— Tick 任务的收集与调度FTickFunction— 单个 Tick 任务的基类FPhysScene::Tick()— 物理子步循环