实时对战核心原理
概述
跑酷、弹射、动作、格斗类游戏追求极致的实时同步:几十毫秒的延迟都会破坏手感。这类游戏常用帧同步(Lockstep):服务器只转发玩家输入,所有客户端跑同一份确定性逻辑逐帧演算,保证每个玩家看到完全一致的画面。本文讲透实时对战的核心原理:Lockstep 模型、客户端输入队列、服务器 Tick 驱动、逻辑帧与渲染帧分离。
一、为什么需要帧同步
1.1 状态同步的局限
状态同步(第 4 周已讲):
服务器算状态 → 广播变化
每帧都要广播所有状态 → 带宽高、延迟叠加
实时动作类的问题:
30 帧/秒 × 每个实体位置/速度 = 海量广播
客户端看到的状态永远滞后一个 RTT
精细操作(格斗、弹射)对延迟极其敏感帧同步的思路:
不广播状态,只广播"输入"
输入很小(按了哪个键)→ 带宽极低
所有客户端用同一逻辑算 → 结果一致
延迟只影响输入到达时间,不累积误差1.2 帧同步 vs 状态同步
| 维度 | 帧同步 | 状态同步 |
|---|---|---|
| 广播内容 | 玩家输入 | 权威状态 |
| 带宽 | 极小 | 较大 |
| 服务器压力 | 低(只转发) | 高(每帧计算) |
| 确定性要求 | 极高 | 无 |
| 反外挂难度 | 高 | 低 |
| 适用 | 动作/格斗/弹射 | 回合制/策略/MOBA 部分 |
二、Lockstep 模型
2.1 核心思想
Lockstep = 锁定步调:
所有客户端按相同的"逻辑帧"步进
每帧收集所有玩家的输入
所有客户端用相同输入执行相同逻辑
→ 状态处处一致
经典流程:
玩家按"前进" → 输入入队 → 上报服务器
服务器聚合本帧所有玩家输入 → 广播给全员
客户端收到全部输入 → 本帧逻辑演算 → 渲染2.2 确定性(Determinism)
帧同步成败的关键:逻辑必须确定性
相同输入 + 相同逻辑 → 相同输出(任何机器/任何时间)
确定性破坏源:
浮点运算差异(不同 CPU 结果不同)→ 用定点数
使用系统时间随机 → 用共享种子随机
多线程乱序 → 单线程逐帧执行
依赖哈希/集合顺序 → 固定遍历顺序工程要求:
逻辑层与渲染层严格分离
逻辑层只依赖输入 + 种子 + 确定性函数
所有逻辑用整数/定点数计算
一旦双方状态分叉 → 用权威快照纠正(防作弊/容错)2.3 帧号机制
逻辑帧编号:
服务器分配逻辑帧号(如 1,2,3...)
每帧 = 固定时长(如 1/60 秒)
输入必须带帧号,参与该帧演算
作用:
对齐:所有客户端同一帧号执行同一批输入
回放:按帧号回放输入序列(断线恢复)
去重:重复输入按帧号丢弃三、客户端输入队列
3.1 输入的产生与缓存
输入路径:
玩家操作 → 本地逻辑立即执行(减少手感延迟)
同时输入入队 → 定时批量上报服务器
(或立即上报,按网络策略)
本地先行:
自己的输入本地立即生效(画面跟手)
他人的输入等服务器广播 → 存在"回滚/修正"机制3.2 输入队列设计
java
public class InputQueue {
private final ArrayDeque<InputFrame> queue = new ArrayDeque<>();
// 本地输入:立即入队并上报
public void pushLocal(int frame, byte keyState) {
queue.add(new InputFrame(frame, keyState));
}
// 服务器广播的他人输入:按帧号插入
public void pushRemote(int frame, long playerId, byte keyState) {
queue.add(new InputFrame(frame, keyState));
}
// 取某一帧的完整输入集(所有玩家)
public List<InputFrame> inputsForFrame(int frame) {
// 按帧号收集,不足则等待(该帧未开始)
}
}输入队列关键点:
本地输入与远程输入按帧号对齐
帧号缺失 → 该帧等待(延迟补偿见下篇)
输入去重(帧号幂等)3.3 输入上报频率
上报策略:
定时批量(每 N 帧打包一次)→ 减少包数量
事件触发(按键变化才发)→ 减少流量
实时动作游戏:每帧或隔帧上报
打包优点:
10 帧输入一个包 → 服务器压力小
网络抖动时多帧缓冲更抗丢四、服务器 Tick 驱动
4.1 服务器角色
帧同步服务器职责(只转发,不计算):
收集各玩家输入
按帧号聚合 → 广播给全员
维护逻辑帧推进
权威快照(定期/异常时下发纠错)
服务器不做:
不跑游戏逻辑(逻辑在客户端)
不算胜负碰撞(客户端判定,服务器抽查)4.2 Tick 循环
服务器 Tick 循环:
固定间隔(如 20ms/帧)触发
每个 Tick:
1. 推进当前帧号
2. 聚合本帧所有输入
3. 广播帧数据包(帧号 + 各玩家输入)
4. 检查超时/掉线
实现:
ScheduledExecutorService / HashedWheelTimer
或自旋循环 + 时间校准java
public class FrameSyncServer {
private int currentFrame;
private final Map<Integer, List<Input>> pendingInputs = new HashMap<>();
// Tick:服务器驱动帧推进
public void tick() {
int frame = ++currentFrame;
List<Input> inputs = pendingInputs.remove(frame);
if (inputs == null) {
inputs = Collections.emptyList();
}
// 广播本帧:帧号 + 输入集
broadcast(new FramePacket(frame, inputs));
}
// 玩家上报输入(带帧号)
public void onInput(long playerId, int frame, byte keyState) {
pendingInputs.computeIfAbsent(frame, k -> new ArrayList<>())
.add(new Input(playerId, keyState));
}
}4.3 帧率与延迟平衡
帧率选择:
60 帧:手感最好,带宽/服务器压力大
30 帧:足够大多数休闲动作
20 帧:卡牌/休闲够用
延迟影响:
服务器广播延迟 = 玩家输入到达时间差
延迟高玩家 → 拖慢整局(Lockstep 同步最慢者)
对策:
限制高延迟玩家(延迟阈值踢出/降级)
延迟补偿算法(下篇详述)五、逻辑帧与渲染帧分离
5.1 为什么分离
逻辑帧:游戏世界推进的单位(固定 60Hz)
渲染帧:显示器刷新(可变 30-144Hz)
不分离的问题:
渲染卡顿直接影响逻辑推进
不同性能设备逻辑不同步
分离的好处:
逻辑固定步进,确定性不受渲染影响
渲染自由插值,画面流畅
性能不足设备:渲染降帧,逻辑不降双循环模式:
逻辑循环:固定步长推进世界
渲染循环:读取世界状态绘制
渲染可在逻辑帧之间插值(平滑运动)5.2 逻辑推进实现
java
public class GameLoop {
private static final long STEP_NS = 16_666_667L; // 1/60 秒
private long accumulator;
public void loop() {
long last = System.nanoTime();
while (running) {
long now = System.nanoTime();
accumulator += now - last;
last = now;
// 逻辑固定步进(可能一帧补多步)
while (accumulator >= STEP_NS) {
stepOnce(); // 推进一个逻辑帧
accumulator -= STEP_NS;
}
// 渲染:读取当前世界状态
render(interpolate(accumulator));
}
}
}要点:
accumulator 固定步长累积推进
渲染在任意时刻读取"插值后的状态"
渲染帧率独立于逻辑帧率六、帧同步的风险与对策
| 风险 | 对策 |
|---|---|
| 逻辑分叉(不同步) | 确定性规范 + 权威快照纠错 |
| 恶意客户端传假输入 | 服务器随机抽查 + 复算校验 |
| 高延迟拖累全队 | 延迟补偿 + 延迟上限 |
| 断线重连 | 输入序列重放(下篇) |
| 浮点不一致 | 定点数运算 |
适用判断:
帧同步适合:2-8 人、输入简单、逻辑轻(跑酷/弹射)
不适合:海量实体、复杂逻辑(MOBA 大场景用状态同步+预测)七、小结
实时对战的核心是"广播输入而非状态":Lockstep 模型让所有客户端按同一逻辑帧、用同一批输入演算,达成处处一致;确定性是帧同步的生命线,逻辑层必须与渲染层分离、用定点数、固定遍历序;客户端输入队列本地先行并按时批量上报;服务器 Tick 驱动只做收集、聚合、广播,不跑逻辑;逻辑帧与渲染帧分离保证逻辑步进固定、渲染自由插值。理解这套原理,下一章的服务器实现(输入聚合、延迟补偿、断线回放)就顺理成章了。