Unity FPSSample Demo研究

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中):

复制代码
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, boolreliable, 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,refoutput);
    }
    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

相关推荐
HONT4 个月前
Megacity Unity Demo工程学习
unitydemo