Netcode for Gameobjects

前言

基于Netcode for Gameobjects制作了一个具备大厅、房间(类似于帕鲁)、多个玩家移动、攻击、动画同步打Boss的简易Demo,总结了一些制作过程中的个人理解

目前的一些理解

所有NetcodeObject只在服务器具有权威实例,客户端持有各个NetcodeObject的镜像,只做同步。

客户端需要修改某些内容,采用ServerRPC,由服务器校验和修改权威实例/数据的内容,广播后由各个客户端镜像同步。

RPC

  • ServerRPC:客户端调用,服务器执行。
  • ClinentRPC:由服务器调用,客户端执行。
[ClientRpc]
void SendMsgToToOtherClientRpc(PlayerInfo playerInfo, string message)
{
    if (!IsServer && NetworkManager.LocalClientId != playerInfo.id)
    {
        AddDialogueCell(playerInfo.name, message);
    }
}
//任何客户端都可以发送大厅消息
[ServerRpc(RequireOwnership = false)]
void SendMsgToServerRpc(PlayerInfo playerInfo, string message)
{
    AddDialogueCell(playerInfo.name, message);
    SendMsgToToOtherClientRpc(playerInfo, message);
}
/// <summary>
/// 发送聊天消息
/// </summary> 
private void OnSendBtnClicked()
{
    if (string.IsNullOrEmpty(_input.text))
    {
        return;
    }
    //本地添加消息
    PlayerInfo playerInfo = GameManager.Instance.AllPlayerInfos[NetworkManager.Singleton.LocalClientId];
    AddDialogueCell(playerInfo.name, _input.text);

    //host直接通知其他客户端同步消息,而client需请求服务器,再由服务器去通知其他客户端更新
    if (IsServer)
    {
        SendMsgToToOtherClientRpc(playerInfo, _input.text);

    }
    else
    {
        SendMsgToServerRpc(playerInfo, _input.text);
    }

}

PS:在2.x版本(Unity6.x)的NGO,PRC已修改为[RPC(SendTo)]的写法,本人使用的是2022LTS版本。

[ServerRpc(RequireOwnership=)]

  • false的适用情况:代表只有NetcodeObject的持有者才能调用该方法,对于大厅、聊天等应设为false,否则,如果是host模式,只有host可以调用该方法,其他客户端想要发送消息会被拒绝。
  • true的适用情况:例如一个道具,只有拥有这个道具的玩家才能使用,再比如一些私人物品(储物箱),只有拥有者可以调用打开这个储物箱(这个过程还是需要Server去进行验证或者修正)。

ClinetId

NetworkManager.LocalClientId和NetworkManager.Singleton.LocalClientId

NGO 的设计是一个游戏场景里只能有一个有效的 NetworkManager(全局唯一)

对于一个Client来说,NetworkManager.LocalClientId和NetworkManager.Singleton.LocalClientId的值都是当前本地客户端的ID。

  • NetcodeObject持有NetworkWork的引用,方便自己获取会员状态、注册注销自己等操作。
  • 而Singleton方便我们在非NetworkObject中获取本地客户端的ID

OwnerID

NGO是一个服务器权威的同步框架,每一个NetcodeObject只有在服务器才有唯一的权威实例,每一个客户端持有的都是自己、其他客户端、怪物、道具的镜像。

但是对于一些逻辑我们只有拥有这个Object的Clinent才能使用(道具、玩家本身的位移等)

例如我们进入游戏后,摄像机需要跟随我们自身角色、我们操作的也只有自身角色,其他客户端角色可以通过NetcodeVariable、NetworkTransform、NetcodeAnimator同步自身位置和动画。

private void OnStartGame()
{
    //获取拥有这个NetworkObject的数据,而非本地客户端
    PlayerInfo playerInfo = GameManager.Instance.AllPlayerInfos[OwnerClientId];
    //获取对应玩家要使用的人物模型
    Transform body = transform.GetChild(playerInfo.gender);
    body.GetComponent<Rigidbody>().isKinematic = false;
    Transform other = transform.GetChild(1 - playerInfo.gender);
    other.gameObject.SetActive(false);


    //设置同步的模型trans
    PlayerSync playerSync = GetComponent<PlayerSync>();
    playerSync.SetTarget(body);
    playerSync.enabled = true;

    //本地玩家摄像机跟随
    if (IsLocalPlayer)
    {
        //防止按下后每一个客户端都更新动画,我们只控制本地玩家,其他玩家只同步
        body.GetComponent<PlayerMove>().enabled = true;
        GameCtrl.Instance.SetFollowTarget(body);

    }
    //设置出生位置
    transform.position = GameCtrl.Instance.GetSpawnPosition();

}

NetworkVariable和RPC

  • RPC:一次调用,不保留状态,通常用于客户端请求服务器进行某些操作,以及服务器广播,让客户端进行同步。
  • NetcodeVariable:持续的状态,如HP、位置等,服务器写,并进行增量式广播同步(一般自定义内容需要重写IEquatable\<T\>
public NetworkVariable<int> Health = new NetworkVariable<int>(100);

public override void OnNetworkSpawn()
{
    if (IsClient)
    {
        Health.OnValueChanged += (oldValue, newValue) =>
        {
            Debug.Log($"血量从 {oldValue} → {newValue}");
            UIManager.Instance.UpdateHealthBar(newValue);
        };
    }
}

Listen Server和P2P

ListenServer和P2P是常见于一些少数玩家联机的方案

  • ListenServer:由一个玩家同时作为Client和Server(房主),其他玩家输入PermissionKey去加入房间
  • P2P:所有玩家都是“平等节点”,彼此直接互连交换数据,没有固定的Server

Host(Listen Server)

比较典型的游戏就是MineCraft,本质上还是服务器权威,只是对于房主来说,自身同时是Server和Client

但是执行同步逻辑依然是要走Client->Server的流程(个人觉得等价于直接在服务器写权威状态。NGO 会自动把变更复制到有读权限的客户端,只是这个过程还是是还是会有验证逻辑,但全都在本机,内部执行过程和具体原理本人还没有深入研究)

例如使用一个道具,检测到自己是Host,不需要再去走广播逻辑,直接更新服务器的权威内容(自己就是Server,但仍有会有道具的验证逻辑),随后再去通知其他客户端同步。

//host直接通知其他客户端同步消息,而client需请求服务器,再由服务器去通知其他客户端更新
    if (IsServer)
    {
        SendMsgToToOtherClientRpc(playerInfo, _input.text);

    }
    else
    {
        SendMsgToServerRpc(playerInfo, _input.text);
    }

带来的问题,其实在MineCraft很明显,房主很流畅,其他玩家会卡,因为其他玩家需要先ServerRpc,再由Server通知客户端更新,需要走一个RTT流程,而Host约等于0RTT

Unity Gaming Services

该服务测试/开发阶段免费,默认就是这种方式,但发布后需要收费。

UnityRely

  • Host 向 UGS 申请一个 Relay Allocation
  • Unity 分配一个中继节点 + 安全的 token。
  • Host 用这个分配结果初始化 NGO 的 UnityTransport
  • Host 把 joinCode分享给别人。
  • 客户端拿到 joinCode → 向 UGS 请求对应的 Relay Allocation ,得到 Relay 地址和 token
  • 配置 UnityTransport → 连接 Host。
  • 从此 所有 NGO 的同步数据都通过 Relay 转发。

UnityLobby

  • Host 创建 Lobby → UGS 返回一个 joinCode
  • 其他玩家输入 joinCode → UGS 找到对应 Lobby → 返回 Relay Allocation 信息。
  • 得到Relay地址和token。
  • 玩家用这个信息配置 NGO → 连接。

P2P

比较典型的游戏,马里奥制造2,一个人卡大家都卡,一个人掉大家全退出

  • 每帧输入采集: 每个玩家只采集“输入"。
  • 每个玩家把自己的输入广播给所有其他玩家, 只有当所有人的输入都收齐后,大家才进入下一帧。
  • 各个客户端用相同的输入集、相同的逻辑代码,在本地模拟游戏状态。

理论上,每台机子都算出相同的结果,就不需要传输完整状态(当然也可以同步状态)。

所以就会有一荣俱荣,一损俱损的情况出现。

PS:P2P(完全网状:互相直连、部分网状:基于互相转发)、专用服务器(星形)、Host是一种网络拓补,状态同步和帧同步是一种同步策略,P2P、权威服务器、HOST都可以采用状态同步/帧同步策略。

对比(AI总结)

对比项P2PListen Server
网络拓扑全员互联一主多客(星型,所有人连到 Host)
权威无固定权威,每人平等Host(Listen Server)是唯一权威
抗作弊难(每人都能改状态)较好(Host 裁决)
NAT/防火墙问题复杂(所有人需打洞)较简单(只需连 Host)
容错任意人掉线都影响同步Host 掉线整个房间崩溃
实现难度高(帧锁、输入同步、共识算法)相对低(普通 C/S 架构)

打洞、端口转发、中继服务器

由于基本上家庭网络都没有公网IP,借助CGNAT访问外网

    1. 你的设备(内网 IP):192.168.0.100:12345 发起一个请求 → 目标服务器 203.0.113.10:80
    2. 家用路由器 NAT:把 192.168.0.100:12345 翻译成 100.64.50.10:54321(运营商大私网地址)。随后建立映射表:192.168.0.100:12345 <-> 100.64.50.10:54321
    3. 运营商 CGNAT:把 100.64.50.10:54321 翻译成公网 203.0.113.77:62001。随后建立映射表:100.64.50.10:54321 <-> 203.0.113.77:62001
    4. 公网传输:数据包以源地址 203.0.113.77:62001 的形式到达目标服务器 203.0.113.10:80
    5. 目标服务器:认为你就是 203.0.113.77:62001,于是回包发给这个地址端口。
    6. 运营商NAT存储了映射表,将包转发到100.64.50.10:54321(运营商大私网地址)
    7. 路由器NAT也存储了对应映射表,将包转发到192.168.0.100:12345
    8. 你的设备接受回包实现上网

所以我们常用策略是打洞->(失败)中继服务,如果你有公网IP,可以做端口转发。

打洞

  • 双方 A 和 B 都先主动联系一个 中介服务器(STUN/信令服务器),上报自己的外网 IP:Port。
  • 中介服务器告诉 A:“B 的外网是 203.0.113.77:62001”;告诉 B:“A 的外网是 198.51.100.44:51234”(此时彼此知道了对方在运营商的真正出口公网IP)。
  • A、B 同时向对方的真是公网IP发送 UDP 包。
  • 彼此的NAT 看到“出站访问过这个地址”,就允许回包进来。
  • 于是 A、B 成功建立直连。

关键:双方都要几乎同时对对方发包,这样 NAT 表会为这个目的地建映射,允许回包。

但事实上现在CGNAT都会使用对称NAT策略,打洞成功率不高。

因为对称NAT对于不同的目标会使用不同的端口去映射,而不像Cone NAT会使用一个固定的端口和地址:

  1. A发送给中介服务器,中介服务器发现请求的地址是203.0.113.77:62001,告知B
  2. B发送给中介服务器,中介服务器发现请求的地址是203.0.113.78:62001,告知A
  3. A和B认为彼此知道对方的实际出口公网了
  4. 此时A请求B(中介服务器告知的IP是203.0.113.78:62001),对称NAT不会再使用A(203.0.113.77:62001)而是会再次分配一个其他的端口,如203.0.113.77:62002,此时

此时就会变成CGNAT记录的是203.0.113.77:62002 <->203.0.113.78:62001

对于B请求A(中介服务器告知的IP是203.0.113.77:62001),同样自己出口很可能变成203.0.113.78:62002,此时记录的是203.0.113.77:62001 <->203.0.113.78:62002

压根过不了NAT

端口转发

如果你有公网IP,你设置路由器告诉7777192.168.0.100:7777,把所有请求7777端口的内容都转发到内网,此时其他玩家就可以随意请求你了,CGNAT无论怎么映射,目标地址都是你的固定IP,所以其他客户端的每次的出口IP无所谓,因为NAT总会记录,发送方IP<->你的固定公网ip的映射,你作为HOST非常合适(注意这样相当于把你的内网暴露了,有一定危险

Relay

  • A → Relay 出站

    • A 内网 192.168.0.100:12345 → 家用 NAT → 100.64.10.5:54321 → 运营商 NAT → 公网 203.0.113.50:60001 → Relay。
    • A 的 NAT 表里记录:

      192.168.0.100:12345 ↔ 203.0.113.50:60001 (目的地 Relay)
  • B → Relay 出站

    • B 内网 192.168.1.50:40000 → NAT → 公网 198.51.100.88:62011 → Relay。
    • B 的 NAT 表:

      192.168.1.50:40000 ↔ 198.51.100.88:62011 (目的地 Relay)
  • A 发给 B

    • A 先把数据发给 Relay(出站包,NAT 会放行)。
    • Relay 收到包,检查目标 ID\= B,然后把包转发给 B 的公网地址 198.51.100.88:62011
    • B 的 NAT 查表:

      • 发现这个端口对应的目标就是 Relay,允许回包进来。
      • 所以包顺利到达 B 内网 192.168.1.50:40000
  • B 发给 A

    • 同理,B 发到 Relay → Relay 转发给 A 的公网地址 203.0.113.50:60001 → A 的 NAT 查表 → 回到 A 内网。

总结

根本在于家庭NAT的策略都是,你必须先出战,你的出口NAT才建立映射,别人直接打你,NAT无映射,直接丢包

  • 打洞通过中介服务器(STUN),获得彼此真正的出口公网IP地址,双方同时发包,让彼此路由器NAT建立双方映射,直连,一个RTT,延迟很低
  • Rely,通过一个中继服务器(TURN),中继服务器分别知道A-TURN 和B-TURN的NAT映射,且A和B的目标地址不变,即便采取对称NAT,CGNAT也不会改变出口公网IP,那么A和B通过TURN转发数据来实现彼此通信,两个RTT,延迟会高一些,但很稳定

所以基本策略就是

  1. 尝试打洞,打洞成功直接直连
  2. 打洞失败,走Rely

端口转发会给具备公网IP的一方带来安全隐患,不建议使用。

AI总结表

特性端口转发 (Port Forwarding)打洞 (Hole Punching / STUN)中继 (Relay / TURN / Unity Relay)
原理在 NAT/路由器上手动写死规则:把公网端口映射到内网设备双方先访问 STUN 得到公网 IP:Port,再互相发包利用 NAT “出站即允许回包”所有客户端都出站连到 Relay 服务器,由它转发数据
是否需要公网 IP✅ 必须要有(ISP 分配给你)至少一方 NAT 为 Cone,或一方有公网 IP;双对称 NAT 常失败❌ 不需要,任何 NAT 下都可用
NAT 类型要求家用 NAT 必须可配置,不能是 CGNAT至少一方 Cone NAT/公网 IP无要求,100% 可靠
配置复杂度高(用户手动配置路由器端口/UPnP/DDNS)中(需要 STUN/信令,且依赖 NAT 类型)低(自动,客户端只要能出站连 Relay)
延迟最低(直连)低(直连,成功时接近端口转发)较高(所有流量经 Relay,多一跳)
带宽消耗仅双方之间仅双方之间双倍消耗(Relay 服务器也要转发所有流量)
稳定性高(有公网 IP 时稳定)中(Cone NAT 情况下成功率高;对称 NAT基本挂)最高(保证一定能联通)
安全性风险大:内网端口暴露到公网较安全:NAT 临时映射,外部难以持久攻击高:玩家看不到真实 IP,只看到 Relay

部署

支持打服务器包和客户端包,其中服务器会渲染、音频等无用功能保持高性能

结语

NGO虽然方便,但隐藏了很多底层逻辑,黑盒的感觉,不知道具体细节(官方文档写的也不详细),但的确对于快速开发中小型网络游戏很方便(尤其那种开房间的几个人联机的游戏),使用上方便,但对于学习上不太友好。本文如有任何错误,恳请指出!

本文总结了基于Netcode for GameObjects开发的多人联机Demo经验,核心要点如下:

  1. 网络架构
  2. 采用服务器权威模式,客户端仅持有对象镜像
  3. 所有逻辑修改需通过ServerRPC由服务器验证执行
  4. RPC机制
  5. ServerRPC:客户端→服务器调用(可设置RequireOwnership权限)
  6. ClientRPC:服务器→客户端广播
  7. 示例展示了聊天系统的RPC调用链
  8. 关键概念
  9. ClientId:区分本地客户端标识
  10. OwnerID:标识网络对象归属权
  11. 同步控制:通过NetworkTransform等组件实现位置/动画同步
  12. 实践案例
  13. 玩家角色控制:本地玩家启用操作组件,远程玩家仅同步
  14. 权限管理:道具等私有物品需验证OwnerClientId

文章提供了2022LTS版本与Unity6.x的API差异说明,并强调服务器权威验证的重要性。

最后修改:2025 年 08 月 27 日
如果觉得我的文章对你有用,请随意赞赏