Godot 的多人联网入门示例只要五行:MultiplayerAPI.create_default_interface() → ENetMultiplayerPeer → create_server(6969) → multiplayer.multiplayer_peer = peer。五行背后是三层的分工,分层分得干净是这个 API 设计得最好的部分——也是为什么后面写预测、回放、换传输协议时,你知道该在哪一层动手。这篇把三层拆开,顺带讲一个容易被忽略的带宽优化:NodePath 整数缓存。
三层结构
从下往上看:
传输层:MultiplayerPeer。接口契约只有几个动词:put_packet()、get_packet()、poll()。它不关心包里是什么,只负责把字节流送达。实现有 ENetMultiplayerPeer(默认,基于 ENet 的可靠 UDP)、WebSocketMultiplayerPeer、WebRTCMultiplayerPeer(桌面端要插件)。每个 peer 有 unique_id:服务器永远是 1,客户端是连接时服务器分配的正整数,1 号保留。
编解码与寻址层:SceneMultiplayer。MultiplayerAPI.create_default_interface() 返回的就是它。这一层知道字节包里装的是 RPC 调用还是同步数据,知道目标节点是哪个——这是它名字里 Scene 的由来:它的寻址语言是 NodePath,不是对象引用。它还管着权威校验(@rpc 注解的 authority 规则)、RPC 集合 checksum、packet 大小上限等。
语义层:你的节点树。@rpc 函数、MultiplayerSynchronizer、authority 标记,全在这一层表达。引擎把"哪个函数能被远端调"当作脚本元数据管理,网络层只负责忠实执行。
为什么寻址语言是 NodePath
设计选择:网络包里的节点标识是 NodePath 的整数缓存,不是路径字符串本身。原因是开销对比太悬殊。一条典型路径 /root/World/Players/Alice/CharacterBody3D 有五十多个字节,而高频 RPC 每帧都发;把它映射成一个小整数(第一次出现时字符串和整数一起发,之后只发整数),单包立刻瘦下来。SceneMultiplayer 内部为每个对端维护这张路径↔整数对照表,两端各自 lazily 建立。
这个设计有个硬约束,我在七-2 那篇也提过:两端节点必须有相同的 NodePath。缓存表按路径字符串对齐,你在一端动态 add_child 用了自动命名(@2@3 这种),另一端的同名节点路径就对不上,整数缓存指过去就是静默错目标。force_readable_name = true 不是美观问题,是寻址正确性问题。
顺带一提权威模型:SceneMultiplayer 的 authority 是逐节点的,不是逐连接的。set_multiplayer_authority(peer_id) 标记"这个节点归哪个 peer 管",@rpc("authority") 调用时引擎检查调用方的声明 authority 是否匹配。默认整个树归服务器(1 号)。这套模型对"玩家各自控制自己的角色"非常顺手:角色出生时 set_multiplayer_authority(该玩家的id),之后这个角色上的权威 RPC 自动路由对。
但要清醒:authority 是声明不是证明。引擎只检查对端"自称"是谁——remote_sender 来自连接元数据,客户端可以伪造。Godot 没有任何加密身份验证内建在这层(auth_timeout 管的是连接握手不是身份)。所有安全敏感的状态变更,服务器端必须重新校验,把 authority 当成纯粹的路由提示用,不是信任边界。这一条写进团队规范能少踩一半的坑。
换传输层只要一行
三层结构的回报在这。场景树里任何节点拿 multiplayer 属性访问的都是本层级的 MultiplayerAPI(SceneTree 持有一个默认的,任何子树可以用 set_multiplayer(custom_api, root_path) 换自己的)。传输层只是一个可替换属性:
# 局域网 UDP
var peer := ENetMultiplayerPeer.new()
peer.create_server(PORT)
# 浏览器环境(Web 导出),其余代码一行不改
var peer := WebSocketMultiplayerPeer.new()
peer.listen(PORT) # 或 create_client(url)
语义层和编解码层完全无感。这也正是七-8 自定义 MultiplayerAPI(离线回放)可行的原因:分层之后,注入点清晰——你可以换传输(自定义 peer),也可以换掉整个编解码层。
几个默认值的含义
SceneMultiplayer 上有几个值得知道默认值的属性:
max_sync_packet_size和max_delta_packet_size:同步包和 delta 包的大小上限,后者大得多(KB 级 vs 64KB 级)。区分两类包的大小预算是因为语义不同——同步包每帧都发,大了直接吃满带宽;delta 包低频,可以奢侈一点;server_relay:服务器是否在客户端之间转发 RPC。开着时客户端 A 发给 B 的包经服务器中转,能做权限审查;关掉直连省一跳延迟,但 Godot 高层 API 并不直接支持客户端间直连寻址,实际项目基本都保持开着;root_path:NodePath 寻址的根,默认整个场景树,自定义 MultiplayerAPI 挂子树时会用到。
还有一个连接层的默认行为要心里有数:auth_timeout 默认 3 秒,握手没完成就踢——自建握手协议的别在这上面磨叽。
小结
SceneMultiplayer 的设计哲学可以总结成一句话:语义层只管声明,编解码层只管执行,传输层只管送达。三层各自能换,这是后面所有进阶玩法(预测、回放、Web 适配)的地基。遇到多人 bug 时先定位是哪层的问题:包没到是传输层,到了没分发是编解码层,分发错了是语义层的 authority 或路径问题。分层排查比对着一行 rpc_id() 猜快得多。
本文基于 Godot 4.2–4.4 源码与文档,关键文件:scene/main/scene_multiplayer.h/.cpp、scene/main/multiplayer_api.h、各 *multiplayer_peer 实现。