把章节变成可单步观察的过程
分类:工程设计 / 网络同步
先说结论
抖动缓冲故意把显示时间放在最新服务器时间之后一小段距离,用已收到的两帧快照做插值。它用固定的额外延迟换取平滑,不能消除丢包,也不能替代本地预测。
三个乱序到达的快照
快照 Tick 100:服务器时间 10.0,位置 0,网络到达 10.08
快照 Tick 101:服务器时间 10.1,位置 1,网络到达 10.25
快照 Tick 102:服务器时间 10.2,位置 2,网络到达 10.23
显示延迟:0.15 秒
到本地时间 10.30 时,渲染目标服务器时间约为 10.15,可在 Tick 101 与 102 之间插值得到位置 1.5。缓冲按服务器时间排序,不应按到包顺序把 102 放在 101 前面。
先把标题翻译成人话
这篇讨论的主要不是“自己的角色按键后怎样立即移动”,而是“其他玩家怎样在我的屏幕上平滑移动”。
服务器可能每 50ms 发送一次其他玩家的位置,但这些包到达客户端的时间并不整齐。客户端采用三步处理:
抖动缓冲:先把位置快照存一小会儿,吸收到达时间的忽快忽慢。
快照插值:在前后两个已知位置之间,算出当前画面应该显示的位置。
可观测性:记录包、缓冲和插值状态,让卡顿或瞬移的原因能够被查到。
一句话记忆:缓冲负责等齐,插值负责画顺,可观测性负责查清。
顺着最小代码走一遍
假设服务器发来的每个快照只有时间和位置:
public readonly record struct Snapshot(
double ServerTime,
Vector3 Position);
readonly List<Snapshot> buffer = new();
const double interpolationDelay = 0.10; // 故意显示服务器 100ms 前的状态
收包:先存,不立即显示
void OnSnapshotReceived(Snapshot snapshot)
{
buffer.Add(snapshot);
buffer.Sort((a, b) => a.ServerTime.CompareTo(b.ServerTime));
}
buffer.Add 是“缓冲”:收到快照后先保存。Sort 是为了处理乱序,例如 10.10s 的包可能比 10.05s 更早到达;播放顺序应该看服务器时间,而不是到达顺序。
每帧渲染:显示稍早的时间
void Render(double estimatedServerNow)
{
double renderTime = estimatedServerNow - interpolationDelay;
(Snapshot left, Snapshot right) = FindAround(buffer, renderTime);
double duration = right.ServerTime - left.ServerTime;
float t = (float)((renderTime - left.ServerTime) / duration);
transform.position = Vector3.Lerp(left.Position, right.Position, t);
}
按顺序理解这几行:
renderTime:现在估计服务器走到10.20s,但画面故意播放10.10s。FindAround:找到包住10.10s的左右两帧,比如10.05s和10.15s。t:算出目标时间位于这段区间的百分比,此处是0.5。Lerp:显示在两个位置正中间,角色不再从左快照瞬移到右快照。
具体数字如下:
左快照:时间 10.05s,位置 x = 0
右快照:时间 10.15s,位置 x = 10
渲染时间:10.10s
t = (10.10 - 10.05) / (10.15 - 10.05) = 0.5
x = Lerp(0, 10, 0.5) = 5
这段最小代码还不够安全:左右快照可能找不到、时间可能相同、快照可能跨越传送,网络也可能长时间没有新包。后文的 SnapshotSampleStatus 和完整示例,就是把这些失败情况从“代码崩溃或角色乱跳”变成明确结果。
可观测性:不要只返回一个位置
如果 FindAround 找不到右快照,只返回 Vector3.zero,画面会把角色拉到世界原点,开发者却不知道发生了什么。更合适的接口会同时返回:
return new SnapshotSample(
Status: SnapshotSampleStatus.Underrun,
Position: null,
LeftTick: latest.ServerTick,
RightTick: null,
Error: "目标渲染时间已超过最新快照");
这里的 Status 和 Error 就是最小的可观测性:上层可以选择冻结或外推,调试面板也能明确显示“缓冲欠载”,而不是只看到角色突然停住。
这篇文章深化什么
网络预测关注:
本地怎样在权威结果到达前先响应输入,
以及权威状态到达后怎样校正预测。
快照插值解决的是另一类问题:
远端状态快照到达时间不均匀时,
怎样在已经收到的权威快照之间平滑显示。
这篇文章是在网络预测主题基础上,继续深入:
网络抖动
快照缓冲
时钟映射
插值时间线
乱序、丢包和缓冲欠载
失败与质量状态的可观测性
预测和插值可以同时存在,但它们不能混为一谈:
本地受控对象常需要输入预测与校正。
远端对象常使用延迟一小段时间的快照插值。
具体对象采用哪种模型必须由同步协议明确。
生活化模型:用广播节目理解抖动缓冲
想象直播节目每隔固定节奏发送一小段音频。
发送端:
片段 101
片段 102
片段 103
片段 104
网络到达端可能变成:
101 很快到
103 先到
102 稍后到
104 延迟更久
如果播放器“收到一段就立刻播一段”:
到达间隔会直接变成播放卡顿。
抖动缓冲的做法是:
先积累一小段内容。
播放时间故意落后发送时间。
只要延迟波动没有耗尽缓冲,就能按稳定时间线播放。
快照插值也是同样思路:
不是显示刚刚到达的最新包,
而是显示一个略早的服务器时间点,
用该时间点前后的两份快照插值得到状态。
代价是:
更稳定,但增加显示延迟。
三条时间线
网络同步至少涉及:
服务器时间
客户端本地时间
渲染时间
服务器时间
快照产生时的权威时间或 Tick:
snapshot.serverTick
snapshot.serverTime
客户端本地时间
数据包到达客户端时的单调时钟:
arrivalLocalTime
渲染时间
客户端当前想显示的服务器时间:
renderServerTime =
estimatedServerNow - interpolationDelay
interpolationDelay 不是算法给出的固定常数。它必须根据发送间隔、抖动分布、延迟目标与体验要求确定。
快照应该携带什么
最小快照身份通常包括:
EntityId
ServerTick 或 ServerTime
Sequence
State
状态可能包含:
位置
旋转
速度
动画或运动模式
离散事件引用
连续状态与离散事件不能简单使用同一种插值规则:
位置可以在两个样本之间插值。
“已死亡”或“门已打开”通常不是插到一半。
离散状态的生效时点必须由协议定义。
缓冲区的关键状态
每个实体或同步通道的缓冲区可以记录:
按服务器时间排序的快照
最新收到的服务器时间
最新连续确认区间
估算的服务器当前时间
目标渲染时间
当前插值左快照
当前插值右快照
缓冲质量状态
缓冲质量状态不能只用“有没有数据”表示:
Priming
正在积累足够样本,还不能插值。
Ready
目标时间被两份快照包围,可以插值。
Underrun
目标时间已经超过最新可用快照。
Gap
时间区间内存在缺失或不连续。
ClockUncertain
服务器时间映射不可信。
InvalidSnapshot
收到无法验证的状态。
这些状态让渲染层和监控系统知道“现在结果的质量是什么”。
从收包到显示的逐步流程
第一步:接收并验证快照
验证:
实体身份有效
Tick 或时间戳格式有效
状态字段满足协议
版本与当前同步协议一致
失败时:
记录 InvalidSnapshot
保留包身份和原始解析错误
不把无效状态插入缓冲
不能把无效坐标替换成零向量后继续。
第二步:记录到达时间
使用单调本地时钟记录:
arrivalLocalTime
系统墙钟可能被校时或人工修改,不适合作为经过时间的唯一依据。
第三步:按服务器时间插入
网络可能乱序:
Tick 103 先到
Tick 102 后到
缓冲区按服务器时间排序,而不是按到达顺序播放。
如果重复收到相同身份快照:
内容相同 -> 记录重复包
内容不同 -> 报告协议冲突
不能任意使用“最后到达者覆盖”而隐藏同一 Tick 内容不一致。
第四步:更新时钟映射
客户端需要估算:
当前本地单调时间对应哪个服务器时间。
单个包的:
arrivalLocalTime - snapshotServerTime
包含网络延迟,不能直接当成精确时钟偏移。
常见估计会结合:
往返时间采样
服务器时间同步消息
偏移平滑
异常样本识别
具体估计器、窗口和异常阈值是协议选择,必须通过网络测量验证。
第五步:计算目标渲染时间
renderTime =
estimatedServerNow - interpolationDelay
然后在缓冲中寻找:
left.serverTime <= renderTime <= right.serverTime
第六步:验证插值区间
即使左右快照存在,也要检查:
是否属于同一实体生命代次
是否跨越传送或重生
状态模式是否允许连续插值
时间顺序是否合法
传送前位置与传送后位置之间做线性插值,可能让角色穿过整张区域。这类离散边界需要协议标记。
第七步:计算插值
对于允许线性插值的位置:
t =
(renderTime - left.serverTime)
/ (right.serverTime - left.serverTime)
position = Lerp(left.position, right.position, t)
当左右时间相等时,分母为零,不能继续计算。重复 Tick 应在插入或区间选择阶段处理。
旋转常使用适合旋转空间的插值。使用线性、球面插值还是受运动模型约束的插值,是实现选择。
第八步:输出带质量的结果
渲染采样结果不应只返回一个位置:
SampleStatus
RenderedServerTime
LeftSnapshotId
RightSnapshotId
InterpolationFactor
State
Error
这样调用方不会把“没有可用样本”误认为原点或静止。
最小 C# 插值结果
public enum SnapshotSampleStatus
{
Ready,
Priming,
Underrun,
Gap,
InvalidInterval
}
public readonly record struct Snapshot(
long ServerTick,
double ServerTime,
Vector3 Position,
int LifetimeGeneration);
public readonly record struct SnapshotSample(
SnapshotSampleStatus Status,
Vector3? Position,
long? LeftTick,
long? RightTick,
string? Error);
public static SnapshotSample SamplePosition(
Snapshot left,
Snapshot right,
double renderServerTime)
{
if (left.LifetimeGeneration != right.LifetimeGeneration)
{
return new SnapshotSample(
SnapshotSampleStatus.InvalidInterval,
null,
left.ServerTick,
right.ServerTick,
"左右快照属于不同实体生命代次,禁止跨代插值。");
}
double duration = right.ServerTime - left.ServerTime;
if (duration <= 0d)
{
return new SnapshotSample(
SnapshotSampleStatus.InvalidInterval,
null,
left.ServerTick,
right.ServerTick,
"快照时间区间不是严格递增。");
}
if (renderServerTime < left.ServerTime ||
renderServerTime > right.ServerTime)
{
return new SnapshotSample(
SnapshotSampleStatus.Gap,
null,
left.ServerTick,
right.ServerTick,
"渲染时间不在当前快照区间内。");
}
float factor = (float)(
(renderServerTime - left.ServerTime) / duration);
return new SnapshotSample(
SnapshotSampleStatus.Ready,
Vector3.Lerp(left.Position, right.Position, factor),
left.ServerTick,
right.ServerTick,
null);
}
这段示例只在确认区间有效时返回位置。
把它和前面的最小代码对照:
LifetimeGeneration 检查 -> 防止把角色死亡前和重生后的位置连起来
duration <= 0 -> 防止重复或倒序时间造成除零和错误比例
renderTime 区间检查 -> 明确报告缺少左/右快照,不能盲目插值
factor -> 就是前面例子中的 t
Vector3.Lerp -> 根据 t 计算左右位置之间的显示位置
SnapshotSample.Status -> 把本次结果是否可信一起交给调用方
它没有在 Underrun 时:
冻结最后位置
外推速度
跳到最新快照
这些都属于可感知的降级或补偿策略,必须由同步协议明确确认后才能实现。
缓冲区查询伪代码
Sample(renderTime):
if clock mapping is uncertain:
return ClockUncertain
if buffer has not accumulated enough snapshots:
return Priming
left, right = FindBracketingSnapshots(renderTime)
if left is missing:
return Gap with earliest available time
if right is missing:
return Underrun with latest available time
if interval crosses discrete state boundary:
return InvalidInterval with boundary reason
return Interpolate(left, right, renderTime)
所有非 Ready 结果都保留具体原因,不返回默认位置。
正常路径
服务器按序产生:
Tick 201
Tick 202
Tick 203
网络虽然有轻微到达波动,但缓冲中已有:
201, 202, 203
目标渲染时间落在 202 与 203 之间:
找到左右快照
验证同一生命代次
计算插值因子
输出 Ready
边界路径
乱序到达
203 先到
202 后到
缓冲按服务器时间插入后仍是:
202, 203
观测数据应增加 OutOfOrderArrivalCount,让网络质量变化可见。
重复快照
相同 Tick、相同内容:
可识别为重复传输
不需要重复插入
记录重复计数
相同 Tick、不同内容:
这是协议一致性错误
记录两份内容身份
不任意覆盖
实体重生
同一个 EntityId 被重新使用:
LifetimeGeneration 发生变化
旧代次快照不能与新代次快照插值。缓冲应按生命代次隔离或清晰切段。
传送
传送快照应携带离散边界:
TeleportId
或 MovementDiscontinuity
边界如何显示由表现协议决定,但不能把跨边界线性插值当成正常移动。
失败路径
缓冲欠载 Underrun
目标渲染时间已经超过最新快照:
没有右快照可用于插值。
必须报告:
目标渲染时间
最新服务器时间
缺少的时间跨度
最近到达间隔
当前插值延迟
冻结、外推或跳帧都不是无条件正确的处理,不能在基础采样器中静默选择。
缓冲过深
快照积累过多,实际显示时间显著落后目标延迟:
可能是时钟估算漂移
消费速度不正确
延迟调节器异常
系统应暴露:
BufferDepthTime
OldestSnapshotAge
RenderDelayError
不能为了追赶而无记录地丢弃一段时间线。
快照间隔异常
左右快照时间差远大于发送周期:
可能发生丢包或服务器停顿。
是否仍允许跨大间隔插值,需要协议阈值与运动语义依据。没有依据时应返回 Gap 或显式质量状态,而不是假装连续。
时钟跳变
估算的服务器时间突然大幅变化:
renderTime 可能向前跳或倒退。
应记录:
旧偏移
新偏移
触发样本
估计器状态
受影响的渲染时间
平滑修正还是立即重建时间线属于明确的时钟协议选择。
无效数值
快照位置出现非有限数值:
NaN
Infinity
应拒绝该快照并报告字段与包身份。替换为零会把网络或序列化错误变成看似合法的位置。
插值与预测怎样协作
远端对象
常见数据流:
权威快照
-> 抖动缓冲
-> 目标渲染时间
-> 快照插值
-> 远端表现
本地受控对象
常见数据流:
本地输入
-> 本地预测
-> 权威确认
-> 重演或校正
本地对象也可能需要对其他状态做插值,但不能把远端插值缓冲直接套在本地输入响应上,否则会额外增加操控延迟。
同一场景的时间一致性
如果本地对象显示预测未来,而远端对象显示延迟历史:
它们处在不同显示时间线上。
命中判定、视觉特效和相对距离展示必须明确使用:
权威时间
预测时间
还是渲染时间
不能把不同时间线的坐标直接当作同一时刻事实。
进阶:插值延迟怎样确定
延迟不是“越大越好”或“越小越好”。
需要测量:
快照发送间隔
到达间隔分布
抖动分位
乱序概率
丢包模式
目标端到端延迟
缓冲欠载率
固定延迟与自适应延迟都是实现选择。
自适应延迟如果存在,至少应明确:
观测窗口
增大与减小条件
变化速率
上下界来源
时间线调整方式
这些都是行为影响值,必须来自测量和体验约束,不能凭经验写入固定常数。
可观测性
包到达指标
SnapshotReceivedCount
DuplicateSnapshotCount
ConflictingSnapshotCount
OutOfOrderArrivalCount
InvalidSnapshotCount
ArrivalInterval
EstimatedJitter
缓冲指标
BufferedSnapshotCount
BufferDepthTime
InterpolationDelay
OldestSnapshotAge
LatestSnapshotAge
GapCount
UnderrunCount
PrimingDuration
采样指标
ReadySampleCount
SampleStatus
RenderServerTime
LeftTick
RightTick
InterpolationFactor
LifetimeGeneration
时钟指标
EstimatedServerOffset
OffsetUncertainty
RoundTripTime
ClockAdjustmentCount
RenderTimeRegressionCount
一次问题诊断至少需要把这些事件关联到:
连接身份
实体身份
同步通道
服务器 Tick
本地到达时间
采样时刻
代价
抖动缓冲与插值增加:
额外显示延迟
每个实体的快照内存
按时间排序与查找成本
时钟同步复杂度
离散状态边界处理
观测数据量
它换来的是:
隔离一部分网络到达抖动
让远端运动基于两个已知权威状态
更清晰地区分网络质量与渲染质量
通用机制与实现选择
可验证的通用机制
网络到达间隔可能与发送间隔不同。
缓冲能用额外延迟吸收一部分到达抖动。
插值需要目标时间前后的两份有效快照。
快照必须按服务器时间而不是到达顺序排列。
不同生命代次和离散运动边界不能直接连续插值。
欠载时不存在完整插值区间。
必须由具体系统确定
快照发送频率
插值延迟
时钟估计器
缓冲容量与回收
丢包和大间隔判定
旋转与高阶运动插值
欠载后的明确行为
传送与离散状态协议
本地预测与远端插值的时间线关系
最后总结
网络预测回答:
权威结果未到时,本地怎样先响应?
快照插值回答:
已经到达的权威快照不均匀时,
怎样在稍晚的稳定时间线上显示远端状态?
一个可靠的抖动缓冲系统需要:
区分服务器、本地与渲染时间
按服务器时间排序快照
用两份合法快照包围目标时间
把乱序、重复、缺口和欠载作为明确状态
不为失败返回默认位置
不在未确认时静默冻结、外推或跳到最新状态
让每次采样都能追溯到快照和时间证据