UnityNFE(NetcodeForEntities)入门手记
前言
NetcodeForEntites是Unity基于DOTs框架搭建的高性能网络框架,相比NGO,它除了可以容纳人数大于NGO(NGO支持十多人,NFE支持上百人),还有自己的预测回滚系统,并支持回放系统等,缺点是学习路径陡峭,需要了解ECS系统,且中文文档较少。
基础系统
一个状态网络框架,最基础的功能我认为有以下几点:允许客户端接入,将所需变量同步过去,利用Rpc发送消息,这些概念在NGO或虚幻的网络框架都有涉及
允许客户端接入
建立服务端与客户端世界
在NFE中,我们需要重写ClientServerBootstrap脚本来指定连接端口并连接
[UnityEngine.Scripting.Preserve]
public class GameBootstrap : ClientServerBootstrap
{
public override bool Initialize(string defaultWorldName)
{
AutoConnectPort = 7979;
return base.Initialize(defaultWorldName);
}
}
这里能指定端口号,同时创建两个World(服务端World与客户端World),World是ECS中的概念,类似实体容器,存储实体和系统等。
建立连接
重写完这个脚本后,我们需要建立连接,因为我们只是创建了世界,客户端和服务端名没有连接到一起。
建立连接主要依赖两个组件,NetworkId与NetworkStreamInGame,NetworkId是每一个客户端世界中的唯一单例,用于建立和服务端的连接,而NetworkStreamInGame组件则是连接上的证明,当客户端和服务端上NetworkId相同的实体上都有该组件,则证明已经进入游戏。
至于客户端与服务端通信,则需要通过Rpc组件,即SendRpcCommandRequest和ReceiveRpcCommandRequest组件,SendRpcCommandRequest的TargetConnection字段用于标记需要发信息的客户端,通常我们放到带NetworkId的实体上,而服务端通过ReceiveRpcCommandRequest的SourceConnection字段取到对应的客户端。注意你在客户端创建Send之后,会自动在服务端创建Receive,具体流程如下:
找到客户端的NetworkId实体(以下简称客户端实体)->为它添加NetworkStreamInGame组件->创建一个实体为它加上SendRpcCommandRequest组件发Rpc,TargetConnection设为客户端实体->服务端一直监听带ReceiveRpcCommandRequest的实体->为ReceiveRpcCommandRequest的SourceConnection实体添加NetworkStreamInGame->销毁Rpc实体或组件防止多次触发
具体代码如下
//客户端请求进入游戏的命令,这里用作标签,也可以附加参数
public struct GoInGameRequest : IRpcCommand
{
}
//客户端处理进入游戏的系统
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation|
WorldSystemFilterFlags.ThinClientSimulation)]
partial struct GoInGameClientSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
//只在有NetworkId且没有NetworkStreamInGame组件的实体存在时更新
state.RequireForUpdate();
var builder =new EntityQueryBuilder(Allocator.Temp)
.WithAll()
.WithNone();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ecb=new EntityCommandBuffer(Allocator.Temp);
//找到没有进入游戏的连接实体,添加进入游戏组件,并发送进入游戏请求
foreach (var(id,entity)in SystemAPI.Query>()
.WithEntityAccess().WithNone())
{
ecb.AddComponent(entity);
var req=ecb.CreateEntity();
ecb.AddComponent(req);
ecb.AddComponent(req,new SendRpcCommandRequest
{
TargetConnection=entity
});
}
ecb.Playback(state.EntityManager);
}
}
//服务器处理进入游戏的系统
[BurstCompile]
//只在服务器模拟世界中运行
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
partial struct GoInGameServerSystem : ISystem
{
//用于从实体获取NetworkId组件
private ComponentLookup networkIdFromEntity;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
var builder =new EntityQueryBuilder(Allocator.Temp)
.WithAll()
.WithAll();
state.RequireForUpdate(state.GetEntityQuery(builder));
networkIdFromEntity=state.GetComponentLookup(true);
}
public void OnUpdate(ref SystemState state)
{
var prefab=SystemAPI.GetSingleton().CubeToSpawner;//获取玩家实体
state.EntityManager.GetName(prefab,out var prefabName );
var worldName=state.WorldUnmanaged.Name;
var ecb=new EntityCommandBuffer(Allocator.Temp);
networkIdFromEntity.Update(ref state);
//处理所有收到进入游戏请求的实体
foreach (var(reqSrc,entity)in SystemAPI.Query>()
.WithEntityAccess()
.WithAll())
{
//添加NetworkStreamInGame组件,表示该连接已进入游戏状态
ecb.AddComponent(reqSrc.ValueRO.SourceConnection);
var networkId=networkIdFromEntity[reqSrc.ValueRO.SourceConnection];
var player=ecb.Instantiate(prefab);
ecb.DestroyEntity(entity);
}
ecb.Playback(state.EntityManager);
}
}
玩家的生成
基本上,在游戏中利于预制体生成玩家与ECS基本没有区别,都是可以用ecb或EntityManager的Instantiate方法生成玩家,不过在生成时我们需要对预制体进行处理,为它添加GhostAuthoringComponent组件,这会将它标记为在网络中同步的实体,类似NGO的NetworkObject,此外该组件还有一些变量可以设置,比如将SupportedGhostModes改为预测(本地玩家)或插值(其他玩家),HasOwner(是否被某个玩家拥有)等
当然,一个可操作角色被创建出来,既不会自动被玩家拥有,也不会在断开连接时销毁,我们只需要两行代码即可实现
//设置GhostOwner组件以关联玩家和实体,设置所有权
ecb.SetComponent(player,new GhostOwner() { NetworkId=networkId.Value});
//加入LinkedEntityGroup以确保传输整个实体及其子实体,断开连接时一并销毁
ecb.AppendToBuffer(reqSrc.ValueRO.SourceConnection,new
LinkedEntityGroup{Value=player});
将这两行代码添加到上面的服务器系统中的合适位置,即可赋予玩家所有权和在退出游戏时销毁对应Ghost实体
读取玩家的输入
想要让玩家操作自己的角色,主要分成两步,用输入组件监听玩家按键输入,用对应系统映射玩家输入并修改对应的值
public struct PlayerInput : IInputComponentData
{
public float2 MoveInput;
public InputEvent JumpEvent;
public InputEvent FireEvent;
}
注意瞬时输入用InputEvent,按下时调用Set方法,检查时用IsSet检查即可
另外用一个输入系统监听玩家按键输入,再用一个玩家移动系统改变玩家实体的位置(LocalTransform)即可,基本上与ECS差不多,唯一有区别的时你只能操作拥有的角色,我们再上面展示了GhostOwner的创建,当创建了这个组件,在对应的客户端的实体上会加上GhostOwnerIsLocal的组件。
进阶
部分零碎知识
WorldSystemFilter时用来区分世界的,添加到系统上,用于区分只在客户端上执行的系统,只在服务端上执行的系统。
各系统组执行顺序:GhostInputSystemGroup → PredictedSimulationSystemGroup → ServerSimulation → PresentationSystemGroup
第一个用于采集输入,第二个用于预测,第三个用于服务端做权威判断,第四个用于处理表现层
GhostField用于添加到需要同步的组件变量上,同步服务端和客户端的值
预测回滚系统
预测回滚是为了处理玩家输入的延迟问题,一般情况下,只有服务端能够修改玩家的位置等权威变量,而预测回滚是直接在客户端先预测玩家位置并修改,随后服务端再计算一次,如果差距不大就忽视,否则就进行回滚,即偷偷将变量修改成服务端算的值。
该系统需要依赖NFE的NetworkTime单例组件,它其中有几个重要变量:
- InputTargetTick,比当前服务端早几帧,提前预测位置
- ServerTick,服务端权威帧,算出来的即为权威位置
- IsFirstTimeFullyPredictingTick,bool变量,判断是否是第一次传送数据过来,因为服务端可能在几帧内都传数据过来,导致多次播放音效或动画
当然,既然需要预输入帧与权威帧进行比较,我们当然需要一个容器来保存输入,这时,我们可以用ICommandData来存储,ECS会自动创建对应的缓冲区DynamicBuffer,我们只需要添加或移除每帧数据即可。下面展示一个简单的移动预测回滚
所需组件如下:
//CommandData存储每帧的结果,用于实现环形缓冲区,预测和回滚
public struct MoveCommand : ICommandData
{
//必须实现 ICommandData 接口
public NetworkTick Tick { get;set; }
public float2 MoveInput;
public float3 PredictedPosition; //预测后的位置
public float3 PredictedVelocity; //预测后的速度
}
public struct PlayerInput : IInputComponentData
{
public float2 MoveInput;
public InputEvent JumpEvent;
public InputEvent FireEvent;
}
//玩家历史输入组件,用于记录之前的输入
public struct PlayerInputHistory :IBufferElementData
{
public NetworkTick Tick; //输入对应的网络Tick
public float2 MoveInput; //移动输入
public InputEvent JumpEvent;
public InputEvent FireEvent;
}
public struct PlayerPosition : IComponentData
{
[GhostField]
public float3 Value;
}
public struct PlayerVelocity : IComponentData
{
//保留2位小数同步
[GhostField(Quantization =100)]
public float3 Value;
}
之后是对应的系统
[UpdateInGroup(typeof(GhostInputSystemGroup))]
partial struct PlayerInputSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var networkTime = SystemAPI.GetSingleton();
var targetTick = networkTime.InputTargetTick;
foreach(var (historyBuffer,input) in SystemAPI.Query
,RefRW>()
.WithAll())
{
var moveInput=new float2
(
Input.GetAxis("Horizontal"),
Input.GetAxis("Vertical")
);
bool isFirePressed=Input.GetMouseButtonDown(0);
bool isJumpPressed=Input.GetKeyDown(KeyCode.Space);
input.ValueRW.MoveInput=moveInput;
if (isFirePressed)
{
input.ValueRW.FireEvent.Set();
}
if (isJumpPressed)
{
input.ValueRW.JumpEvent.Set();
}
PlayerInputHistory history = new PlayerInputHistory()
{
Tick= targetTick,
MoveInput = moveInput,
};
if (isFirePressed)
{
history.FireEvent.Set();
}
if (isJumpPressed)
{
history.JumpEvent.Set();
}
historyBuffer.Add(history);
}
}
这个系统做的事情很简单,记录在InputTargetTick帧的输入,并同时写入玩家输入与历史输入
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
partial struct PlayerMovePredictSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var networkTime= SystemAPI.GetSingleton();
var currentTick=networkTime.InputTargetTick;//预测的帧
var ecbSingleton=SystemAPI.GetSingleton();
var ecb=ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
foreach(var (position,velocity,inputBuffer,commandBuffer,entity)in
SystemAPI.Query<
RefRW,
RefRW,
DynamicBuffer,
DynamicBuffer
>().WithAll().WithEntityAccess())
{
float2 currentInput= float2.zero;
bool hasInput = false;
foreach(var input in inputBuffer)
{
if(input.Tick==currentTick)
{
currentInput=input.MoveInput;
hasInput=true;
break;
}
}
//没有找到对应的输入,保持速度
float deltaTime = SystemAPI.Time.DeltaTime;
float speed = 5f;
float3 newVelocity=new float3(currentInput.x * speed,0,currentInput.y * speed);
float3 newPosition=position.ValueRO.Value + newVelocity * deltaTime;
if(newPosition.y<0)
{
newPosition.y=0;
}
velocity.ValueRW.Value=newVelocity;
position.ValueRW.Value=newPosition;
StoreCommand(commandBuffer,currentTick,currentInput,newVelocity,newPosition);
if(networkTime.IsFirstTimeFullyPredictingTick&&hasInput&&
math.lengthsq(currentInput)>0)
{
//第一次预测这个帧时才播放音效,避免多次播放
PlayFootstepEffect(newPosition);
}
}
}
private void StoreCommand(DynamicBuffer buffer, NetworkTick tick, float2 input, float3 velocity, float3 position)
{
for(int i=0;i64)
{
buffer.RemoveAt(0);
}
}
private void PlayFootstepEffect(float3 newPosition)
{
}
}
这个是预测系统,主要检查在对应的TargetInputTick时有没有输入,并将输入写入缓冲区,同时限制缓冲区的大小,同时修改玩家的速度和位置等,起到预测位置的作用
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
partial struct PlayerMoveServerSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate(); // ✅ 同步来的输入
state.RequireForUpdate();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var networkTime = SystemAPI.GetSingleton();
var currentTick = networkTime.ServerTick; // 权威帧
float deltaTime = SystemAPI.Time.DeltaTime;
float speed = 5f;
// 查询 PlayerInput(NetCode 同步的),不是 PlayerInputHistory
foreach (var (input, position, velocity) in
SystemAPI.Query, RefRW, RefRW>()
.WithAll())
{
float2 currentInput = input.ValueRO.MoveInput;
// 权威模拟
float3 newVelocity = new float3(currentInput.x * speed, 0, currentInput.y * speed);
float3 newPosition = position.ValueRO.Value + newVelocity * deltaTime;
// 简单地面碰撞
if (newPosition.y < 0) newPosition.y = 0;
velocity.ValueRW.Value = newVelocity;
position.ValueRW.Value = newPosition;
}
}
}
接下来是服务端的权威判断,注意这里的时间是ServerTick,直接修改玩家的坐标,带有GhostField的字段会平滑处理
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
partial struct PlayerRollbackSystem : ISystem
{
private NetworkTick lastServerTick;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
state.RequireForUpdate();
lastServerTick = default;
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var networkTime = SystemAPI.GetSingleton();
var currentTick = networkTime.ServerTick;
// 检测是否发生了回滚(tick倒退)或正在重模拟
bool isRollingBack = lastServerTick != default
&& !currentTick.IsNewerThan(lastServerTick);
bool isResimulating = !networkTime.IsFirstTimeFullyPredictingTick;
// 只在回滚或重模拟时处理
if (!isRollingBack && !isResimulating)
{
lastServerTick = currentTick;
return;
}
foreach (var (position, velocity, commandBuffer) in
SystemAPI.Query, RefRW,
DynamicBuffer>()
.WithAll())
{
// 查找当前tick的预测记录
MoveCommand? currentCommand = null;
for (int i = 0; i < commandBuffer.Length; i++)
{
if (commandBuffer[i].Tick == currentTick)
{
currentCommand = commandBuffer[i];
break;
}
}
// 如果没找到记录,清理后续历史并跳过(数据缺失)
if (!currentCommand.HasValue)
{
CleanupFutureHistory(commandBuffer, currentTick);
continue;
}
float3 serverPos = position.ValueRO.Value; // NetCode已应用快照
float3 predictedPos = currentCommand.Value.PredictedPosition;
float error = math.distance(predictedPos, serverPos);
// 误差分级处理
if (error < 0.1f)
{
// 小误差,接受服务器值,只需清理历史
CleanupFutureHistory(commandBuffer, currentTick);
}
else if (error > 2.0f)
{
// 大误差:瞬移并清空历史
position.ValueRW.Value = serverPos;
velocity.ValueRW.Value = float3.zero;
commandBuffer.Clear();
}
else
{
// 中等误差:瞬移但保留有效历史
position.ValueRW.Value = serverPos;
CleanupFutureHistory(commandBuffer, currentTick);
}
}
lastServerTick = currentTick;
}
private void CleanupFutureHistory(DynamicBuffer buffer, NetworkTick tick)
{
// 删除比当前tick更新的所有记录
for (int i = buffer.Length - 1; i >= 0; i--)
{
if (buffer[i].Tick.IsNewerThan(tick))
{
buffer.RemoveAt(i);
}
}
}
}
之后是回滚系统,将数据历史与服务端权威进行判断,如果差距过大就自行插值修改
//GhostInputSystemGroup → PredictedSimulationSystemGroup → ServerSimulation → PresentationSystemGroup
//在Presentation阶段更新玩家视觉表现
[UpdateInGroup(typeof(PresentationSystemGroup))]
partial struct PlayerVisualSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var networkTime = SystemAPI.GetSingleton();
// 1. 远程玩家(插值表现)
foreach (var (position, ghostOwner) in
SystemAPI.Query, RefRO>()
.WithNone()) // 非本地玩家
{
// 使用 GhostSnapshotSystem 自动插值的位置
// 或者手动实现平滑跟随
SmoothVisualUpdate(position.ValueRO.Value);
}
// 2. 本地玩家(预测表现,需要平滑处理)
foreach (var position in
SystemAPI.Query>()
.WithAll())
{
// 使用预测位置,但相机跟随需要平滑
SmoothCameraFollow(position.ValueRO.Value);
}
}
private void SmoothCameraFollow(float3 value)
{
}
private void SmoothVisualUpdate(float3 value)
{
}
}
最后的表现系统只有空壳,你可以在这里根据PlayerPosition组件的值确定玩家的位置等
结语
NFE的功能强大,但是学习曲线陡峭,请酌情考虑






