多人游戏里两个经典问题其实是同一个问题的两半:你的时钟和网络不同步(不知道"现在"是服务器的什么时候),以及别人的状态带着延迟到达(收到的位置永远滞后一个 RTT)。两个问题的共同解法是把远端实体画在"过去"——前提是你知道自己落后多少。这篇讲时钟偏移估计和插值缓冲的数学,Godot 侧的实现骨架直接可抄。
问题拆解:为什么不能直接画收到的位置
服务器 30Hz 广播玩家位置。本地收到快照的时间戳是 $t_{recv}$,内容是服务器在 $t_{send}$ 时刻的状态,$t_{recv} - t_{send} \approx R$(RTT)。如果收到就画,两个灾难:
- 位置是台阶:30Hz 快照直接渲染是 30fps 的跳变,本地 144fps 的画面里实体一卡一卡;
- 延迟不可控:网络抖动让 $R$ 在 50ms 到 200ms 之间晃,台阶的间隔和高度都跟着晃。
插值缓冲的思路:不画最新快照,画两个快照之间按时间混合的结果,混合时钟比"最新快照时间"再往回退固定深度 $\delta$。抖动被深度吸收——只要抖动 < $\delta$,渲染永远有前后两个快照可用,混合权重连续变化,视觉上就是匀速平滑。
代价是 $\delta$ 毫秒的固定延迟。这是网络游戏的根本权衡:平滑度和延迟共享同一个预算,谁多拿谁少拿。
第一步:时钟对表
渲染"过去"需要一把能换算成服务器时间的本地钟。Godot 没有内建 NTP,标准做法是自己量偏移。客户端在本地 $t_0$ 发 ping,服务器立刻回包附上服务器时刻 $t_s$,客户端在 $t_1$ 收到。假设去程回程对称(各占 $R/2$),服务器发出响应的真实时刻约 $t_s$,而那时客户端的钟是 $t_0 + R/2$,所以偏移:
$$ \theta = t_s - \frac{t_0 + t_1}{2} $$
$t_1 = t_0 + R$ 代入即 $\theta = t_s + R/2 - t_1$——服务器时间 = 本地时间 + $\theta$。单次测量的误差来自路径不对称:去程 $R_1$、回程 $R_2$ 时,估计值偏离真值
$$ \epsilon = \frac{R_1 - R_2}{2} $$
无法从单次测量消除——你不知道哪段慢。工程对策是采样取优:连续发 8~10 个 ping,只取 RTT 最小的那次计算 $\theta$。最小 RTT 样本里路径最可能接近对称(排队延迟最小化时,不对称主要是传播延迟的固有差异)。之后还要慢漂移校正:每隔几十秒重测一次,$\theta$ 用增量平滑更新,单次跳变直接丢弃。
Godot 里没有这些,得自己写:一个 ClockSync 节点,multiplayer.connected_to_server 后启动,_physics_process 里每 2 秒一轮 ping,multiplayer.multiplayer_peer.get_statistic(PEER_STAT_RTT) 能拿 ENet 现成的 RTT——但注意 ENet 的 RTT 是它自己的滚动平均,比"取最小样本"噪声大,对表还是自己发 ping 干净。
第二步:插值权重
缓冲里存按服务器时间戳排序的快照 $(t_j, S_j)$。渲染时刻选 $t_{render} = t_{server\_now} - \delta$(已用 $\theta$ 换算),在缓冲中找到包围 $t_{render}$ 的两个快照 $t_0 \le t_{render} < t_1$,线性插值权重:
$$ \alpha = \frac{t_{render} - t_0}{t_1 - t_0}, \qquad S(t_{render}) = S_0 + \alpha (S_1 - S_0) $$
旋转用 slerp,其余分量 lerp。$\alpha \in [0, 1)$ 恒成立时渲染永不外推——这正是缓冲深度存在的意义:$\delta$ 保证 $t_{render}$ 永远落在已收快照区间内部。
深度怎么定?约束是吸收抖动:设快照间隔 $T_s = 1/f_{snap}$、RTT 抖动上界 $J$(比如实测 95 分位 RTT − 最小 RTT),需要 $\delta \ge J + T_s$ 才能在抖动尖峰时仍有双快照可用。经验起点 $\delta = 2\sim 3$ 个快照间隔:快照 30Hz($T_s \approx 33$ms)配 $\delta = 100$ms,总视觉延迟约 $R + \delta$,局域网联机 150ms 内手感可接受。
缓冲饥饿与外推
深度选小了,网络一抖缓冲里只剩一个快照,$t_{render}$ 掉到区间外。两种兜底:
- 钳制(clamp):权重钉在 1,实体静止等待新快照——表现为突然卡顿后跳变;
- 外推(extrapolate):用最近速度 $v$ 做线性预测 $S(t_{render}) = S_1 + v \cdot (t_{render} - t_1)$——短期平滑长期飞,撞墙穿模都靠服务器快照纠正。
多数动作游戏选短时间外推(200ms 上限)加钳制。无论哪种,缓冲深度应该是自适应的:持续饥饿就把 $\delta$ 加大 20%,持续富余($t_{render}$ 落后缓冲尾部太多)就缩小,目标是把 $t_{render}$ 保持在缓冲中段。固定深度在全天候网络环境下要么浪费延迟要么卡顿。
Godot 实现骨架
ClockSync(自己写)
├─ 周期性 ping 服务器,取最小 RTT 样本估 θ
└─ server_now() = Time.get_ticks_msec() + θ
RemoteEntityBuffer(每个远端实体一个)
├─ MultiplayerSynchronizer 收到快照 → push(server_now_at_send, state)
├─ _physics_process: 丢弃 t < t_render − margin 的旧快照
└─ sample(t_render) → 二分找包围快照,lerp/slerp 出状态
两个容易忽略的坑。其一,别拿本地物理 tick 当服务器时间戳——两端 tick 不同步,必须用对表后的时间;快照里的状态本身是服务器发的,时间戳要本地打上接收时刻再反推(接收时刻 − RTT/2)。其二,MultiplayerSynchronizer 的默认节奏是"到了就应用",没有缓冲概念——插值缓冲要在它的 delta_synchronized 信号上自建,别改同步节点本身。
预算分配建议
总延迟预算 $R + \delta$ 分给谁?输入回环(玩家自己)走预测不占预算(七-5);远端实体的 $\delta$ 是主要支出。按游戏类型:合作 PvE 取小($R + \delta \le 120$ms,怪物的插值延迟玩家不敏感);竞技 PvP 取更小并配服务器 rewind(延迟补偿,又是另一篇的量)。快照频率也是预算的一部分:$f_{snap}$ 翻倍让 $\alpha$ 的台阶变细,但带宽线性涨——30Hz 是多数项目的甜点,60Hz 留给格斗和竞速。
本文模型参考 Gambetta 的 client-server game networking 系列与 Valve 的 Source Multiplayer Networking 文档;Godot 侧截至 4.4 无内建时钟同步与插值缓冲,均需自行实现。