原文地址:
http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/
写在翻译之前
在遇见Unity3D之前我对物件/组件模型知之甚少,接触了Unity3D之后便对这种模式带来的优势所深深吸引,后来自己项目组也开始渐渐引入这种开发模式,自己也很想对此有所总结有所积累。在自己行文之前很怕自己考虑不够,所以先翻译一篇这方面非常有价值的博文。
本文中作者称【物件】为【实体】,它【Entity】与Unity3D中的【GameObject】几乎是等价的概念。为了保持一致性,我也在翻译时采用此种译法,读者切勿见怪。:)
用组件的方式来重构你的游戏物件
近年来,游戏开发者总是坚持使用层次深邃的继承结构来实现游戏中的物件。现在的趋势已经朝着通过聚合不同功能的组件来实现形形色色的游戏物件的方向前进,进而替代原有的深层次继承结构。本文的主要目的在于介绍这种实现方式的优势以及在实践过程中值得思考的几个细节。我会在后文中将我在一个大型游戏开发项目中使用这种开发模式的经验分享给大家,包括说服程序员以及管理层理解并使用这种开发模式的理由。
游戏物件
不同的游戏会对游戏物件有各种不同的需求,但是大多数游戏中游戏物件的概念都是极其类似的。游戏物件既是在游戏世界中存在的某种物件,通常它能被玩家看见而且可以在场景中走走停停。
这是一些简单的游戏物件的例子:
* Missile 导弹
* Car 骑车
* Tank 坦克
* Grenade 手雷
* Gun 枪支
* Hero 英雄
* Pedestrian 路人
* Alien 外星人
* Jetpack 火箭背包
* Med-kit 医疗箱
* Rock 岩石
游戏物件通常可以做各式各样的事情,下面这些是一些典型案例:
* 执行一段脚本
* 移动
* 作为物理引擎物件和其他物件发生碰撞
* 发射粒子
* 播放声音
* 被玩家拾取
* 被玩家装备
* 爆炸
* 和磁场交互
* 被玩家选为目标
* 寻路
* 播放动画
传统的深层次继承结构
图1
游戏物件在过往的游戏开发过程中往往是通过这种“腐坏的面向对象方式”呈现的。游戏开发者最初这么设计往往出自好意,但是在游戏开发中如果遇见需求快速变更,甚至当下的游戏引擎要被用于另外一个类型的游戏的时候这种结构便捉襟见肘。通常在图1这种复杂程度就很难进展,往往在大型游戏项目中继承关系的树状结构更是深邃迷离。(译者注:不管是自己在大学懵懂受OO之害自己开发的小项目,还是初在职场时项目最初的形态都证明了这一点)
实际的游戏开发过程中,游戏物件通常都会承载各式各样的功能。这种结构下的游戏物件们要么自身实现这个功能,不然就得作为有此种功能的物件的子类。通常这些功能被实现在继承树中靠近根节点的类上,比如CEntity中。这样做的好处在于所有继承于CEntity的子类都拥有了此功能,坏处便在于给了子类太多的负担。
如果功能都实现在CEntity中,本应该很简单的岩石(Rock)和手雷(Grenade)便具有了很多额外的功能,包括一堆不会被调用的成员函数以及一堆无意义的成员变量。通常传统的继承层次的开发模式都会以创造出“一滩浆糊(原文为blob,意为:难以名状的一团)”一样的类型而告终。这种实现了无数错综复杂的功能的blob类型通常是继承树中的一个巨大枝节上的节点,它简直就是“反模式”的典型存在!
这种反模式的blob节点通常在物件继承结构的根部出现,同样在叶子节点也有可能出现。最容易形成这种毒瘤的节点便是用来代表游戏玩家的类。由于大部分单机游戏只有一个玩家操纵的角色,所以这个类型便会拥有无数的功能,通常它会被实现为一个含有数不清的接口的类——CPlayer。
在靠近根节点处实现功能的坏处既是让之下的叶子节点负担了许多它们并不需要的功能。在叶子节点实现功能的方式也非明智之举,这样一些常用功能便被四分五裂,仅仅只有那些特有的子类可以使用这些功能。这样无数的重复代码便会出现在各个叶子节点。最后的结果便是如此混乱的结构需要一次重构,继承树的节点会被重组或者功能会被在树各层节点上进行搬移。(译者注:如果做过类似的工作,就知道这是多么痛苦的事情 T T)
举个实例,刚体(Rigid Body)通常作为物理引擎中会和其他物件发生交互的物件存在,但并不是游戏世界中所有的物件都需要有这般能耐。比如图1中,岩石(CRock)和手雷(CGrenade)继承自CRigid,若是汽车(Vehicle)也需要有这个功能怎么办?那么只能将CRigid往靠近继承树根节点的位置移动,最终会导致它变为我们刚才提到的“一滩浆糊”——拥有所有一个枝节上的类该具有的功能,但是被所有的子类继承着……
组件的集合
物件聚合组件的模式在目前的游戏开发中越来越被接受,组件的功能是单一的并尽量和其他组件无关。这正式传统继承树模式下被忽略和无法企及的,采用这种组合模式后物件将会是一些特定功能的组件的集合。
每个物件现在只会拥有它所需要的功能,一个新功能只需要扩展出一种组件即可。
物件/组件模式的具体实现方式大致可以是如下三种之一,这三种方法也可以看做是把一个blob类重构为这种模式的三个步骤。
1 将物件实现为组织有序的功能聚合体
重构blob物件的通用方法既是将其拆分为多个子物件,各个子物件分别承担相应的功能,并由原来的物件引用拆分出来的子物件。最终这个原有的物件便会变为一个包含有各个子物件的结构体,它的接口实现也会被实现为间接调用这些子物件的具现接口。(译者注:类似Strategy模式)
如果你的游戏物件的功能较少或者时间有限的话那么目前的拆分就基本足够了。你可以选择在实现任意一类物件时讲子功能物件保持为空。如果子功能物件的数量很少,保持目前的结构可以让你省去编写一整套组件管理框架,并让你的代码聚合保持轻量。
这种模式的缺点既是这个物件依然是一个blob物件,所有的功能依然聚合在了一个大物件中。如果你采取这种方式开发,你很难让这个类一直作为子功能物件指针的集合(总是会往其中添加一些成员变量和接口),久而久之还是可能出现前文中提到的继承树模式的缺点。而且在物件的更新(Update)过程中你还必须对各个子功能物件的指针进行空指针检查。(编者注:虽然这类代码很难避免,但是我依然觉得它十分破坏代码的美感)
2 将物件实现为组件容器
下一步骤的重构就是讲各个子功能物件抽象为【组件】,即所有的功能物件都共享一个基类,游戏物件便可以用数据容器持有多个组件。
但这样的实现并不彻底,我们依然有一个根物件用来代表我们游戏世界中的物体。不过这样子的结构已经比较合理并且实用了,它对于一些依赖游戏物件作为具现类的旧代码而言已经可以运行了。
重构到现在,你的游戏物件已经不再是以前的功能载体,而逐渐变为了一个连接着具体功能组件和遗留代码之间的接口类,一个腐朽代码库和一个崭新架构的桥梁。如果时间允许,最后你还可以把游戏物件实现得更为整体和单一,最终把引用原有物件的代码转换为直接依赖它所聚合的组件。最后的最后,你的物件类将会变为单纯的组件聚合体。
3 将物件实现为单纯的组件聚合体
重构的最后一步,物件将会变为组成它功能的集合体。图2展示的是各个游戏物件是如何被组件所组合而成的。最后各个用来具象化代表汽车、玩家、目标的类将会消失。图标中的每一列都代表一个特定的功能组件,每一行都代表一类具有多个特定功能的物件。在物件中,这些组件之间是独立的(译者注:功能组件应该尽量少的耦合)。
图2
真实的经历
我第一次实现物件/组件模型是在Neversoft开发Tony Hawk系列作品的时候。当时我们的游戏物件系统已经被用于开发过了3个连续作品,日积月累所致我们的物件继承结构变为了我之前提到的反模式的blob类的寄生处。物件的负担日益增重的问题也越演越烈,它们持有了太多根本不需要的功能和数据,然后代码维护变难、重复代码滋生、游戏效率变低,惨不忍睹……
我在sweng-gamedev邮件组中第一次听说了这种组件模型的想法,然后便跃跃欲试,经过两年的重构,这个系统便完成了。
为什么它要花这么长时间?好吧,首先我们是有版本压力的——每年一作Tony Hawk;其次我在最初没有能预估到这个工作的规模……一个三年多的遗留代码库包含了太多太多的腐坏的代码,其中对各个游戏物件的具象类的依赖数不胜数,由此可见这必须得花些时间。
所遇的阻力
在整个重构过程中我遇见的第一个问题就是将这套系统的优劣给其他程序员解释。如果你无法理解这套系统的好处,那么在你眼中接下来要做的事情就是无目的,不必要,忒复杂的工作……在原有物件继承模式下工作过多年的程序员已经习惯了那种设计思路和工作方式,甚至在这种模式下他们非常能干,基本任何问题都能找到解决方案。
想要传教这种新鲜思想的确很难,你必须用平淡的语言去描述这种模式如何提高游戏的开发效率。下面是一些典型的对话:
“现在要搞一些新功能到我们的游戏中要花太多的时间,而且现在的模式有很多bug啊!如果我们现在改为物件/组件模式开发,它会让我们增加新功能时效率更高,Bug更少。”
我说服他们改变的方式并不是正大光明的途径,首先我和几个程序员兄弟私下讨论这种开发模式并且说服他们这是一个非常棒的想法。然后我自己实现了一套基本框架,然后把原有的游戏物件的一小部分转为了组件的方式实现。
然后我把这些工作结果给其他程序员展示,之前他们的迷惑和困扰都被可以运行的代码给一一击破。
缓慢的过程
框架实现后,将物件继承树转换为物件/组件模式的工作进展得很缓慢。这是一份很枯燥的工作,你必须花非常多的时间将原有的代码和功能放置到新的地方,形成新的组件……而且我们还要为接下来的游戏版本实现更多新的功能。
像之前说的那样,我们遇见的最大的困难便是把“玩滑板的人”这个类重构(Tony Hawk是一款滑板游戏,谁不知道呢?呵呵;)。它含有了太多太多的功能,基本上不可能一次性把这些功能抽离出来。而且在游戏系统没有整个使用组件模型来工作之前,重构工作都是完不成的,除非“玩滑板的人”自身是一个组件……
在此处我们创造了一个“blob组件”,它就像刚才说的那样,是一个巨大的包含了无数功能的“玩滑板的组件”。然后我们终于把原有的物件继承树硬生生的变成了物件/组件模式。然后我们再花心思来把这个blob组件拆分为一个个的原子组件。
结果
最初这次重构的结果基本上是难以名状的。不过慢慢的代码变得更加清晰,维护一个个功能单一的组件变得更为轻松。创建一个个的物件只需要聚合一些个组件即可。
我们还创建了一套数据驱动的组件创建系统,设计师们便可以通过自己的意愿创造出各种各样的物件(译者注:基本上就是Unity3D的模式)。这个系统在实际开发中发挥了至关重要的作用,它极大的提高了设计师创造新功能物件的效率。
最后程序员们都爱上了组件系统。新功能依赖组件模式开发,bug变少了,代码更易维护和重用。
实现细节
组件有统一的接口即意味着具象的组件通过继承抽象基类并实现基类接口来实现。这些虚接口有一些额外的开销,不要让这些虚函数开销影响你继续下去,这些开销和继承树结构下的接口调用开销相比根本算不上什么。
每个组件都有一些统一的接口,那么要在每个组件里面加上一些帮助调试的成员函数就非常简单。这比在各类具象子类上去写输出调试信息的代码要轻松多了,每个组件只需要实现自身的部分就好。这之后我们对每个物件都可以实现实时监控(译者注:就像Unity3D一样:),这在之前的继承树结构下是不可能完成的任务。
理想状况下,各个组件不应该知道其他组件的具体实现。不过,实践中往往会出现组件间的功能依赖,那么在访问其他组件的过程中就会产生一些效率问题。解决方案便是组件间的访问需要一种非常快速的实现。最开始我们让组件之间的相互访问都通过组件管理器,最后测试发现这大概花销了5%的CUP时间,最后还是让组件间存储其他组件的指针直接调用。(译者注:实际开发过程中,还是应该注意尽量不要让组件间耦合)
物件聚合组件的顺序是很重要的,最开始我们没有在意这些。每个组件按任意的顺序放在物件之中,然后各自被Update,这样很难解决组件在更新顺序上出现的细节问题。
组件的创建是数据驱动的,那么组件在物件中的顺序就很难得以保证。如果一个物件的物理信息在动画信息之前更新,另外一个物件却不是如此,那么他们之间就会产生一些细微的差异(译者注:3D游戏中动画更新会影响骨骼以及各个用于发生碰撞的绑定盒)。这些依赖是不可避免的,所以在代码中一定要记得处理它们。
结论
将继承树重构为组件模型是我做过的最棒的决定。最开始有一些失望,因为在重构遗留代码时花掉了太多的时间。不过最后它所展现出的轻量、灵活、健壮和可重用的特性都证明这些是值得的。
引用
Scott Bilas: GDC 2002 Presentation: A Data-Driven Game Object System
http://scottbilas.com/files/2002/gdc_san_jose/game_objects_slides.pdf
Bjarne Rene: Component Based Object Management. Game Programming Gems 5, 2005, page 25.
Kyle Wilson: Game Object Structure: Inheritence vs Aggregation, 2002
http://gamearchitect.net/Articles/GameObjects1.html