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, 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
Unity FpsSample Demo研究的更多相关文章
- Unity 官方 Demo: 2DPlatformer 的 SLua 版本。
9月份时,趁着国庆阅兵的假期,将 Unity 官方 Demo: 2DPlatformer 移植了一个 SLua 版本,并放在了我的 GitHub 账号下:https://github.com/yauk ...
- Unity AngryBots愤怒的机器人demo研究
做为Unity早期的经典demo,一直从3.5以后沿用到4.7.x版本.但其内部一些做法十分不合理.比如使用过多的根目录, 创建怪物和玩家不用SpawnPoint.AI.CheckPoint的代码实现 ...
- Unity Web前端研究
原地址:http://blog.csdn.net/libeifs/article/details/7200630 开发环境 Window7 Unity3D 3.4.1 MB525defy Andro ...
- Unity 跑酷Demo难题总结
问题1:路面拼接处理 在拼接路的时候,如果两个路挨的太近就会出现贴图闪烁,如下所示 解决办法 如果把路改小就会出现断层,但不会出现贴图闪烁 PS:我是把贴图放在Cube上的,所以路是有厚度. 附注 刚 ...
- Unity制作FPS Demo
等到把这个Unity FPS Demo[僵尸杀手]完成后再详细补充一下,使用Unity制作FPS游戏的经历,今天做个标识.
- Unity接入谷歌支付
文章理由 前段时间负责Unity接入Google内购功能,一开始研究别人的技术博客时发现,他们的文章都有些年头了,有些细节的地方已经不像n年前那样了,技术永远是需要更新的,而这篇就作为2016年末的最 ...
- Unity LightmapParameters的使用
Unity5的烘培十分不好用,今天看官方demo时发现可以用LightmapParameters对模型的GI配置进行单独覆写,介绍一下 LightmapParameters可以把全局光照的配置做成预设 ...
- C#程序员整理的Unity 3D笔记(十):Unity3D的位移、旋转的3D数学模型
遇到一个想做的功能,但是实现不了,核心原因是因为对U3D的3D数学概念没有灵活吃透.故再次系统学习之—第三次学习3D数学. 本次,希望实现的功能很简单: 如在小地图中,希望可以动态画出Player当前 ...
- 【转】如何使用Unity创造动态的2D水体效果
原文:http://gamerboom.com/archives/83080 作者:Alex Rose 在本篇教程中,我们将使用简单的物理机制模拟一个动态的2D水体.我们将使用一个线性渲染器.网格渲染 ...
- Unity 的 unitypackage 的存放路径
Windows,C:\Users\<username>\AppData\Roaming\Unity\Asset Store Mac OS X,~/Library/Unity/Asset S ...
随机推荐
- QT自定义右键菜单
利用QMenu和QAction可以实现非常实用的右键菜单功能.具体实现思路如下: 1.在xxx.h文件中添加如下头文件 #include <QMenu> #include <QCon ...
- 严格根号带修 RMQ
其实很简单,把之前随机数据的解法中维护块内数据的数据结构换成约束 RMQ,这样子复杂度 严格 单点修改 \(O(\sqrt n)\),区间查询 \(O(1)\),线性空间. 唯一的问题是常数太大了,有 ...
- ubuntu20 python2 通过安装包安装mysql-python
前言 众里寻他千百度,蓦然回首,那人却在灯火阑珊处. 最近要测试一个python2环境的代码,其中用到了MySQLdb, 查询网络,发现要安装mysql-python,于是就进行了一番艰难的探索,现在 ...
- node.js 增删改查(原始)
index.js 连接数据库 const mongoose = require('mongoose') //数据库连接27017是mongodb数据库的默认端口 mongoose.connect(' ...
- 深度解读昇腾CANN内存复用技术,降低网络内存占用
本文分享自华为云社区<深度解读昇腾CANN内存复用技术,降低网络内存占用>,作者: 昇腾CANN. 随着大模型的兴起,神经网络规模不断扩大,对内存资源的消耗也越来越高,如何降低AI算法的内 ...
- c 语言学习第四天
if 语句 格式: // 1 // 其他语句... if(表达式){ // 其他语句... } // 其他语句... // 2 if(表达式){ }else{ } // 3 if(表达式1){ }el ...
- MathType选项灰色无法点击或者word无法粘贴,治本解决方案
问题描述: mathtype安装过后,word中会出现mathtype的选项,但是这时mathtype中的选项是虚的,无法点击,而且此时word无法粘贴内容. 解决步骤: 1.打开word选项,点击加 ...
- 全网最适合入门的面向对象编程教程:11 类和对象的Python实现-子类调用父类方法-模拟串口传感器和主机
全网最适合入门的面向对象编程教程:11 类和对象的 Python 实现-子类调用父类方法-模拟串口传感器和主机 摘要: 本节课,我们主要讲解了在 Python 类的继承中子类如何进行初始化.调用父类的 ...
- 在Django中,多数据操作,你可以编写测试来查询另一个数据库服务器中的数据,并将结果导入当前Django项目的数据库表中
在Django中,你可以编写测试来查询另一个数据库服务器中的数据,并将结果导入当前Django项目的数据库表中.下面是一个简单的示例: 假设你有一个Django应用程序,名为myapp,并且你希望从另 ...
- 深入理解 Vue 3 组件通信
在 Vue 3 中,组件通信是一个关键的概念,它允许我们在组件之间传递数据和事件.本文将介绍几种常见的 Vue 3 组件通信方法,包括 props.emits.provide 和 inject.事件总 ...