谈谈游戏循环(Game Loop)的设计
游戏循环是整个游戏的心跳,设计好坏直接影响物理稳定性、帧率平滑度和网络同步的可靠性。对服务器端开发来说,这个问题还有额外的维度。
从最简单的写法开始,看它哪里出问题
// 版本一:最朴素的写法
while (running) {
process_input();
update();
render();
}
这个循环跑多快完全取决于硬件。高端机器一秒跑 500 次,低端机器跑 30 次,游戏速度就完全不一样——快机器上子弹飞得像光速,慢机器上慢如蜗牛。游戏发展史上早期很多 bug 都源于此。
版本二:可变时间步长(Variable Timestep)
auto last_time = now();
while (running) {
auto current_time = now();
float dt = current_time - last_time; // 这帧花了多长时间
last_time = current_time;
process_input();
update(dt); // 用实际经过的时间来推进逻辑
render();
}
逻辑上更合理了:pos += velocity * dt,不管帧率高低,位移都是”速度 × 时间”,结果相同。
但这带来了新问题。物理不稳定:dt 越大,积分误差越大。玩家在低帧率下穿墙、弹簧系统爆炸、碰撞检测失效,都是大 dt 惹的祸。确定性丢失:同一段逻辑,60fps 运行和 30fps 运行,浮点累积误差不同,结果有细微偏差,帧同步游戏直接 desync。
版本三:固定时间步长(Fixed Timestep)——正确方向
const float FIXED_DT = 1.0f / 60.0f; // 60 tick/s
auto last_time = now();
while (running) {
auto current_time = now();
float elapsed = current_time - last_time;
last_time = current_time;
update(FIXED_DT); // 永远用固定步长更新
render();
// 如果处理太快,sleep 等到下一个 tick
sleep_until(last_time + FIXED_DT);
}
物理和游戏逻辑永远以固定步长运行,行为稳定、确定。但问题是渲染和逻辑完全绑死——逻辑 60 tick,渲染也只能 60fps,显示器明明能跑 144Hz 也用不上。而且如果某帧逻辑处理超时,整个循环就掉帧。
版本四:解耦逻辑与渲染(工业级标准做法)
这是 Glenn Fiedler 在经典文章《Fix Your Timestep!》里提出的方案,目前大多数商业引擎的核心实现:
const float FIXED_DT = 1.0f / 60.0f;
float accumulator = 0.0f;
auto last_time = now();
while (running) {
auto current_time = now();
float frame_time = current_time - last_time;
last_time = current_time;
// 防止"死亡螺旋":单帧最多追赶 250ms
frame_time = std::min(frame_time, 0.25f);
accumulator += frame_time;
// 用固定步长消耗累积时间(可能执行多次)
while (accumulator >= FIXED_DT) {
process_input();
update(FIXED_DT); // 物理、AI、游戏逻辑
accumulator -= FIXED_DT;
}
// 渲染:用剩余时间做插值,画面更平滑
float alpha = accumulator / FIXED_DT; // 0.0 ~ 1.0
render(alpha); // 在上一帧和当前帧状态之间插值
}
关键点逐一解释:
accumulator 是”还没被逻辑消耗掉的真实时间”。某帧渲染花了 33ms,固定步长 16ms,那就执行两次 update,消耗掉 32ms,剩 1ms 留到下一帧继续累积。
frame_time = min(frame_time, 0.25f) 是防止死亡螺旋的关键。如果某帧逻辑特别重花了 500ms,accumulator 就堆积 500ms,下一帧要追赶 31 次 update,每次 update 又花很多时间,accumulator 越积越多,游戏进入无限追赶卡死。加上这行上限,最坏情况下游戏以慢动作运行,但不会死锁。
render(alpha) 里的插值是这个方案最精妙的地方。逻辑每 16ms 一跳,渲染可以 144fps,渲染时根据 alpha 在”上一个逻辑帧状态”和”当前逻辑帧状态”之间线性插值,画面非常丝滑,没有任何抖动。
服务器端的 Game Loop 设计
这部分对你的 EA 岗位直接相关,面试大概率会聊到。
服务器没有渲染,但挑战不少:
Tick Rate 的选择
Tick rate 决定服务器每秒推进多少次游戏逻辑,直接影响游戏体验精度和服务器开销:
| 游戏类型 | 典型 Tick Rate | 原因 |
|---|---|---|
| CS2 / Valorant | 64 / 128 tick | 射击判定精度要求高 |
| Overwatch | 60 tick | 技能判定 + 控制成本 |
| MMORPG | 20 tick | 实体多,带宽和 CPU 成本优先 |
| 足球手游 | 20-30 tick | 移动网络带宽受限 |
Tick rate 不是越高越好。128 tick 的服务器 CPU 和带宽消耗是 64 tick 的两倍,FC Mobile 这类全球化手游需要在体验和运维成本之间取得平衡。
管理多个游戏房间
一台服务器同时跑几十上百个游戏房间,每个房间有自己的 loop,有几种常见设计:
// 方案一:每个房间一个线程(简单,线程切换开销大)
for (auto& room : rooms) {
std::thread([&room] {
while (room.running) {
room.update(FIXED_DT);
sleep_until(next_tick);
}
}).detach();
}
// 方案二:单线程事件循环(epoll + timer),所有房间共享
// 类似 Node.js 模型,IO 密集型时效率高,但 update 不能阻塞
// 方案三:线程池 + 任务队列(工业级常用)
// 每个 tick,把所有房间的 update 作为任务提交到线程池
// 无依赖的房间并行更新,充分利用多核
处理客户端延迟和掉包
服务器 loop 里还要处理网络不稳定:
void Room::update(float dt) {
// 1. 收集本 tick 内到达的所有客户端指令
auto inputs = collect_inputs_with_timeout(TICK_TIMEOUT);
// 2. 对超时未收到输入的客户端,用上一帧输入补全(input prediction)
for (auto& player : players_) {
if (!inputs.has(player.id))
inputs.set(player.id, player.last_input); // 重复上帧指令
}
// 3. 权威逻辑推进
simulate(inputs, dt);
// 4. 广播状态差量(delta,不是全量,节省带宽)
broadcast_delta_state();
}
服务器 loop 和客户端 loop 的时序对齐
客户端和服务器的 tick 并不天然对齐。客户端需要在正确的时刻发送指令,服务器才能在对应 tick 收到。实践中服务器会给每个 tick 打一个序列号,客户端用这个序列号做对齐和回滚:
Server tick 100 → 广播状态 Client 收到后知道这是 tick 100 的权威状态 Client 检查自己在 tick 100 的预测结果,对比校正
面试里的考察角度
EA 面试官问这个题目通常不是考你背定义,而是想看几件事:你能不能识别固定步长和可变步长各自的问题;你有没有处理过”死亡螺旋”这类边界情况;你在服务器端是怎么管理多房间 loop 的,tick rate 怎么选型。
如果你在之前的项目里设计过服务器 tick 系统,把那个场景的具体决策说出来,比任何理论描述都有说服力。
