GridMate期望解决的主要问题:
多人游戏服务器(主要还是C-S模式,非S-S模式)
GridMate支持的模式:
Peer-to-Peer、C/S、混合(C/S+P2P)。
但是整个Lumberyard工程中只使用过C/S模式,其他模式都没有使用过,也没有实例程序,P2P及混合模式是否可用、能否正常工作未知。但有一个GridHub示例用于展示P2P模式和主副本迁移。C-S模式是目前大多数游戏采用的策略,而随着游戏的发展,复杂度的不断提升,游戏服务器发展为采用多服务器部署的形式,即服务器端内部S-S模式,这样的拓扑结构最终形成了游戏玩家和游戏服务器是C-S模式、游戏服务器集群内部是S-S模式的两重模式叠加。
Primary/Secondary Replica拥有方:
在C/S模式中,Server始终拥有数据的Primary Replica,Client始终拥有数据的Secondary Replica。
在Peer-to-Peer中,生成数据的地方作为数据的Primary Replica,其他节点持有数据的Secondary Replica。
GridMate层级结构:
层级 | 模块 |
---|---|
AzFramework | NetBinding |
GridMate | Replica |
GridMate | Session |
GridMate | Carrier |
GridMate | Driver |
NetBinding:Entity同步数据需要NetBindingComponent,如果Entity中带了NetBindingComponent,NetBindingComponent会收集ReplicaChunks并把它们加入主副本。ReplicaChunk有两种类型,RPC和DataSet,RPC对应的是操作,DataSet对应的是属性数据的状态同步。后面内容会重新提及并有详细的ReplicaChunk及RPC和DataSet的记录。
Replica:GridMate使用数据一致性协议中的单主协议,保证最终一致性,所有节点中只有一个拥有Primary Replica的Node,称为Primary Node,主节点。只有主节点拥有对数据的写权限(在RPC和DataSet中就会有所体现)。GridMate在有P2P的场景下考虑了容灾的问题,当主节点不可用的时候,GridMate会做副本主体迁移,选出一个节点作为新的主节点(Lumberyard只提供了GridHub作为演示)。
Session:会话,维护着GridMate的连接,GridMate只允许有一个GridMate实例处于Active状态,一个GridMate实例可以Add多个Session,Session维护着网络拓扑结构(有P2P、C/S、混合模式,枚举在Session.h的第100行,基于1.27.0.0 beta版本代码)。Session及以下的GridMate层模块不往外向外部开发者暴露,外部开发者能访问到的是一系列的Service,LeaderboardService(排行榜)、GridStorageService、AchievementService,Lumberyard的文档中说支持这些Service,但是在代码中发现创建这些Service的Create接口都没有实现,会跳到xxx_Unimplemented.cpp中,直接返回的false。Lumberyard实现了LANSession和GameLiftSession,对应于局域网调试和放在亚马逊的GameLift服务器上,LANSession和GameLiftSession都只采用GridMate中的C/S模式,并提供LANSessionService和GameLiftSessionService。
Carrier:提供通讯信道(Channel)。每个Channel是一个独立的I/O线程,发送和接收过程会解耦到独立的线程中,并使用端口复用机制(epoll/IOCP)提供经典的C/S模式下的多Client支持。Channel支持可靠和不可靠消息的传递,但是保证消息的顺序性。我理解Carrier层是GridMate对网络传输的进一步的理解和控制监控,Carrier层还提供了Hook,能够模拟真实网络环境、提供网络拥塞控制。
Driver:对传输层接口的最低级别的封装,底下用的还是裸套接字。有SocketDriver和SecureSocketDriver。SocketDriver就是通用套接字的封装,BSD/Posix/WinSock。SecureSocketDriver加了OpenSSL,通过DTLS协议加密传输。
MultiPlayer中ReplicaChunk,RPC与DataSet:
- DataSet
DataSet是状态同步是实现,DataSet只能在ReplicaChunk中,当里面的数据有改变时,会自动进行网络广播同步。单主协议下,DataSet中的数据只有Primary Node才能修改,即Lumberyard的多人游戏只有C/S模式,只有服务器能够修改DataSet中的数据,修改后将会进行数据同步。
DataSet的应用类似下面这样(来自GamePlayerComponent.cpp):
GridMate::DataSet<AZStd::string>::BindInterface<GamePlayerComponent, &GamePlayerComponent::OnNewNetPlayerName> m_name;
将一个string类型的变量作为一个DataSet,当这个值被修改时(这里的“修改”是指:Primary Node修改了这个值,发送了同步,同步到了当前的Node),会触发GamePlayerComponent中的OnNewNetPlayerName函数,在这个函数中会修改name属性的值。
RPC
RPC是发送请求式的操作(远程过程调用),RPC的调用请求会被扔到网络上,然后由Primary Node进行响应,然后根据返回值来决定是否要广播到其他的节点上执行。RPC也在ReplicaChunk中。
RPC的应用类似下面这样(来自ShipComponent.cpp):
GridMate::Rpc<GridMate::RpcArg<bool>>::BindInterface<ShipComponent, &ShipComponent::SetFiringRPC, NetworkUtils::ShipControllerRPCTraits> SetFiring; //1
如上就定义了一个SetFiring的RPC。
在ShipComponent中有一个ShipComponent::SetFiring:
void ShipComponent::SetFiring(bool firing) //2 { m_isFiring = firing; if (!AzFramework::NetQuery::IsEntityAuthoritative(GetEntityId())) { ShipComponentReplicaChunk* shipChunk = static_cast<ShipComponentReplicaChunk*>(m_replicaChunk.get()); shipChunk->SetFiring(firing); // 这个SetFiring是上面的1处的RPC的SetFiring } else { if (m_isFiring) { EBUS_EVENT_ID(GetGun(), ShipGunBus, StartFire); } else { EBUS_EVENT_ID(GetGun(), ShipGunBus, StopFire); } } }
玩家控制的飞船开火时(来自键盘消息,空格按键被按下),会调用2处的ShipComponent::SetFiring,这会发送一个RPC到网络上,作为Server的节点收到RPC请求后,会调用SetFiringRPC,SetFiringRPC返回false因此RPC调用不会再广播到所有Client的节点执行调用。在Server上的SetFiringRPC中,又调用了2处的SetFiring。
bool ShipComponent::SetFiringRPC(bool firing, const GridMate::RpcContext& rpcContext) { if (AllowRPCContext(rpcContext)) { SetFiring(firing); // 执行的是ShipComponent::SetFiring } return false; }
ShipComponent::SetFiring中用IsEntityAuthoritative来判断当前节点是否是Server,GridMate的C/S模式下,Server端的Entity始终是权威的。
在devCodeFrameworkGridMateDocsUML文件夹下有GridMate工作流程的一些UML图,图很简单,只能参考参考。(PlantUML文件在之前我用于博客画图的工具中提到过,这个文件可以通过VS-Code打开,安装PlantUML插件后能够实时查看由代码生成的UML图,安利)
关于多人在线游戏的网络传输与状态同步:
多人在线游戏服务器当前的基本实现形式仍然是C/S架构,即使Server端逐步发展成为多Server,内部局域网相互连接用以支撑大流量分功能的访问,对于玩家来说,仍然还是Client端。因此,在大多数多人在线游戏中,可以确定Server和Client的“角色”。Server是虚拟游戏世界中的上帝,拥有所有能感知到的数据信息;Client是给玩家的在这个虚拟游戏世界中的呈现,这种呈现只是整个游戏世界中的一小部分。客户端软件可以理解为玩家的游戏世界呈现器。
多人在线游戏的同步方式目前有两种:状态同步、帧同步。目前大多数的游戏仍然采用状态同步的同步方式。状态同步的同步内容是游戏中的属性数据,比如A发动了个技能打了B,A把发动技能的请求发送给服务器,服务器负责逻辑计算,算完后得到了新的B的HP值,然后把HP值同步给所有玩家。而帧同步的同步内容是游戏中的操作,比如还是A发动一个技能打了B,A把A发动某个技能打了B这个操作发送给服务器,服务器负责转发这个操作给所有玩家,于是B收到的不是新的HP值,而是“A发动某个技能打了B”这个操作,然后B再在客户端进行逻辑计算,重现展示这个过程,帧同步下逻辑计算放在了客户端,服务器的逻辑计算仅仅用于纠错和防作弊。帧同步由于逻辑计算会在多地进行,控制A和控制B的客户端都会进行逻辑计算,因此帧同步的计算一致性要求很高,要求物理计算等等逻辑计算要保证计算结果的一致性(要解决浮点的丢精度、各个设备的差异的问题),难度较大,而状态同步只有服务器会做逻辑计算,是计算完的结果的分发,因此不会有这个问题。当然,帧同步的好处是,由于传输的是操作,玩家的呈现效果基本无延迟。
目前使用帧同步的游戏如腾讯的王者荣耀,还会实现一套可靠UDP的传输机制,进一步提升网络传输性能。
在Lumberyard提供的多人游戏Gems中,采用了GridMate,只支持状态同步的方式。前面提到Lumberyard的GridMate中有RPC和DataSet,RPC充当的就是前面提到的“A把发动技能的请求发送给服务器”的过程,DataSet充当的就是前面提到的“把HP值同步给所有玩家”的过程。同时,GridMate的RPC会根据运行在服务器上的RPC函数的返回值决定RPC是否广播给所有的客户端调用,这一个模式,是帧同步的处理思想——转发“操作”。
但是在Lumberyard中没有例子使用过这个方式。Lumberyard的飞船游戏是把开火的请求通过RPC发送给服务器,服务器负责计算开火逻辑,有没有打到小行星,然后把结果的值用DataSet同步。飞船游戏的服务器端RPC函数返回的是false,不会把操作转发出去。
对于状态同步,一般还会区分两类数据:shared data和personal data。在Server上这些数据都有,是上帝视角。而从Client上看,只会看到shared data和玩家相关的personal data。shared data一般是游戏世界中都能感知到的数据,personal data则是仅某个或某些玩家独自知道的数据。例如,场景中某个怪物的HP值一般就是shared data,在这个区域附近的玩家都能获知,而某个玩家在某处埋了个地雷,这个地雷数据就是personal data,只会同步给这个玩家或这个玩家的队友。属性数据的类型决定了同步对象。Lumberyard的飞船游戏是很简单的一个示例,并没有处理这种区分数据类型的状态同步。
协议
本文以上内容遵循CC BY-ND 4.0协议,署名-禁止演绎。
转载请注明出处:https://tis.ac.cn/blog/kongdeyou/lumberyard_gridmate/
作者:kongdeyou(https://tis.ac.cn/blog/author/kongdeyou/)