网络角色传送后出现半程残影,是插值跨过了不连续边界

服务器把角色从位置 2 传送到位置 102,客户端却短暂画出角色经过位置 52。网络包没有携带这段移动,玩法也没有执行位移;这个中间位置完全由渲染端制造。只要插值器看到 Tick 102 和 104 两个合法快照,再按时间比例计算线性插值,它就会把一次不连续状态变化误读成高速连续运动。

插值延迟可以吸收抖动,却不能判断两份状态之间是否存在可走的轨迹。传送、复活换点、跨场景迁移和服务端强制纠偏都可能切断连续性。若快照只保存 Tick 与位置,渲染层没有足够信息区别“走了 100 米”和“在两个空间点之间切换”。

连续运动轨迹在传送点被明确切开

位置差不能可靠识别传送

用距离阈值猜测传送看似简单,例如两帧相差超过 20 米便停止插值。但阈值同时绑定了地图尺度、载具速度、服务器发送间隔和丢包数量。高速载具可能合法跨过阈值,近距离闪现又可能落在阈值以内;一次连续运动在丢失数帧后也会被误判。

更直接的合同是让状态生产者声明连续段。研究探针 Program.cs 中的 Snapshot 除了 TickPosition,还携带 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,那是服务器从未发布过的幽灵位置。

Tick 100 到 106 的同段插值、传送断点与迟到包拒绝规则

$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;渲染端收到的不是一个需要猜测的大位移,而是一条明确禁止跨越的时间线断点。