i007.cc

i007.cc

优先队列-降维打击

05.价值资料

谈谈游戏循环(Game Loop)的设计

游戏循环是整个游戏的心跳,设计好坏直接影响物理稳定性、帧率平滑度和网络同步的可靠性。对服务器端开发来说,这个问题还有额外的维度。


从最简单的写法开始,看它哪里出问题

cpp
// 版本一:最朴素的写法
while (running) {
    process_input();
    update();
    render();
}

 

这个循环跑多快完全取决于硬件。高端机器一秒跑 500 次,低端机器跑 30 次,游戏速度就完全不一样——快机器上子弹飞得像光速,慢机器上慢如蜗牛。游戏发展史上早期很多 bug 都源于此。


版本二:可变时间步长(Variable Timestep)

cpp
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)——正确方向

cpp
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!》里提出的方案,目前大多数商业引擎的核心实现:

cpp
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,有几种常见设计:

cpp
// 方案一:每个房间一个线程(简单,线程切换开销大)
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 里还要处理网络不稳定:

cpp
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 系统,把那个场景的具体决策说出来,比任何理论描述都有说服力。

发表回复