1.前言
Unity FpsSample Demo大约是2018发布,用于官方演示MLAPI(NetCode前身)+DOTS的一个FPS多人对战Demo。
Demo下载地址(需要安装Git LFS) :https://github.com/Unity-Technologies/FPSSample
下载完成后3-40GB左右,若大小不对可能下载不完整。
时间原因写的并不完整,但大致描绘了项目的框架轮廓。
1.1.附带文档与主配置界面
在项目根目录可以找到附带的文档:
在项目中的Fps Sample/Windows/Project Tools处可以打开主配置界面:
其中打包AssetBundle的方式值得一提,因为在资源底部标记AssetBundle的方式非常的不方便,
FpsSample将AssetBunlde通过Hash值存到了ScriptableObject里,并且区分Server/Client,
服务端打包AssetBundle时将用一些资源及耗费性能较少的替代文件,客户端打包的AssetBundle
则是完整版本。
2.GameLoop
可参考文档SourceCode.md,不同的GameLoop决定当前游戏下的主循环逻辑:
游戏内的几种GameLoop分别对应如下:
- ClientGameLoop 客户端游戏循环
- ServerGameLoop 服务端游戏循环
- PreviewGameLoop 编辑器下执行关卡测试时对应的游戏循环(单机跑图模式)
- ThinClientGameLoop 调试用的轻量版客户端游戏循环,内部几乎没有System
2.1 GameLoop触发逻辑
游戏的入口是Game.prefab:
GameLoop接口定义在Game.cs中:
public interface IGameLoop { bool Init(string[] args); void Shutdown(); void Update(); void FixedUpdate(); void LateUpdate(); }
然后通过命令初始化所需要的GameLoop,内部会通过反射创建(Game.cs中):
voidCmdServe(string[] args){ RequestGameLoop(typeof(ServerGameLoop), args); Console.s_PendingCommandsWaitForFrames =1;}
IGameLoop gameLoop =(IGameLoop)System.Activator.CreateInstance(m_RequestedGameLoopTypes[i]);initSucceeded= gameLoop.Init(m_RequestedGameLoopArguments[i]);
3.网络运行逻辑
3.1 ClientGameLoop
先来看下ClientGameLoop,初始化会调用Init函数,NetworkTransport为Unity封装的网络层,
NetworkClient为上层封装,附带一些游戏逻辑。
publicboolInit(string[] args){ ... m_NetworkTransport =new SocketTransport(); m_NetworkClient =newNetworkClient(m_NetworkTransport);
3.1.1 NetworkClient内部逻辑
跟进去看下NetworkClient的结构,删了一些内容,部分接口如下:
publicclass NetworkClient{ ... publicbool isConnected { get; } public ConnectionState connectionState { get; } publicint clientId { get; } public NetworkClient(INetworkTransport transport) publicvoidShutdown()public void QueueCommand(int time, DataGenerator generator) public void QueueEvent(ushort typeId, bool reliable, NetworkEventGenerator generator) ClientConnection m_Connection;}
其中QueueCommand用于处理角色的移动、跳跃等信息,包含于Command结构中。
QueueEvent用于处理角色的连接、启动等状态。
3.1.2 NetworkClient外部调用
继续回到ClientGameLoop,在Update中可以看到NetworkClient的更新逻辑
publicvoid Update(){ Profiler.BeginSample("ClientGameLoop.Update"); Profiler.BeginSample("-NetworkClientUpdate");m_NetworkClient.Update(this, m_clientWorld?.GetSnapshotConsumer()); //客户端接收数据 Profiler.EndSample(); Profiler.BeginSample("-StateMachine update"); m_StateMachine.Update(); Profiler.EndSample(); // TODO (petera) change if we have a lobby like setup one dayif (m_StateMachine.CurrentState() == ClientState.Playing && Game.game.clientFrontend != null) Game.game.clientFrontend.UpdateChat(m_ChatSystem); m_NetworkClient.SendData(); //客户端发送数据
其中ClientGameLoop Update函数签名如下:
publicvoid Update(INetworkClientCallbacks clientNetworkConsumer, ISnapshotConsumer snapshotConsumer)
参数1用于处理OnConnect、OnDisconnect等消息,参数2用于处理场景中各类快照信息。
3.1.3 m_NetworkClient.Update
进入Update函数看下接收逻辑:
publicvoid Update(INetworkClientCallbacks clientNetworkConsumer, ISnapshotConsumer snapshotConsumer){ ... TransportEvent e =newTransportEvent();while(m_Transport.NextEvent(ref e)) { switch (e.type) { case TransportEvent.Type.Connect: OnConnect(e.connectionId); break;case TransportEvent.Type.Disconnect: OnDisconnect(e.connectionId); break;caseTransportEvent.Type.Data:OnData(e.connectionId, e.data, e.dataSize, clientNetworkConsumer, snapshotConsumer); break; } }}
可以看见具体逻辑处理在OnData中
3.1.4 m_NetworkClient.SendData
进入SendData函数,看下发送数据是如何处理的。
publicvoidSendPackage<TOutputStream>()where TOutputStream : struct, NetworkCompression.IOutputStream{ ...if (commandSequence > 0) { lastSentCommandSeq =commandSequence;WriteCommands(info,refoutput);}WriteEvents(info,ref output); int compressedSize = output.Flush(); rawOutputStream.SkipBytes(compressedSize); CompleteSendPackage(info, refrawOutputStream);}
可以看见,这里将之前加入队列的Command和Event取出写入缓冲准备发送。
3.2.ServerGameLoop
和ClientGameLoop一样,在Init中初始化Transport网络层和NetworkServer。
publicboolInit(string[] args){ // Set up statemachine for ServerGame m_StateMachine = newStateMachine<ServerState>(); m_StateMachine.Add(ServerState.Idle, null, UpdateIdleState, null); m_StateMachine.Add(ServerState.Loading, null, UpdateLoadingState, null);m_StateMachine.Add(ServerState.Active, EnterActiveState, UpdateActiveState, LeaveActiveState);m_StateMachine.SwitchTo(ServerState.Idle);m_NetworkTransport= new SocketTransport(NetworkConfig.serverPort.IntValue, serverMaxClients.IntValue); m_NetworkServer = new NetworkServer(m_NetworkTransport);
注意,其中生成快照的操作在状态机的Active中。
Update中更新并SendData:
publicvoidUpdate(){UpdateNetwork();//更新SQP查询服务器和调用NetWorkServer.Updatem_StateMachine.Update();m_NetworkServer.SendData();m_NetworkStatistics.Update();if (showGameLoopInfo.IntValue > 0) OnDebugDrawGameloopInfo();}
3.2.1 Server - HandleClientCommands
来看一下接收客户端命令后是如何处理的,在ServerTick函数内,调用
HandleClientCommands处理客户端发来的命令
publicclass ServerGameWorld : ISnapshotGenerator, IClientCommandProcessor{ ... publicvoid ServerTickUpdate() { ... m_NetworkServer.HandleClientCommands(m_GameWorld.worldTime.tick, this); }
publicvoidHandleClientCommands(int tick, IClientCommandProcessor processor){ foreach(varcin m_Connections) c.Value.ProcessCommands(tick, processor);}
然后反序列化,加上ComponentData交给对应的System处理:
publicclass ServerGameWorld : ISnapshotGenerator, IClientCommandProcessor{
...publicvoidProcessCommand(intconnectionId,inttick,ref NetworkReader data) {
...if (tick ==m_GameWorld.worldTime.tick)client.latestCommand.Deserialize(ref serializeContext, ref data);if (client.player.controlledEntity != Entity.Null) { var userCommand = m_GameWorld.GetEntityManager().GetComponentData<UserCommandComponentData>( client.player.controlledEntity); userCommand.command =client.latestCommand;m_GameWorld.GetEntityManager().SetComponentData<UserCommandComponentData>( client.player.controlledEntity,userCommand); } }
4.Snapshot
4.1 Snapshot流程
项目中所有的客户端命令都发到服务器上执行,服务器创建Snapshot快照,客户端接收Snapshot快照同步内容。
Server部分关注ReplicatedEntityModuleServer和ISnapshotGenerator的调用:
publicclass ServerGameWorld : ISnapshotGenerator, IClientCommandProcessor{ public ServerGameWorld(GameWorld world, NetworkServer networkServer, Dictionary<int, ServerGameLoop.ClientInfo> clients, ChatSystemServer m_ChatSystem, BundledResourceManager resourceSystem) { ... m_ReplicatedEntityModule =new ReplicatedEntityModuleServer(m_GameWorld, resourceSystem, m_NetworkServer); m_ReplicatedEntityModule.ReserveSceneEntities(networkServer); } publicvoid ServerTickUpdate() { ... m_ReplicatedEntityModule.HandleSpawning(); m_ReplicatedEntityModule.HandleDespawning(); } publicvoidGenerateEntitySnapshot(intentityId,ref NetworkWriter writer) { ... m_ReplicatedEntityModule.GenerateEntitySnapshot(entityId, ref writer); } publicstringGenerateEntityName(int entityId) { ... return m_ReplicatedEntityModule.GenerateName(entityId); }}
Client部分关注ReplicatedEntityModuleClient和ISnapshotConsumer的调用:
foreach(varidinupdates){var info = entities[id]; GameDebug.Assert(info.type !=null,"Processing update of id {0} but type is null", id); fixed(uint* data = info.lastUpdate) { var reader = new NetworkReader(data, info.type.schema); consumer.ProcessEntityUpdate(serverTime, id, ref reader); }}
4.2 SnapshotGenerator 流程
在ServerGameLoop中调用快照创建逻辑:
publicclass ServerGameWorld : ISnapshotGenerator, IClientCommandProcessor{ void UpdateActiveState() { int tickCount = 0;while (Game.frameTime > m_nextTickTime) { tickCount++; m_serverGameWorld.ServerTickUpdate(); ... m_NetworkServer.GenerateSnapshot(m_serverGameWorld, m_LastSimTime);}
在Server中存了所有的实体,每个实体拥有EntityInfo结构,结构存放了snapshots字段。
遍历实体并调用GenerateEntitySnapshot接口生成实体内容:
unsafepublicclassNetworkServer{unsafepublicvoid GenerateSnapshot(ISnapshotGenerator snapshotGenerator, float simTime) { ... // Run through all the registered network entities and serialize the snapshotfor(var id = 0; id < m_Entities.Count; id++) { var entity = m_Entities[id]; EntityTypeInfo typeInfo; bool generateSchema = false;if(!m_EntityTypes.TryGetValue(entity.typeId,out typeInfo)) { typeInfo =new EntityTypeInfo() { name = snapshotGenerator.GenerateEntityName(id), typeId = entity.typeId, createdSequence = m_ServerSequence, schema = new NetworkSchema(entity.typeId + NetworkConfig.firstEntitySchemaId) }; m_EntityTypes.Add(entity.typeId, typeInfo); generateSchema =true; } // Generate entity snapshotvar snapshotInfo = entity.snapshots.Acquire(m_ServerSequence); snapshotInfo.start = worldsnapshot.data +worldsnapshot.length;var writer = new NetworkWriter(snapshotInfo.start, NetworkConfig.maxWorldSnapshotDataSize / 4- worldsnapshot.length, typeInfo.schema, generateSchema); snapshotGenerator.GenerateEntitySnapshot(id, ref writer); writer.Flush(); snapshotInfo.length = writer.GetLength();
4.3 SnapshotConsumer 流程
在NetworkClient的OnData中处理快照信息
case TransportEvent.Type.Data: OnData(e.connectionId, e.data, e.dataSize, clientNetworkConsumer, snapshotConsumer);break;
对应的处理函数:
publicvoidProcessEntityUpdate(intserverTick,intid,ref NetworkReader reader){ var data = m_replicatedData[id]; GameDebug.Assert(data.lastServerUpdate < serverTick, "Failed to apply snapshot. Wrong tick order. entityId:{0} snapshot tick:{1} last server tick:{2}", id, serverTick, data.lastServerUpdate); data.lastServerUpdate = serverTick; GameDebug.Assert(data.serializableArray !=null,"Failed to apply snapshot. Serializablearray is null");foreach(varentryin data.serializableArray) entry.Deserialize(ref reader, serverTick); foreach(varentryin data.predictedArray) entry.Deserialize(ref reader, serverTick); foreach(varentryin data.interpolatedArray) entry.Deserialize(ref reader, serverTick); m_replicatedData[id] =data;}
5.游戏模块逻辑
5.1 ECS System扩展
BaseComponentDataSystem.cs类中包含了各类System基类扩展:
- BaseComponentSystem<T1 - T3> 筛选出泛型MonoBehaviour到ComponentGroup,但忽略已销毁的对象(DespawningEntity),可以在子类中增加IComponentData筛选条件
- BaseComponentDataSystem<T1 - T5> 筛选出泛型ComponentData,其余与BaseComponentSystem一致
- InitializeComponentSystem<T> 筛选T类型的MonoBehaviour然后执行Initialize函数,确保初始化只执行一次
- InitializeComponentDataSystem<T,K> 为每个包含ComponentData T的对象增加ComponentData K,确保初始化只执行一次
- DeinitializeComponentSystem<T> 筛选包含MonoBehaviour T和已销毁标记的对象
- DeinitializeComponentDataSystem<T> 筛选包含ComponentData T和已销毁标记的对象
- InitializeComponentGroupSystem<T,S> 同InitializeComponentSystem,但标记了AlwaysUpdateSystem
- DeinitializeComponentGroupSystem<T> 同DeinitializeComponentSystem,但标记了AlwaysUpdateSystem
5.2 角色创建
以编辑器下打开Level_01_Main.unity运行为例。
运行后会进入EditorLevelManager.cs触发对应绑定的场景运行回调:
[InitializeOnLoad]publicclassEditorLevelManager{static EditorLevelManager() { EditorApplication.playModeStateChanged += OnPlayModeStateChanged; } ... staticvoid OnPlayModeStateChanged(PlayModeStateChange mode) { if (mode == PlayModeStateChange.EnteredPlayMode) { ... caseLevelInfo.LevelType.Gameplay: Game.game.RequestGameLoop( typeof(PreviewGameLoop), new string[0]); break; } }
在PreviewGameLoop中写了PreviewGameMode的逻辑,在此处若controlledEntity为空则触发创建:
publicclass PreviewGameMode : BaseComponentSystem {...protectedoverridevoidOnUpdate(){if (m_Player.controlledEntity == Entity.Null) { Spawn(false);return; }}
最后调到此处进行创建:
CharacterSpawnRequest.Create(PostUpdateCommands, charControl.characterType, m_SpawnPos, m_SpawnRot, playerEntity);
在创建后执行到CharacterSystemShared.cs的HandleCharacterSpawn时,会启动角色相关逻辑:
publicstaticvoid CreateHandleSpawnSystems(GameWorld world,SystemCollection systems, BundledResourceManager resourceManager, boolserver){systems.Add(world.GetECSWorld().CreateManager<HandleCharacterSpawn>(world, resourceManager, server)); // TODO (mogensh) needs to be done first as it creates presentationsystems.Add(world.GetECSWorld().CreateManager<HandleAnimStateCtrlSpawn>(world));}
如果把这行代码注释掉,运行后会发现角色无法启动。
5.3 角色系统
角色模块分为客户端和服务端,区别如下:
Client | Server | 说明 |
UpdateCharacter1PSpawn | 处理第一人称角色 | |
PlayerCharacterControlSystem | PlayerCharacterControlSystem | 同步角色Id等参数 |
CreateHandleSpawnSystems | CreateHandleSpawnSystems | 处理角色生成 |
CreateHandleDespawnSystems | CreateHandleDespawnSystems | 处理角色销毁 |
CreateAbilityRequestSystems | CreateAbilityRequestSystems | 技能相关逻辑 |
CreateAbilityStartSystems | CreateAbilityStartSystems | 技能相关逻辑 |
CreateAbilityResolveSystems | CreateAbilityResolveSystems | 技能相关逻辑 |
CreateMovementStartSystems | CreateMovementStartSystems | 移动相关逻辑 |
CreateMovementResolveSystems | CreateMovementResolveSystems | 应用移动数据逻辑 |
UpdatePresentationRootTransform | UpdatePresentationRootTransform | 处理展示角色的根位置旋转信息 |
UpdatePresentationAttachmentTransform | UpdatePresentationAttachmentTransform | 处理附加物体的根位置旋转信息 |
UpdateCharPresentationState | UpdateCharPresentationState | 更新角色展示状态用于网络传输 |
ApplyPresentationState | ApplyPresentationState | 应用角色展示状态到AnimGraph |
HandleDamage | 处理伤害 | |
UpdateTeleportation | 处理角色位置传送 | |
CharacterLateUpdate | 在LateUpdate时序同步一些参数 | |
UpdateCharacterUI | 更新角色UI | |
UpdateCharacterCamera | 更新角色相机 | |
HandleCharacterEvents | 处理角色事件 |
5.4 CharacterMoveQuery
角色内部用的还是角色控制器:
角色的生成被分到了多个System中,所以角色控制器也是单独的GameObject,
创建代码如下:
publicclass CharacterMoveQuery : MonoBehaviour{ publicvoid Initialize(Settings settings, Entity hitCollOwner) { //GameDebug.Log("CharacterMoveQuery.Initialize");this.settings =settings;var go = newGameObject("MoveColl_" + name,typeof(CharacterController),typeof(HitCollision));charController= go.GetComponent<CharacterController>();
在Movement_Update的System中将deltaPos传至moveQuery:
class Movement_Update : BaseComponentDataSystem<CharBehaviour, AbilityControl, Ability_Movement.Settings>{protectedoverridevoid Update(Entity abilityEntity, CharBehaviour charAbility, AbilityControl abilityCtrl, Ability_Movement.Settings settings ) { // Calculate movement and move charactervar deltaPos = Vector3.zero; CalculateMovement(ref time, ref predictedState, ref command, ref deltaPos); // Setup movement query moveQuery.collisionLayer = character.teamId == 0? m_charCollisionALayer : m_charCollisionBLayer; moveQuery.moveQueryStart = predictedState.position; moveQuery.moveQueryEnd = moveQuery.moveQueryStart +(float3)deltaPos; EntityManager.SetComponentData(charAbility.character,predictedState); }}
最后在moveQuery中将deltaPos应用至角色控制器:
class HandleMovementQueries : BaseComponentSystem{ protectedoverridevoid OnUpdate() { ... var deltaPos = query.moveQueryEnd -currentControllerPos;charController.Move(deltaPos);query.moveQueryResult= charController.transform.position; query.isGrounded = charController.isGrounded; Profiler.EndSample(); }}
6.杂项
6.1 MaterialPropertyOverride
这个小工具支持不创建额外材质球的情况下修改材质球参数,
并且无项目依赖,可以直接拿到别的项目里用:
6.2 RopeLine
快速搭建动态交互绳节工具
参考: