游戏引擎C++反射和序列化方案心得
凡是上了点规模的游戏引擎,都需要面对C++反射和序列化的问题。反射和序列化对游戏引擎非常重要,因为在游戏里,需要处理大量的数据和对象,数据驱动的编程模式是非常普遍的。比如对于World Editor,有了反射机制,就能很方便的建立起对象属性和编辑操作的映射(也可以手动hard code去把对象属性列举出来,但这样只能应付小规模的项目)。
先来说说反射,反射需要解决两个问题,一是能根据某种标识符动态生成对象,二是能自动提取对象的Meta Data。Java或C#这一类原生支持反射的语言还能够动态查找或执行对象的成员Method,但个人觉得这个对游戏引擎而言不是很重要。对于第一点,动态生成对象的标识符可以是字符串或者数字ID,说白了就是下面这种形式:
MyClass* obj = CreateInstance("MyClass");
具体实现起来没什么难度,基本上就是一个map里保存下ID和构造函数的函数指针,然后使用的时候去map里查找。
提取对象的Meta Data有一定的难度,并且做法也比较多,目前知道的几种做法包括:
- 自己修改原生C++,实现一套元对象系统。典型案例有Qt和UE4,两者的具体做法是开发一个预处理器,根据一些规则去解析cpp代码然后生成新的cpp代码,Qt的QObject和MOC以及UE4的UObject和UHT都是这个思路。这么做的好处就是能够完全按照自己的规则来实现,能满足各种复杂需求(比如UE4的Blueprint),是一种“重量级”的解决方案,很有技术含量。缺点就是复杂度高,难以理解,开发和维护成本较高。
- 借助其他语言的反射机制,很多脚本语言就支持反射,比如Python。以游戏界常用的Lua为例,Lua虽然没有反射的概念,但是Lua有强大的table类型,可以借助table来生成对象的反射数据。比如可以定义一个Lua table来表示游戏里的怪物:
Monster = { ["Position"] = {0.0,0.0,0.0}, ["Rotation"] = {0.0,0.0,0.0}, ["Scale"] = {1.0,1.0,1.0}, ["Healthy"] = 100, ["Attack"] = 10 }
- 完全使用原生C++,用宏,模板技巧抽取对象的反射信息,或者借助Boost之类的库,这样做对外界的依赖是最小的,但灵活性和代码可读性会差一些。
有了反射机制,就能开发一个通用的系统去自动序列化任何包含反射信息的C++对象,将其存储成相应的格式,并且包含对象的标识符,这样就能根据标识符讲对象反序列化出来。
序列化有一种“重量级”的做法:生成一个完整的对象运行时二进制映像,也就是将对象在运行时的整个内存布局完整地保存下来,然后反序列化的时候就能生成和运行时一模一样的对象,这样做的好处就是能实现真正意义上的保存和再现,所有的数据都是完整的。但是实现起来非常复杂,首先C++对象的内存布局牵扯到诸如虚函数表虚拟继承等问题,不同的编译器有不同的实现,然后对于指针类型还需要靠存储额外的指针修正表来恢复所有的指针数据。目前所知道的只有Havok引擎实现了类似的系统。
《游戏引擎架构》一书中作者介绍了一种“生成器类型”的架构,简单说来就是把游戏对象的描述抽取出来,这部分数据成为Spawner,Spawner是轻量级的数据类型,仅包含数据的表示方式,用于在运行时实例化游戏对象。Spawner包含游戏对象的类型标识符和简单的键值对,比如对于一个GameObject,设计一个对应的GameObjectSpawner:
struct GameObjectSpawner { Vector m_Position; Vector m_Rotation; Vector m_Scale; MeshInstance* m_Mesh; };
使用的时候,只需要用Spawner去生成对象即可:
GameObjectSpawner* pSpawner = LoadFromFile();
GameObject* obj = SpawnFrom(pSpawner);
Spawner的类型很简单,可以使用脚本或者原生C++去实现,书中作者推荐的方法是使用脚本语言,这样做的好处是简单、低耦合、鲁棒。Spawner模块无需深入了解引擎对象的细节,保持了很松散的耦合。同时其他不了解C++的人员也可以使用,运行时的对象也不会因为Spawner的错误而崩溃。
序列化还有一个问题就是数据的版本兼容,一种简单保险的做法是给每个数据一个默认值,这样如果读到旧的文件,文件中没有此数据就使用默认值。但有时候情况更为复杂,比如旧版本中的数据中y轴是向上的,新版本中z轴是向上的,这时候就需要根据版本号自行转换数据,很难有完美的解决方案,只能直接hardcode各种版本兼容的代码,虽然不美观,但总归是能运行的。