技术博客

时钟同步与插值缓冲:远端玩家的平滑移动是怎么实现的

第 117 / 149 篇 · 2025-08-21 · godot · 网络同步游戏架构插值

多人游戏里两个经典问题其实是同一个问题的两半:你的时钟和网络不同步(不知道"现在"是服务器的什么时候),以及别人的状态带着延迟到达(收到的位置永远滞后一个 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}$ 保持在缓冲中段。固定深度在全天候网络环境下要么浪费延迟要么卡顿。

插值缓冲:渲染时刻落在两个快照之间 服务器时间 → 快照 j-2 快照 j-1 快照 j 快照 j+1 … δ(缓冲深度) t_render:此刻渲染这个实体 权重公式 α = (t_render − t_{j-1}) / (t_j − t_{j-1})  S = S_{j-1} + α·(S_j − S_{j-1}) α ∈ [0,1) 恒成立 ⇔ δ ≥ 抖动上界 J + 快照间隔 —— 这是深度的存在意义 饥饿兜底:外推 ≤ 200ms(用速度线性预测),超时钳制冻结;深度自适应:饥饿 +20%,富余收缩 预算铁律:平滑度和延迟共享 δ —— 30Hz 快照经验起点 δ = 2~3 个间隔 ≈ 100ms

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 无内建时钟同步与插值缓冲,均需自行实现。