网络角色传送后出现半程残影,是插值跨过了不连续边界
服务器把角色从位置 2 传送到位置 102,客户端却短暂画出角色经过位置 52。网络包没有携带这段移动,玩法也没有执行位移;这个中间位置完全由渲染端制造。只要插值器看到 Tick 102 和 104 两个合法快照,再按时间比例计算线性插值,它就会把一次不连续状态变化误读成高速连续运动。
插值延迟可以吸收抖动,却不能判断两份状态之间是否存在可走的轨迹。传送、复活换点、跨场景迁移和服务端强制纠偏都可能切断连续性。若快照只保存 Tick 与位置,渲染层没有足够信息区别“走了 100 米”和“在两个空间点之间切换”。

位置差不能可靠识别传送
用距离阈值猜测传送看似简单,例如两帧相差超过 20 米便停止插值。但阈值同时绑定了地图尺度、载具速度、服务器发送间隔和丢包数量。高速载具可能合法跨过阈值,近距离闪现又可能落在阈值以内;一次连续运动在丢失数帧后也会被误判。
更直接的合同是让状态生产者声明连续段。研究探针 Program.cs 中的 Snapshot 除了 Tick 和 Position,还携带 Segment。服务端或确定性模拟在发生不连续迁移时推进段号;渲染时间线不推断原因,只检查包围采样时间的两个快照是否属于同一段。
readonly record struct Snapshot(uint Tick, int Segment, double Position);
enum PushResult
{
Accepted,
OutOfOrder,
Invalid,
}
enum SampleMode
{
Empty,
HoldOldest,
Interpolate,
HoldLatest,
SegmentCut,
}
readonly record struct SampleResult(
double Position,
SampleMode Mode,
uint LeftTick,
uint RightTick);
sealed class SnapshotTimeline
{
private readonly List<Snapshot> snapshots = [];
public PushResult Push(Snapshot snapshot)
{
if (!double.IsFinite(snapshot.Position) || snapshot.Segment < 0)
return PushResult.Invalid;
if (snapshots.Count > 0 && snapshot.Tick <= snapshots[^1].Tick)
return PushResult.OutOfOrder;
snapshots.Add(snapshot);
return PushResult.Accepted;
}
public SampleResult Sample(double renderTick)
{
if (snapshots.Count == 0)
return new(0, SampleMode.Empty, 0, 0);
if (renderTick <= snapshots[0].Tick)
return At(snapshots[0], SampleMode.HoldOldest);
for (var i = 1; i < snapshots.Count; i++)
{
var right = snapshots[i];
if (renderTick > right.Tick)
continue;
var left = snapshots[i - 1];
if (left.Segment != right.Segment)
return At(right, SampleMode.SegmentCut, left.Tick);
var alpha = (renderTick - left.Tick) /
(right.Tick - left.Tick);
var position = left.Position +
(right.Position - left.Position) * alpha;
return new(position, SampleMode.Interpolate,
left.Tick, right.Tick);
}
return At(snapshots[^1], SampleMode.HoldLatest);
}
private static SampleResult At(
Snapshot snapshot,
SampleMode mode,
uint? leftTick = null) =>
new(snapshot.Position, mode,
leftTick ?? snapshot.Tick, snapshot.Tick);
}
SnapshotTimeline 拥有已接受时间线,渲染调用者只提交 renderTick。同段时它执行插值;跨段时采用右侧快照,因为采样时间已经进入由右快照声明的新连续段。这个规则没有补造过渡动画,也不会把旧位置继续显示到传送后。若产品需要淡出、遮罩或落地特效,那应由表现状态机围绕 SegmentCut 结果处理,而不是污染位置求值。
这里采用右侧快照,而不是在断点前一直保持左侧位置,还有一个时序原因:渲染游标通常落后于最新服务端 Tick,用这段延迟换取左右样本。当游标已经越过左右快照之间的时间中点时,客户端事实上已经拥有新段的权威首帧;继续展示旧段只会额外延长传送延迟。若设计要求传送前必须播放蓄力或黑屏,服务器应把该阶段编码为显式玩法状态,渲染层不能通过滞留旧位置暗中补偿。
断点应当成为采样结果,而非特殊回调
固定输入包含四份快照:(100, 0, 0)、(102, 0, 2)、(104, 1, 102)、(106, 1, 104)。Tick 101.5 的左右快照同属段 0,位置为 1.50;Tick 103 横跨段 0 与段 1,返回 SegmentCut 和右侧位置 102。朴素插值会返回 52,那是服务器从未发布过的幽灵位置。

在 $PROJECT_ROOT 执行探针:
dotnet build $LAB_ROOT/SnapshotInterpolationLab.csproj \
-c Release --nologo -p:NuGetAudit=false
dotnet run --project $LAB_ROOT/SnapshotInterpolationLab.csproj \
-c Release --no-build
最终复核构建退出码为 0,0 个警告、0 个错误,耗时 1.70 秒;运行退出码为 0,8 个测试通过、0 个失败、0 个跳过,测试计时 3.80 毫秒。决定性输出为:
normal tick=101.5 mode=Interpolate position=1.50 span=100->102
teleport tick=103.0 mode=SegmentCut position=102.00 naive=52.00 span=102->104
failure late_tick=102 result=OutOfOrder retained=102.00
tests pass=8 fail=0 skip=0 duration_ms=3.80
迟到的 Tick 102 即使带着位置 50,也不能插入已经推进到 Tick 106 的活动时间线。ProbeSuite.RejectedSnapshotPreservesTimeline 断言它返回 OutOfOrder,随后在 Tick 103 采样仍得到位置 102。乱序重排若是协议需求,应先在有明确窗口上限的接收缓冲中完成;已经进入单调渲染时间线的历史不能被任意回写。
段号属于模拟语义,表现层只消费它
段号不必为每个实体全局唯一,只需在该实体可见生命周期内单调区分连续区间。它可以随快照一起压缩传输,也可以由可靠的出生、复活或迁移事件更新,但事件与快照必须共享同一状态身份,否则迟到事件仍可能切错时间线。实体销毁后复用网络 ID 时,还要把出生代次纳入身份,不能只延续旧段号。
架构评审时还应检查谁有权推进段号。客户端摄像机、动画状态机和网络插值器都不应根据画面距离自行推进;这个决定必须来自拥有位置跃迁语义的模拟层。否则同一快照在不同帧率、不同丢包条件下可能被分成不同连续段,回放和旁观者也无法复现相同结果。渲染端可以记录断点数量与原因用于诊断,但不能反向修改权威时间线。
这个探针没有覆盖旋转的最短弧插值、丢包外推、缓冲淘汰和多实体批处理。它确认的是更前置的架构边界:插值器只能在生产者证明连续的区间内生成中间状态。开头那次从 2 到 102 的传送因此不会再经过 52;渲染端收到的不是一个需要猜测的大位移,而是一条明确禁止跨越的时间线断点。