如何让客户端预测位置平滑同步到服务器权威位置,同时保持即时响应?
结论
第二版使用 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)
收到有效服务器快照后,按以下顺序处理:
- 保存校正前的本地预测位置。
- 恢复服务器快照包含的物理状态。
- 删除已确认输入,再重放剩余输入。
- 把校正前后的位置差加入视觉偏移。
下面的校正函数依赖上一步新增的 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 会持续增长,需要设置缓存策略。重新启动服务器时,还应重置输入编号、确认号和本地历史,或者使用会话标识区分重启前后的数据。若增加了延迟快照,也要过滤旧会话消息和过期快照。
备注:内容仅供参考。
