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裡,
這樣可以自動收集,不必手動一個個檔案去標記。
並且AssetBundle區分了Server/Client,
服務端打包AssetBundle時將用一些資源及耗費效能較少的替代版本
而客戶端打包的AssetBundle則是完整版本。
2.GameLoop
可參考文件SourceCode.md,不同的GameLoop決定當前遊戲下的主迴圈邏輯:
遊戲內的幾種GameLoop分別對應如下:
- ClientGameLoop 客戶端遊戲迴圈
- ServerGameLoop 服務端遊戲迴圈
- PreviewGameLoop 編輯器下執行關卡測試時對應的遊戲迴圈(單機跑圖模式)
- ThinClientGameLoop 除錯用的輕量版客戶端遊戲迴圈,內部幾乎沒有System
2.1 GameLoop觸發邏輯
遊戲的入口是Game.prefab:
IGameLoop介面定義在Game.cs中:
public interface IGameLoop { bool Init(string[] args); void Shutdown(); void Update(); void FixedUpdate(); void LateUpdate(); }
然後透過命令初始化所需要的GameLoop,內部會透過反射建立(Game.cs中):
void CmdServe(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為上層封裝,附帶一些遊戲邏輯。
public bool Init(string[] args) { ... m_NetworkTransport = new SocketTransport(); m_NetworkClient = new NetworkClient(m_NetworkTransport);
3.1.1 NetworkClient內部邏輯
跟進去看下NetworkClient的結構,刪了一些內容,部分介面如下:
public class NetworkClient { ... public bool isConnected { get; } public ConnectionState connectionState { get; } public int clientId { get; } public NetworkClient(INetworkTransport transport) public void Shutdown() 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的更新邏輯
public void 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 day if (m_StateMachine.CurrentState() == ClientState.Playing && Game.game.clientFrontend != null) Game.game.clientFrontend.UpdateChat(m_ChatSystem); m_NetworkClient.SendData(); //客戶端傳送資料
其中ClientGameLoop Update函式簽名如下:
public void Update(INetworkClientCallbacks clientNetworkConsumer, ISnapshotConsumer snapshotConsumer)
引數1用於處理OnConnect、OnDisconnect等訊息,引數2用於處理場景中各類快照資訊。
3.1.3 m_NetworkClient.Update
進入Update函式看下接收邏輯:
public void Update(INetworkClientCallbacks clientNetworkConsumer, ISnapshotConsumer snapshotConsumer) { ... TransportEvent e = new TransportEvent(); 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; case TransportEvent.Type.Data: OnData(e.connectionId, e.data, e.dataSize, clientNetworkConsumer, snapshotConsumer); break; } } }
可以看見具體邏輯處理在OnData中
3.1.4 m_NetworkClient.SendData
進入SendData函式,看下傳送資料是如何處理的。
public void SendPackage<TOutputStream>() where TOutputStream : struct, NetworkCompression.IOutputStream { ...if (commandSequence > 0) { lastSentCommandSeq = commandSequence; WriteCommands(info, ref output); } WriteEvents(info, ref output); int compressedSize = output.Flush(); rawOutputStream.SkipBytes(compressedSize); CompleteSendPackage(info, ref rawOutputStream); }
可以看見,這裡將之前加入佇列的Command和Event取出寫入緩衝準備傳送。
3.2.ServerGameLoop
和ClientGameLoop一樣,在Init中初始化Transport網路層和NetworkServer。
public bool Init(string[] args) { // Set up statemachine for ServerGame m_StateMachine = new StateMachine<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:
public void Update() { UpdateNetwork();//更新SQP查詢伺服器和呼叫NetWorkServer.Update m_StateMachine.Update(); m_NetworkServer.SendData(); m_NetworkStatistics.Update(); if (showGameLoopInfo.IntValue > 0) OnDebugDrawGameloopInfo(); }
3.2.1 Server - HandleClientCommands
來看一下接收客戶端命令後是如何處理的,在ServerTick函式內,呼叫
HandleClientCommands處理客戶端發來的命令
public class ServerGameWorld : ISnapshotGenerator, IClientCommandProcessor { ... public void ServerTickUpdate() { ... m_NetworkServer.HandleClientCommands(m_GameWorld.worldTime.tick, this); }
public void HandleClientCommands(int tick, IClientCommandProcessor processor) { foreach (var c in m_Connections) c.Value.ProcessCommands(tick, processor); }
然後反序列化,加上ComponentData交給對應的System處理:
public class ServerGameWorld : ISnapshotGenerator, IClientCommandProcessor {
... public void ProcessCommand(int connectionId, int tick, 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的呼叫:
public class 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); } public void ServerTickUpdate() { ... m_ReplicatedEntityModule.HandleSpawning(); m_ReplicatedEntityModule.HandleDespawning(); } public void GenerateEntitySnapshot(int entityId, ref NetworkWriter writer) { ... m_ReplicatedEntityModule.GenerateEntitySnapshot(entityId, ref writer); } public string GenerateEntityName(int entityId) { ... return m_ReplicatedEntityModule.GenerateName(entityId); } }
Client部分關注ReplicatedEntityModuleClient和ISnapshotConsumer的呼叫:
foreach (var id in updates) { 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中呼叫快照建立邏輯:
public class 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介面生成實體內容:
unsafe public class NetworkServer { unsafe public void GenerateSnapshot(ISnapshotGenerator snapshotGenerator, float simTime) { ... // Run through all the registered network entities and serialize the snapshot for (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 snapshot var 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;
對應的處理函式:
public void ProcessEntityUpdate(int serverTick, int id, 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 (var entry in data.serializableArray) entry.Deserialize(ref reader, serverTick); foreach (var entry in data.predictedArray) entry.Deserialize(ref reader, serverTick); foreach (var entry in 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] public class EditorLevelManager { static EditorLevelManager() { EditorApplication.playModeStateChanged += OnPlayModeStateChanged; } ... static void OnPlayModeStateChanged(PlayModeStateChange mode) { if (mode == PlayModeStateChange.EnteredPlayMode) { ... case LevelInfo.LevelType.Gameplay: Game.game.RequestGameLoop( typeof(PreviewGameLoop), new string[0]); break; } }
在PreviewGameLoop中寫了PreviewGameMode的邏輯,在此處若controlledEntity為空則觸發建立:
public class PreviewGameMode : BaseComponentSystem { ... protected override void OnUpdate() { if (m_Player.controlledEntity == Entity.Null) { Spawn(false); return; } }
最後調到此處進行建立:
CharacterSpawnRequest.Create(PostUpdateCommands, charControl.characterType, m_SpawnPos, m_SpawnRot, playerEntity);
在建立後執行到CharacterSystemShared.cs的HandleCharacterSpawn時,會啟動角色相關邏輯:
public static void CreateHandleSpawnSystems(GameWorld world,SystemCollection systems, BundledResourceManager resourceManager, bool server) { systems.Add(world.GetECSWorld().CreateManager<HandleCharacterSpawn>(world, resourceManager, server)); // TODO (mogensh) needs to be done first as it creates presentation systems.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,
建立程式碼如下:
public class CharacterMoveQuery : MonoBehaviour { public void Initialize(Settings settings, Entity hitCollOwner) { //GameDebug.Log("CharacterMoveQuery.Initialize"); this.settings = settings; var go = new GameObject("MoveColl_" + name,typeof(CharacterController), typeof(HitCollision)); charController = go.GetComponent<CharacterController>();
在Movement_Update的System中將deltaPos傳至moveQuery:
class Movement_Update : BaseComponentDataSystem<CharBehaviour, AbilityControl, Ability_Movement.Settings> { protected override void Update(Entity abilityEntity, CharBehaviour charAbility, AbilityControl abilityCtrl, Ability_Movement.Settings settings ) { // Calculate movement and move character var 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 { protected override void 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
快速搭建動態互動繩節工具
參考:
https://www.jianshu.com/p/347ded2a8e7a
https://www.jianshu.com/p/c4ea9073f443