谈“GDC游戏对象结构会议记要”本文所基于的《记要》并不是最新的,只是因为有参考价值,拿来说说。
游戏对象结构圆桌会议讨论了游戏对象系统设计中的常见问题,由数百人参加,包括来自Gnostic Labs、Ion Storm、Sammy Studios、Gas Powered Games、Sony和Cyan等公司的代表。
聚合与继承要点:有广泛地避免深层继承结构的动向,甚至有关于深层继承到底是传统C++设计方法还是糟糕的设计的争论。大部分开发人员都熟悉继承结构的缺点,尤其是对不断变化的需求的灵活性。
这个话题并不是游戏领域特有的,而作为基本的编程理论,已经经过了足有的讨论和验证。在控制不要有过深的继承层次上已经没有多少争议,对接口的使用和倾向于不使用多重继承也已广泛地被接受。
在详细分工的项目里,接口经常也被用来隔离各模块。这种模块间只通过接口交互的架构可以充分实现各模块的独立性,便于模块的替换和独立测试,尤其在C++里可以避免包含大量的对消费模块不必要的头文件,大大加快了编译速度。但做这种设计是很有挑战性的,因为要找到代码的引用关系网中最小的交互界面,而且随着实现工作的展开,可能需要对接口进行修改,如果经常需要修改接口,那么接口的优势就被抵消了。
数据驱动设计要点:几乎所有人都认同数据驱动是好方法,缺点是不太好调试。
经典的对象继承结构来描述游戏中的对象的方法的主要缺点是要由程序员来实现易变的游戏设计内容,会造成继承结构和代码实现的不断变动,最终使一个完美的设计乱作一团。数据驱动明智地把内容设计交还给内容设计人员,对游戏对象的设计的变动不再需要修改程序,大大加快了制作、测试过程。
数据驱动设计的重点是设计行为可由数据定制的对象和设计数据的表达形式。数据一般倾向于使用纯文本,便于编辑和版本兼容;可以是INI格式的,XML的,CSV表格的,或者自定义的结构化格式,也有用脚本语言的。数据驱动是很迷人的设计,但缺乏较好的数据内容编辑工具又会使我们远离目标,因为随着数据规模的变大,手工维护和查错会变得越来越困难。
Scene Graph要点:绝大部分都使用某种混合模式,而不是使用纯Scene Graph或者不使用。
大部分开发者使用把活动游戏对象放到一个扁平表里而把场景对象组织在一个Scene Graph里的混合模式。而与此相悖,大部分3D引擎却提供纯Scene Graph结构,当然其中有些可以比较容易的混合进去一块扁平结构,但相当一部分的Scene Graph设计难于实现混合结构,尤其是商业应用较少的引擎。
在制作一个完整的项目时,我们希望各个模块最好都是组件而不是框架,因为项目只能有一个框架,基于框架的模块会难于集成。但3D引擎的Scene Graph是个两难问题,如果提供Scene Graph必然会产生框架,很多设计者便会很自然的把输入处理、窗口处理等各种功能框入Scene Graph框架,所以很多游戏不得不以显示引擎的框架作为主框架。
大部分3D引擎都提供一个纯Scene Graph结构,但正如生物学上的杂种优势一样,不纯粹的结构往往更有生命力,所以不必忌惮引入其他结构,一把钥匙开一把锁,每个部分都可以用最合适的结构,即使这可能有悖于你的程序美学。
数据库要点:大家对数据库的性能都有所抱怨,但在大型网络游戏上对数据库还是很有兴趣。
数据库和大型网络游戏看起来很般配,但由于游戏的实时性,数据库显得太慢了,但在很多对实时性要求不高的局部模块,数据库还是能提供很多便利,尤其在查询、数据安全、缓存、事务和多线程的支持上。
不过在OOP盛行的现代,我们都喜欢对象结构的数据,在使用数据库时可能会面对O/R Mapping(对象-关系映射)的问题。这个问题在Java或dotNet上不是什么新鲜玩意,但出现在C++的工程里就不是那么有趣了,尤其是对象结构复杂、有很多动态集合的时候。
运行时类信息要点:大部分人都使用RTTI,但对C++自身的RTTI的性能不甚满意。很多人使用虚方法GetType()返回一个枚举来判断,但没人使用COM的QueryInterface的模式。
很多开发团队都使用宏或模板设计实现自己的RTTI系统,一小部分团队还在类信息里加入的反射功能。
对RTTI的使用应该是谨慎且节俭的,因为RTTI有较大的性能代价,尤其是在C++这种允许多重继承的语言里。有种观点甚至认为在强类型语言中,良好设计的程序是不需要RTTI的。但RTTI在自动处理未知对象上大有用武之地,如通用的游戏对象编辑器中,可通过运行时类信息来列举对象的所有属性。不过C++内置的RTTI过于精简,MFC中的CClassInfo提供了一个自己实现RTTI的例子。
多线程要点:大部分人主张多线程应在有限的局部使用,以免使大部分代码都不得不写成线程安全的而大大增加复杂度且严重影响性能。而对兴起中的SMP系统没有多少考虑。
现在的3D渲染部分已经不需要放到独立的线程里了,因为API层内部已经封装了渲染线程,所以只剩下后台装载数据等比较有理由用到多线程的地方。但随着超线程、多内核的出现,SMP(对称多处理器)结构将成为未来的主流,这时多几个线程可以得到更高的处理能力。但并行计算还没有引起游戏领域太多的兴趣,主要是缺少这方面的研究。
工具要点:比较意外,只有三分之一的人表示他们使用游戏编辑器,不到十分之一的人有独立的对象编辑器,还有一小部分人使用作为3DS Max插件的编辑器。似乎普遍使用脚本来布置对象和设置属性。
工具是人类起源的原因,也是大部分软件的用途所在,能把越多的东西交给计算机做,我们需要做的工作就越少。在一个游戏的开发中,工具开发占了程序开发部分很大的比重。但也有团队只用脚本和配置数据,只依赖于很少的简单工具。这似乎和团队的人员构成有关,技术背景较多的更倾向于脚本和数据配置,以获得完全的控制能力和灵活性,并可以利用各种文本编辑工具进行批量编辑、生成、追踪版本差异;而非技术背景较多的则倾向于直观、所见即所得的编辑工具。
游戏编辑工具往往和游戏本身的代码有依赖关系,尤其是在使用自定义的数据格式时。要消减这种由数据格式造成的依赖性,XML是个不错的媒介,当然还有很多简单的文本格式。
对象ID要点:基本上是各显神通,但通常都有一个主要由脚本调用的字符串标识和一个内部的数字标识。
使用ID引用对象的主要原因包含:对象间有较多的相互引用,而且对象可能随时被删除,这时需要使所有对它的引用失效;需要在网络环境下唯一标识对象;在磁盘上保存游戏状态;为脚本提供对对象的引用。
ID必须是全局唯一的,并且可以快速转换到对象指针。在分布式环境下,要只有一个节点负责分配ID,以保证唯一性,但其他节点可以预先申请并缓存一些可用ID。注意避免刚释放的ID立刻再分配后导致仍然存在的对该ID的引用指向错误的对象,可以尽量推迟ID再分配的时间,或者在ID中加入递增的区别位,使每次再分配的ID具有不同的区别位也就成了一个不同的ID。
序列化和持久化要点:明确区分用于保存的序列化和用于网络传输的序列化。保存的重点在版本兼容,传输的重点在压缩数据量。
保存数据时,二进制方式是最快最紧凑的方式,但版本兼容很难。在序列化对象时,保存一个版本号在应付复杂一点的情况时就很棘手,试想当在基类加了一个数据成员而几个子类当前处于不同的版本号时……
XML被炒得很热,但对它主要有两点抱怨:一是需要一个重量级的解析模块,而且由于语法复杂,解析速度很慢;二是XML并不简洁,不易手工编辑,容易有简单但难于发现的文法错误。但XML能表达复杂的对象结构,又有现成可用的解析代码,又比较时髦,对于较新的游戏项目还是比较有吸引力。
很多经典引擎都定义了自己的简洁的结构化文本格式,并被广泛应用。这些格式大多偏爱INI格式中的key=value的简洁形式,在此基础上加上了结构块的语法。
网络传输上大部分选择了二进制数据流,但也有少数使用文本的脚本语句。文本协议用于网络传输上同样可以实现服务器和客户端间的不同版本的兼容,当然其代价是要视情况好好权衡的。