网络同步-对象复制(replication)
从一台主机向另一台主机传输对象状态的行为称为复制(replication)。
主机在序列化对象的内部数据之前必须先序列化3类数据:
1. 标记当前数据包是包含对象状态的数据包。
2.唯一标识对象的标识符。(对象的网络标识符)
3.对象所属的类标识符。
一、标记当前数据包是包含对象状态的数据包
enum PacketType { PT_Hello, PT_Replication, PT_Disconnect, PT_Max };
每个数据包可能有含有不同的功能,在数据包的第一个字段内指示该数据包的功能。
二、唯一标识对象的标识符
通过链接上下文记录每一个对象,可以为下一个对象生成唯一的标识符,也叫网络标识符。
class LinkingContext { public: LinkingContext():mNextNetworkId(1) { } uint32_t GetNetworkId(const GameObject* inGameObject, bool inShouldCreateIfNotFound) { auto it = mGameObjectToNetworkIdMap.find(inGameObject); if(it != mGameObjectToNetworkIdMap.end()) { return it->second; } else if(inShouldCreateIfNotFound) { uint32_t newNetworkId = mNextNetworkId++; AddGameObject(inGameObject, newNetworkId); return newNetworkId; } else { return 0; } } void AddGameObject(const GameObject *inGameObject, uint32_t inNetworkId) { mNetworkIdGameObjectMap[inNetworkId] = inGameObject; mGameObjectToNetworkIdMap[inGameObject] = inNetworkId; } void RemoveGameObject(GameObject *inGameObject) { uint32_t networkId = mGameObjectToNetworkIdMap[inGameObject]; mGameObjectToNetworkIdMap.erase(inGameObject); mNetworkIdGameObjectMap.erase(networkId); } private: std::unordered_map<uint32_t, GameObject*> mNetworkIdGameObjectMap; std::unordered_map<const GameObject*, uint32_t> mGameObjectToNetworkIdMap; uint32_t mNextNetworkId; };
三、对象所属的类标识符
为什么需要序列化对象所属类的标识符?
当远程主机收到数据包,首先会读取第一部分的数据得知这个是一个包含对象的数据包。然后读取第二部分的数据拿到这个对象标识符(对象网络标识符),远程主机通过这个对象标识符查找自己是否已经有这个对象,如果有,那么反序列化接收到的对象数据来更新对象状态。
如果没有找到对象,那么就需要创建这个对象。创建对象就需要这个对象类的信息。因此发送方在对象标识符后面序列化该对象类的标识符来提供远程主机创建对象的信息。
对象创建注册表(Object Creation Registry)
#define CLASS_IDENTIFICATION(inCode, inClass)\ enum {kClassId = inCode};\ virtual uint32_t GetClassId() const {return kClassId;}\ static GameObject *CreateInstance() {return new inClass();} class GameObject { public: CLASS_IDENTIFICATION('GOBJ', GameObject) virtual ~GameObject(); }; class RoboCat:public GameObject { public: CLASS_IDENTIFICATION('RBCT', RoboCat) }; typedef GameObject* (*GameObjectCreationFunc)(); class ObjectCreationRegistry { public: static ObjectCreationRegistry &Get() { static ObjectCreationRegistry sInstance; return sInstance; } template<class T> void RegistryCreationFunction() { assert(mNameToGameObjectCreationFunctionMap.find(T::kClassId) == mNameToGameObjectCreationFunctionMap.end()); mNameToGameObjectCreationFunctionMap[T::kClassId] = T::CreateInstance; } GameObject *CreateGameObject(uint32_t inClassId) { GameObjectCreationFunc creationFunc = mNameToGameObjectCreationFunctionMap[inClassId]; GameObject *gameObject = creationFunc(); return gameObject; } private: ObjectCreationRegistry(){} //value是每个对象类的创建函数的函数指针 std::unordered_map<uint32_t, GameObjectCreationFunc> mNameToGameObjectCreationFunctionMap; }; //在合适的位置调用以下函数,注册每个类对象的创建函数 void RegisterObjectCreation() { ObjectCreationRegistry::Get().RegistryCreationFunction<GameObject>(); ObjectCreationRegistry::Get().RegistryCreationFunction<RoboCat>(); }
对象创建注册表其实就是把所有类的创建函数放进了map数据结构里面。在合适的地方调用RegistryObjectCreation()来将类的创建函数放进map里面。
在这个过程中还是用到了很多小技巧简化代码的。
1.四元符定义和获取类的标识符还有调用创建函数的代码基本上都一致,因此利用了一个宏定义(CLASS_IDENTIFICATION)
2.通过模板的方式实现能对所有类都只调用一个函数将类的创建函数放入map容器里面。
游戏世界状态的复制同步
一、同步服务端世界所有对象的状态
如果服务端的所有游戏对象状态可以塞进一个数据包里面,那么其中一种同步方法就是在一个数据包里面包含世界所有对象的状态发送给客户端。
当接收端检测到数据包为同步对象状态的数据包,循环访问数据包中每个序列化的游戏对象。
如果游戏对象存在,客户端找到这个对象并反序列化状态到对象中。如果游戏对象不存在,那么客户端通过后面的类标识创建这个对象并放序列化状态到对象中。
当客户端处理完数据包,销毁所有未出现在数据包中的本地数据对象。
二、 只同步在服务端状态发生变化的对象状态
由于每台客户端都保存着自己世界状态的副本,因此没必要也不太可能在一个数据包里面复制所有的游戏对象。
更好的办法是选择一种叫世界状态增量(world state delta)的办法,服务端创建表示世界状态变化的数据包发送给客户端,客户端在自己的世界中更新这些状态。
每个世界状态增量包内部包含需要改变的对象的对象状态增量(object state delta)。
因此每个对象状态增量表示以下三种复制行为中的一种。
1.创建游戏对象
2.更新游戏对象
3.销毁游戏对象
由于采用更新对象状态增量的办法,因此应该新增新的数据包头类型。
局部对象状态的复制
当发送对象的状态更新时,因为客户端主机上保留了一份对象状态的副本,服务端并不需要再发送该对象的所有状态。服务端可以选择序列化自上次以来更新的某个数据。
因此可以位域来表示对象的每一个属性数据,在序列化对象数据之前先序列化这些位域的组合来表示哪些数
//依据下列的枚举值组合成一个数可以表示接下来的数据包含哪些数据 //例如:inProperties=MSP_Name|MSP_LegCount enum MouseStatus { MSP_Name = 1 << 0, MSP_LegCount = 1 << 1, MSP_HeadCount = 1 << 2, MSP_Heath = 1 << 3, MSP_MAX }; //序列化数据时候依据inProperties判断要序列化哪些数据 void MouseStatus::Write(OutputMemoryBitStream &inStream, uint32_t inProperties) { inStream.Write(inProperties, GetRequireBits<MSP_MAX>::Value); if ((inProperties & MSP_Name) != 0) { inStream.Write(mName); } if ((inProperties & MSP_LegCount) != 0) { inStream.Write(mLegCount); } if ((inProperties & MSP_HeadCount) != 0) { inStream.Write(mHeadCount); } if ((inProperties & MSP_Heath) != 0) { inStream.Write(mHeath); } } void MouseStatus::Read(InputMemoryBitStream &inStream) { uint32_t writtenProperties = 0; inStream.Read(writtenProperties, GetRequiredBits<MSP_MAX>::Value); if ((writtenProperties & MSP_Name) != 0) { inStream.Read(mName); } if ((writtenProperties & MSP_LegCount) != 0) { inStream.Read(mLegCount); } if ((writtenProperties & MSP_HeadCount) != 0) { inStream.Read(mHeadCount); } if ((writtenProperties & MSP_Heath) != 0) { inStream.Read(mHeath); } }
综上,一个较好的同步对象状态的数据包各个字段如下:
三、 远程过程调用的同步
远程过程调用(RPC)会当成特殊的对象进行同步。其中序列化和反序列化的对象网络标识符为PRC标识符,数据内容为函数的参数。其中不再需要类标识符了。
因为RPC被当成特殊的对象,因此需要有额外的字段表示RPC:
enum ReplicationAction { RA_Create, RA_Update, RA_Destroy, RA_RPC, RA_MAX };
当反序列化字段ReplicationAction发现值为:RA_RPC的时候,将输入流装交给RPC模块处理。此外不同于普通对象,RPC不需要类标识符。】
因此当数据包为传输RPC时各个字段如下:
RPC功能模块功能:
1.从每个RPC标识符到解封装胶水函数的映射,解封装胶水函数用于反序列化远程过程调用的参数并调用恰当的函数。
typedef void (*RPCUnwrapFunc) (InputMemoryBitStream&); class RPCManager { public: void RegisterUnwrapFunction(uint32_t inName, RPCUnwrapFunc inFunc) { assert(mNameToRPCTable.find(inName) == mNameToRPCTable.end()); mNameToRPCTable[inName] = inFunc; } void ProcessPRC(InputMemoryBitStream &inStream) { uint32_t name; inStream.Read(name); mNameToRPCTable[name](InputMemoryBitStream); } unordered_map<uint32_t, RPCUnwrapFunc> mNameToRPCTable; }; void UnwrapPlaySound(InputMemoryBitStream &inStream) { string soundName; Vector3 location; float volume; inStream.Read(soundName); inStream.Read(location); inStream.Read(volume); PlaySound(soundName, location, volume); } void RegisterRPCs(RPCManager *inRPCManager) { inRPCManager->RegisterUnwrapFunction('PSND', UnwrapPlaySound); }