《Cocos 游戏联机实战》1.3 时钟、延迟与抖动

本节讲两端时钟为什么对不齐(Date.now 不同源与单调时钟的意义),给出 NTP 式时钟偏移估计的 L1 实现——多次采样、取最小 RTT 样本作权威偏移、滑动平均平稳化,并用假时钟断言偏移收敛;同时讲 RTT 与抖动计算、延迟预算怎么切(单向延迟 / RTT / 处理延迟),以及这些估计值在 4.2 和解、5.2 超时、9.3 延迟补偿里怎么被复用。

1.3 时钟、延迟与抖动

1.2 节的消息里到处是时间:tick、serverTimeMs、holdMs。可这些数字只有在「两端对同一时刻有共同理解」时才有意义——而现实是客户端和服务端的时钟根本不同源,两台机器的 Date.now() 差几秒都属正常。这一节就把这个前提补上。

本节写出一个可复用的时钟估计模块:用「发→收→回→到」四元组估计服务端时钟相对本地的偏移,用最小 RTT 样本作权威值、用滑动平均平稳化,同时算出 RTT 与抖动。全部是 L1 纯逻辑,用假时钟断言偏移收敛。这个模块是 4.2 和解、5.2 超时、9.3 延迟补偿共同的地基,本节就要把它写成能直接 import 的形态。

1.3.1 两端时钟为什么对不齐

第一个坑是把 Date.now() 当时间轴。它返回的是墙上时钟(wall clock),可以被用户改、被系统 NTP 校正、被时区影响。用它测「一个包走了多久」,只要期间系统对了一次时,结果就能是负数。

正确做法是用单调时钟(monotonic clock):只保证「一直往前走、不受系统时间调整影响」,不保证起点和别的机器一致。浏览器与 Node 里是 performance.now(),Cocos 里是 game.totalTime。用它测时间间隔是准的。

于是问题拆成两半:

需求用什么时钟说明
测间隔(一个包走了多久、一帧多久)单调时钟performance.now() / game.totalTime,不受调时影响
对齐两端的时间轴(这个快照对应我本地哪一刻)单调时钟 + 估计的偏移偏移靠网络采样算出来,1.3.2 节就是干这个

我们永远不去对齐墙上时钟,只估计「服务端时钟 = 本地单调时钟 + offset」里的那个 offset。 一旦有了 offset,任何服务端时间戳都能换算到本地时间轴上。

1.3.2 NTP 式偏移估计(L1)

估计方法借自 NTP。一次往返拿到四个时间戳:

  • t0:客户端发出请求(本地单调 ms)
  • t1:服务端收到请求(服务端时钟 ms)
  • t2:服务端发出回复(服务端时钟 ms)
  • t3:客户端收到回复(本地单调 ms)

于是:

  • RTT = (t3 - t0) - (t2 - t1),把服务端处理时间 (t2 - t1) 扣掉,剩下的才是纯网络往返。
  • 偏移估计 = ((t1 - t0) + (t2 - t3)) / 2。若上下行对称,这个值恰好等于真实 offset;不对称时误差是「上下行差的一半」。

单次采样会被抖动污染,所以要多次采样:取 RTT 最小的那次作权威偏移(路径最对称、最可信),再用滑动平均给一个平稳的展示值。

// assets/scripts/net/session/clock-sync.ts

/** 一次往返采样的四个时间戳 */
export interface ClockSample {
  t0: number; // 客户端发出(本地单调 ms)
  t1: number; // 服务端收到(服务端时钟 ms)
  t2: number; // 服务端回复(服务端时钟 ms)
  t3: number; // 客户端收到回复(本地单调 ms)
}

const SMOOTH_ALPHA = 0.1; // 滑动平均系数,越小越平稳、越大越灵敏

export class ClockSync {
  private readonly localNow: () => number;
  private bestRtt = Number.POSITIVE_INFINITY;
  private bestOffset = 0;
  private smoothOffset = 0;
  private haveSmooth = false;
  private lastRtt = 0;
  private jitter = 0;
  private sampleCount = 0;

  constructor(localNow: () => number) {
    this.localNow = localNow;
  }

  /** 喂入一次四元组,更新偏移、RTT 与抖动估计 */
  addSample(s: ClockSample): void {
    const rtt = (s.t3 - s.t0) - (s.t2 - s.t1);
    const offset = ((s.t1 - s.t0) + (s.t2 - s.t3)) / 2;
    this.sampleCount += 1;

    // 最小 RTT 的样本路径最对称,作权威偏移
    if (rtt < this.bestRtt) {
      this.bestRtt = rtt;
      this.bestOffset = offset;
    }

    // 滑动平均给一个平稳的展示值
    if (!this.haveSmooth) {
      this.smoothOffset = offset;
      this.haveSmooth = true;
    } else {
      this.smoothOffset += SMOOTH_ALPHA * (offset - this.smoothOffset);
    }

    // 抖动:相邻 RTT 绝对差的滑动平均
    this.jitter += SMOOTH_ALPHA * (Math.abs(rtt - this.lastRtt) - this.jitter);
    this.lastRtt = rtt;
  }

  /** 本地单调时间 → 估计的服务端时间 */
  serverTime(localMs: number): number {
    return localMs + this.bestOffset;
  }

  /** 服务端时间 → 估计的本地单调时间(和解时会用到) */
  localTime(serverMs: number): number {
    return serverMs - this.bestOffset;
  }

  get offsetMs(): number {
    return this.bestOffset;
  }

  get smoothedOffsetMs(): number {
    return this.smoothOffset;
  }

  get rttMs(): number {
    return this.bestRtt === Number.POSITIVE_INFINITY ? 0 : this.bestRtt;
  }

  get jitterMs(): number {
    return this.jitter;
  }

  get count(): number {
    return this.sampleCount;
  }
}

serverTime() 与 localTime() 是一对互逆换算。4.2 节收到快照时,会用 localTime(snapshot.serverTimeMs) 把它落到本地时间轴上,判断「这个权威状态对应我预测的第几帧」。

1.3.3 用假时钟断言偏移收敛

测试不需要真网络,只要按公式构造四元组。构造一个上下行对称、服务端恒快 trueOffset 的场景:

// assets/scripts/net/session/clock-sync.test.ts
import { describe, expect, it } from 'vitest';
import { ClockSync, type ClockSample } from './clock-sync';

function makeSample(
  local: number,
  downMs: number,
  upMs: number,
  procMs: number,
  trueOffset: number,
): ClockSample {
  const t0 = local;
  const t1 = t0 + downMs + trueOffset; // 服务端钟 = 本地钟 + trueOffset
  const t2 = t1 + procMs;
  const t3 = t0 + downMs + procMs + upMs;
  return { t0, t1, t2, t3 };
}

describe('时钟同步', () => {
  it('对称路径下偏移收敛到真实值', () => {
    const sync = new ClockSync(() => 0);
    const trueOffset = 1000;
    const rtts = [120, 40, 80, 40, 60];
    let local = 0;
    for (const rtt of rtts) {
      const half = rtt / 2;
      sync.addSample(makeSample(local, half, half, 2, trueOffset));
      local += 200;
    }
    expect(sync.offsetMs).toBeCloseTo(trueOffset, 6);
  });

  it('RTT 取所有样本中的最小值', () => {
    const sync = new ClockSync(() => 0);
    sync.addSample(makeSample(0, 60, 60, 0, 500)); // rtt 120
    sync.addSample(makeSample(0, 20, 20, 0, 500)); // rtt 40 ← 最小
    sync.addSample(makeSample(0, 40, 40, 0, 500)); // rtt 80
    expect(sync.rttMs).toBeCloseTo(40, 6);
  });

  it('抖动随 RTT 波动累积', () => {
    const sync = new ClockSync(() => 0);
    sync.addSample(makeSample(0, 10, 10, 0, 0)); // rtt 20
    sync.addSample(makeSample(0, 60, 60, 0, 0)); // rtt 120,波动大
    expect(sync.jitterMs).toBeGreaterThan(0);
  });
});

第一个断言是核心:无论 RTT 怎么变,只要上下行对称,最小 RTT 样本的偏移就是精确的 trueOffset。第二个断言验证「最小 RTT 胜出」这条规则真的生效——如果实现里误用最后一次采样,这里会返回 80 而不是 40。注意测试全程没有网络:ClockSync 不碰 WebSocket,只吃四元组,这正是把会话层写成纯逻辑的回报。

1.3.4 RTT 与抖动

**RTT(往返时延)**是上下行之和。它有几种用法:判定超时(5.2 节)、决定重传间隔、触发和解。

**抖动(jitter)**是 RTT 的波动程度。ClockSync 用「相邻 RTT 绝对差的滑动平均」来算:一次 RTT 从 40 跳到 120,抖动会明显抬升。抖动大意味着网络不稳定,表现层需要更深的插值缓冲(9.1 节),否则画面会一顿一顿。

指标计算方式典型量级(参考,需在你的网络条件下复测)
RTT(t3 - t0) - (t2 - t1),取多样本最小值同城 2050 ms,跨区 100200 ms
单向延迟对称假设下 ≈ RTT / 2上表的一半
抖动相邻 RTT 绝对差的滑动平均稳定网络 < 10 ms,弱网可达数十毫秒

数值只是量级参考。你的网络条件下的真实值必须实测——同一个游戏在 WiFi、4G、地铁里能差出一个数量级。

1.3.5 延迟预算怎么切

联机里说「延迟」往往指三件不同的事,混用就会得出错误结论:

概念定义用来做什么
单向延迟本地发出到对端收到预测回滚的时间窗、表现层「拖后播放」的毫秒数
RTT往返一圈超时判定、重传间隔、和解触发频率
处理延迟服务端收到到发出 (t2 - t1)从往返里扣除,才能得到纯网络延迟

把处理延迟从往返里扣掉是本节的关键一步:(t3 - t0) 是「网络往返 + 服务端处理」,如果直接拿它当 RTT,会高估网络延迟,让超时阈值设得过松、插值缓冲开得过深。所以 1.3.2 节的公式先做减法 (t3 - t0) - (t2 - t1),剩下的才是网络往返。

1.3.6 这个模块后面被谁消费

ClockSync 是跨章复用的模块,本节建立的数据结构后面会被三处消费,先交代清楚,免得你读到那里才发现伏笔:

消费点用到的字段用途
4.2 服务器和解与重演localTime() / offsetMs把服务端快照的 serverTimeMs 落到本地时间轴,判断该回滚到第几帧
5.2 超时、重传与幂等rttMs / jitterMs估算合理超时阈值(如 rtt + 4 × jitter + 固定余量)
9.3 延迟补偿与体验权衡单向延迟 / jitterMs决定表现层「拖后多少毫秒」播放、插值缓冲开多深

所以本节不是「讲个概念就走」:ClockSync 的接口(addSample / serverTime / localTime / rttMs / jitterMs)从这一节起就冻结,后面章节直接 import 使用,不再改签名。

1.3.7 Cocos 侧:用 game.totalTime 当本地钟(L2)

ClockSync 需要一个单调时钟来源。在 Cocos 里用 game.totalTime(引擎自启动以来经过的毫秒数),它基于引擎内部单调计时,不受系统调时影响。用一个组件把它包起来注入:

import { _decorator, Component, game } from 'cc';

const { ccclass } = _decorator;

@ccclass('LocalClock')
export class LocalClock extends Component {
  /** 单调毫秒,作为 ClockSync 的本地时间来源 */
  now(): number {
    return game.totalTime;
  }
}

game、game.totalTime、_decorator.ccclass、Component 均已对照 @cocos/creator-types@3.8.8 的 cc.d.ts 确认。注意 game.totalTime 单位是毫秒,而 game.deltaTime 单位是秒——这两个极易混淆,混用会让偏移估计差 1000 倍。

1.3.8 客户端该做什么

本节交付了联机时间轴的地基模块。落地清单:

  • 本地一律用单调时钟(performance.now() / game.totalTime),绝不用 Date.now() 测间隔
  • 落地 ClockSync:addSample 收四元组,取最小 RTT 样本作偏移、滑动平均平稳化
  • 跑通三个断言:偏移收敛、RTT 取最小、抖动随波动上升
  • 记住延迟预算的三分法:单向延迟 / RTT / 处理延迟,往返里要先扣处理延迟
  • 冻结 ClockSync 接口,后面 4.2 / 5.2 / 9.3 直接复用

到这里第 1 章收尾:我们建立了权威边界(1.1)、协议与编解码(1.2)、时间轴(1.3)三块地基。有了它们,下一章才能回答一个更本质的问题:两个客户端凭什么能算出完全一样的世界? 答案不是「发快照」,而是「发输入 + 确定性地重放」。第 2 章从确定性模拟与定点数讲起——那是让「同样的输入在两端产出同样的结果」成为物理事实的第一块砖。

阅读导航:上一节:1.2 协议选型与序列化 · 下一节:2.1 确定性模拟与定点数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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