延迟补偿与回滚¶
先看时间差¶
“我明明打中了,为什么没伤害?”这个问题通常不是服务器没收到射击,而是客户端、服务器和目标位置对不上时间。
玩家在本地看到敌人站在门口,按下开火。输入上行要花几十毫秒,等服务器处理时,敌人已经往旁边走了一步。如果服务器直接按当前状态判定,这一枪就会被判空。
用时间轴看清楚¶
延迟补偿的做法是:服务器收到射击后,回退到玩家当时看到的世界状态,再用历史碰撞体做判定。1
保存最近的状态¶
服务器需要保留一小段时间的碰撞体或角色位置。可以用环形 Buffer,按 Tick 覆盖最旧的数据:
public sealed class SnapshotHistory
{
private readonly Snapshot[] _buffer;
private readonly int _capacity;
public SnapshotHistory(int capacity)
{
_capacity = capacity;
_buffer = new Snapshot[capacity];
}
public void Record(in Snapshot snapshot)
{
_buffer[snapshot.Tick % _capacity] = snapshot;
}
public bool TryGet(int tick, out Snapshot snapshot)
{
snapshot = _buffer[tick % _capacity];
return snapshot.Tick == tick;
}
}
实际项目里,位置和朝向不需要每个 Tick 存一份完整快照。可以只记录受影响实体的碰撞体、速度和关键状态,再在查询时插值。
命中判定的流程¶
- 客户端发送射击输入:方向、时间戳、客户端 Tick、武器状态。
- 服务器根据收到命令的时间和 RTT 估算玩家当时看到的时间,不要直接相信客户端传来的任意时间。
- 从历史状态里取出对应 Tick,并做插值。
- 在世界状态上执行射线或弹道检测。
- 再用当前状态检查目标是否仍然存活、是否在合法视野内。
- 广播命中结果,客户端播放反馈。
简化后的判定代码:
public HitResult ResolveHitscan(
ShotCommand command,
SnapshotHistory history,
int rewindTick)
{
if (!history.TryGet(rewindTick, out var past))
return HitResult.RejectedOutOfWindow;
var ray = new Ray(command.Origin, command.Direction);
return PhysicsLayers.Raycast(past.Colliders, ray, command.MaxDistance);
}
rewindTick 由服务器计算,大致是当前 Tick 减去半程 RTT 和插值延迟,最后再夹到允许的回溯窗口里。这里的 PhysicsLayers.Raycast 只是示意,实际项目可以用物理查询、扫掠检测或自己的碰撞结构。关键是查询的对象来自历史状态,不是当前状态。
回溯窗口不能无限大¶
窗口越大,高延迟玩家越容易命中,但服务器需要保存更多历史状态,回放成本也更高。2 常见范围在 100–300 ms,具体要看服务器 Tick、最大 RTT 和玩法节奏。
| 窗口 | 影响 |
|---|---|
| 太小 | 高延迟玩家经常打空 |
| 太大 | 回放成本上升,作弊空间变大 |
| 没有上限 | 不公平,也不适合线上运营 |
回溯还要限制时间戳偏差、射速、弹药、视线和目标存活状态。否则客户端可以伪造一个很早的时间点,去打当时还没有出现的目标。
回滚与重模拟¶
如果客户端做了本地预测,收到权威状态后要重放未确认输入。3 格斗或高频物理游戏里,这个过程可能涉及角色碰撞和动画事件,不能只改位置。
一个可用的调试方式是记录:
- 服务器确认的 Tick
- 客户端当时的预测状态
- 重放后的状态
- 两者误差
这样出现“回滚后穿墙”或“命中后又被拉回”时,能直接定位是哪一步模拟不一致。
命中日志怎么记¶
把每次命中的回溯 Tick、玩家 RTT 和最终判定位置写进调试日志。只在训练场测不够,最好用不同延迟和丢包组合跑一遍,观察命中率是否随网络条件明显偏斜。
如果某档延迟的命中率突然高很多,通常不是补偿写得好,而是时间估算或窗口限制出了问题。