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总结)
| 对比项 | P2P | Listen Server |
|---|---|---|
| 网络拓扑 | 全员互联 | 一主多客(星型,所有人连到 Host) |
| 权威 | 无固定权威,每人平等 | Host(Listen Server)是唯一权威 |
| 抗作弊 | 难(每人都能改状态) | 较好(Host 裁决) |
| NAT/防火墙问题 | 复杂(所有人需打洞) | 较简单(只需连 Host) |
| 容错 | 任意人掉线都影响同步 | Host 掉线整个房间崩溃 |
| 实现难度 | 高(帧锁、输入同步、共识算法) | 相对低(普通 C/S 架构) |
打洞、端口转发、中继服务器
由于基本上家庭网络都没有公网IP,借助CGNAT访问外网
- 你的设备(内网 IP):
192.168.0.100:12345发起一个请求 → 目标服务器203.0.113.10:80。 - 家用路由器 NAT:把
192.168.0.100:12345翻译成100.64.50.10:54321(运营商大私网地址)。随后建立映射表:192.168.0.100:12345 <-> 100.64.50.10:54321 - 运营商 CGNAT:把
100.64.50.10:54321翻译成公网203.0.113.77:62001。随后建立映射表:100.64.50.10:54321 <-> 203.0.113.77:62001 - 公网传输:数据包以源地址
203.0.113.77:62001的形式到达目标服务器203.0.113.10:80。 - 目标服务器:认为你就是
203.0.113.77:62001,于是回包发给这个地址端口。 - 运营商NAT存储了映射表,将包转发到
100.64.50.10:54321(运营商大私网地址) - 路由器NAT也存储了对应映射表,将包转发到
192.168.0.100:12345 - 你的设备接受回包实现上网
- 你的设备(内网 IP):
所以我们常用策略是打洞->(失败)中继服务,如果你有公网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会使用一个固定的端口和地址:
- A发送给中介服务器,中介服务器发现请求的地址是203.0.113.77:62001,告知B
- B发送给中介服务器,中介服务器发现请求的地址是203.0.113.78:62001,告知A
- A和B认为彼此知道对方的实际出口公网了
- 此时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,你设置路由器告诉7777 → 192.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)
- A 内网
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)
- B 内网
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 内网。
- 同理,B 发到 Relay → Relay 转发给 A 的公网地址
总结
根本在于家庭NAT的策略都是,你必须先出战,你的出口NAT才建立映射,别人直接打你,NAT无映射,直接丢包
- 打洞通过中介服务器(STUN),获得彼此真正的出口公网IP地址,双方同时发包,让彼此路由器NAT建立双方映射,直连,一个RTT,延迟很低
- Rely,通过一个中继服务器(TURN),中继服务器分别知道A-TURN 和B-TURN的NAT映射,且A和B的目标地址不变,即便采取对称NAT,CGNAT也不会改变出口公网IP,那么A和B通过TURN转发数据来实现彼此通信,两个RTT,延迟会高一些,但很稳定
所以基本策略就是
- 尝试打洞,打洞成功直接直连
- 打洞失败,走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经验,核心要点如下:
- 网络架构
- 采用服务器权威模式,客户端仅持有对象镜像
- 所有逻辑修改需通过ServerRPC由服务器验证执行
- RPC机制
- ServerRPC:客户端→服务器调用(可设置RequireOwnership权限)
- ClientRPC:服务器→客户端广播
- 示例展示了聊天系统的RPC调用链
- 关键概念
- ClientId:区分本地客户端标识
- OwnerID:标识网络对象归属权
- 同步控制:通过NetworkTransform等组件实现位置/动画同步
- 实践案例
- 玩家角色控制:本地玩家启用操作组件,远程玩家仅同步
- 权限管理:道具等私有物品需验证OwnerClientId
文章提供了2022LTS版本与Unity6.x的API差异说明,并强调服务器权威验证的重要性。
1 条评论
果博东方客服开户联系方式【182-8836-2750—】?薇- cxs20250806】
果博东方公司客服电话联系方式【182-8836-2750—】?薇- cxs20250806】
果博东方开户流程【182-8836-2750—】?薇- cxs20250806】
果博东方客服怎么联系【182-8836-2750—】?薇- cxs20250806】