多人游戏的带宽是硬预算。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——这就是量化值的钱。
和引擎默认路径的配合
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 特有。