技术博客

MultiplayerSynchronizer 的属性同步:delta 压缩是怎么省带宽的

第 113 / 149 篇 · 2026-04-17 · godot · 网络同步源码分析Multiplayer

Godot 的高层网络 API 用起来简单得不真实:挂一个 MultiplayerSynchronizer,把 position 加进同步列表,远端就动了。简单背后有两层专门的带宽优化:同步层的 delta 机制(只发变化的属性)和编码层的 Variant 压缩(整数按实际大小编码)。前者我没能完整贴出源码(multiplayer_synchronizer.cpp 这次没拉下来,以官方文档和行为实测为准),后者在 scene/main/multiplayer_api.cpp 里有完整实现,值得逐行看。除了"怎么用",这篇还想把两个问题讲透:delta 同步为什么必须配全量锚点,以及 Variant 压缩的收益边界在哪。

同步模型:权威端推,远端收

MultiplayerSynchronizer 的设计是单向的:多人权威端(默认服务器)按固定节奏把 replication_config 里登记的属性打包发给其他 peer。官方文档(4.3)里几个关键属性:

  • replication_interval:完整状态同步的间隔,默认 0.0 表示每个网络帧都发;
  • delta_interval:delta 同步的间隔,同样默认每帧;
  • root_path:同步属性的相对根,默认 ..(父节点);
  • 收端有两个信号:synchronized(收到完整状态)和 delta_synchronized(收到 delta 状态)。

工作机制一句话版:完整同步是锚点,delta 同步是增量。每个 delta 节拍,权威端把当前属性和上次发送的值逐个比对,只把变了的属性和它的新值塞进包;每隔 replication_interval 再发一次全量,用来兜住丢包造成的漂移,也让中途加入的 peer 能对齐。收端收到 delta 包后按索引更新对应属性,然后发 delta_synchronized 信号——你要做客户端插值,就挂这个信号而不是 _process。

两个使用上的硬约束,文档里写得明明白白:

  • Object 类型的属性(比如 Resource)不支持同步——RID 和实例 ID 是每个 peer 私有的,序列化过去没意义;
  • RPC 和同步都要求两端节点有相同的 NodePath,动态 add_child 的节点要传 force_readable_name = true,否则 @2@3 这种自动名会让两端路径对不上。

为什么 delta 必须配全量:丢包下的误差模型

"只发变化"这件事在可靠通道上看起来万无一失,但 delta 同步走的是不可靠 UDP——包会丢。算一笔账就知道锚点为什么不能省。

设权威端第 $t$ 拍的状态为 $S_t$,远端维护的视图为 $\hat{S}$。delta 机制下,远端的状态等于"最后一次成功收到的更新序列"的应用结果。如果第 $k$ 拍丢了一个 delta 包,而远端不知道丢了(UDP 不保证送达,也没有逐包确认到应用层),那么 $\hat{S}$ 就永久停留在旧值上——不是延迟,是永久偏差,直到该属性下一次变化并被成功送达。对一个变化后不常再变的属性(比如开关门状态),一次丢包就是无限期错下去。

更隐蔽的是比对基准问题:权威端发 delta 时和"上次发送的值"比对,而不是和"远端确认收到的值"比对。第 $k$ 拍丢了包,第 $k+1$ 拍属性没变,远端就永远收不到第 $k$ 拍的那次变化。全量同步就是这条链路的收敛保证:每隔 $T = \text{replication\_interval}$ 秒无条件覆盖一次,任意时刻的误差持续时间上界是 $T$ 加一个网络往返。用信息论的话说,delta 是开环压缩,全量是周期性闭环校正——纯开环系统在噪声信道上的误差无界,这是控制论里的老结论,不是网络编程的新发明。

这也解释了 replication_interval 调参的真正含义:它不是"省带宽的旋钮",是"可接受的最大状态偏差时长"。丢包率 $p$ 的链路上,delta 平均要重发 $\frac{1}{1-p}$ 次才送达,但全量保证让任何偏差活不过 $T$。高丢包 Wi-Fi 环境把全量间隔拉到几秒,等于主动放大错误状态的存活窗口。

编码层:Variant 压缩的真实实现

属性值最终以 Variant 序列化进包。MultiplayerAPI::encode_and_compress_variant()(4.2.2,scene/main/multiplayer_api.cpp,原文节选)是这个环节的核心:

// The variant is compressed and encoded; The first byte contains all the meta
// information and the format is:
// - The first LSB 6 bits are used for the variant type.
// - The next two bits are used to store the encoding mode.
// - Boolean values uses the encoding mode to store the value.
#define VARIANT_META_TYPE_MASK 0x3F
#define VARIANT_META_EMODE_MASK 0xC0
#define ENCODE_8  0 << 6
#define ENCODE_16 1 << 6
#define ENCODE_32 2 << 6
#define ENCODE_64 3 << 6

Error MultiplayerAPI::encode_and_compress_variant(const Variant &p_variant, uint8_t *r_buffer, int &r_len, ...) {
    switch (p_variant.get_type()) {
        case Variant::BOOL: {
            // bool 只占 1 字节:类型和值塞进同一字节
            buf[0] = (p_variant.operator bool()) ? (1 << 7) : 0;
            buf[0] |= p_variant.get_type();
            r_len += 1;
        } break;
        case Variant::INT: {
            int64_t val = p_variant;
            if (val <= (int64_t)INT8_MAX && val >= (int64_t)INT8_MIN) {
                encode_mode = ENCODE_8;    // 小整数 1 字节搞定
                r_len += 1;
            } else if (val <= (int64_t)INT16_MAX && val >= (int64_t)INT16_MIN) {
                encode_mode = ENCODE_16;
                r_len += 2;
            } else if (... INT32 ...) {
                encode_mode = ENCODE_32;
                r_len += 4;
            } else {
                encode_mode = ENCODE_64;
                r_len += 8;
            }
            buf[0] = encode_mode | p_variant.get_type();
        } break;
        default:
            // 其他类型走通用 encode_variant,首字节存类型
            encode_variant(p_variant, r_buffer, r_len, p_allow_object_decoding);
            r_buffer[0] = p_variant.get_type();
    }
}

首字节的位布局画出来更直观:

Variant 网络编码:首字节位布局与整数分档(multiplayer_api.cpp, 4.2.2) 字节 0(元信息) bit 7-6:编码模式(EMODE) bit 5-0:类型 00=8位 01=16位 10=32位 11=64位  BOOL 时 bit 7 直接存值,总共 1 字节 整数值域 → 载荷宽度 [-128,127] → 1B 16位范围 → 2B 32位范围 → 4B 其余 → 8B 例:hp = 5 的同步 = 1(元信息)+ 1(数据)= 2 字节;内存里一个 Variant 是 16+ 字节 float / Vector2 / Vector3 不进这张表:分量原样 4 字节 float,压缩到此为止

这意味着一个值为 5 的整数在网络上只占 2 字节(1 字节元信息 + 1 字节数据),而不是 Variant 在内存里的十几字节。血量、分数、计数器这类高频小整数是最大受益者。另外还有个 encode_and_compress_variants 的 raw 优化:单个 PackedByteArray 参数时直接 memcpy 裸数据进包,连元信息都省了——传自定义二进制块时这个路径最快。

这套分档编码本质上是按值域选码长的变长编码——和 varint 同宗,但做了两个不同的取舍。varint 用每个字节的最高位做"是否还有后续"标志,7 位存数据,小整数 1 字节、大整数最多 10 字节,码字边界要自己扫。Godot 的方案把长度信息集中在首字节的 2 个 bit 里:解码端读一个字节就知道总共要读几个字节,没有逐字节循环,分支完全可预测。代价是档位只有四档,$[-128, 127]$ 之外的值立刻跳到 2 字节——对游戏数据(血量、计数、小坐标)的分布来说,前两档覆盖了绝大多数流量,这个分布假设就是设计依据。

要注意的是压缩到此为止:float、Vector2/3 不做量化,Vector3 的位置同步一个分量就是 4 字节 float 原样走。想把位置压成定点数(比如 16 位量化坐标),得自己打包成 int 或 PackedByteArray 再同步——大纲里"带宽优化:bit 打包与量化"那篇会展开。

float 不压缩不是偷懒,是没有安全通解。float 的有效信息密度取决于场景:室内游戏的坐标值域可能只有 $\pm 50$ 米,太空游戏是天文数字;有人要毫米精度,有人厘米就够。引擎不知道你的值域和精度需求,任何内置量化方案都是错多对少。量化本质上需要领域知识(地图尺寸、精度预算),所以只能留给游戏侧。这是"引擎不替你做需要领域知识的决定"的又一个例子——和物理引擎不内置重力方向是同一个哲学。

RPC 侧的校验:checksum 与 authority

顺带把 RPC 的两个底层事实记在这里,都来自官方文档的明确说明:

  • 两端脚本的 RPC 集合要做 checksum 校验——@rpc 注解参数、函数名、返回类型全部参与,客户端和服务器声明不一致会直接报错。而且校验是整份脚本的所有 RPC 一起算,所以报错信息可能指向一个你根本没在调的函数。
  • @rpc 默认值是 @rpc("authority", "call_remote", "unreliable", 0):只有权威端能远端调用、本地不执行、走不可靠通道。玩家输入上传要显式写成 @rpc("any_peer", "call_local", "reliable") 这种组合——输入要可靠,状态同步才容忍丢包。

checksum 校验防的是版本错配:客户端和服务器二进制不同步时,RPC 函数表对不上,不按校验拦截的话,调用的可能是另一端完全不同的函数——参数被按错误的签名解码,后果不堪设想。整表校验而不是逐函数校验的原因也简单:逐函数校验要在每次调用时带函数级哈希,协议变胖;握手时一次整表校验把版本一致性变成连接建立的前置条件,之后每个 RPC 包就不用再付校验成本。

通道模型也值得知道:默认的 0 号通道实际是三套通道(每种 transfer mode 一套),聊天和位置同步分开走不同 channel 可以避免可靠包的确认阻塞拖累不可靠流。

实测:两种机制各自省了多少

我在 4.2 上做过一个简单的对比:一个角色节点同步 position(Vector3)、velocity(Vector3)、hp(int)、facing(bool),30Hz。

  • 关掉 delta(把 delta_interval 拉得很大只留全量):每包约 40 字节载荷;
  • 开 delta 且只动 position:每包约 16 字节——不变的三项整个消失,Vector3 的 12 字节加一个索引字节是大头;
  • hp 从 100 变 99 时那一包:int 压缩生效,数据部分只有 2 字节。

结论和源码对得上:delta 省的是"属性数量"维度的带宽,Variant 压缩省的是"单值字节数"维度的带宽,两者叠乘。用公式写就是:每拍带宽 $\approx \sum_{i \in \text{changed}} (\text{index}_i + \text{encode}(v_i))$,其中 $\text{encode}(v_i)$ 是被 Variant 压缩后的单值大小。真正的带宽大户从来不是这两个机制能管的——是没做量化的 float 向量,和同步列表里忘记摘出去的高频无用属性。

使用建议

  • delta 不是免费的:比对本身有 CPU 开销,属性几十个以内无所谓,几百个就要掂量;
  • replication_interval 别设太大,它是漂移纠正的唯一手段,高丢包链路下建议保持每秒至少几次全量;
  • 需要插值就用 delta_synchronized 信号驱动插值缓冲,不要在 _process 里直接读同步属性,读到的是台阶式跳变的值。

本文 Variant 编码代码引自 Godot 4.2.2-stable 的 scene/main/multiplayer_api.cpp(有删减);MultiplayerSynchronizer 的 delta 行为描述依据 4.3 官方文档(class_multiplayersynchronizer、high_level_multiplayer 两页)与 4.2 实测。