4.3 插值与外推
4.1 与 4.2 处理的全是自己的角色:先预测,再和解。但双人竞速的屏幕上还有另一个人。他的位置不来自本地输入,而是从服务端一份份快照里流过来的——快照 20 Hz 到达,屏幕却要 60 Hz 刷新。如果每一帧都直接用「最新快照」摆他的位置,他就会以 20 Hz 的节奏一顿一顿地跳。
这一节解决远端玩家的平滑问题,顺带把「自己的角色」和「别人的角色」在客户端的两套处理口径彻底讲清。
4.3.1 远端玩家为什么不能直接用快照
先想清楚一个问题:我们能不能像 4.1 那样预测远端玩家? 不能。预测的前提是「我知道驱动这个角色的输入」——自己的角色,输入来自我的键盘;远端玩家的输入在他的设备上,我根本看不到。硬要猜他下一帧按什么,猜错的概率比猜对高。
所以远端玩家只剩下两条路:插值与外推。它们的共同前提是——快照里不止有位置,还有速度(vx / vy)。有了速度,才能谈「往哪走」和「走多快」。
4.3.2 渲染延迟(render delay)
插值要成立,必须有一个前提:渲染的时刻,得落在两份已知快照之间。如果渲染总在「最新快照之后」,就只能外推;而外推不稳定(见下)。于是联机客户端普遍采用一个反直觉的做法——故意把远端玩家的状态回放晚一点。
具体说,客户端维护一个估计的服务端时间(时钟估计方法见 1.3 时钟、延迟与抖动 )。渲染远端玩家时,不用「现在」,而用:
renderTime = serverNow - RENDER_DELAY_MS
RENDER_DELAY_MS 通常取一到两个快照间隔(20 Hz 快照下就是 50–100 ms)。这样,renderTime 永远落在「刚收到的最新快照」和「上一份快照」之间,插值器几乎总能找到前后两个锚点,输出自然平滑。
代价是远端玩家的画面延迟又多了这 50–100 ms。但注意:这个延迟只加在「别人」身上,不影响自己的操作手感——而人眼对「别人的动作慢半拍」几乎无感,对「自己的操作慢半拍」极度敏感。用一个玩家察觉不到的延迟,换远端平滑,是笔划算的交易。
4.3.3 插值 vs 外推
两种方式对应两种时间关系:
| 方式 | 何时用 | 表现 | 风险 |
|---|---|---|---|
| 插值 | renderTime 落在两份快照之间 | 稳定、连续、可预测 | 有额外延迟 |
| 外推 | renderTime 超出最新快照 | 跟手、不滞后 | 丢包时位置会「跳」、可能发散 |
正常情况几乎全是插值(渲染延迟保证了这一点)。外推只在「最新快照还没到、但渲染时间已经越过它」时短暂登场——也就是恰好丢了一个包的时候。因此外推的正确用法不是「主力」,而是兜底:用一小段、带衰减地补,撑到下一份快照到达为止。
4.3.4 快照缓冲与时间轴对齐
实现插值需要一个按服务端时间排序的快照缓冲。收到快照就按 serverTimeMs 插进去;渲染时用 renderTime 去缓冲里查。查的规则:
renderTime早于最旧的快照 → 用最旧的(还没数据,先冻住)。renderTime落在两份快照之间 → 按时间比例插值,得到位置。renderTime晚于最新快照 → 外推(下一小节讲上限)。
缓冲必须按 serverTimeMs 排序,而不是按到达顺序。 网络会乱序,先发的快照可能后到;只有服务端时间戳才是稳定的排序键。这也是为什么 1.3 的时钟估计是这一切的地基——没有统一的 serverTimeMs,快照之间就没有可比的时间轴。
4.3.5 外推的衰减与上限
外推最危险的地方是它会一直算下去。如果远端玩家其实已经停住了,但外推还在按他最后的速度往前推,他就会「冲出屏幕」。更糟的是丢包持续几秒时,外推位置可能越飘越远,等下一份快照到达,画面「啪」地弹回来。
解法有两条,必须同时用:
- 衰减(damping): 外推得越久,越不信任速度。把外推位移乘一个随
ahead递减的系数,让外推「越来越慢」。 - 上限(cap): 外推超过一个时间上限(通常 100–200 ms)后直接冻结在最新快照位置,不再往前推。冻结虽然会「卡住」,但至少不会飘到天涯海角。
外推位移 = 速度 × dt × (1 - ahead / maxExtrapolateMs)
ahead 是「渲染时间超出最新快照多久」。当 ahead 逼近 maxExtrapolateMs,系数趋近 0,位移自然停住——衰减和上限是同一件事的两面,用一个乘数就同时实现了「越来越慢」和「到点冻结」。
4.3.6 L1:插值器 + 外推器
把上面的规则写成代码,就是一个 SnapshotBuffer:
// assets/scripts/net/sync/snapshot-buffer.ts —— 纯 TS,无 cc 依赖
export interface RemoteSnapshot {
readonly serverTimeMs: number;
readonly x: number; readonly y: number;
readonly vx: number; readonly vy: number;
}
export interface Sample {
readonly x: number; readonly y: number;
readonly extrapolated: boolean;
}
export class SnapshotBuffer {
private buf: RemoteSnapshot[] = [];
private capacity: number;
constructor(capacity = 32) { this.capacity = capacity; }
push(s: RemoteSnapshot): void {
this.buf.push(s);
this.buf.sort((a, b) => a.serverTimeMs - b.serverTimeMs);
while (this.buf.length > this.capacity) this.buf.shift();
}
get size(): number { return this.buf.length; }
sample(renderTime: number, maxExtrapolateMs: number): Sample | null {
if (this.buf.length === 0) return null;
const newest = this.buf[this.buf.length - 1];
const oldest = this.buf[0];
if (renderTime <= oldest.serverTimeMs) {
return { x: oldest.x, y: oldest.y, extrapolated: false };
}
if (renderTime >= newest.serverTimeMs) {
const ahead = renderTime - newest.serverTimeMs;
if (ahead >= maxExtrapolateMs) {
return { x: newest.x, y: newest.y, extrapolated: false }; // 冻结
}
const dt = ahead / 1000;
const damp = 1 - ahead / maxExtrapolateMs; // 外推越久越不信任速度
return {
x: newest.x + newest.vx * dt * damp,
y: newest.y + newest.vy * dt * damp,
extrapolated: true,
};
}
for (let i = 0; i < this.buf.length - 1; i++) {
const a = this.buf[i];
const b = this.buf[i + 1];
if (renderTime >= a.serverTimeMs && renderTime <= b.serverTimeMs) {
const span = b.serverTimeMs - a.serverTimeMs;
const t = span > 0 ? (renderTime - a.serverTimeMs) / span : 0;
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
extrapolated: false,
};
}
}
return { x: newest.x, y: newest.y, extrapolated: false };
}
}
注意插值分支用的坐标也是定点整数,比例 t 是唯一的浮点参与——插值只影响表现层,不影响逻辑层,所以这里允许浮点。
4.3.7 vitest:抖动输入连续、丢包不外推发散
第一个断言验证:即便快照的到达时间带抖动,采样输出也连续。测试给每份快照人为加上 ±15 ms 的时间抖动,然后按 10 ms 步长采样,断言相邻采样的位移不会突变:
import { describe, it, expect } from 'vitest';
import { SnapshotBuffer } from '../net/sync/snapshot-buffer';
describe('4.3 插值与外推', () => {
it('抖动输入下输出位置连续', () => {
const buf = new SnapshotBuffer();
const jitter = [0, 12, -8, 5, -15, 10, 0, -3];
for (let i = 0; i < 8; i++) {
buf.push({ serverTimeMs: 1000 + i * 50 + jitter[i], x: i * 20, y: 0, vx: 400, vy: 0 });
}
let prev = buf.sample(1000, 100)!;
let maxJump = 0;
for (let t = 1010; t <= 1300; t += 10) {
const cur = buf.sample(t, 100)!;
maxJump = Math.max(maxJump, Math.abs(cur.x - prev.x));
prev = cur;
}
expect(maxJump).toBeLessThan(30);
});
});
第二个断言验证外推的边界:只剩一份快照、之后不再到达(等价于持续丢包)时,外推位置必须有界,且在超过上限后冻结,绝不无限发散:
it('丢包时外推位置不发散,超时后冻结', () => {
const buf = new SnapshotBuffer();
buf.push({ serverTimeMs: 1000, x: 0, y: 0, vx: 3000, vy: 0 });
const maxExt = 100;
let peak = 0;
for (let t = 1000; t <= 1200; t += 10) {
peak = Math.max(peak, Math.abs(buf.sample(t, maxExt)!.x));
}
expect(peak).toBeLessThanOrEqual(100); // 有界,不随外推时间线性增长
const frozen = buf.sample(1200, maxExt)!;
const later = buf.sample(5000, maxExt)!;
expect(later.x).toBeCloseTo(frozen.x, 5); // 超上限后冻结
});
第二段的 peak <= 100 是关键:速度是 3000 单位/秒,若不做衰减与上限,1 秒外推会飘出 3000 单位;有了「衰减 × 上限」,峰值被压在 0.25 × 3000 × 0.1 = 75 附近,稳稳有界。
4.3.8 接进 Cocos:远端玩家视图(L2)
远端视图组件把采样结果写进节点位置。注意它用的是估计的服务端时间减去渲染延迟,而不是本地帧时间:
import { _decorator, Component, Node } from 'cc';
import { SnapshotBuffer, type RemoteSnapshot } from './snapshot-buffer';
const { ccclass, property } = _decorator;
const RENDER_DELAY_MS = 100;
const MAX_EXTRAPOLATE_MS = 120;
const FIXED_SCALE = 100;
@ccclass('RemotePlayerView')
export class RemotePlayerView extends Component {
@property(Node)
body: Node | null = null;
private buffer = new SnapshotBuffer(32);
private renderTime = 0;
/** serverNowMs 来自 1.3 的时钟估计 */
pushSnapshot(s: RemoteSnapshot, serverNowMs: number): void {
this.buffer.push(s);
this.renderTime = serverNowMs - RENDER_DELAY_MS;
}
update(dt: number) {
this.renderTime += dt * 1000; // 本地帧时间推进渲染时钟
const s = this.buffer.sample(this.renderTime, MAX_EXTRAPOLATE_MS);
if (!s) return;
this.body?.setPosition(s.x / FIXED_SCALE, -s.y / FIXED_SCALE, 0);
}
}
renderTime 平时跟着本地帧时间推进,每次收到新快照时用 serverNow - RENDER_DELAY_MS 校正一次,防止本地时钟和服务端时钟缓慢漂移。三个常量(渲染延迟、外推上限、定点比例)都应该是可配置项,方便 7.1 调参。
4.3.9 自己预测,别人插值
把这一章的三节并排看,客户端对「自己」和「别人」用的是两套完全不同的口径,这正是 4.1–4.3 的核心结论:
| 维度 | 自己的角色(4.1 + 4.2) | 远端玩家(4.3) |
|---|---|---|
| 数据来源 | 本地输入 | 服务端快照 |
| 主策略 | 预测(先动) | 插值(回放延迟) |
| 纠错方式 | 和解 + 回滚重演 | 不需要纠错,只做平滑 |
| 兜底 | 硬切 | 外推 + 衰减上限 |
| 关键时间 | 本地逻辑帧 | 服务端时间轴 |
| 能感知延迟 | 极敏感(必须消除) | 不敏感(可牺牲 50–100 ms) |
一句话记住:自己的角色要「即时正确」,别人的角色要「平滑可信」。 把两套口径混用——比如给自己的角色加插值——会亲手制造出玩家最恨的输入延迟。
4.3.10 工程决策
- 远端玩家的渲染时钟独立于本地帧时钟。 用
serverNow - RENDER_DELAY_MS校正,别直接拿本地时间。 - 缓冲按
serverTimeMs排序,不按到达顺序。 乱序是常态,时间戳才是稳定键。 - 渲染延迟取一到两个快照间隔。 太小则频繁外推,太大则远端明显滞后。
- 外推必须同时有衰减和上限。 只有衰减会「慢慢飘远」,只有上限会「突然卡死」。
- 插值只碰表现层。 逻辑层仍用定点整数,别让插值的浮点渗回模拟。
到这里,「两个客户端怎么对齐同一份世界」这条主线走完了:先同步(第 2、3 章),再预测与和解(第 4 章),最后把远端表现补平。但联机真正的考验在网络变差的时候——掉线、超时、抖动。下一章处理这些容错问题,从「掉线之后怎么回到同一局」讲起。
阅读导航:上一节:4.2 服务器和解与重演 · 下一节:5.1 重连流程与状态恢复 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。