技术博客

带宽优化:bit 打包与量化,把 Transform 压进 64 位

第 121 / 149 篇 · 2026-03-25 · godot · 网络同步带宽优化量化

多人游戏的带宽是硬预算。50 个玩家同屏、每人 20Hz 的位置同步,裸 Transform(4×3 个 float,48 字节)一路可靠广播,服务器出口就是 $50 \times 20 \times 48 \times 49 \approx 2.3\ \mathrm{MB/s}$——还没算旋转和动画状态。这篇讲把浮点压成定长 bit 的标准手法:量化(quantization)+ 位打包(bit packing),以及 Godot 自带的 PackedByteArray 编码助手怎么用。

量化:用误差界换带宽

量化的思想很朴素:位置不会出现在地图任意处,而是在一个已知的包围盒 $[x_{\min}, x_{\max}]$ 内。把这段区间均匀切成 $2^n$ 份,坐标只需传格子下标(n 位整数),对端用下标乘回步长还原:

$$ \Delta = \frac{x_{\max} - x_{\min}}{2^n - 1}, \qquad \hat{x} = x_{\min} + k \cdot \Delta,\quad k = \mathrm{round}\!\left(\frac{x - x_{\min}}{\Delta}\right) $$

还原误差有硬上界 $|\hat{x} - x| \le \Delta/2$。这是量化的核心性质:带宽由 n 决定,精度由 n 决定,误差界可以事前算出来。选一个 1024m × 1024m 的地图、每轴 16 位:$\Delta = 1024/65535 \approx 1.56\ \mathrm{cm}$,误差半格 0.78cm——对角色移动肉眼无感,三个轴共 48 位,比三个 float 的 96 位省一半。

位数分配按需求算,不要拍脑袋。脚步移动精度要求高(16 位/轴),炮弹轨迹可以糙(12 位/轴,$\Delta \approx 25\ \mathrm{cm}$ 在爆炸半径面前无意义),血量 0–100 直接 7 位。为每个字段单独算"业务上能接受的最大误差",反推位数,这是量化设计的完整流程,也是它和"随手 cast 成 int"的区别。

位打包:把 bit 流进字节

n 不一定是 8 的倍数(16 位对齐刚好,12 位就不是),于是需要位级写入器。原理一句话:维护一个 64 位累加器 acc 和已用位数 nbits,写入 k 位值 v 时

func write_bits(acc: int, nbits: int, v: int, k: int) -> Array:
    var mask := (1 << k) - 1
    acc = acc | ((v & mask) << nbits)
    nbits += k
    return [acc, nbits]
    # nbits >= 64 时 flush 低 32/64 位进 PackedByteArray,acc 右移

读取端镜像操作:按同样的顺序、同样的 k 序列读回。这里有个工程纪律:打包格式就是协议本身,两端字段顺序必须逐位一致,且字段顺序以后不能乱插——插入即破协议。所以打包器要配一个版本号字节,或者干脆用 Godot 4.3 的 PackedByteArray.encode_u16/decode_u16 系列只做字节对齐的字段,bit 级部分自己管。

一个 Transform 的 64 位布局实例

以第三人称同步为例,设计一个 64 位定长包:

字段 位数 编码
x, z 位置 2×16 1024m 图幅量化(≈1.6cm 步长)
y 位置 12 0–64m 高度量化(≈1.6cm 步长)
朝向 yaw 12 0–360° 均匀量化(≈0.088° 步长)
动画状态 6 64 个状态的枚举
标志位 2 蹲伏、开火

总位数 $32+12+12+6+2 = 64$,恰好一个 uint64。对比裸 RPC 一个 Transform(位置 12B + 基向量 36B + 缩放 12B)的 60 字节,压缩比接近 $7.5\times$。50 人 20Hz 的例子里,带宽从 2.3 MB/s 降到约 0.31 MB/s——这就是量化值的钱。

64 位定长状态包的位布局(第三人称角色) x / z 位置 2×16 位 1024m 图幅量化,≈1.6cm 步长 y 12 位 0–64m yaw 12 位 ≈0.09° 步长 动画 6 位 64 状态 标志 2 位 写端:acc 累加 → 满 32/64 位 flush 进 PackedByteArray(字节流序 = 协议) 读端:同序同宽读出,k = round((x − xmin) / Δ) 反量化还原 误差界 Δ/2 事前可算:12 位 @1024m ≈ 12.5cm;16 位 ≈ 0.78cm 发送侧 k = round((x−xmin)/Δ),接收侧 x̂ = xmin + k·Δ,往返各量化一次

和引擎默认路径的配合

Godot 4.3 起 PackedByteArray 有 encode_u8/u16/u32/u64/float 和对应 decode,省了自己做字节序的心。但 MultiplayerSynchronizer 的默认配置不走这条路——它按 Variant 编码(七-2 说的那套),Vector3 就是 3 个 double。要吃到量化的收益有两条路:

一是把同步属性从 Vector3 换成打包后的 int,写两个 setter 做编解码——改动小,但同步面板里属性变成魔数,可读性差;二是高频状态(位置、朝向)干脆退出 synchronizer,改走自定义不可靠 RPC 发打包字节流,低频高价值状态(血量、拾取)留给 synchronizer 走可靠通道。这正是七-4 说的"通道分工"在数据层的延伸:高频糙数据走不可靠 + 量化,低频精数据走可靠 + Variant,两条流在不同通道互不拖累。

别量化的东西

有界、有已知物理意义的量适合量化。两类不适合:一是无界量,比如累积的游戏时间、积分总数,位数定不下来;二是需要精确值的量,金币、弹药数、匹配分数——这类是离散枚举不是测量值,直接给足位数的整数即可,量化反而引入无意义的误差。判断标准一句话:这个数错了"一点"业务上能接受吗?能接受才量化。

本文基于 Godot 4.3–4.4;位打包代码为 GDScript 示意(生产建议下沉到 GDExtension 用 C++ 位运算,省掉 Variant 装箱),量化误差推导为通用方法,非 Godot 特有。