Unity FPSSample Demo研究

HONT發表於2024-08-16

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

相關文章