实现Client-side Prediction与回滚
解读
在国内 Unity 项目面试中,“Client-side Prediction + 回滚” 几乎成了实时竞技类游戏(MOBA、格斗、足球、赛车)的必考题。
面试官真正想确认的是:
- 你是否理解**“状态权威在服务器”**这条红线;
- 能否在200 ms 抖动网络下,用预测+回滚把操作延迟压到**“0 帧感”**;
- 代码落地时能否不破坏 Unity 主线程帧率,又不爆内存;
- 是否具备**“用 C# 写确定性逻辑”的硬核能力,因为国内项目普遍拒绝 Lock-step 全同步**(外挂多、版本碎片化),只能服务器发快照 + 客户端预测。
知识点
- 输入命令(Input Command)
将玩家操作封装成不可变结构体,含帧号、摇杆、按键、随机种子,固定 4~8 byte,方便后续哈希校验。 - 确定性帧同步(Deterministic Tick)
逻辑层必须完全脱离 Unity 的 Time.deltaTime,使用固定 DT(如 16.667 ms),并关闭 Unity 物理,改用定点数数学库(FixedMath.NET 或自写 int64 物理),保证回滚结果一致。 - 快照(Snapshot)
服务器每66 ms广播一次压缩后的世界状态(位置、速度、技能冷却、Buff 掩码),客户端收到后插值渲染,同时校正逻辑。 - 回滚缓冲区(Rollback Buffer)
客户端维护环形数组HistoryBuffer<InputCmd>与HistoryBuffer<State>,长度**≥ RTT 上限 + 2 秒**(国内 4G 实测 600 ms,取 120 帧)。 - 预测-回滚算法
- 本地提前执行输入;
- 收到服务器快照后,二分查找对应帧号;
- 若状态误差 > 阈值(0.02 m 位置/0.1° 旋转),回滚到该帧 → 重放后续输入 → 平滑(Smooth Corrector);
- 重放期间禁止渲染插值,用**“瞬移+运动模糊”**掩盖抖动。
- 内存与 GC
快照使用结构体数组 + Unsafe 内存池,每帧复用,避免 Unity GC.Alloc;
历史缓冲区用NativeArray<State>,Unity 2021+ 可配在 PersistentAllocator,Burst 加速重放。 - 热更新兼容
国内项目普遍用HybridCLR 或 ILRuntime,逻辑层必须拆成纯 C# DLL,不依赖 UnityEngine.Object,否则热更后类型哈希变化导致回滚结果不一致。 - 外挂防御
服务器下发的快照带帧哈希(xxHash64),客户端回滚后即时校验,哈希不一致直接踢出房间,防止**“本地改速度”**类外挂。
答案
以下给出可直接落地的 Unity 2022 LTS 方案,单局 6 人,RTT 200 ms,回滚 120 帧,内存占用 < 6 MB。
- 定义输入命令
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; // 帧哈希
}
- 确定性世界状态
public struct State : IEquatable<State>
{
public FixedVector3 pos;
public FixedVector3 vel;
public int hp;
public ulong buffMask;
public uint hash;
}
- 环形缓冲区
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];
}
- 客户端主循环
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);
}
- 收到服务器快照
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);
}
- 性能数据
- 重放 120 帧**< 1.2 ms**(Burst + FixedMath);
- 每帧 GC 0 B;
- 4G 网络下肉眼无回滚感;
- 单局内存State 48 byte × 120 × 6 玩家 = 34 kB,InputCmd 16 byte × 120 × 6 = 11 kB,总计 < 50 kB。
拓展思考
- WebGL 无法使用 UDP,只能WebSocket + 可靠传输,此时回滚窗口缩短到 6 帧(100 ms),需要加大快照频率(33 ms)并客户端插值,如何在不牺牲公平性的前提下把误差藏进动画?
- 国内安卓碎片化严重,ARMv7 老设备浮点非确定性导致回滚哈希不一致,是否值得引入“服务器下发浮点矫正表”还是直接强制 64 位 ABI?
- 当战斗逻辑越来越复杂,Buff 系统带随机(暴击、闪避),如何做到“随机数可回滚”而不暴露随机种子给外挂?(提示:服务器每帧下发下一个随机子序列的哈希,客户端预测用本地种子,回滚时再用服务器种子重算)
- 国内发行要求“战局回放文件 < 1 MB/min”,如何把上述 120 帧快照压缩到“仅存关键帧 + 输入差分 + 位压缩”,并支持“倍速播放”?