Unity FPSSample Demo研究

1.前言

 

Unity FpsSample Demo大约是2018发布,用于官方演示新的网络传输层(UnityTransport)+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 Client - ClientGameLoop

先来看下ClientGameLoop,初始化会调用Init函数,NetworkTransport为Unity封装的网络层,

NetworkClient为上层封装,附带一些游戏逻辑。

public bool Init(string[] args)
{
    ...
    m_NetworkTransport = new SocketTransport();
    m_NetworkClient = new NetworkClient(m_NetworkTransport);

3.1.1 Client - 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和QueueEvent接口。

其中Command用于处理角色的移动、跳跃等信息,包含于Command结构体中。

Event用于处理角色的连接、启动等状态。

3.1.2 Client - 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 Client - 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 Client - 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. Server - 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();

最后在NetworkServer.cs中遍历所有Connections执行发送Snapshot:

public void SendData()
{...foreach (var pair in m_Connections)
    {
        ...case NetworkCompression.IOStreamType.Huffman:
                //pair.Value.SendPackage<HuffmanOutputStream>(m_NetworkCompressionCapture);
                //函数展开:
                public void SendPackage<TOutputStream>(NetworkCompressionCapture networkCompressionCapture) where TOutputStream : struct, NetworkCompression.IOutputStream
                {
                    ...
                    if (mapReady && server.m_ServerSequence > snapshotServerLastWritten)
                    {
                        WriteSnapshot(ref output);
                    }
                    WriteEvents(packageInfo, ref output);
                }

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 预测

游戏中的预测功能指网络不稳定收不到新的数据包时,将游戏数据回滚到最后一个数据包的时间点,

在此基础上得到时间差,根据deltaTime循环调用Update执行预测函数,实现一种本地补偿的做法。

public class ClientGameWorld
{
    ...
    public void Update(float frameDuration)
    {
        ...
        if (IsPredictionAllowed())
        {
            // ROLLBACK. All predicted entities (with the ServerEntity component) are rolled back to last server state 
            m_GameWorld.worldTime.SetTime(m_NetworkClient.serverTime, m_PredictedTime.tickInterval);
            PredictionRollback();

            
            // PREDICT PREVIOUS TICKS. Replay every tick *after* the last tick we have from server up to the last stored command we have
            for (var tick = m_NetworkClient.serverTime + 1; tick < m_PredictedTime.tick; tick++)
            {
                m_GameWorld.worldTime.SetTime(tick, m_PredictedTime.tickInterval);
                m_PlayerModule.RetrieveCommand(m_GameWorld.worldTime.tick);
                PredictionUpdate();
#if UNITY_EDITOR                 
                // We only want to store "full" tick to we use m_PredictedTime.tick-1 (as current can be fraction of tick)
                m_ReplicatedEntityModule.StorePredictedState(tick, m_PredictedTime.tick-1);
#endif                
            }

回滚也只是回滚到最后一次反序列化数据包的时间点:

class PredictedComponentSerializer<T> : IPredictedSerializer 
    where T : struct, IPredictedComponent<T>, IComponentData    
{
    SerializeContext context;
    T m_lastServerState;

    public void Serialize(ref NetworkWriter writer)
    {
        var state = context.entityManager.GetComponentData<T>(context.entity);
        state.Serialize(ref context, ref writer);
    }

    public void Deserialize(ref NetworkReader reader, int tick)
    {
        context.tick = tick;
        m_lastServerState.Deserialize(ref context, ref reader);
        
#if UNITY_EDITOR
        if (ReplicatedEntityCollection.SampleHistory)
        {
            var index = serverStateTicks.GetIndex((uint)tick);
            if(index == -1)                
                index = serverStateTicks.Register((uint)tick);
            serverStates[index] = m_lastServerState;
        }
#endif          
    }
    public void Rollback()
    {
        context.entityManager.SetComponentData(context.entity, m_lastServerState);
    }

5.2 插值

插值接口签名如下:

public interface IInterpolatedComponent<T> : IInterpolatedDataBase
{
    void Serialize(ref SerializeContext context, ref NetworkWriter writer);
    void Deserialize(ref SerializeContext context, ref NetworkReader reader);
    void Interpolate(ref SerializeContext context, ref T first, ref T last, float t);
}

游戏中有一些组件无法实现插值,于是在实现接口时直接返回first:

public void Interpolate(ref SerializeContext context, ref InterpolatedState first, ref InterpolatedState last,
    float t)
{
    this = first;
}

插值实现代码如下(DataComponentSerializers.cs):

if (interpValid)
{
    var prevState = stateHistory[lowIndex];
    var nextState = stateHistory[highIndex];
    state.Interpolate(ref context, ref prevState, ref nextState, interpVal);
}

6.ID分配

游戏中需要联机交互的实体,在NetworkServer、NetworkClient中都有与传输id匹配的结构体,

例如Server中的如下:

unsafe class EntityInfo
{
    public EntityInfo()

    public void Reset()

    public ushort typeId;
    public int predictingClientId = -1;

    public int spawnSequence;
    public int despawnSequence;
    public int updateSequence;

    public SequenceBuffer<EntitySnapshotInfo> snapshots;
    public uint* prediction;                                // NOTE: used in WriteSnapshot but invalid outside that function
    public byte[] fieldsChangedPrediction = new byte[(NetworkConfig.maxFieldsPerSchema + 7) / 8];

    public byte GetFieldMask(int connectionId)
}

游戏中需要联机交互的对象都会挂载ReplicatedEntityData这个ComponentData:

var repData = new ReplicatedEntityData( guid);
entityManager.SetComponentData(entity, repData);

对应的System拿到ReplicatedEntityData,进行处理:

protected override void Initialize(Entity entity, ReplicatedEntityData spawned)
{
    var typeId = m_assetRegistry.GetEntryIndex(spawned.assetGuid);
    spawned.id = m_network.RegisterEntity(spawned.id, (ushort)typeId, spawned.predictingPlayerId);

注意RegisterEntity函数的细节,这里的id是用于联机的id,如果这个id已存在则

直接使用,如果id不存在则从一个空闲实体中分配:

public int RegisterEntity(int id, ushort typeId, int predictingClientId)
{
    Profiler.BeginSample("NetworkServer.RegisterEntity()");
    EntityInfo entityInfo;
    int freeCount = m_FreeEntities.Count;

    if (id >= 0)
    {
        GameDebug.Assert(m_Entities[id].spawnSequence == 0, "RegisterEntity: Trying to reuse an id that is used by a scene entity");
        entityInfo = m_Entities[id];
    }
    else if (freeCount > 0)
    {
        id = m_FreeEntities[freeCount - 1];
        m_FreeEntities.RemoveAt(freeCount - 1);
        entityInfo = m_Entities[id];
        entityInfo.Reset();
    }
    else
    {
        entityInfo = new EntityInfo();
        m_Entities.Add(entityInfo);
        id = m_Entities.Count - 1;
    }

    entityInfo.typeId = typeId;
    entityInfo.predictingClientId = predictingClientId;
    entityInfo.spawnSequence = m_ServerSequence + 1; // NOTE : Associate the spawn with the next snapshot

    if (serverDebugEntityIds.IntValue > 1)
        GameDebug.Log("Registred entity id: " + id);

    Profiler.EndSample();
    return id;
}

这意味着使用者无需关心id的分配问题,当id为空时会自动分配

对于游戏中的静态对象,因为无法区分先后顺序,则使用Guid的方式,

在首次启动时进行绑定:

public class GameWorld
{
...
    public void RegisterSceneEntities()
    {
        // Replicated entities are sorted by their netID and numbered accordingly
        var sceneEntities = new List<ReplicatedEntity>(Object.FindObjectsOfType<ReplicatedEntity>());
        sceneEntities.Sort((a, b) => ByteArrayComp.instance.Compare(a.netID, b.netID));
        for (int i = 0; i < sceneEntities.Count; i++)
        {
            var gameObjectEntity = sceneEntities[i].GetComponent<GameObjectEntity>();

            var replicatedEntityData = gameObjectEntity.EntityManager.GetComponentData<ReplicatedEntityData>(gameObjectEntity.Entity);
            replicatedEntityData.id = i;
            gameObjectEntity.EntityManager.SetComponentData(gameObjectEntity.Entity,replicatedEntityData);
        }
        m_sceneEntities.AddRange(sceneEntities);
    }

netID排序后可保证每一台联机设备顺序都是一致的。

7.传输层细节

7.1 数据压缩

游戏中储存float字段做了FloatQ压缩,float量化:

public float ReadFloatQ()
{
    GameDebug.Assert(m_Schema != null, "Schema required for reading quantizied values");
    ValidateSchema(NetworkSchema.FieldType.Float, 32, true);
    return (int)m_Input[m_Position++] * NetworkConfig.decoderPrecisionScales[m_CurrentField.precision];
}
public void WriteFloatQ(string name, float value, int precision = 3)
{
    ValidateOrGenerateSchema(name, NetworkSchema.FieldType.Float, 32, true, precision);
    m_Output[m_Position++] = (uint)Mathf.RoundToInt(value * NetworkConfig.encoderPrecisionScales[precision]);
}
public static class NetworkConfig
{
    ...
    public readonly static float[] encoderPrecisionScales = new float[] { 1.0f, 10.0f, 100.0f, 1000.0f };
    public readonly static float[] decoderPrecisionScales = new float[] { 1.0f, 0.1f, 0.01f, 0.001f };

部分数据用了Delta量的做法来写入,例如155,157,159三个Byte,写入后则是155,2,2方便后面的压缩:

output.WritePackedUInt((uint)server.m_TempDespawnList.Count, NetworkConfig.despawnCountContext);
foreach (var id in server.m_TempDespawnList)
{
    output.WritePackedIntDelta(id, previousId, NetworkConfig.idContext);
    previousId = id;
}

 将Delta量转换成UInt交错编码:

public struct RawOutputStream : IOutputStream
{
    public void WritePackedUIntDelta(uint value, uint baseline, int context)
    {
        int diff = (int)(baseline - value);
        uint interleaved = (uint)((diff >> 31) ^ (diff << 1));      // interleave negative values between positive values: 0, -1, 1, -2, 2
        WritePackedUInt(interleaved, context);
    }

假设baseline是10,value是9,储存成UInt时为2

假设baseline是10,value是11,储存成UInt时为1

差异为负数结果为单数,差异为正数结果为双数。

UInt Delta量解码:

public struct RawInputStream : IInputStream
{
    public uint ReadPackedUIntDelta(uint baseline, int context)
    {
        uint folded = ReadPackedUInt(context);
        uint delta = (folded >> 1) ^ (uint)-(int)(folded & 1);    // Deinterleave values from [0, -1, 1, -2, 2...] to [..., -2, -1, -0, 1, 2, ...]
        return baseline - delta;
    }

或许是让网络传输格式尽量都为UInt和100以内数以方便提高压缩率?

7.2 分包处理

大于MTU值后会对数据包进行分包发送,在保证验证的情况下,标注头部字节

分包信息:

public class NetworkConnection<TCounters, TPackageInfo>
    where TCounters : NetworkConnectionCounters, new()
    where TPackageInfo : PackageInfo, new()
{
    ...
    if (packageSize > NetworkConfig.packageFragmentSize)
    {
        // Package is too big and needs to be sent as fragments
        var numFragments = packageSize / NetworkConfig.packageFragmentSize;
        //GameDebug.Log("FRAGMENTING: " + connectionId + ": " + packageSize + " (" + numFragments + ")");
        var lastFragmentSize = packageSize % NetworkConfig.packageFragmentSize;
        if (lastFragmentSize != 0)
            ++numFragments;
        else
            lastFragmentSize = NetworkConfig.packageFragmentSize;

        for (var i = 0; i < numFragments; ++i)
        {
            var fragmentSize = i < numFragments - 1 ? NetworkConfig.packageFragmentSize : lastFragmentSize;

            var fragmentOutput = new BitOutputStream(m_FragmentBuffer);
            fragmentOutput.WriteBits((uint)NetworkMessage.FRAGMENT, 8); // Package fragment identifier
            fragmentOutput.WriteBits(Sequence.ToUInt16(outSequence), 16);
            fragmentOutput.WriteBits((uint)numFragments, 8);
            fragmentOutput.WriteBits((uint)i, 8);
            fragmentOutput.WriteBits((uint)fragmentSize, 16);
            fragmentOutput.WriteBytes(m_PackageBuffer, i * NetworkConfig.packageFragmentSize, fragmentSize);
            int fragmentPackageSize = fragmentOutput.Flush();

            transport.SendData(connectionId, m_FragmentBuffer, fragmentPackageSize);
            counters.packagesOut++;
            counters.bytesOut += fragmentPackageSize;
        }
        counters.fragmentedPackagesOut++;
    }
    else
    {
        transport.SendData(connectionId, m_PackageBuffer, packageSize);
        counters.packagesOut++;
        counters.bytesOut += packageSize;
    }

7.3 RTT计算逻辑

RTT计算在NetworkConnection.cs中:

var timeOnServer = (ushort)input.ReadBits(8);
TPackageInfo info;
if (outstandingPackages.TryGetValue(outSequenceAckNew, out info))
{
    var now = NetworkUtils.stopwatch.ElapsedMilliseconds;
    rtt = (int)(now - info.sentTime - timeOnServer);
}

根据官方PPT的讲解,客户端在1000ms的时间点向服务端发了一个包,服务器

在1050ms的时间点收到了这个包,并花费30ms处理这个包,在1080ms的时间点发给客户端,

客户端在1130ms的时间点收到了包。

在计算RTT时就是:1130(now)-1000(sentTime)-30(timeOnServer)=100ms

7.4 Sequence

Sequence序列为一个自增int值,它告知了服务端客户端当前处于哪一个序列状态:

unsafe public void GenerateSnapshot(ISnapshotGenerator snapshotGenerator, float simTime)
{
    var time = snapshotGenerator.WorldTick;
    GameDebug.Assert(time > serverTime);      // Time should always flow forward
    GameDebug.Assert(m_MapInfo.mapId > 0);    // Initialize map before generating snapshot

    ++m_ServerSequence;
public void SendPackage<TOutputStream>(NetworkCompressionCapture networkCompressionCapture) where TOutputStream : struct, NetworkCompression.IOutputStream
{
    ...
    ServerPackageInfo packageInfo;
    BeginSendPackage(ref rawOutputStream, out packageInfo);
    ...
    packageInfo.serverSequence = server.m_ServerSequence;   // the server snapshot sequence
    packageInfo.serverTime = server.serverTime;             // Server time (could be ticks or could be ms)

并且通过sequence也能知道客户端在哪个sequence中删了或创建了Entity,

服务端需不需要补发或者处理:

// Note to future self: This is a bit tricky... We consider lifetimes of entities
// re the baseline (last ack'ed, so in the past) and the snapshot we are building (now)
// There are 6 cases (S == spawn, D = despawn):
//
//  --------------------------------- time ----------------------------------->
//
//                   BASELINE          SNAPSHOT
//                      |                 |
//                      v                 v
//  1.    S-------D                                                  IGNORE
//  2.    S------------------D                                       SEND DESPAWN
//  3.    S-------------------------------------D                    SEND UPDATE
//  4.                        S-----D                                IGNORE
//  5.                        S-----------------D                    SEND SPAWN + UPDATE
//  6.                                         S----------D          INVALID (FUTURE)

8.动画

因为动画需要更好的网络同步,所以使用了Playable Graph而不是Animator。

感觉更像是UE动画蓝图的翻版,但还是很蹩脚。

内部使用支持Jobs新的动画API,这些在早先发布的Animation Rigging插件中就已使用。

9.游戏模块逻辑

9.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

9.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));
}

如果把这行代码注释掉,运行后会发现角色无法启动。

9.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   处理角色事件

9.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();
    }
}

9.5 异步射线查询

游戏中的交互碰撞(如可以打碎的桶)挂载HitCollisionOwner组件,将碰撞信息添加进ComponentData让指定System进行处理。

发送异步射线查询请求:

var queryReciever = World.GetExistingManager<RaySphereQueryReciever>();
internalState.rayQueryId = queryReciever.RegisterQuery(new RaySphereQueryReciever.Query()
{
    origin = eyePos,
    direction = direction,
    distance = distance,
    ExcludeOwner = charAbility.character,
    hitCollisionTestTick = command.renderTick,
    radius = settings.hitscanRadius,
    mask = collisionMask,
});
EntityManager.SetComponentData(abilityEntity,internalState);

获得异步射线查询结果:

var queryReciever = World.GetExistingManager<RaySphereQueryReciever>();
RaySphereQueryReciever.Query query;
RaySphereQueryReciever.QueryResult queryResult;
queryReciever.GetResult(internalState.rayQueryId, out query, out queryResult);
internalState.rayQueryId = -1;

var impact = queryResult.hit == 1;
if (impact)
{
    ...

考虑到这种联机游戏会有8-16个玩家的射线判断操作同时在服务端进行处理,

这么做有一定优化效果。

9.6 伤害事件处理

在射线查询后拿到碰撞到的角色后,附加伤害事件至对应实体:

class Melee_HandleCollision : BaseComponentDataSystem<Ability_Melee.LocalState>
{
    ...
    protected override void Update(Entity abilityEntity, Ability_Melee.LocalState localState)
    {
        ...
        if (queryResult.hitCollisionOwner != Entity.Null)
        {
            ...
            var damageEventBuffer = EntityManager.GetBuffer<DamageEvent>(queryResult.hitCollisionOwner);
            DamageEvent.AddEvent(damageEventBuffer, charAbility.character, settings.damage, query.direction, settings.damageImpulse);
        }
    }
}

因此只要实体的DyanmicBuffer<DamageEvent>中有数据,就说明有伤害事件等待处理,

在CharacterModuleServer.cs的OnUpdate中,处理实体的伤害事件:

protected override void OnUpdate()
{
    ...
    var entityArray = Group.GetEntityArray();
    var healthStateArray = Group.GetComponentDataArray<HealthStateData>();
    for (int i = 0; i < entityArray.Length; i++)
    {
        var healthState = healthStateArray[i];
        var entity = entityArray[i]; 
        var damageBuffer = EntityManager.GetBuffer<DamageEvent>(entity);
        for (var eventIndex=0;eventIndex < damageBuffer.Length; eventIndex++)
        {
            ...
            var damageEvent = damageBuffer[eventIndex];
            healthState.ApplyDamage(ref damageEvent, m_world.worldTime.tick);
            EntityManager.SetComponentData(entity,healthState);

10.杂项

10.1 MaterialPropertyOverride

这个小工具支持不创建额外材质球的情况下修改材质球参数,

并且无项目依赖,可以直接拿到别的项目里用:

10.2 RopeLine

快速搭建动态交互绳节工具


 参考:

https://www.jianshu.com/p/347ded2a8e7a

https://www.jianshu.com/p/c4ea9073f443

posted @ 2024-08-16 17:55  HONT  阅读(205)  评论(0编辑  收藏  举报