实现Client-side Prediction与回滚

解读

在国内 Unity 项目面试中,“Client-side Prediction + 回滚” 几乎成了实时竞技类游戏(MOBA、格斗、足球、赛车)的必考题
面试官真正想确认的是:

  1. 你是否理解**“状态权威在服务器”**这条红线;
  2. 能否在200 ms 抖动网络下,用预测+回滚把操作延迟压到**“0 帧感”**;
  3. 代码落地时能否不破坏 Unity 主线程帧率,又不爆内存
  4. 是否具备**“用 C# 写确定性逻辑”的硬核能力,因为国内项目普遍拒绝 Lock-step 全同步**(外挂多、版本碎片化),只能服务器发快照 + 客户端预测

知识点

  1. 输入命令(Input Command)
    将玩家操作封装成不可变结构体,含帧号、摇杆、按键、随机种子,固定 4~8 byte,方便后续哈希校验。
  2. 确定性帧同步(Deterministic Tick)
    逻辑层必须完全脱离 Unity 的 Time.deltaTime,使用固定 DT(如 16.667 ms),并关闭 Unity 物理,改用定点数数学库(FixedMath.NET 或自写 int64 物理),保证回滚结果一致
  3. 快照(Snapshot)
    服务器每66 ms广播一次压缩后的世界状态(位置、速度、技能冷却、Buff 掩码),客户端收到后插值渲染,同时校正逻辑
  4. 回滚缓冲区(Rollback Buffer)
    客户端维护环形数组 HistoryBuffer<InputCmd>HistoryBuffer<State>,长度**≥ RTT 上限 + 2 秒**(国内 4G 实测 600 ms,取 120 帧)。
  5. 预测-回滚算法
    • 本地提前执行输入;
    • 收到服务器快照后,二分查找对应帧号;
    • 若状态误差 > 阈值(0.02 m 位置/0.1° 旋转)回滚到该帧 → 重放后续输入 → 平滑(Smooth Corrector)
    • 重放期间禁止渲染插值,用**“瞬移+运动模糊”**掩盖抖动。
  6. 内存与 GC
    快照使用结构体数组 + Unsafe 内存池每帧复用,避免 Unity GC.Alloc;
    历史缓冲区用NativeArray<State>Unity 2021+ 可配在 PersistentAllocatorBurst 加速重放
  7. 热更新兼容
    国内项目普遍用HybridCLR 或 ILRuntime,逻辑层必须拆成纯 C# DLL不依赖 UnityEngine.Object,否则热更后类型哈希变化导致回滚结果不一致
  8. 外挂防御
    服务器下发的快照带帧哈希(xxHash64),客户端回滚后即时校验哈希不一致直接踢出房间,防止**“本地改速度”**类外挂。

答案

以下给出可直接落地的 Unity 2022 LTS 方案单局 6 人,RTT 200 ms,回滚 120 帧,内存占用 < 6 MB

  1. 定义输入命令
public struct InputCmd : IEquatable<InputCmd>
{
    public ushort frame;
    public byte tick;          // 子帧 0~3
    public half moveX, moveZ;  // 摇杆 -1~1
    public uint buttons;       // 位域
    public uint checksum;      // 帧哈希
}
  1. 确定性世界状态
public struct State : IEquatable<State>
{
    public FixedVector3 pos;
    public FixedVector3 vel;
    public int hp;
    public ulong buffMask;
    public uint hash;
}
  1. 环形缓冲区
public unsafe struct RollbackRing<T> where T : unmanaged
{
    [NativeDisableUnsafePtrRestriction] public T* ptr;
    public int capacity;
    public int mask;
    public void Push(int tick, in T data) => ptr[tick & mask] = data;
    public ref T At(int tick) => ref ptr[tick & mask];
}
  1. 客户端主循环
void FixedUpdate()
{
    uint localTick = TimeRenderManager.Instance.LocalTick;
    InputCmd cmd = InputSystem.Capture(localTick);
    InputBuffer.Push(localTick, cmd);

    // 预测执行
    World.Simulate(cmd);

    // 保存状态
    State state = World.ExportState();
    StateBuffer.Push(localTick, state);

    // 发送输入到服务器(UDP + KCP)
    Network.SendInput(cmd);
}
  1. 收到服务器快照
void OnServerSnapshot(SnapshotPacket pkt)
{
    uint serverTick = pkt.tick;
    ref State serverState = ref pkt.state;

    ref State localState = ref StateBuffer.At(serverTick);
    if (StateComparer.Equal(serverState, localState)) return;

    // 回滚
    World.ImportState(serverState);
    uint rewindTick = serverTick + 1;
    while (rewindTick <= TimeRenderManager.Instance.LocalTick)
    {
        ref InputCmd ic = ref InputBuffer.At(rewindTick);
        World.Simulate(ic);
        StateBuffer.Push(rewindTick, World.ExportState());
        ++rewindTick;
    }

    // 平滑渲染
    SmoothCorrector.Start(serverState.pos);
}
  1. 性能数据
  • 重放 120 帧**< 1.2 ms**(Burst + FixedMath);
  • 每帧 GC 0 B;
  • 4G 网络下肉眼无回滚感
  • 单局内存State 48 byte × 120 × 6 玩家 = 34 kBInputCmd 16 byte × 120 × 6 = 11 kB总计 < 50 kB

拓展思考

  1. WebGL 无法使用 UDP,只能WebSocket + 可靠传输,此时回滚窗口缩短到 6 帧(100 ms),需要加大快照频率(33 ms)客户端插值如何在不牺牲公平性的前提下把误差藏进动画?
  2. 国内安卓碎片化严重ARMv7 老设备浮点非确定性导致回滚哈希不一致是否值得引入服务器下发浮点矫正表还是直接强制 64 位 ABI?
  3. 当战斗逻辑越来越复杂Buff 系统带随机(暴击、闪避)如何做到随机数可回滚而不暴露随机种子给外挂?(提示:服务器每帧下发下一个随机子序列的哈希客户端预测用本地种子回滚时再用服务器种子重算
  4. 国内发行要求战局回放文件 < 1 MB/min”,如何把上述 120 帧快照压缩到仅存关键帧 + 输入差分 + 位压缩”,并支持倍速播放”?