PAYATHON 2026

如何让客户端预测位置平滑同步到服务器权威位置,同时保持即时响应?

数据小许

结论

第二版使用 correction offset 的思路是对的:客户端立即完成物理状态的服务器校正,画面通过逐渐衰减的偏移量平滑这次位置变化。正常输入仍然直接驱动本地预测,无需等待服务器回复。

当前跳动还与输入记录和服务器确认有关:输入记录与物理步没有一一对应,服务器也没有返回可靠的“已处理输入”编号。需要先修正这两点,否则每次同步都会产生新的误差,插值只能暂时掩盖问题。

60 Hz 物理模拟配合前后状态插值,本身会引入约一个物理步的显示延迟。如果要求输入立刻体现在画面上,需要在这点延迟、直接显示最新状态的步进感,以及渲染外推之间作出选择。

为什么现有代码会拖尾和跳动

第一版把正常移动也一起平滑了

问题中的代码做了两层处理:

localPlayer.renderPos.lerp(
  p5.Vector.lerp(localPlayer.lastPos, localPlayer.pos, alpha),
  0.1
);

内层根据固定物理步的进度,在前后两个物理状态之间插值。外层让画面每帧只追赶目标位置的 10%。因此,即使服务器没有发送校正,角色正常移动、停止和反向时,画面也会拖在后面。

外层的 0.1 是按帧计算的,帧率变化后,追赶速度也会变化。提高 frameRate() 不能解决这种处理方式带来的根本问题。

第二版的 alpha 用错了位置

localPlayer.renderPos.lerp(
  p5.Vector.add(localPlayer.pos, localPlayer.offset),
  alpha
);

alpha = physicsAccumulator / physicsTickRate 表示当前帧处于两个物理步之间的什么位置,应当用于确定的两个物理状态之间的插值。

这里却把它用作上一帧画面位置追赶新目标的比例。每经过一个物理步,alpha 都会重新从较小的值开始,追赶力度因此不断变化,画面也会额外滞后。

输入编号没有对应实际模拟步

客户端通过独立的 setInterval() 采样输入,再通过 draw() 中的 accumulator 执行物理步。这两个循环没有严格对应关系:同一个输入可能用于多个物理步,也可能还没用于任何物理步就被下一个输入替换。

但 reconciliation 执行了:

for (const input of pendingInputs) {
  physicsTick(localPlayer, input);
}

这等于假设每条输入恰好代表一个固定物理步。原始预测不满足这个假设,重放结果就可能不同。

服务器同样存在问题:

sendInput: input => remotePlayer.input = input

这里只替换了当前输入,没有执行物理计算。因此,remotePlayer.input.id 表示最近收到的输入编号,不能保证它已经体现在本次广播的位置中。服务器也可能在多次物理计算中重复使用同一编号的输入。

第二版还有毫秒单位错误

第二版写的是:

setInterval(() => {
  // ...
}, inputTickRate);

inputTickRate 的值为 1 / 60 秒,而 setInterval() 接受毫秒。若保留这个定时器,应传入 inputTickRate * 1000。对于当前的 reconciliation 方式,更合适的修复是把输入采样合并到固定物理步中。

第一步:让一条输入对应一个固定物理步

移除独立采样输入的 setInterval(),在本地每个物理步开始时生成输入。以下代码替换 draw() 中原来的物理循环,初始化仍沿用原有逻辑:

physicsAccumulator += deltaTime / 1000;

while (physicsAccumulator >= physicsTickRate) {
  const input = getInput();

  localPlayer.input = input;
  localPlayer.lastPos.set(localPlayer.pos);
  physicsTick(localPlayer, input);

  pendingInputs.push({ ...input });
  server.sendInput({ ...input });

  physicsAccumulator -= physicsTickRate;
}

代码先执行本地预测并将输入加入 pendingInputs,再发送给服务器。即使示例中的服务器回调是同步的,服务器确认到达时,输入也已经加入了 pendingInputs。

现在每个输入编号都对应一次 physicsTick(),重放才有稳定的含义。没有按键时也生成零输入,以保持模拟步序列完整。

第二步:让服务器明确确认已经处理的输入

服务器快照需要新增 lastProcessedInputId。只有执行完对应输入的物理步后,才能更新这个字段。

对于当前只有输入驱动位移的简化示例,可以采用下面的模型。它假设输入可靠、有序到达,编号从 0 开始:

let lastProcessedInputId = -1;
const inputQueue = [];

function receiveInput(input) {
  inputQueue.push({ ...input });
}

function processQueuedInputs() {
  while (inputQueue.length > 0) {
    const input = inputQueue.shift();

    if (input.id <= lastProcessedInputId) continue;

    physicsTick(remotePlayer, input);
    lastProcessedInputId = input.id;
  }
}

function makeSnapshot() {
  return {
    id: remotePlayer.id,
    pos: { ...remotePlayer.pos },
    vel: { ...remotePlayer.vel },
    speed: remotePlayer.speed,
    lastProcessedInputId
  };
}

将 sendInput 接到 receiveInput(),服务器处理循环调用 processQueuedInputs(),广播循环发送 makeSnapshot() 的结果。原来不断对最新输入调用 physicsTick(remotePlayer) 的逻辑应当一起替换,否则会重复推进模拟。

这个模型用于说明“一条输入对应一步,确认号对应已执行状态”。实际多人游戏如果要求服务器按世界时钟持续推进,还需要定义 server tick、输入对应的 tick,以及缺失或迟到输入的处理规则,不能直接无限消费客户端提交的步数。无论采用哪种协议,客户端重放都必须遵守服务器相同的步序。

如果使用可能乱序的传输,还需要处理输入排序、去重和缺口。服务器不能越过未处理的编号就宣称已经连续确认。

第三步:只平滑校正产生的偏移

初始化 localPlayer 时保留第二版的:

offset: createVector(0, 0)

收到有效服务器快照后,按以下顺序处理:

  1. 保存校正前的本地预测位置。
  2. 恢复服务器快照包含的物理状态。
  3. 删除已确认输入,再重放剩余输入。
  4. 把校正前后的位置差加入视觉偏移。

下面的校正函数依赖上一步新增的 lastProcessedInputId,不能直接用原代码的 remotePlayer.input.id 代替:

function reconcile(snapshot) {
  const before = localPlayer.pos.copy();

  localPlayer.speed = snapshot.speed;
  localPlayer.vel.set(snapshot.vel.x, snapshot.vel.y);
  localPlayer.pos.set(snapshot.pos.x, snapshot.pos.y);

  pendingInputs = pendingInputs.filter(
    input => input.id > snapshot.lastProcessedInputId
  );

  for (const input of pendingInputs) {
    physicsTick(localPlayer, input);
  }

  const correction = p5.Vector.sub(before, localPlayer.pos);

  // Preserve the visual position across this correction.
  localPlayer.offset.add(correction);

  // Shift both interpolation endpoints by the same displacement.
  localPlayer.lastPos.sub(correction);
}

在 onUpdateTick() 的已初始化分支中调用 reconcile(snapshot),并移除原来将 lastPos 覆盖为当前 pos.copy() 的操作。

before 是临时保存的校正前位置;lastPos 继续表示渲染插值的前一个端点。两者不要混用。

设校正前后的位置分别为 P_old、P_new,视觉偏移为 O,则:

O_new = O_old + P_old - P_new
P_new + O_new = P_old + O_old

物理位置可以立即改变,视觉位置在校正发生的瞬间仍然连续。

代码同时平移了 lastPos。这样,使用前后状态插值时,两个端点具有相同的校正位移,加入 offset 后也能保持连续。这是位置校正的视觉处理;碰撞或速度变化仍可能改变后续运动轨迹。

第四步:直接生成渲染位置,让 offset 按时间衰减

移除两版代码中对 renderPos 的递归追赶,改为每帧根据物理状态和偏移直接计算:

const alpha = physicsAccumulator / physicsTickRate;

const basePos = p5.Vector.lerp(
  localPlayer.lastPos,
  localPlayer.pos,
  alpha
);

localPlayer.renderPos.set(basePos).add(localPlayer.offset);

// Example tuning value: the remaining offset halves every 80 ms.
const correctionHalfLife = 0.08;
const decay = Math.pow(0.5, (deltaTime / 1000) / correctionHalfLife);
localPlayer.offset.mult(decay);

继续使用:

translate(localPlayer.renderPos);

0.08 秒只是调参起点,不是固定要求。半衰期越短,位置修正越快;越长,追赶权威位置所需的时间越久。

这段代码只对服务器校正留下的 offset 做平滑。没有校正误差时,offset 为零,普通移动不会再额外经过一层缓慢追赶。

如果更看重输入响应,可以直接以最新物理位置为基础:

localPlayer.renderPos.set(localPlayer.pos).add(localPlayer.offset);

这会去掉前后物理状态插值带来的延迟,但 60 Hz 的位置更新在高刷新率屏幕上可能呈现步进感。如果还需要更平滑的显示,可以考虑渲染外推,但它对反向、停止和碰撞的预测可能出错,不应把外推结果写回物理状态。

还需要修正模拟服务器和边界处理

原代码中的:

setTimeout(runUpdate, updateTickRate * 1000 + ping);

改变的是广播间隔。传入 300 后,广播间隔约为 316.7 ms,并没有模拟一次快照在网络上传输 300 ms;输入发送也仍然是立即生效的。

要模拟单向下行延迟,应保持广播节奏,在生成时复制快照,再延迟交付:

const snapshot = makeSnapshot();
setTimeout(() => onUpdateTick(snapshot), downlinkDelayMs);

不要在延迟回调中直接读取持续变化的 remotePlayer,否则收到的就不是发送时的状态。若要模拟上行延迟,应另行延迟输入交付,并明确延迟参数表示单向时间还是 RTT。

客户端使用 accumulator,服务器使用递归 setTimeout(),二者不保证推进相同数量的物理步。正式实现应根据选定的 tick 协议处理计时和补步,不能仅凭两个地方都写了 1 / 60 就认为时间线一致。

当前碰撞代码在下一次调用时才处理已经超过 100 的位置,可能造成越界后回拉。对这个简化边界,可以改为先移动再夹紧位置,并确保客户端、服务器和重放都使用同一实现。

如何判断修复是否有效

先检查每条输入是否在初次预测、服务器执行和重放中都代表同样的一步,再观察校正前后的位置差。对于这个简单移动模型,在状态、步序和输入都一致的前提下,重放后的预测应当能够对齐;持续出现明显误差时,应继续检查协议和计时。

确认这些条件后,再调整 offset 的半衰期。对于传送、重生或特别大的误差,可以直接重置 pos、lastPos、renderPos 并清空 offset,避免画面长时间滑向新位置。阈值需要按场景尺度确定。

点击“Stop Server”可以帮助观察本地渲染是否还有拖尾,但不能验证 reconciliation 是否正确。停止期间 pendingInputs 会持续增长,需要设置缓存策略。重新启动服务器时,还应重置输入编号、确认号和本地历史,或者使用会话标识区分重启前后的数据。若增加了延迟快照,也要过滤旧会话消息和过期快照。

备注:内容仅供参考。