远端玩家平滑移动:定速线段插值、路径缓冲与断档纠偏
前言
做多人实时游戏时,有一个很常见、也很容易被忽略的问题:
本地玩家的移动看起来很自然,远端玩家却总有一种“假的”、“拖的”、“卡一下再追上”的感觉。
这不是因为远端玩家不能动,而是因为它通常动得不对。
本地玩家往往由摇杆、速度向量、物理系统直接驱动,所以它的移动天然带有明确的速度语义。远端玩家则只能依赖网络下发的一批离散位置点 moveList,接收端必须自己决定:这些点应该如何被还原成一条连续运动轨迹。
很多项目里,远端玩家都是这么写的:收到一个新的目标点,然后每帧用 lerp 往前追。这个方案实现快,代码短,刚跑起来的时候甚至会让人觉得“还行”。但只要你对画面观感稍微敏感一点,就很快会发现它的问题。
这篇文章想讲清楚一件事:
为什么“追点”不是一个稳定的远端移动模型,以及为什么“按时间回放轨迹”会更接近你真正想要的效果。
我会把这件事拆成三部分:
- 旧方案为什么容易抖
- 新方案到底是怎么做的
- 这套方案背后的数学原理是什么
旧方案为什么看起来不对
很多远端玩家的实现,本质上都接近下面这个模型:
- 收到新的
moveList - 选出一个当前目标点
- 每帧执行:
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 步:
- 收到路径点后先去重
- 判断这批点是否自带时间信息
- 把相邻点拆成多个线段
- 没有时间信息时,用固定速度为每条线段估算时长
- 有时间信息时,直接使用点位
offsetMs构建时间轴 - 给每条线段分配开始时间和结束时间
- 每帧根据当前时间找到所在的线段,并在该线段上插值得到位置
对应的数学图我拆成了两张,分别讲“路径如何变成带时间的轨迹”以及“某个时刻如何计算当前位置”。
图 1:先把路径点拆成线段,再给每条线段分配持续时间。
图 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 不需要一刀切替换旧协议。接收端完全可以采用下面这套兼容策略:
- 如果点位带合法
offsetMs,按真实时间轴回放 - 如果没有,退回
distance / speed估算 - 如果
seq不比上一次新,直接丢弃
所以第二阶段并不是推翻第一阶段,而是在第一阶段之上补上更可信的时间语义。
每一帧怎么计算当前位置
当你知道当前这一帧的时间 now 后,只要做两件事:
- 找到
now落在哪一条线段里 - 计算它在线段上的进度
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 ~ 500msS2:500ms ~ 833msS3: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:后续待播放路径
处理逻辑是:
- 如果当前没有在播,新的路径直接成为
activePath - 如果当前已经在播,新的路径进入
bufferedPaths - 当前路径播完,再接着播队列里的下一条路径
可以把它理解成一个小型播放队列:
[正在播放的轨迹] -> [下一段轨迹] -> [再下一段轨迹]
这一步解决的是“路径被新消息打断”的问题。
它的好处非常直接:
- 当前轨迹不会被粗暴截断
- 新旧路径衔接更连续
- 网络消息的离散节奏不会直接映射成角色顿挫
插值延迟:故意慢一点,反而更稳
这是多人同步里一个很经典但也很反直觉的策略。
很多人直觉上会觉得:
远端角色当然应该尽量追到最新位置,越新越好。
但现实恰恰相反。
如果角色永远追“当前最新时刻”,很容易出现这样的节奏:
路径播到尾巴 -> 下一包还没到 -> 停住一下 -> 新包到了再继续
于是可以给播放系统一个很小的固定延迟,比如 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看的是“当前渲染位置离最新权威点是不是已经太远了”
第二阶段协议补上 offsetMs、durationMs 之后,这条 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 相比,这通常是更值得投入的方向。
phaser
mathematics
vectorOperations
]