做局域网联机这个需求,最初我以为就是两个客户端互发坐标而已,一台电脑做服务器,其余客户端连上来,回合制发点数据就行。真正动手才发现,Unity里局域网联机的坑比想象中多得多——旧教程还在推UNET,新项目已经全部换Netcode for GameObjects,两者API完全不兼容;好不容易连上了,防火墙又把UDP端口拦了;连上了,角色却穿模或者瞬移;再往下深挖,TickRate、网络变量权限、动态生成的Spawn权限,每个地方都能卡你半天。
这篇内容基于Unity 2021.3 LTS加Netcode for GameObjects 1.x,从方案选型讲到环境配置,从移动同步Demo写到房间自动发现,再把联机调试里我实际踩过的六个问题完整拆一遍,最后给出一套适用于局域网场景的带宽和延迟优化思路。适合第一次做局域网联机的Unity开发者,也适合正在评估“用官方方案还是第三方插件”的团队参考。
1. 从需求出发:你的局域网联机到底属于哪一派
1.1 同步粒度决定技术选型
局域网联机的第一件事不是打开编辑器写脚本,而是先回答一个问题:你要同步什么。
市面上所有联机方案本质上分成两条路线。第一种是状态同步,服务器是唯一权威源,所有角色的位置、血量、得分、拾取记录都由服务器计算并下发,客户端只负责表现。第二种是帧同步,服务器不计算游戏逻辑,它只收集每个客户端的操作指令,然后以固定的节奏把所有人的指令转发给所有客户端,每个客户端自己跑同一套逻辑,跑出相同的结果。
两者的适用场景差异很大。RPG、射击、生存建造这类不确定性强的玩法,状态同步更合适,因为每一帧都有大量随机事件,服务器做权威校验能避免玩家各跑各的结论。格斗游戏、RTS、部分休闲竞技,因为需要极其严格的输入公平性和历史回放能力,帧同步才是更主流的选择。
局域网和公网在这一点上没有本质区别,只是延迟从几十甚至几百毫秒降到了几毫秒。但这会带来一个新的倾向:很多人误以为局域网延迟低,就不需要设计同步策略了,实际上同步策略选错,局域网也会翻车。比如在状态同步方案里,如果代码写得像帧同步一样依赖每个客户端的本地运算,一旦某个设备的帧率波动,两台机器的物理引擎产生微小差异,几分钟后角色位置就会明显分叉。
1.2 权威服务器不是公网专用概念
很多初学者有一种错觉:局域网联机就是点对点,没有服务器。严格来说,多数局域网联机游戏确实不跑独立服,但“没有独立服”不等于“没有权威端”。
Netcode for Games的标准做法是Host模式,也就是Host端同时扮演服务器和客户端。Host拥有所有NetworkVariable的写权限,决定角色是否真的可以移动、攻击是否命中、物品是否被捡起来。其他客户端把操作意图通过ServerRpc发给Host,由Host逻辑校验后,再通过ClientRpc或NetworkVariable把结果同步出去。
这个设计看着多绕了一圈,但它解决了一个核心问题:一致性。如果所有客户端各自修改一个共享数据,A客户端改了血量,B客户端没改,后面所有逻辑都会乱套。局域网虽然只有几毫秒延迟,多绕一圈的代价微乎其微,但换来的数据一致性是巨大的。
所以我在做局域网Demo时也坚持这个架构:本地玩家操作自己的角色,移动的最终确认交给Host,Host在每帧计算完权威位置后再通过NetworkTransform同步给所有客户端。这套架构在局域网环境下完全感受不到额外时延,却能在后续扩展时避免大量并发问题。
1.3 现成网络方案横评:为什么我选了Netcode for GameObject
目前Unity环境下的局域网联机,主流选项基本是四个:UNET(已废弃)、Netcode for GameObjects(官方)、Mirror(社区方案)、Photon PUN 2(商业方案)。
先排除UNET。搜局域网联机教程,目前仍然能搜到大量UNET时代的代码,涉及NetworkManager、NetworkServer、NetworkClient这些类。在Unity 2021之后,这些类已经不在官方包里,强行找历史包接入,要么API不兼容,要么在IL2CPP打包时出现各种奇怪的报错。一句话:现在新项目再碰UNET,纯粹是给自己挖坑。
Mirror是当年HLAPI被砍之后由社区接棒维护的方案,社区活跃度高,文档和视频教程也很丰富,积累了非常多生产环境的踩坑总结。如果你的团队里有人特别熟悉Mirror的API,选它完全没有问题。
PUN 2则是一个商业化的可选项,跨平台消息服务做得成熟,但它的核心能力在云端,纯局域网场景需要额外依赖AppId和云端的房间配置服务,反而不纯粹。
我最终选择Netcode for GameObjects,理由很直接:官方维护、和Unity Transport传输层配合最紧密、API设计比较现代,NetworkVariable与RPC的概念分层清晰,对局域网场景来说足够用。它的短板也很明确——比较新,一些边界场景的文档还不够完善,遇到问题需要在Unity官方论坛和GitHub Issue里翻答案。
这段取舍在章节后面会反复出现,因为后面的每一个坑,几乎都和框架的边界行为有关。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建:Netcode for GameObjects的安装与NetworkManager配置细节
2.1 版本组合别乱配,Transport和Netcode必须同步
凡是做Unity联机的人,几乎都在Package Manager里撞过版本坑。Netcode本身只是一套API,真正的网络收发是由Unity Transport包执行的,这两个包的版本必须匹配。Netcode 1.x对应Transport 2.x,如果项目中同时混入了Transport 1.x和2.x,或者Netcode版本与Transport大版本不匹配,运行时会直接报DllNotFoundException或各种稀奇古怪的加载错误。
我建议把项目固定在Unity 2021.3 LTS或更高版本,如果你在用2022 LTS也可以。安装方式没什么特殊的:Window -> Package Manager,左上角切换到Unity Registry,搜索Netcode for GameObjects,直接安装。Unity会自动把配套的Unity Transport一起拉进来,正常情况下不需要手动单独安装Transport。
这里有一个很小的细节:安装后检查一下Project Settings里是否生成了Netcode for GameObjects的配置分页,如果没有,说明包没有正常加载,需要重启编辑器。我曾经在没有重启编辑器的情况下直接添加NetworkManager组件,结果Inspector面板一片空白,排查了半小时才知道是包没有完成重编译。
2.2 NetworkManager的初始化与连接参数
场景里新建一个空物体,命名为NetworkManager,挂上NetworkManager组件和Unity Transport组件,这是最标准的接法。
Unity Transport上有一个Server Listen Port,默认是9998。这个端口就是局域网内所有客户端要连接的目标端口,整个网络房间里所有人都要连同一个IP加这个端口。局域网场景里不需要配置URL或域名,没有DNS解析这回事,客户端只要知道IPv4地址和端口就能建立连接。
在NetworkManager的Inspector面板里,有一个Network Config区块,里面有几个参数直接影响到联机稳定性:
- Connection Timeout:默认10秒。在局域网环境里连接建连通常非常快,但如果网络不稳定、有防火墙拦截,客户端会卡在连接中状态很久。我一般会调成5秒,如果连不上就尽快让用户看到失败提示,而不是干等。
- Max Connections:默认100。不要以为这个值越大越好。局域网联机时,每个连接都会占用网络带宽和CPU资源,尤其是RPC广播场景,连接数翻倍,服务器端的处理压力会跟着翻倍。小型项目10到20个足够。
- Tick Rate:默认30,后续会专门展开。
还有一个容易被忽略的点:NetworkManager挂载的场景里,必须保证只有唯一一个NetworkManager对象,而且这个对象不要放在DontDestroyOnLoad列表里再实例化,否则会触发多个NetworkManager的重复初始化警告。
2.3 NetworkPrefabs列表的注册规则
联机场景中,动态生成的角色、子弹、掉落物,都不是各自客户端自己想生成就能生成的。Netcode要求所有动态生成的Prefab必须先注册到NetworkManager的Network Prefabs列表里。
进入NetworkManager的Inspector,找到Network Prefabs列表,把玩家Prefab、子弹Prefab、拾取物Prefab全部拖进去。这一步很容易被遗忘,一旦漏注册,运行时会报Prefab not in NetworkPrefabs List,客户端连接后无法正常生成物体。
场景内置的静态NetworkObject不需要注册到Prefab列表,它们只需要在场景中保留NetworkObject组件,由框架在场景加载时自动注册追踪。这种区分在场景搭建时很容易搞混——同样是箱子,放在场景里的是静态物体,动态生成的则要进Prefab列表,而且两者在规范上最好分开,不要用同一个Prefab既放场景又动态生成,否则在Spawn时会引发GlobalObjectIdHash冲突。
我在实际项目里踩过这个坑。当时一个补给箱Prefab既放在了场景初始位置,又会在玩家开启后动态生成,结果场景加载时提示重复的GlobalObjectIdHash,排查了很久才发现是同一个Prefab被两种方式同时使用导致的。
3. 从零写一个可局域网联机的移动同步Demo
3.1 玩家Prefab与NetworkTransform的正确接法
先创建一个空Prefab,名字叫PlayerRoot,挂上三个组件:CharacterController、NetworkObject、NetworkTransform,再挂一个自定义的PlayerController脚本。
CharacterController负责本地的物理移动和碰撞检测。NetworkObject负责把当前物体纳入网络同步体系。NetworkTransform负责把物体的Transform变化同步给网络中的其他客户端。
NetworkTransform是一个容易误操作的地方。它有好几种同步模式,默认的Transform同步会把position、rotation、scale全都同步。对于玩家角色来说,如果你用的是CharacterController,不要让NetworkTransform去同步scale,否则Unity会持续警告CharacterController与Transform的同步冲突。更稳妥的做法是在NetworkTransform上关掉Scale同步,只保留Position和Rotation。
PlayerController脚本的核心是所有权判断。所有客户端都能同步到PlayerRoot的位置,但只有这个物体的拥有者(也就是创建它的那位玩家)才被允许控制它。判断方式是NetworkBehaviour的IsOwner属性。
csharp复制using UnityEngine;
using Unity.Netcode;
public class PlayerController : NetworkBehaviour
{
public float moveSpeed = 5f;
public float rotateSpeed = 720f;
void Update()
{
if (!IsOwner) return;
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
Vector3 inputDir = new Vector3(h, 0f, v).normalized;
if (inputDir.magnitude < 0.01f) return;
// 更新朝向
Quaternion targetRot = Quaternion.LookRotation(inputDir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRot, rotateSpeed * Time.deltaTime);
// 更新位置
transform.position += inputDir * moveSpeed * Time.deltaTime;
}
}
这段代码看起来和单机游戏几乎一样,唯一的不同是开头那一行 IsOwner 判断。但它的同步逻辑完整依赖NetworkTransform。本地玩家改了Transform,NetworkTransform检测到变化,通过网络发送给服务器,服务器再广播给其他客户端。另一台机器的玩家看到的,就是这个角色在平滑移动。
3.2 用NetworkVariable同步玩家昵称和颜色
移动只是开始,大多数联机项目还需要同步玩家的个性化信息,比如昵称、头像、颜色。这些低频但需要全局一致的数据,适合用NetworkVariable实现。
NetworkVariable的核心特性有三个:值对所有人可见、默认只有服务器可以写、当值变化时自动同步给所有客户端。因为默认只有服务器能写,所以客户端想修改自己的昵称时,不能直接给NetworkVariable赋值,必须调用ServerRpc,让服务器来改。
csharp复制using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
public class PlayerInfo : NetworkBehaviour
{
public NetworkVariable<FixedString32Bytes> playerName = new NetworkVariable<FixedString32Bytes>("Player");
public NetworkVariable<Color> playerColor = new NetworkVariable<Color>(Color.white);
[ServerRpc(RequireOwnership = true)]
public void SetAppearanceServerRpc(string name, Color color)
{
playerName.Value = new FixedString32Bytes(name);
playerColor.Value = color;
}
}
这里有个性能层面的注意点。不要直接用NetworkVariable
当服务器把playerName和playerColor改完之后,所有客户端会收到OnValueChanged回调。在回调里把UI文本和材质颜色更新一下,就能实现所有人同步看到昵称和颜色变化。
需要注意的是,NetworkVariable的写入权限默认是ServerOnly,也就是只有服务器能写。如果某个客户端直接给playerName赋值,运行时报错。这是很多新手容易踩的地方。
3.3 客户端连入时的动态生成与所有权分配
玩家点击Host按钮启动服务器和本地客户端,点击Client按钮作为客户端连接到其他房间。连接成功后,服务器需要为这个新玩家生成一个角色。
Netcode里生成网络物体必须调用NetworkObject.Spawn或者它的变体。在Host模式里,服务器端可以直接生成玩家Prefab,并把所有权分配给刚连入的客户端。
csharp复制using Unity.Netcode;
using UnityEngine;
public class ConnectionManager : NetworkBehaviour
{
public GameObject playerPrefab;
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
NetworkManager.Singleton.OnClientConnectedCallback += OnClientConnected;
NetworkManager.Singleton.OnClientDisconnectedCallback += OnClientDisconnected;
}
private void OnClientConnected(ulong clientId)
{
if (!IsServer) return;
GameObject player = Instantiate(playerPrefab, new Vector3(clientId * 2f, 1f, 0f), Quaternion.identity);
NetworkObject netObj = player.GetComponent<NetworkObject>();
netObj.SpawnAsPlayerObject(clientId, true);
}
private void OnClientDisconnected(ulong clientId)
{
if (!IsServer) return;
Debug.Log($"Client {clientId} disconnected");
}
}
这段代码里最关键的是SpawnAsPlayerObject。它做了三件事:把Prefab实例化到网络场景中、给这个实例分配对应的客户端所有权、把这个物体标记为玩家的PlayerObject。拥有者客户端会收到OnNetworkSpawn回调,并可以通过IsOwner判断这是不是自己的角色。
每个客户端的PlayerController会在OnNetworkSpawn里判断IsOwner,如果为真,就启用本地摄像机;如果为假,就保持摄像机禁用。这个逻辑不复杂,但很多人会在场景里直接放一台主摄像机,然后所有客户端都共享它的画面,导致所有玩家都在用同一个视角,整个联机同步看起来毫无意义。
3.4 RPC的调用边界:谁该发、谁该收
NetworkVariable适合同步持续变化的状态,而RPC适合同步瞬间发生的事件。用前面的玩家信息同步来举例:如果玩家想发一条聊天消息,这就是一个典型的事件型同步,我们需要用RPC来完成。
Netcode里有两类RPC。ServerRpc由客户端发起、在服务器上执行;ClientRpc由服务器发起、在所有客户端上执行。还是以跳跃为例,正确的流程是:本地玩家按下跳跃键,调用一个ServerRpc告诉服务器“我想跳了”,服务器进行校验后,调用一个ClientRpc通知所有客户端。
csharp复制public class PlayerJump : NetworkBehaviour
{
[ServerRpc]
public void RequestJumpServerRpc()
{
// 服务器校验是否可以跳跃
if (!CanJump()) return;
JumpClientRpc();
}
[ClientRpc]
private void JumpClientRpc()
{
GetComponent<Animator>().SetTrigger("Jump");
}
}
新人在这个环节最常见的错误,是在客户端上直接修改Transform位置后,再向所有客户端广播“这个角色跳了”,试图省钱省力。这种做法会导致不可控的同步偏差。因为网络同步是有延迟的,每个客户端执行Jump的时间点都不一样,而且本地的跳跃动作和服务器权威位置是分离的,很快就会出现表现不一致。
局域网环境延时很低,但这个设计问题并不会因为延时低而消失。只要网络包到达的顺序有一点波动,不做服务器权威校验就会产生不稳定表现。
4. 局域网房间发现:自动搜房而不是手动输IP
4.1 手输IP的体验问题
如果游戏是给两个人做着玩的,手输IP确实是最省事的方案。但局域网联机通常发生在现场展会、教室、多人聚会等场景,让用户手动找到电脑IP再敲进文本框,体验非常糟糕。
更麻烦的是,很多人的电脑并不只有一张网卡。我见过一台机器同时有Wi-Fi、有线以太网、VMware虚拟网卡、Docker虚拟网卡,Windows会为每张网卡分配不同网段的IP。用户在路由器管理界面看到一个IP,游戏里看到的却是另一个IP,根本连不上。所以我认为,局域网联机项目宁可多花半天时间做一个自动发现功能,也不要让玩家手动输入IP。
4.2 用UDP广播实现局域网房间发现
Netcode本身没有提供广播发现的能力,我们需要写一个额外的UDP组件配合它。原理很简单:服务器在某个UDP固定端口监听,客户端往当前局域网的广播地址发送一条请求数据包,服务器收到后回一条包含自己IP和数据的信息,客户端拿到这个信息后向用户展示房间列表,用户点击房间后用这个IP去连接Netcode的TCP或UDP传输端口。
csharp复制using System;
using System.Collections;
using System.Net;
using System.Net.Sockets;
using System.Text;
using UnityEngine;
public class NetworkDiscovery : MonoBehaviour
{
public int discoveryPort = 47777;
public string gameTag = "LAN_DEMO_V1";
private UdpClient udpClient;
public event Action<IPEndPoint, string> OnReceiveResponse;
public void StartListening()
{
StopListening();
udpClient = new UdpClient(discoveryPort);
StartCoroutine(ReceiveLoop());
}
public void StopListening()
{
if (udpClient != null)
{
udpClient.Close();
udpClient = null;
}
}
public void SendBroadcastRequest()
{
using (UdpClient client = new UdpClient())
{
client.EnableBroadcast = true;
byte[] data = Encoding.UTF8.GetBytes(gameTag);
client.Send(data, data.Length, new IPEndPoint(IPAddress.Broadcast, discoveryPort));
}
}
private IEnumerator ReceiveLoop()
{
while (udpClient != null)
{
try
{
var task = udpClient.ReceiveAsync();
yield return new WaitUntil(() => task.IsCompleted);
UdpReceiveResult result = task.Result;
string msg = Encoding.UTF8.GetString(result.Buffer);
OnReceiveResponse?.Invoke(result.RemoteEndPoint, msg);
}
catch (Exception e)
{
Debug.LogWarning("Discovery receive error: " + e.Message);
yield break;
}
}
}
}
在服务器端的房间里,调用StartListening进入监听状态。在客户端搜索房间时,调用SendBroadcastRequest,同时也要进入监听状态,这样才能收到服务器的应答包。收到应答后,OnReceiveResponse会给出服务器的IPEndPoint,用它去设置Unity Transport的连接地址,再调用NetworkManager.StartClient。
4.3 UDP搜索在复杂网络环境下的坑
这套UDP广播搜索方案本身不复杂,但实际使用中有几个隐性前提。
第一个坑是广播隔离。同一个Wi-Fi下,如果路由器开启了AP隔离,客户端发往255.255.255.255的广播包不会到达服务器,服务器也收不到任何请求。遇到这种情况,单纯加大发送次数是没用的,要么在网络设置里关掉AP隔离,要么在代码里提供手动输入IP的兜底方案。手动输入IP虽然体验差,但它是最后的逃生通道,我建议无论如何都保留。
第二个坑是响应去重。一台机器上如果同时运行两个客户端,或者网卡配置比较复杂,同一个服务器可能会被搜索到多次。在房间里做IP去重是基本操作,使用HashSet记录已经返回的IPEndPoint即可。
第三个坑是多网卡绑定。在某些电脑上,默认的网络接口不是我们期望的那个有线网卡或Wi-Fi网卡,UdpClient可能会绑定到虚拟网卡上,导致收不到广播。这种情况下需要遍历NetworkInterface获取物理网卡,再绑定到具体的LocalEndPoint。代码会复杂一些,但在做展会项目时非常值得,因为展会现场的电脑环境各种奇形怪状,虚拟网卡基本上是标配。
5. 联机测试中的常见问题与排查链路
5.1 能Ping通对方,但游戏却连不上
这是一个出现频率极高的问题。两台电脑在同一局域网内,cmd里ping IP能通,但游戏里始终提示连接超时。
排查链路从端口开始。Netcode默认监听9998端口,但Windows防火墙很可能没有为这个UDP端口放行。Unity编辑器在第一次运行时会弹Windows安全警报,很多人随手点了个“允许访问”,但允许的往往是公共网络,如果你的两台电脑分属专用网络和公共网络,那依然会被拦截。
更稳妥的做法是直接给游戏的可执行文件添加防火墙入站规则,或者干脆在开发阶段暂时关闭防火墙做对比测试,确认是防火墙问题后再按规范配置例外。Windows下可以用PowerShell快速添加:
powershell复制netsh advfirewall firewall add rule name="MyLanGame" dir=in action=allow protocol=UDP localport=9998
这只是把UDP 9998和UDP 47777放行,实际部署时应该精确限制规则范围,而不是把整个网络全部打开。
5.2 连接成功,但看不到对方角色
这种问题的特征是:服务器日志显示客户端连上了,客户端也显示连上了,但场景里看不到对方的角色。
排查路径要区分两种情况。第一种是客户端生成了角色,但角色在别的坐标或相机看不到。这时检查PlayerPrefab的生成位置生成逻辑,比如我上面的代码把所有玩家都放在了ClientId乘以2的偏移位置,如果ClientId相差太大,出生点就可能超出了摄像机视野。改进方法是把出生点集中在一个很小的范围,而不是直接基于ClientId做算术。
第二种是客户端根本没有生成本地角色。排查Prefab是否在NetworkManager的Network Prefabs列表里注册,检查SpawnAsPlayerObject是否传了正确的ClientId。如果是Host模式,还要区分清楚客户端ID不等于玩家索引,不能想当然地用for循环去给所有客户端生成角色。
5.3 局域网连接稳定,但角色移动有明显卡顿瞬移
局域网内出现瞬移,问题大概率不在网络延迟,而在于NetworkTransform的插值设置。
Netcode的NetworkTransform在默认情况下,会把收到的位置数据做插值平滑。如果插值延迟设得太大,角色会明显滞后于操作;如果设得太小,又会出现抖动。更常见的情况是,开发者为了省事把NetworkTransform的插值关掉了,每帧直接硬同步位置,结果在网络波动时表现出肉眼可见的瞬移。
合理的做法是保留插值,同时把插值延迟调低。Netcode的NetworkTransform提供了InterpolationDelay选项,局域网环境下我习惯调到0.05到0.1秒之间,既能看到平滑移动,又不会让操作有明显的滞后感。本地玩家自己操控的角色不参与插值,直接走输入控制逻辑,远程角色才需要插值。
5.4 Android设备连不上PC端服务器
移动端联机还有一个特殊的东西:权限。PC端编辑器运行不受影响,打包成Android APK后,如果AndroidManifest里没有联网权限,连接会静默失败。
Unity默认打包的Android工程其实已经带了INTERNET权限,但如果你自定义过Manifest,或者用了某些第三方SDK把权限合并规则改乱了,就很容易把这条权限挤掉。
另一个和路由器AP隔离有关的问题也常出现在移动端。手机连的是Wi-Fi,PC连的是网线,这两个设备虽然在同一局域网,但路由器如果开启了“无线隔离”,PC和手机之间是完全不可见的。测试遇到手机连不上PC时,先不要怀疑代码,登录路由器后台查看AP隔离设置,顺手关掉再试。这一步能排查掉大量的“玄学问题”。
5.5 运行一段时间后掉线或卡死
最后一种高发问题不是连不上,而是连上后运行一段时间突然掉线。
我遇到过一个非常隐蔽的坑:客户端在后台切出去再切回来,UnityTransport的连接状态会发生变化,但UI层没有响应这种变化,玩家以为还连着,实际传输层已经断开。解决方案是在UI上监听NetworkManager的OnClientDisconnectCallback,及时把状态切换到断线重连页面。
还有一种是MTU问题。局域网环境虽然带宽充足,但单个UDP报文如果超过MTU,会被底层网络分片,分片丢失就会导致RPC消息不完整。Netcode内部有消息分片机制,但如果网络设备不支持分片重组,处理会非常慢,表现出来就是偶发卡顿和掉线。这种问题只在非常特殊的嵌入式网卡上遇到过,处理方法是调小发送消息体,避免一次性发送超大字节数组,尤其是在RPC参数里尽量不要拼接大量字符串。
6. 局域网也不是免死金牌:Tick、带宽与延迟的优化取舍
6.1 TickRate:决定同步频率的“心跳”
Netcode的NetworkConfig里有一个TickRate,默认是30。这个值代表网络系统每一秒处理多少个同步tick。TickRate越高,网络状态更新的频率就越高,角色移动越平滑,但CPU和带宽开销也越大。
局域网项目很容易犯一个错误:把TickRate调到60甚至90,觉得反正局域网带宽大。但实际上TickRate影响的不仅仅是带宽,还有服务器的CPU负载,尤其是RPC和NetworkVariable的变更检测都是在这个频率上执行的。30 TickRate对于局域网完全够用,追求极致流畅可以调到45到60,超过60后收益已经很低,反而会让服务器的逻辑负担翻倍。
6.2 带宽消耗的一个简单估算方法
局域网项目的带宽开销很容易被低估。我习惯通过一个简单公式来估算:单角色每秒带宽等于同步包大小乘以同步频率,再乘以角色数量。
以一个10人项目为例,每个角色每秒同步位置和旋转,NetworkTransform在30 TickRate下,大约每秒产生30个包,每个包按50字节计算,一个角色的同步流量是1.5KB/s,10个角色就是15KB/s。再加上RPC、状态变量和心跳,总带宽大概在20KB/s左右。这个数值在局域网环境下完全不是问题,但如果你在同步包里面塞了额外的数组或者大量字符串字段,一个包达到几百字节,同步频率再翻倍,带宽开销就会直线上升。
所以优化的核心不是减少TickRate,而是控制每个同步包的内容。NetworkTransform的同步字段尽量精简,RPC参数不要传递大对象,能用FixedString就不要用string,能用枚举就不要传字符串。
6.3 局域网该不该做插值和预测
很多教程会把网络同步优化直接等同于“做预测、做插值”,但这套组合在局域网环境下未必需要全套引入。
插值是必须的。远程角色的位置信息是离散的,如果没有插值,角色移动就会一卡一卡。Netcode的NetworkTransform已经内置了插值实现,直接配置好InterpolationDelay即可。预测则要看场景,本地玩家对自己的角色做输入驱动,不需要预测;远程玩家使用客户端预测加服务器校正的方案,在局域网延迟极低的情况下收益并不明显,反而增加了复杂度。所以我的原则是:局域网项目默认只开插值,预测功能等到真实延迟测试超过50ms再考虑引入。
6.4 高频RPC和GC的管理
最后聊一个和网络框架关系不大、但直接影响联机体验的点:GC。
联机场景里,每秒可能有大量RPC和NetworkVariable回调,如果这些回调内部频繁创建新对象,Unity的GC压力会很大,表现就是帧率不规律的掉帧,以及玩家操作延迟。如果玩家反馈“偶尔卡一下”,先去看看Profiler的GC Alloc曲线。
RPC回调里的字符串拼接是GC的主要来源。避免在回调里反复拼接日志字符串,避免在Update里动态创建List,避免每帧都new一个新的Vector3数组,这些在单机游戏里不是大问题,在联机场景下会被高频同步放大。用对象池管理子弹和特效,也是在联机项目中能立竿见影的性能手段。
说实话,做完这个局域网联机Demo,我最大的体会是:Netcode for GameObjects这套方案确实能跑,但前提是你理解它的设计边界。同步粒度决定了技术选型,服务器权威保证了一致性,NetworkVariable和RPC各有各的适用场景,自动搜房拯救了玩家体验,而防火墙、AP隔离、多网卡这些看似非代码的问题,反而占据了排查时间的大头。
如果你现在评估一个局域网联机需求,我的建议是先搭一个最小的移动同步Demo验证选型,别一上来就做房间列表、大厅、断线重连这些功能。只有把最基础的“我在你屏幕上能看到你的角色在动”跑通了,后面的功能才有地方挂靠。另一个建议是,把所有网络交互都做成可配置的,比如TickRate、发现端口、连接超时,都暴露到Inspector上,因为现场环境的多变程度,永远超过程序员的预期。
