八、起源引擎序列化和反序列化之元数据定义

736人浏览 / 0人评论

    在计算机语言里,序列化和反序列化是逃避不开的话题,将数据通过网络发送、数据保存到硬盘都离不开序列化。序列化 (Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程。游戏引擎序列化和反序列化的基本对象是实体,实体是游戏世界里的物体,它包含许多属性,如位置、朝向、模型、运动方式等,每个属性的类型大小不相同,硬编码读写每个属性的工作量太大不现实,一个设想是如果能想办法拿到实体总共多少个属性和每个属性的信息,就可以循环处理每个属性,用通用的代码来实现序列化。所以难点在于怎么拿到每个属性的描述信息。对于支持反射的计算机语言如Java很简单可以用原生的方法获取每个属性信息。而起源引擎使用的c++语言不支持反射,所以只能采用最笨的办法:给每个实体类手动维护一个信息表来实现。也可以叫元数据。这个表记录了这个实体类的名字,它有多少个属性,每个属性的名称,属性相对于实体数据起始位置的偏移,属性的类型是int型还是float型,或者是数组型,每个属性的字节大小,属性值的最小值,最大值,属性的标识等信息。在服务端这个表叫SendTable,客户端叫RecvTable。

    在服务端引擎初始化时,拿到游戏里所有的实体类信息表,依次编号。在客户端连接到服务端时下载服务端所有实体类信息表,然后和本地的实体类信息表用名称去一一匹配。如果不匹配说明两端代码不一致不能运行游戏,在需要序列化时实体时,引擎拿到实体对应的信息表,记录下信息表的编号到数据包,然后依次循环信息表里每一个属性,根据属性的偏移计算出内存数据起始位置,根据属性的大小计算出内存数据的结束位置,根据属性类型用对应的方法去读取这块内存,记录下每个属性的编号和它的编码后的值到数据包。然后将数据包发送到客户端,客户端在收到这个数据包后,就会根据数据包里标识的信息表编号找到匹配上的客户端实体类信息表,然后调用实体类的工厂方法构造出客户端实体,然后依次循环数据包里的属性值列表,根据属性的编号找到对应的客户端实体类信息表的对应属性,从而计算出实体的该属性的内存位置,然后根据属性类型用不同的方法解码数据包的属性值写入内存。从而完成了反序列化。以上是基本原理,下面看下起源引擎具体的实现方式。

    在服务端实体类cpp文件里能看到类似如下代码:

class CBaseEntity : public IServerEntity
{
public:
	DECLARE_SERVERCLASS();
}

IMPLEMENT_SERVERCLASS_ST_NOBASE( CBaseEntity, DT_BaseEntity )
	SendPropDataTable( "AnimTimeMustBeFirst", 0, &REFERENCE_SEND_TABLE(DT_AnimTimeMustBeFirst), SendProxy_ClientSideAnimation ),
	SendPropInt   (SENDINFO(m_flSimulationTime), SIMULATION_TIME_WINDOW_BITS, SPROP_UNSIGNED|SPROP_CHANGES_OFTEN|SPROP_ENCODED_AGAINST_TICKCOUNT, SendProxy_SimulationTime),
	...
	SendPropInt		(SENDINFO(m_bAnimatedEveryTick),		1, SPROP_UNSIGNED ),
	SendPropBool( SENDINFO( m_bAlternateSorting )),
#ifdef TF_DLL
	SendPropArray3( SENDINFO_ARRAY3(m_nModelIndexOverrides), SendPropInt( SENDINFO_ARRAY(m_nModelIndexOverrides), SP_MODEL_INDEX_BITS, 0 ) ),
#endif
END_SEND_TABLE()

    DECLARE_SERVERCLASS宏将实体类声明成支持序列化的,给实体类声明了几个方法。

#define DECLARE_SERVERCLASS()									\
	public:														\
		virtual ServerClass* GetServerClass();					\
		static SendTable *m_pClassSendTable;					\
		template <typename T> friend int ServerClassInit(T *);	\
		virtual int YouForgotToImplementOrDeclareServerClass();	\

    IMPLEMENT_SERVERCLASS_ST_NOBASE和IMPLEMENT_SERVERCLASS_ST宏定义了服务端实体类及其属性列表。需要传入类的名称如CBaseEntity,类的信息表的名称如DT_BaseEntity。带不带_NOBASE后缀的作用是有些类序列化时不需要父类的属性就定义成_NOBASE的。如果带上父类,在序列化时会先用父类属性列表序列化再用子类属性列表序列化。这个宏是将实体类定义宏IMPLEMENT_SERVERCLASS_INTERNAL和实体属性列表定义宏BEGIN_SEND_TABLE_NOBASE组合在了一起。

#define IMPLEMENT_SERVERCLASS_INTERNAL( DLLClassName, sendTable ) \
namespace sendTable \ 
{ \
 struct ignored; \
 extern SendTable g_SendTable; \
} \
CHECK_DECLARE_CLASS( DLLClassName, sendTable ) \
static ServerClass g_##DLLClassName##_ClassReg(\
 #DLLClassName, \
 &sendTable::g_SendTable\
); \
\
ServerClass* DLLClassName::GetServerClass() {return &g_##DLLClassName##_ClassReg;} \
SendTable *DLLClassName::m_pClassSendTable = &sendTable::g_SendTable;\
int DLLClassName::YouForgotToImplementOrDeclareServerClass() {return 0;}

    IMPLEMENT_SERVERCLASS_INTERNAL宏里做了两件事,一是用实体类名称和信息表指针构造出了ServerClass,二是实现了DECLARE_SERVERCLASS宏里声明的方法从而将实体类和它的信息表关联起来了。这样引擎就可以从实体拿到它的类定义和属性列表。

ServerClass( const char *pNetworkName, SendTable *pTable )
{
   m_pNetworkName = pNetworkName;
   m_pTable = pTable;
   m_InstanceBaselineIndex = INVALID_STRING_INDEX;
   // g_pServerClassHead is sorted alphabetically, so find the correct place to insert
   if ( !g_pServerClassHead )
   {
    g_pServerClassHead = this;
    m_pNext = NULL;
   }
   else
   {
    ServerClass *p1 = g_pServerClassHead;
    ServerClass *p2 = p1->m_pNext;
    // use _stricmp because Q_stricmp isn't hooked up properly yet
    if ( _stricmp( p1->GetName(), pNetworkName ) > 0)
    {
     m_pNext = g_pServerClassHead;
     g_pServerClassHead = this;
     p1 = NULL;
    }
    while( p1 )
    {
     if ( p2 == NULL || _stricmp( p2->GetName(), pNetworkName ) > 0)
     {
      m_pNext = p2;
      p1->m_pNext = this;
      break;
     }
     p1 = p2;
     p2 = p2->m_pNext;
    } 
   }
}
ServerClass* CServerGameDLL::GetAllServerClasses()
{
return g_pServerClassHead;
}    

在ServerClass构造函数里会将所有ServerClass的实例按名称排序后串成一条链。 这样引擎只要拿到链首地址遍历就可以得到所有服务端实体类信息表。

#define BEGIN_SEND_TABLE_NOBASE(className, tableName) \
template <typename T> int ServerClassInit(T *); \
namespace tableName { \
 struct ignored; \
} \
template <> int ServerClassInit<tableName::ignored>(tableName::ignored *); \
namespace tableName { \
 SendTable g_SendTable;\
 int g_SendTableInit = ServerClassInit((tableName::ignored *)NULL); \
} \
template <> int ServerClassInit<tableName::ignored>(tableName::ignored *) \
{ \
 typedef className currentSendDTClass; \
 static const char *g_pSendTableName = #tableName; \
 SendTable &sendTable = tableName::g_SendTable; \
 static SendProp g_SendProps[] = { \
  SendPropInt("should_never_see_this", 0, sizeof(int)),  // It adds a dummy property at the start so you can define "empty" SendTables.
#define END_SEND_TABLE() \
 };\
 sendTable.Construct(g_SendProps+1, sizeof(g_SendProps) / sizeof(SendProp) - 1, g_pSendTableName);\
 return 1; \
} 

    BEGIN_SEND_TABLE_NOBASE宏在命名空间里调用了一个模板方法初始化实体类属性列表,使用命名空间的作用是避免全局变量名冲突。这里构造了一个全局的数组g_SendProps,SendPropInt("should_never_see_this", 0, sizeof(int)),这一行是为了能够定义空的属性列表,这一行会在以后的处理中被排除掉。在END_SEND_TABLE这里才将属性列表数组g_SendProps补充完整,然后调用了信息表的构造函数。

SendProp SendPropInt(
	const char *pVarName,
	int offset,
	int sizeofVar=SIZEOF_IGNORE,	// Handled by SENDINFO macro.
	int nBits=-1,					// Set to -1 to automatically pick (max) number of bits based on size of element.
	int flags=0,
	SendVarProxyFn varProxy=0
	);
...

    SendPropXXX这些方法是用来描述实体类的属性的,格式大同小异。比如SendPropInt需要的参数有属性的名称,属性的偏移,属性的大小,属性的bit数,属性的标识,属性的自定义取值方法。这些方法将所有需要序列化的字段的信息传入实体类信息表。

    在客户端实体类cpp文件里能看到类似如下代码:

class C_BaseEntity : public IClientEntity
{
public:
	DECLARE_CLIENTCLASS();
    ...
}
IMPLEMENT_CLIENTCLASS(C_BaseEntity, DT_BaseEntity, CBaseEntity);
BEGIN_RECV_TABLE_NOBASE(C_BaseEntity, DT_BaseEntity)
	RecvPropDataTable( "AnimTimeMustBeFirst", 0, 0, &REFERENCE_RECV_TABLE(DT_AnimTimeMustBeFirst) ),
	RecvPropInt( RECVINFO(m_flSimulationTime), 0, RecvProxy_SimulationTime ),
	RecvPropInt( RECVINFO( m_ubInterpolationFrame ) ),
	...
	RecvPropBool ( RECVINFO( m_bAlternateSorting ) ),
	#ifdef TF_CLIENT_DLL
	RecvPropArray3( RECVINFO_ARRAY(m_nModelIndexOverrides), RecvPropInt( RECVINFO(m_nModelIndexOverrides[0]) ) ),
	#endif
END_RECV_TABLE()

    DECLARE_CLIENTCLASS宏将实体类声明为可序列化的,内部同样声明了几个方法。

#define DECLARE_CLIENTCLASS() \
virtual int YouForgotToImplementOrDeclareClientClass();\
virtual ClientClass* GetClientClass();\
static RecvTable *m_pClassRecvTable; \
DECLARE_CLIENTCLASS_NOBASE()

    IMPLEMENT_CLIENTCLASS是用来定义客户端实体类的,需要传入实体类的名称如C_BaseEntity,类的信息表的名称如DT_BaseEntity,还需要传入和服务端匹配的服务端类名称用做将来类匹配的依据。BEGIN_RECV_TABLE_NOBASE是用来定义客户端实体类属性列表的。

#define IMPLEMENT_CLIENTCLASS(clientClassName, dataTable, serverClassName) \
INTERNAL_IMPLEMENT_CLIENTCLASS_PROLOGUE(clientClassName, dataTable, serverClassName) \
static IClientNetworkable* _##clientClassName##_CreateObject( int entnum, int serialNum ) \
{ \
 clientClassName *pRet = new clientClassName; \
 if ( !pRet ) \
  return 0; \
 pRet->Init( entnum, serialNum ); \
 return pRet; \
} \
ClientClass __g_##clientClassName##ClientClass(#serverClassName, \
            _##clientClassName##_CreateObject, \
            NULL,\
            &dataTable::g_RecvTable);

    IMPLEMENT_CLIENTCLASS宏做了两件事,一是实现了DECLARE_CLIENTCLASS声明的那几个方法,从而将实体和信息表关联起来了。二是用匹配的服务端类名字,实体类构造工厂方法,和信息表构造了ClientClass。这儿工厂方法是用于引擎根据服务端数据包动态创建客户端实体使用的。

ClientClass( const char *pNetworkName, CreateClientClassFn createFn, CreateEventFn createEventFn, RecvTable *pRecvTable )
{
m_pNetworkName = pNetworkName;
m_pCreateFn  = createFn;
m_pCreateEventFn= createEventFn;
m_pRecvTable = pRecvTable;
// Link it in
m_pNext    = g_pClientClassHead;
g_pClientClassHead = this;
}
ClientClass *CHLClient::GetAllClasses( void )
{
return g_pClientClassHead;
}    

ClientClass内部同样将所有ClientClass的实例串成了一条链。但是没有排序。同样有给引擎提供的获取信息表链的方法。

#define BEGIN_RECV_TABLE_NOBASE(className, tableName) \
template <typename T> int ClientClassInit(T *); \
namespace tableName { \
 struct ignored; \
} \
template <> int ClientClassInit<tableName::ignored>(tableName::ignored *); \
namespace tableName { \
 RecvTable g_RecvTable; \
 int g_RecvTableInit = ClientClassInit((tableName::ignored *)NULL); \
} \
template <> int ClientClassInit<tableName::ignored>(tableName::ignored *) \
{ \
 typedef className currentRecvDTClass; \
 const char *pRecvTableName = #tableName; \
 RecvTable &RecvTable = tableName::g_RecvTable; \
 static RecvProp RecvProps[] = { \
  RecvPropInt("should_never_see_this", 0, sizeof(int)),  // It adds a dummy property at the start so you can define "empty" SendTables.
  
#define END_RECV_TABLE() \
  }; \
 RecvTable.Construct(RecvProps+1, sizeof(RecvProps) / sizeof(RecvProp) - 1, pRecvTableName); \
 return 1; \
}

    BEGIN_RECV_TABLE_NOBASE和END_RECV_TABLE功能是开始定义属性列表和结束定义属性列表,细节和服务端类似。

RecvProp RecvPropInt(
const char *pVarName, 
int offset, 
int sizeofVar=SIZEOF_IGNORE, // Handled by RECVINFO macro, but set to SIZEOF_IGNORE if you don't want to bother.
int flags=0, 
RecvVarProxyFn varProxy=0
);
...

    RecvPropXXX这些方法是用来描述客户端实体类的属性的,格式大同小异。比如RecvPropInt需要的参数有属性的名称,属性的偏移,属性的大小,属性的标识,属性的自定义赋值方法。这些方法将所有需要序列化的字段的信息传入实体类信息表。

    通过这些宏和方法的组合,给所有需要序列化的实体类设置了信息表注册全局变量,是一个大工程。在game.dll和client.dll加载的时候会初始化这些全局变量,dll加载完成后,引擎就可以调用接口方法拿到这些信息表做后续处理。

 

全部评论