《Cocos 游戏联机实战》4.3 插值与外推

远端玩家怎么动得平滑:讲清渲染延迟为什么故意把远端状态回放晚一个缓冲时间,对比插值(稳但有延迟)与外推(跟手但丢包会跳),交代快照缓冲与时间轴对齐、外推的衰减与上限,给出插值器与外推器的 L1 纯 TS 实现,并用 vitest 断言「抖动输入下输出连续」「丢包时外推不发散」。

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 去缓冲里查。查的规则:

  1. renderTime 早于最旧的快照 → 用最旧的(还没数据,先冻住)。
  2. renderTime 落在两份快照之间 → 按时间比例插值,得到位置。
  3. 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 工程决策

  1. 远端玩家的渲染时钟独立于本地帧时钟。 用 serverNow - RENDER_DELAY_MS 校正,别直接拿本地时间。
  2. 缓冲按 serverTimeMs 排序,不按到达顺序。 乱序是常态,时间戳才是稳定键。
  3. 渲染延迟取一到两个快照间隔。 太小则频繁外推,太大则远端明显滞后。
  4. 外推必须同时有衰减和上限。 只有衰减会「慢慢飘远」,只有上限会「突然卡死」。
  5. 插值只碰表现层。 逻辑层仍用定点整数,别让插值的浮点渗回模拟。

到这里,「两个客户端怎么对齐同一份世界」这条主线走完了:先同步(第 2、3 章),再预测与和解(第 4 章),最后把远端表现补平。但联机真正的考验在网络变差的时候——掉线、超时、抖动。下一章处理这些容错问题,从「掉线之后怎么回到同一局」讲起。

阅读导航:上一节:4.2 服务器和解与重演 · 下一节:5.1 重连流程与状态恢复 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画