DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 13
STEP 01 / CODE / LOGIC

先说结论

客户端立即用本地输入预测,服务器按 Tick 生成权威状态;客户端收到确认后回到服务器状态,删除已确认输入,再重放尚未确认的输入追上当前时刻。校正不是简单把角色瞬移到旧快照。

客户端已预测输入:Tick 100、101、102、103
服务器确认:处理到 Tick 101,权威位置 x=5.0
客户端操作:
1. 回到 x=5.0
2. 删除输入 100、101
3. 重放输入 102、103
4. 得到新的当前预测位置

先说结论

客户端立即用本地输入预测,服务器按 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 回调;历史条目被确认或对象销毁时要释放,避免旧输入在新生命周期中被重放。

调试时应该回答什么

一个有用的预测调试器不只画两条线,还要能回答:

  1. 当前输入属于哪个 Tick?
  2. 客户端已经预测到哪里?
  3. 服务器确认到了哪个 Tick?
  4. 同一确认 Tick 上的误差是多少?
  5. 哪些输入被裁剪,哪些进入重放队列?
  6. 当前轨迹差异来自延迟、丢包,还是权威规则冲突?

交互实验把这些状态放在同一张时间轴上,并使用明确标注的教学参数演示正常、抖动和冲突场景。

打开网络预测交互实验

最后记住

本地输入立即预测,同时按 Tick 发给服务器。
服务器返回权威状态和已确认 Tick。
客户端回到权威状态,删除已确认输入,再重放未确认输入。
表现可以平滑误差,但逻辑必须使用校正后的当前状态。