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
https://carlself.github.io/posts/fpssample%E5%88%86%E6%9E%90/