《Cocos 游戏联机实战》3.3 带宽预算与压缩

前两节解决了「发什么」,这节解决「发多小」。先把 60 Hz 状态同步的每帧字节数按条件推出来,再逐条过五种压缩手段:位打包、量化定点、变长整数、字段掩码、增量基线。配纯 TS 的位读写与量化实现和 vitest 断言,最后讲量化误差怎么在表现层补回来。

3.3 带宽预算与压缩

前两节定了「发什么」:只发权威状态(3.1),只发看得见的(3.2)。这一节解决「发多小」。带宽是联机里最稀缺的资源之一——移动网络下每个玩家每秒能用多少下行是硬约束,超了就是卡顿与掉线。压缩不是「能压就压」,而是先把预算算清,再按缺口选手段。

3.3.1 先把预算算出来

不给出条件就谈「包大小」是耍流氓。我们钉死一组《方块冲刺》双人竞速的参考条件:

  • 状态同步频率:60 Hz(与逻辑帧同频)。
  • 每帧下发实体数:8 个(两名玩家 + 若干道具 + 特效)。
  • 每个实体 5 个数值字段:id、x、y、vx、vy。

按紧凑二进制逐位算:

字段位宽说明
id8最多 256 个实体
x12定点,量程 ±1000 逻辑像素
y12同上
vx10定点,量程 ±20 像素 / 帧
vy10同上
每实体合计52 位约 6.5 字节

再算整帧:8 实体 × 52 位 = 416 位;包头 frame(16) + ackFrame(16) + 实体数(8) = 40 位。合计 456 位 ≈ 57 字节 / 帧。

于是下行带宽 = 57 字节 × 60 帧 ≈ 3420 字节 / 秒 ≈ 3.3 KB/s(每客户端)。

作为对照:同样内容用 JSON 文本发,每实体大约 50 到 60 字节,8 实体就是 400 多字节,加包头约 440 字节 / 帧 → 约 26 KB/s,比紧凑二进制大 7 到 8 倍。

这组数字是参考值,不是实测值:位宽、实体数、帧率都随你的游戏变,实际带宽还要叠加 WebSocket 帧头、TCP / QUIC 首部与重传,务必在你的网络条件下复测。它的意义是给出量级——状态同步的下行通常是每秒几 KB 到几十 KB 的量级,远小于很多人直觉里的「几百 KB」。

3.3.2 五种压缩手段

手段做什么典型收益代价
位打包把数值按「实际需要的位数」写进连续比特流相对 32 位对齐省 2 到 4 倍需手写读写器,调试难
量化定点把浮点按量程映射成有限位整数12 位定点替代 32 位浮点有量化误差,需表现层补偿
变长整数小数值用少字节,大数值才用多字节对「多数值偏小」友好读写分支多,最坏情况更差
字段掩码只发「本帧变化」的字段,其余位标记跳过变化稀疏时收益大需配合脏字段与基线
增量基线只发相对上一基线的差见 3.1,省掉未变实体需 ack 与定期全量

这五种可以叠加:位打包是载体,量化定点决定每个数值占几位,字段掩码决定哪些字段进流,增量基线决定哪些实体进流,变长整数是位打包之上的一层优化。本节 L1 实现前两种(位打包 + 量化),它们是收益最大、最通用的两个。

3.3.3 L1:位打包读写

位打包的本质是把「一串数值」按各自的位宽拼成连续比特,再按字节输出。下面这份纯 TypeScript 零 cc 依赖,写入端累积到 8 位就吐一个字节:

// mini2d/net/bitpack.ts —— 纯 TS,无 cc 依赖
export class BitWriter {
  private readonly out: number[] = [];
  private acc = 0;      // 累积位:低 bitsUsed 位有效
  private bitsUsed = 0;

  /** 写入 value 的低 bits 位(0 <= bits <= 32) */
  writeBits(value: number, bits: number): void {
    let v = value >>> 0;
    let n = bits;
    while (n > 0) {
      const take = Math.min(n, 8 - this.bitsUsed);
      const mask = take === 8 ? 0xff : (1 << take) - 1;
      this.acc += (v & mask) * 2 ** this.bitsUsed;
      this.bitsUsed += take;
      v = Math.floor(v / 2 ** take);
      n -= take;
      while (this.bitsUsed >= 8) {
        this.out.push(this.acc & 0xff);
        this.acc = Math.floor(this.acc / 256);
        this.bitsUsed -= 8;
      }
    }
  }

  /** 写入一个 32 位整数(有符号按无符号重解释) */
  writeInt32(value: number): void {
    this.writeBits(value >>> 0, 32);
  }

  /** 收尾:不足一字节的部分补零后输出 */
  finish(): Uint8Array {
    if (this.bitsUsed > 0) {
      this.out.push(this.acc & 0xff);
      this.acc = 0;
      this.bitsUsed = 0;
    }
    return new Uint8Array(this.out);
  }
}

export class BitReader {
  private readonly data: Uint8Array;
  private pos = 0;
  private acc = 0;
  private bitsUsed = 0;

  constructor(data: Uint8Array) {
    this.data = data;
  }

  /** 读取 bits 位,返回无符号结果(0 <= bits <= 32) */
  readBits(bits: number): number {
    let result = 0;
    let filled = 0;
    let n = bits;
    while (n > 0) {
      if (this.bitsUsed === 0) {
        this.acc = this.pos < this.data.length ? this.data[this.pos] : 0;
        this.pos += 1;
        this.bitsUsed = 8;
      }
      const take = Math.min(n, this.bitsUsed);
      const mask = (1 << take) - 1;
      result += (this.acc & mask) * 2 ** filled;
      this.acc = this.acc >>> take;
      this.bitsUsed -= take;
      filled += take;
      n -= take;
    }
    return result;
  }

  readInt32(): number {
    return this.readBits(32) | 0;
  }
}

写入端用 2 ** bitsUsed 累加而不是左移,是为了避开 JavaScript 位运算的 32 位截断:一次最多累积 7 + 8 = 15 位,用乘法得到精确结果。读出端同理用 * 2 ** filled 重建,readInt32 最后用 | 0 把无符号 32 位重新解释成有符号。

3.3.4 L1:量化与体积断言

量化把浮点按量程映射成有限位整数,是省位宽的主力:

// mini2d/net/quantize.ts —— 纯 TS,无 cc 依赖

/** 把 [-range, range] 的浮点量化成 bits 位定点整数 */
export function quantize(value: number, range: number, bits: number): number {
  const levels = (1 << bits) - 1;
  const clamped = Math.max(-range, Math.min(range, value));
  const normalized = (clamped + range) / (2 * range); // 映射到 [0, 1]
  return Math.round(normalized * levels);
}

/** 量化码还原成浮点 */
export function dequantize(code: number, range: number, bits: number): number {
  const levels = (1 << bits) - 1;
  return (code / levels) * (2 * range) - range;
}

配 vitest,把「round-trip 无损(位打包)」和「量化误差有界 + 体积优势」两条钉住:

import { describe, expect, it } from 'vitest';
import { BitReader, BitWriter } from './bitpack';
import { dequantize, quantize } from './quantize';

describe('位打包', () => {
  it('写入后读出,值原样返回', () => {
    const w = new BitWriter();
    w.writeBits(5, 3);
    w.writeBits(300, 9);
    w.writeBits(1, 1);
    w.writeBits(4000000000, 32);
    const r = new BitReader(w.finish());
    expect(r.readBits(3)).toBe(5);
    expect(r.readBits(9)).toBe(300);
    expect(r.readBits(1)).toBe(1);
    expect(r.readBits(32)).toBe(4000000000);
  });

  it('打包体积远小于 JSON', () => {
    const entities = [
      { id: 1, x: 123.4, y: 56.7, vx: 3.2, vy: -1.1 },
      { id: 2, x: -200.5, y: 88.8, vx: 0.5, vy: 2.4 },
    ];
    const w = new BitWriter();
    for (const e of entities) {
      w.writeBits(e.id, 8);
      w.writeBits(quantize(e.x, 1000, 12), 12);
      w.writeBits(quantize(e.y, 1000, 12), 12);
      w.writeBits(quantize(e.vx, 20, 10), 10);
      w.writeBits(quantize(e.vy, 20, 10), 10);
    }
    const packed = w.finish().length;
    const json = new TextEncoder().encode(JSON.stringify(entities)).length;
    expect(packed).toBeLessThan(json);
    expect(packed).toBe(13); // 2 实体 × 52 位 = 104 位 = 13 字节
  });
});

describe('量化', () => {
  it('误差不超过一个步长', () => {
    const range = 1000;
    const bits = 12;
    const step = (2 * range) / ((1 << bits) - 1);
    for (const x of [-999.3, -12.7, 0, 250.5, 999.9]) {
      const back = dequantize(quantize(x, range, bits), range, bits);
      expect(Math.abs(back - x)).toBeLessThanOrEqual(step);
    }
  });
});

第二条断言里 packed 恰好是 13 字节:2 个实体各 52 位、共 104 位、整除成 13 字节;而同一份数据的 JSON 文本有一百多字节。这就是「位打包 + 量化」相对文本协议的差距量级。

3.3.5 量化误差与表现层补偿

量化必然引入误差。以 12 位、量程 ±1000 为例,步长 = 2000 / 4095 ≈ 0.49 像素。这个误差在逻辑层是可接受的(0.5 像素不影响碰撞与判定),但在表现层可能被看见——如果直接拿量化坐标设节点位置,角色会以 0.49 像素为粒度「跳」。

补偿思路有三条:

  1. 逻辑用整数、表现用插值:逻辑层始终走量化后的整数坐标(保证跨端一致),表现层在相邻两个逻辑帧之间做线性插值,把 0.49 像素的台阶抹平成平滑移动(第 4 章 4.3 讲插值)。
  2. 本地实体不做量化:玩家自己操控的角色由本地预测驱动,位置用全精度浮点,不经过量化——量化只作用于远程实体的同步。
  3. 提高位宽:把 12 位提到 14 位,步长降到约 0.12 像素。代价是每实体多 4 位、8 实体每帧多 4 字节,换来视觉更细腻。是否值得,按你的画布缩放比与玩家能否察觉来定。

一句话:量化误差不可怕,可怕的是让它直接显示出来。误差留在逻辑层,表现层用插值兜住。

3.3.6 首包与重连的全量代价

增量省的是「常态带宽」,但有两处必须付全量:

  • 首包:玩家进房时手里什么都没有,服务端必须发一次完整世界(或按 AOI 发一次视野内全量)。以 8 实体、每实体 52 位算,一次约 57 字节,可忽略;但如果世界有几百实体,首包会到几 KB——这时靠 AOI(3.2)把首包也裁到「视野内」。
  • 重连:断线重连后客户端状态已过期,必须重新收一次全量基线(第 5 章讲重连流程)。服务端为每个新会话重建基线、重新等 ack,代价与首包同级。

所以「全量代价」不是持续开销,而是每个会话的入场费。设计上只需保证「入场费 × 峰值入场速率」不会压垮服务端出口带宽,这一点交由服务端容量规划(站内《游戏服务端编程实践》),客户端侧要做的是入场时快速请求、收齐后再开渲染。

3.3.7 L2:把量化码还原到节点

服务端下发的是量化整数,客户端在表现层把它还原成浮点再驱动节点。下面这段 L2 只依赖 cc 公开 API,能过 tsc --noEmit:

import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;

@ccclass('QuantizedView')
export class QuantizedView extends Component {
  /** 与协议约定一致:x / y 为 12 位、量程 ±1000 */
  private static readonly RANGE = 1000;
  private static readonly LEVELS = (1 << 12) - 1;

  /** 把 12 位量化码还原成逻辑坐标 */
  static decode(code: number): number {
    return (code / QuantizedView.LEVELS) * (2 * QuantizedView.RANGE) - QuantizedView.RANGE;
  }

  /** 用(插值后的)逻辑坐标驱动节点;表现层用浮点,不参与网络 */
  present(x: number, y: number): void {
    this.node.setPosition(x, y, 0);
  }
}

decode 与服务端 / 客户端的 dequantize 必须共用同一组常量(位宽 12、量程 1000),否则两边解码不一致,位置会整体偏移。工程上把这些常量集中到一个 protocol/constants.ts,编码端、解码端、测试三处都从那里取——这是「同一份协议」在代码层面的落点。

至此第 3 章闭环:客户端有了「收快照、按 AOI 进出、按位打包解码」的完整链路。但纯状态同步有个绕不开的体验问题——玩家按下跳跃后,要等一个往返(RTT)才看到自己动,手感发黏。下一章讲客户端预测与和解:本地立即应用输入、收到服务端快照后比对、偏差超阈值就回滚重演,把「跟手感」抢回来。

阅读导航:上一节:3.2 兴趣管理与 AOI · 下一节:4.1 本地预测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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