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();
}
}
首字节的位布局画出来更直观:
这意味着一个值为 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 实测。