3.3 带宽预算与压缩
前两节定了「发什么」:只发权威状态(3.1),只发看得见的(3.2)。这一节解决「发多小」。带宽是联机里最稀缺的资源之一——移动网络下每个玩家每秒能用多少下行是硬约束,超了就是卡顿与掉线。压缩不是「能压就压」,而是先把预算算清,再按缺口选手段。
3.3.1 先把预算算出来
不给出条件就谈「包大小」是耍流氓。我们钉死一组《方块冲刺》双人竞速的参考条件:
- 状态同步频率:60 Hz(与逻辑帧同频)。
- 每帧下发实体数:8 个(两名玩家 + 若干道具 + 特效)。
- 每个实体 5 个数值字段:
id、x、y、vx、vy。
按紧凑二进制逐位算:
| 字段 | 位宽 | 说明 |
|---|---|---|
id | 8 | 最多 256 个实体 |
x | 12 | 定点,量程 ±1000 逻辑像素 |
y | 12 | 同上 |
vx | 10 | 定点,量程 ±20 像素 / 帧 |
vy | 10 | 同上 |
| 每实体合计 | 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 像素为粒度「跳」。
补偿思路有三条:
- 逻辑用整数、表现用插值:逻辑层始终走量化后的整数坐标(保证跨端一致),表现层在相邻两个逻辑帧之间做线性插值,把 0.49 像素的台阶抹平成平滑移动(第 4 章 4.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 本地预测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。