把章节变成可单步观察的过程
先说结论
客户端立即用本地输入预测,服务器按 Tick 生成权威状态;客户端收到确认后回到服务器状态,删除已确认输入,再重放尚未确认的输入追上当前时刻。校正不是简单把角色瞬移到旧快照。
确认 Tick 101 后怎样重放
客户端已预测输入:Tick 100、101、102、103
服务器确认:处理到 Tick 101,权威位置 x=5.0
客户端操作:
1. 回到 x=5.0
2. 删除输入 100、101
3. 重放输入 102、103
4. 得到新的当前预测位置
若重放后位置为 5.8,表现层可以在合理范围内平滑靠近;逻辑状态已经是 5.8,不能一边平滑一边继续拿旧位置参与碰撞。
先把标题翻译成人话
玩家按下“向前”时,如果客户端一定要等服务器回复后才移动,操作就会显得迟钝。客户端预测的做法是:
先相信这次输入有效,让自己的角色立即移动;
服务器稍后给出真正结果;
如果两边不同,客户端回到服务器结果,再补算尚未确认的输入。
因此:
- 预测:客户端提前执行自己的输入,解决“按下后没有立即反应”。
- 权威:最终以服务器计算结果为准,客户端不能自行决定穿墙、加速或命中。
- 校正:客户端发现自己的旧预测与服务器结果不同时,恢复服务器状态。
- 重放:恢复之后,把服务器尚未处理的输入按原顺序重新执行,让角色追回客户端眼中的“现在”。
- 收敛:经过恢复和重放,客户端状态重新靠近服务器认可的状态。
这里的“重放”不是播放录像,而是重新调用运动代码计算最近几次输入。
先认识网络条件
代码里的输入和快照都是一个个数据包。它们在网络上传输时通常会遇到:
| 名称 | 它表示什么 | 游戏中的表现 |
|---|---|---|
| 延迟(Latency / Ping) | 一个包往返需要多久 | 操作确认来得晚,待重放输入变多 |
| 抖动(Jitter) | 每个包的延迟忽高忽低 | 确认消息到达节奏不稳定,偶尔一次校正很多 Tick |
| 丢包(Packet Loss) | 某些包没有到达 | 输入或快照中间出现缺口,可能需要冗余或重传 |
| 带宽(Bandwidth) | 一段时间最多能传多少数据 | 历史窗口或快照太大时可能挤塞网络 |
例如三个输入本来每隔 50ms 到达服务器:
正常:50ms、50ms、50ms -> 延迟稳定
抖动:20ms、130ms、35ms -> 平均值不一定很高,但到达没规律
丢包:Tick 14、Tick 15、(空) -> Tick 16 没有到达
用一段最小代码看完整过程
先只看骨架,不考虑碰撞和异常:
void ClientTick()
{
MoveInput input = CaptureInput(++localTick); // 1. 给本次输入编号
pendingInputs.Add(input); // 2. 留下副本,之后可能重放
SendToServer(input); // 3. 发给服务器
predictedState = Simulate(predictedState, input); // 4. 不等待回复,立即移动
}
void OnServerState(StateSnapshot snapshot)
{
predictedState = snapshot.state; // 5. 回到服务器确认的位置
pendingInputs.RemoveThrough(snapshot.confirmedTick); // 6. 删除已确认输入
foreach (MoveInput input in pendingInputs) // 7. 重放未确认输入
predictedState = Simulate(predictedState, input);
}
假设客户端已经执行到 Tick 18,服务器只确认到 Tick 14:
客户端保存的输入:14、15、16、17、18
服务器回复:我确认了 14,这是 Tick 14 的正确状态
客户端接着做:
恢复服务器的 Tick 14 状态
删除输入 14
重放 15、16、17、18
得到新的 Tick 18 预测状态
为什么不能收到服务器位置后直接显示它?因为那个位置属于较早的 Tick 14。如果不重放,玩家刚刚做出的 15 到 18 号输入会暂时消失,画面就会明显向后跳。
网络移动有两个看似冲突的目标:
- 玩家按下方向后,画面应该立刻响应;
- 所有参与者最终必须接受同一份权威状态。
客户端预测把它们拆成一条可回放的 Tick 管线:
采样输入并编号
→ 本地立即模拟
→ 保存输入历史并发送
→ 服务器按 Tick 权威模拟
→ 回传带确认 Tick 的状态
→ 回退到权威状态
→ 删除已确认输入
→ 重放仍未确认输入
预测不是“客户端说了算”,校正也不是“收到坐标就瞬移”。关键是:同一份输入、同一个 Tick、同一套确定性模拟,可以在客户端、服务器和重放阶段重复执行。
1. 输入必须先变成可编号的快照
渲染帧里的摇杆或键盘状态会不断变化。网络模拟不能只保存“当前方向”,而要在每个模拟 Tick 创建独立输入:
MoveInput CaptureInput(uint tick)
{
Vector2 direction = Normalize(rawDirection);
return new MoveInput(
tick,
direction,
sprintPressed);
}
Tick 是输入、预测状态和权威状态之间的共同坐标。没有它,客户端无法知道服务器确认到了哪一步,也无法确定应该重放哪些输入。
可以把 Tick 理解为输入的流水号。服务器回复 confirmedTick = 14,不是说“我大概处理到这里”,而是在明确告诉客户端:14 以及更早的输入都已经包含在这份权威状态里。
输入入口切换时还要避免双写:一旦预测链路真正接管移动,旧的逐帧移动命令应停止提交。否则同一输入会被两个系统各消费一次,误差来源将不再可解释。
2. 本地预测与发送共享同一份输入
客户端为输入写入当前 Tick,放入历史队列,然后立即调用模拟函数:
void Predict(MoveInput input)
{
inputHistory.Add(input);
Send(inputHistory.RecentWindow());
// 当前 Tick 立即响应,不等待网络往返。
predictedState = Simulate(predictedState, input);
}
逐行对应前面的最小流程:
inputHistory.Add(input) 保存输入,给未来的重放使用
Send(RecentWindow()) 把本次输入和少量旧输入发给服务器
Simulate(state, input) 立即得到本地预测结果,不等服务器
发送最近的一小段历史可以让后续数据携带先前输入,从而提高偶发丢包后的恢复机会。但这是冗余,不是可靠送达承诺;窗口大小、发送策略和带宽预算必须由具体系统验证。
本地模拟应保存“实际移动后的结果”,而不只是期望速度。碰撞、地形限制或外部驱动会让实际位移与输入意图不同,权威端必须使用同样的运动规则。
3. 服务器从输入队列推进权威状态
服务器收到输入后先验证来源与 Tick,再按顺序消费:
void ServerTick()
{
MoveInput input = inputQueue.TryDequeueFor(currentTick);
authoritativeState = Simulate(authoritativeState, input);
SendState(new StateSnapshot(
confirmedTick: input.tick,
state: authoritativeState));
}
若某个 Tick 没有输入,服务器该停住、沿用上一输入,还是使用空输入,属于行为规则,不能靠猜测补上。缺包策略必须显式定义,并参与客户端与服务器的一致性测试。
权威端的职责不只是重新算一遍。它还可以拒绝过旧、越权或非法的输入,并把碰撞和状态约束纳入最终结果。
4. 校正由“确认 Tick”切开历史
客户端收到权威快照后,先比较同一 Tick 的预测状态:
void ApplyAuthority(StateSnapshot snapshot)
{
State predictedAtTick = stateHistory.Find(snapshot.confirmedTick);
error = Distance(predictedAtTick.position, snapshot.state.position);
Restore(snapshot.state);
inputHistory.RemoveThrough(snapshot.confirmedTick);
}
这里比较的是“同一 Tick 的两个结果”,不是拿服务器的旧位置和客户端当前的新位置比较。服务器发来 Tick 14 时,应该与 stateHistory 中保存的客户端 Tick 14 预测结果比较;否则网络延迟本身造成的时间差会被误判成位置错误。
无论误差是否肉眼可见,确认 Tick 之前的输入都已经被权威结果覆盖,可以从历史中裁剪。确认 Tick 之后的输入仍然是客户端已执行、服务器尚未确认的部分,不能丢掉。
恢复状态不仅包括位置。若运动模型依赖旋转、水平速度、垂直速度或接地状态,这些状态也必须一起恢复,否则下一次模拟仍会从错误条件出发。
5. 重放把客户端追回“现在”
应用权威状态后,客户端按 Tick 顺序重放剩余输入:
void ReplayPendingInputs()
{
foreach (MoveInput input in inputHistory.After(confirmedTick))
{
predictedState = Simulate(predictedState, input);
stateHistory.Replace(input.tick, predictedState);
}
}
重放应调用与正常预测相同的模拟函数,而不是另写一套“近似修正”。两套逻辑会让误差在每次校正后重新出现。
重放时通常只重复纯逻辑计算。脚步声、粒子、奖励发放和网络发送等副作用不能再次触发,否则一次校正可能播放四次音效、重复生成奖励,甚至把重放输入再次发给服务器。
一次完整收敛可以写成:
客户端当前 Tick = 18
服务器确认 Tick = 14
恢复 Tick 14 的权威状态
删除输入 1…14
重放输入 15、16、17、18
得到新的客户端当前状态
三类必须单独观察的场景
正常往返
输入按序到达,预测轨迹和权威轨迹接近。即使误差很小,历史裁剪仍然要发生,否则队列会持续增长。
延迟与抖动
客户端会在服务器确认较旧 Tick 时已经向前预测多步。校正后的重放队列更长,CPU 峰值和视觉误差都可能增加。调试面板应同时显示当前 Tick、确认 Tick、待重放 Tick 和重放次数。
丢包与权威冲突
旧输入可能借后续冗余再次到达,但服务器环境仍可能得出不同结果,例如客户端尚未知道的阻挡。此时要保留原始权威差异,回退并重放;不能把冲突吞掉成“成功”,也不能用最近输入代替缺失输入。
边界与代价
- 确定性:相同状态和输入若不能得到相同结果,重放只会制造新误差。
- 历史内存:输入与状态历史必须有明确裁剪点;确认停滞时还要有可观察的上限策略。
- 重放成本:延迟越高,单次校正可能重放越多 Tick。
- 丢包不是免费恢复:历史冗余增加带宽,也不能保证恢复所有缺口。
- 视觉平滑与逻辑校正不同:逻辑状态可以立即回到权威结果,渲染层是否平滑追赶是另一套设计。
- 副作用隔离:重放移动时不能重复播放音效、生成奖励或再次发送不可逆事件。
- 生命周期清理:停止网络模拟时要取消 Tick 回调;历史条目被确认或对象销毁时要释放,避免旧输入在新生命周期中被重放。
调试时应该回答什么
一个有用的预测调试器不只画两条线,还要能回答:
- 当前输入属于哪个 Tick?
- 客户端已经预测到哪里?
- 服务器确认到了哪个 Tick?
- 同一确认 Tick 上的误差是多少?
- 哪些输入被裁剪,哪些进入重放队列?
- 当前轨迹差异来自延迟、丢包,还是权威规则冲突?
交互实验把这些状态放在同一张时间轴上,并使用明确标注的教学参数演示正常、抖动和冲突场景。
最后记住
本地输入立即预测,同时按 Tick 发给服务器。
服务器返回权威状态和已确认 Tick。
客户端回到权威状态,删除已确认输入,再重放未确认输入。
表现可以平滑误差,但逻辑必须使用校正后的当前状态。