..

远端玩家平滑移动:定速线段插值、路径缓冲与断档纠偏

前言

做多人实时游戏时,有一个很常见、也很容易被忽略的问题:

本地玩家的移动看起来很自然,远端玩家却总有一种“假的”、“拖的”、“卡一下再追上”的感觉。

这不是因为远端玩家不能动,而是因为它通常动得不对。

本地玩家往往由摇杆、速度向量、物理系统直接驱动,所以它的移动天然带有明确的速度语义。远端玩家则只能依赖网络下发的一批离散位置点 moveList,接收端必须自己决定:这些点应该如何被还原成一条连续运动轨迹。

很多项目里,远端玩家都是这么写的:收到一个新的目标点,然后每帧用 lerp 往前追。这个方案实现快,代码短,刚跑起来的时候甚至会让人觉得“还行”。但只要你对画面观感稍微敏感一点,就很快会发现它的问题。

这篇文章想讲清楚一件事:

为什么“追点”不是一个稳定的远端移动模型,以及为什么“按时间回放轨迹”会更接近你真正想要的效果。

我会把这件事拆成三部分:

  1. 旧方案为什么容易抖
  2. 新方案到底是怎么做的
  3. 这套方案背后的数学原理是什么

旧方案为什么看起来不对

很多远端玩家的实现,本质上都接近下面这个模型:

  1. 收到新的 moveList
  2. 选出一个当前目标点
  3. 每帧执行:
x += (targetX - x) * lerpFactor
y += (targetY - y) * lerpFactor

这种实现的问题不在于它不能工作,而在于它工作得太“临时”了。

lerp 追点天然不是匀速

假设当前位置是 x,目标点是 targetX

nextX = x + (targetX - x) * 0.1

每一帧前进的距离就是:

delta = (targetX - x) * 0.1

这里的关键问题是:

  • 剩余距离大时,前进得更快
  • 剩余距离小时,前进得更慢

也就是说,这个模型本质上不是“匀速前进”,而是“逐渐逼近目标点”。

这就是为什么远端玩家常常会出现一种很明显的视觉特征:

刚开始冲得很快,接近目标点的时候又明显收速。

如果你的目标是让角色像真实在走路一样移动,这种模型先天就不对。

新数据包会把旧路径打断

另一个常见问题是:新路径一到,旧路径就被覆盖了。

于是角色的运动节奏会变成这样:

走一小段 -> 收到新包 -> 重置目标 -> 再走一小段 -> 再重置

这会带来几个直接后果:

  • 路径容易被截断
  • 拐点衔接不连续
  • 一秒一包的输入频率会直接暴露在画面上

玩家不一定能说出是哪里不对,但会明确觉得:

这个角色不像“在移动”,而像“在不断修正位置”。

你其实是在追“消息”,而不是在播“轨迹”

这是我觉得最值得记住的一点。

远端玩家真正应该做的,不是“看到点就追过去”,而是:

根据收到的一批点,推导出一条应该如何随时间变化的位置曲线,然后按时间去播放它。

这就是旧方案和新方案的根本区别。

新方案:按时间回放轨迹

新方案的核心思路很简单:

远端玩家不再追目标点,而是回放一条带时间信息的轨迹。

这套方案现在有两层时间语义:

  • 第一阶段:协议里只有坐标点时,接收端根据 distance / speed 估算每段时长
  • 第二阶段:协议补齐 offsetMs / durationMs / seq 后,接收端直接按真实相对时间回放,并用批次序号淘汰过期包

也就是说,“按时间回放轨迹”并不只是一种数学思想,它已经分成了两种落地模式:

  • V1 回退模式:自己推导时间轴
  • V2 真实时间模式:协议直接提供时间轴

无论是哪一种,播放器最终做的事都一样,只是时间轴的来源不同。

这套方案可以拆成 7 步:

  1. 收到路径点后先去重
  2. 判断这批点是否自带时间信息
  3. 把相邻点拆成多个线段
  4. 没有时间信息时,用固定速度为每条线段估算时长
  5. 有时间信息时,直接使用点位 offsetMs 构建时间轴
  6. 给每条线段分配开始时间和结束时间
  7. 每帧根据当前时间找到所在的线段,并在该线段上插值得到位置

对应的数学图我拆成了两张,分别讲“路径如何变成带时间的轨迹”以及“某个时刻如何计算当前位置”。

remote player motion path and timeline

图 1:先把路径点拆成线段,再给每条线段分配持续时间。

remote player motion sampling and lerp comparison

图 2:根据当前时间在对应线段上做插值,并和 lerp 追点模型对比。

这张图表达的是一件事:

  • 上半部分是几何路径
  • 中间部分是时间轴
  • 下半部分是“在某个时刻如何算当前位置”

从这一步开始,路径就不再只是“点列表”,而是一条可随时间采样的运动轨迹

路径拆线段:先把点变成段

假设收到一批路径点:

P0 = (0, 0)
P1 = (60, 0)
P2 = (60, 40)
P3 = (120, 40)

那么它会被拆成三条线段:

S1: P0 -> P1
S2: P1 -> P2
S3: P2 -> P3

可以把它想象成这样:

(0,40)                     (60,40) ---------------- (120,40)
                             |
                             |
                             |
(0,0) -------------------- (60,0)

对应关系非常清晰:

  • S1:水平向右
  • S2:垂直向上
  • S3:再次水平向右

之所以要拆成线段,而不是直接在点之间来回跳,是因为:

只有线段,才能承载“持续时间”这个概念。

而一旦有了持续时间,我们才有办法谈“按时间采样位置”。

数学核心:没有真实时间时,每段时长 = 距离 / 速度

第一阶段方案的数学部分其实很朴素。

设定一个目标速度 v,单位是像素每秒。对于任意一条线段:

  • 起点:(x1, y1)
  • 终点:(x2, y2)

先计算这条线段的长度:

distance = sqrt((x2 - x1)^2 + (y2 - y1)^2)

然后根据固定速度,计算这条线段应该播放多久:

duration = distance / speed

如果实现里用毫秒:

durationMs = distance / speed * 1000

示例 1:一条水平线段

起点 (0, 0),终点 (60, 0),速度 120 px/s

距离:

distance = sqrt((60 - 0)^2 + (0 - 0)^2)
         = sqrt(3600)
         = 60

时长:

duration = 60 / 120 = 0.5 s = 500 ms

这意味着:

  • 0ms 时在 (0, 0)
  • 250ms 时在 (30, 0)
  • 500ms 时到 (60, 0)

示例 2:一条竖直线段

起点 (60, 0),终点 (60, 40),速度仍然是 120 px/s

距离:

distance = sqrt((60 - 60)^2 + (40 - 0)^2)
         = 40

时长:

duration = 40 / 120
         = 0.333... s
         = 333.33 ms

所以第二段会从 500ms 开始,到大约 833ms 结束。

整条路径的时间轴

整条路径最终会形成一条时间轴:

0ms      500ms      833ms      1333ms
|---------|----------|-----------|
  S1         S2          S3

这一步的意义非常大,因为从这里开始,路径就被转换成了一个“几何 + 时间”的组合体。

但这里要补一句非常关键的话:

distance / speed 不是这套模型永远的真理,它只是协议还没有时间字段时的一种退化估算。

一旦协议能够直接告诉我们“这一批路径点各自对应什么时间位置”,接收端就不应该再自己猜。

第二阶段:把时间轴直接放进协议

如果上行和下行消息都只是:

{
  "moveList": [
    { "x": 100, "y": 200 },
    { "x": 112, "y": 200 },
    { "x": 130, "y": 205 }
  ]
}

那么接收端只能假设这些点在一个固定窗口里平均分布,再回退到 distance / speed 的估算逻辑。

第二阶段落地之后,更合理的结构是:

{
  "moveList": [
    { "x": 100, "y": 200, "offsetMs": 0 },
    { "x": 112, "y": 200, "offsetMs": 120 },
    { "x": 130, "y": 205, "offsetMs": 280 },
    { "x": 160, "y": 220, "offsetMs": 1000 }
  ],
  "moveInfo": {
    "version": 2,
    "seq": 12,
    "durationMs": 1000
  }
}

这几个字段分别承担不同职责:

  • moveList[].offsetMs:定义每个点在本批路径中的真实时间位置
  • moveInfo.durationMs:定义这一批路径总共覆盖多久
  • moveInfo.seq:用于丢弃乱序包和过期包
  • moveInfo.version:让 V1 / V2 双读兼容成为可能

这样播放器就不再需要猜“这些点是不是均匀采样”,而是可以直接构建一条带真实相对时间的轨迹。

工程上更实用的一点是,V2 不需要一刀切替换旧协议。接收端完全可以采用下面这套兼容策略:

  1. 如果点位带合法 offsetMs,按真实时间轴回放
  2. 如果没有,退回 distance / speed 估算
  3. 如果 seq 不比上一次新,直接丢弃

所以第二阶段并不是推翻第一阶段,而是在第一阶段之上补上更可信的时间语义。

每一帧怎么计算当前位置

当你知道当前这一帧的时间 now 后,只要做两件事:

  1. 找到 now 落在哪一条线段里
  2. 计算它在线段上的进度 progress

对于某条线段:

  • 开始时间:startTime
  • 结束时间:endTime
  • 起点:start
  • 终点:end

线段进度:

progress = (now - startTime) / (endTime - startTime)

然后用线性插值算位置:

x = start.x + (end.x - start.x) * progress
y = start.y + (end.y - start.y) * progress

一个具体时刻的计算

假设当前时间是 650ms

根据前面的时间轴:

  • S1: 0ms ~ 500ms
  • S2: 500ms ~ 833ms
  • S3: 833ms ~ 1333ms

所以 650ms 落在 S2

于是:

progress = (650 - 500) / (833 - 500)
         ≈ 150 / 333
         ≈ 0.45

S2 的起点是 (60, 0),终点是 (60, 40),所以:

x = 60 + (60 - 60) * 0.45 = 60
y =  0 + (40 -  0) * 0.45 ≈ 18

因此这一帧角色应该在:

(60, 18)

这就是“按时间回放轨迹”的核心含义:

不是问“我离目标点还有多远”,而是问“在这个时间点,我本来应该在哪里”。

为什么这种方案比 lerp 更稳定

可以直接对比这两种模型。

lerp 追点

delta = remainingDistance * factor

特点:

  • 剩余距离越大,速度越快
  • 接近目标时自然减速
  • 视觉上更像“追赶目标点”

按时间插值

position = f(time)

特点:

  • 同一线段内速度恒定
  • 转向发生在真实拐点
  • 视觉上更像“按照计划播放一段运动轨迹”

一句话总结:

lerp 关心的是剩余距离, 时间插值关心的是当前时刻。

这是两种完全不同的运动哲学。

路径缓冲:新包不要直接覆盖旧路径

只做定速插值仍然不够。因为如果每次新包一到就重置当前轨迹,角色依然会卡。

更合理的做法是维护两层状态:

  • activePath:当前正在播放的路径
  • bufferedPaths:后续待播放路径

处理逻辑是:

  1. 如果当前没有在播,新的路径直接成为 activePath
  2. 如果当前已经在播,新的路径进入 bufferedPaths
  3. 当前路径播完,再接着播队列里的下一条路径

可以把它理解成一个小型播放队列:

[正在播放的轨迹] -> [下一段轨迹] -> [再下一段轨迹]

这一步解决的是“路径被新消息打断”的问题。

它的好处非常直接:

  • 当前轨迹不会被粗暴截断
  • 新旧路径衔接更连续
  • 网络消息的离散节奏不会直接映射成角色顿挫

插值延迟:故意慢一点,反而更稳

这是多人同步里一个很经典但也很反直觉的策略。

很多人直觉上会觉得:

远端角色当然应该尽量追到最新位置,越新越好。

但现实恰恰相反。

如果角色永远追“当前最新时刻”,很容易出现这样的节奏:

路径播到尾巴 -> 下一包还没到 -> 停住一下 -> 新包到了再继续

于是可以给播放系统一个很小的固定延迟,比如 100ms ~ 150ms

renderTime = receiveTime + 120ms

这样做相当于让渲染故意“慢半拍”,给下一包数据留一点缓冲空间。

它的作用是:

  • 降低“播到尾巴等下一包”的停顿感
  • 让多批路径更容易连续拼接
  • 减少网络轻微抖动在画面上的可见性

这个延迟不是为了让角色看起来“慢”,而是为了让角色看起来“连”。

断档纠偏:什么时候该直接吸附

即使有了路径缓冲和插值延迟,仍然会有一种场景需要单独处理:

  • 网络包明显断档
  • 当前渲染位置已经和最新权威点偏差很大

如果这时还坚持慢慢补播旧轨迹,角色就会显得特别拖泥带水。

所以通常会再加一条硬规则:

只有当“路径断档”并且“漂移过大”同时发生时,才直接吸附到最新点。

也就是:

if staleGap && driftTooLarge:
  snapToLatestPoint()

如果写成一个接近工程实现的简化版本,大致会是这样:

function ingestMovePath(points: MovePoint[], receivedAtMs: number) {
  const latestPoint = points[points.length - 1]
  const driftToLatest = distance(renderPosition, latestPoint)
  const queuedEndTimeMs = getQueuedEndTime()
  const staleGap = queuedEndTimeMs === null
    || receivedAtMs - queuedEndTimeMs > stalePathThresholdMs

  const shouldHardSnap = staleGap && driftToLatest > hardSnapDistance

  if (shouldHardSnap) {
    resetMotion(latestPoint, receivedAtMs)
    return
  }

  enqueuePath(points, receivedAtMs + interpolationDelayMs)
}

这里有两个实现细节值得点明:

  • staleGap 看的是“这包消息到达时,当前队列是不是早就播完了”
  • driftToLatest 看的是“当前渲染位置离最新权威点是不是已经太远了”

第二阶段协议补上 offsetMsdurationMs 之后,这条 hard snap 规则本身并没有变化。

变化的是:路径时间轴更真实了,接收端更少需要靠猜测回放旧轨迹,因此真正触发“断档且漂移过大”的概率会比第一阶段更低。

这样做的好处是:

  • 正常情况下仍然优先平滑播放
  • 只有在明显过期时才做硬纠偏
  • 不会因为轻微偏差就频繁吸附

这个策略的重点不是“快”,而是“克制”。

这套方案的工程价值

除了画面观感更稳,这套方案还有两个工程上的优点。

1. 运动逻辑可以被独立测试

一旦把远端移动抽成独立模块,就能直接测试:

  • 同一线段内是否匀速
  • 新路径是否进入缓冲队列
  • 断档时是否会触发 hard snap
  • 播放完成后是否正确切换下一条路径

这比把所有逻辑揉在渲染管理器里更容易验证,也更容易调参。

2. 动画切换会更自然

当位置采样足够稳定以后,动画层就简单很多:

  • 方向可以基于速度向量判断
  • run / idle 可以根据 isMoving 切换
  • 切换时再加一点 mix

这时远端角色的观感会明显更接近本地玩家,而不是一个不停修正位置的代理对象。

这套方案的边界

最后要强调一点:

这套方案已经从“纯前端估算时间轴”走到了“协议直接提供时间轴”,但它仍然不是终点。

它当前的边界主要有 4 个:

  • 如果收到的是 V1 包,接收端仍然只能回退到 distance / speed 估算
  • 即使是 V2,时间还原度也依赖发送端采样是否真实、稳定
  • seq 只能解决乱序和过期,不能消除网络抖动本身
  • 当路径长时间断档、角色已经明显漂移时,仍然需要 hard snap 保证权威位置正确

也就是说,第二阶段解决的是“时间语义缺失”这个核心问题,但它没有取消插值延迟、路径缓冲和断档纠偏的必要性。

真正稳定的远端移动,依然是这几层策略共同工作的结果:

  • 用 V2 时间轴提升轨迹还原度
  • 用缓冲和延迟提升连续性
  • seq 管理新旧顺序
  • 用 hard snap 兜住极端断档

总结

如果把整套方案概括成一句话,那就是:

远端玩家的移动,不应该通过“追点”来模拟,而应该通过“按时间回放轨迹”来还原。

它解决的问题分别是:

  • 时间轴问题:V1 用 distance / speed 估算,V2 用 offsetMs / durationMs 真实回放
  • 顿挫问题:新路径进入缓冲,不直接覆盖当前轨迹
  • 停顿问题:引入固定插值延迟
  • 乱序问题:用 seq 丢弃过期路径
  • 漂移问题:断档且偏差过大时 hard snap
  • 动画问题:稳定的位置采样让方向和动画切换更自然

如果你也在做多人实时角色同步,和一味调 lerp factor 相比,这通常是更值得投入的方向。

Tags: [ phaser  mathematics  vectorOperations  ]