游戏架构之继承对象模型和组件对象模型

1.概述:

在所有游戏性架构相关的内容中,运行时对象模型可能是最复杂的系统,并且不同的游戏引擎呈现出的差异极大。例如Unity3D提供的组件模型,虚幻引擎提供的面向对象继承模型,其他一些游戏则使用一种不同于两者的基于数据驱动的游戏对象模型。

这些不同的游戏对象模型之间呈现出很大的设计思维及使用上的差异,但是它们往往都提供或必须具备一些通用的功能,这些包括但不限于:

  1. 管理游戏对象的创建与销毁。许多游戏引擎都提供一种统一的动态创建、销毁游戏对象的方式,并管理游戏对象的内存及资源。比如Genius-X框架中使用createEntity()、removeEntity(entity)来动态创建、销毁一个游戏对象。但是在Cocos2dx中却不是通过统一的方式创建和销毁游戏对象它是使用每个Node的子类自己的构造函数来创建,并使一种特殊的方式管理内存。
  2. 联系底层游戏引擎。每个游戏对象要能够通过某种方式访问底层游戏引擎系统。例如能够渲染三角形网格、执行碰撞检测,对角色执行动画等。在Unity中可以通过添加一个底层引擎功能相关的组件(Compenent)来访问底层引擎系统。Cocos2dx的Node集成了物理模拟、动画、实时逻辑更新等接口。
  3. 实时模拟对象行为。游戏是一个高度实时的系统,游戏对象的状态、行为在每被都可能会随着时间发生变化,这需要一套高效的游戏对象更新机制。对象可能需要以特定的逻辑次序进行更新。此外,除了逻辑次序,游戏对象之间还可能存在依赖关系,要按照一定的次序更新。
  4. 定义新游戏对象类型。在开发过程中,随着游戏需求的改变和演进,游戏对象模型必须有足够的弹性,可以容易地加入新的对象类型。理想情况下,新的游戏类型应可以完全用数据驱动的方式定义,但是在实际情况中,大部分新增游戏类型都需要程序的参与。在Genius-x框架中,新的不同行为的组合类型可以通过修改数据文件来实现,而新的行为则可以通过脚本来实现,然后修改数据文件来添加新的行为。
  5. 唯一的对象标识符。游戏世界可能会包含成百上千的游戏对象,在运行时必须能够识别和找到想要的对象,这意味着每种对象需要有某种唯一标识符。例如在Cocos2d-x 中可以通过给一个 Node 指定一个字符串名称,然后通过字符串标识符查找一个个游戏对象。
  6. 游戏对象查询。除了上面按游戏唯一标识付查询游戏对家,引擎框架还需要些更高级的查询方式,例如找到某种类型的游戏对象,或者某个范围的敌人等。在面向组件或者属性的架构中,游戏对象是以组件/属性为单位存储的,很容易查找具有某个属性类型的游戏对象组合,并且这种查找游戏对象的方式对数据驱动更友好,笔者称之为面向类型编程。

除了上述提到的这些,运行时游戏对象模型还包括有限状态机,用于同一个网络内的对象复制、对象序列化和持久性存档等。

2. 运行时游戏对象模型

不同的游戏引擎对运行时游戏对象模型有不同的设计,但是大多数游戏引擎采用的架构风格可以归为以下两类。

(1)以对象为中心。在这种架构设计风格中,每种游戏对象包含一个类型定义,该类型定义包含游戏对象的属性及行为,游戏世界中的每一个游戏对象都是这些类型定义的一个实例。新增一个游戏对象类型可能需要新增一个类型定义。

(2)**以属性为中心。**在这种架构设计风格中,每个游戏对象仅用一个唯一的标识符表示。每个对象的属性分布于多个数据表,每个属性类型对应一个表,这些属性以对象标识符为键。属性本身通常是实现为硬编码的类的实例,而游戏对象的行为则是隐含地由它组成的属性集合定义。例如,若某对象含有"血量"属性,则该对象就能被攻击、扣血,并最终死亡(

继承对象模型

在以对象为中心的架构中,每个逻辑对象会实现为类的实例。如下图所示是一个典型的以对象为中心的架构图。

借助面向对象语言继承、重载等功能,我们几乎能为所有的游戏对象构建特定的类型定义。很多游戏引擎都是按照以对象为中心的架构来设计的,例如虚幻引擎所有元素继承自Actor类,Cocos2d-x引擎每个元素则都是Node 的子类。但是在实践中以对象为中心的架构设计通常面临着很多问题,下面我们来讨论其中的一些问题。

继承对象模型存在的问题:

1.复杂的层次结构

以对象为中心的设计能够很方便地使用分类学对对象进行分类,通常在游戏设计早期,程序员都会根据游戏需求,选择某个分类方式对对象进行分类,并用很直观的图形来描述类层次结构树。

刚开始时,游戏对象的类层次结构非常简单。然而随着设计的推进,越来越多的类型被加入进来,对象层次结构就会在纵、横方向不断膨胀,并且所有对象往往都是继承自一个基类,形成一个十分庞大的类层次结构。例如在 Cocos2d-x 中,所有的 UI元素都继承自Node,而很多开发者通常会选择以Node的某个子类作为基类来设计游戏对象, 例如图16.1中的 BaseSprite 可能就是 Cocos2d-x 中的Sprite类,如果游戏非常复杂就会导致一个十分庞大的类层次结构。
复杂的层次结构在开发中会给开发者带来很多麻烦,一个类越是在类层次结构越深的地方,就越难以理解、阅读和修改。要理解一个类,就需要理解其所有父类。每个层级重写的虚函数都会加深理解的难度,对一个父类虚函数的修改可能会影响到所有子类的行为。总之,对象层次结构越复杂,团队开发在理解、维护方面的成本就越大。
2.分类学瓶颈

以对象为中心的设计的另一个问题来自于对对象的分类。分类学使用树形的结构来描述,在每一层中,每个对象都是基于一个特定的标准来进行分类的,当设计选择了某个分类标准后,就很难,甚至不可能用另一个完全不同的标准来分类。

而游戏设计中的对象类型是极易变化的,因为游戏本身都不必遵循客观的规律,这使得游戏中对象的分类学标准是十分不稳固的,当新的需求改变与现有的分类标准造成冲突时,就会对层次结构造成破坏。例如现实世界中爬行类动物不能像鸟类一样飞行,但是游戏中的爬行类动物也可能被设计成是可以飞行的,也需要拥有鸟类的行为。
3.多重继承

当有新的类型是已有几种类型的组合时,开发者倾向使用多重继承来解决这个问题然而多重继承也许是导致麻烦的另一个根源,例如它会使对象具有基类成员的多个版本,如图16.2所示,因此有些团队甚至禁止使用多重继承。

在面对多重继承时,更好的做法是限制每个类只有一个祖先类,其他继承关系则通过mix-in的方式来继承。mix-in类是没有基类的独立类,一个mix-in类通常实现一个通用的功能,或者定义一个接口,这样实现的多重继承不会导致基类成员的多个版本,因为每个继承关系都不会相互交叉。

4.冒泡效应

当新的需求变更破坏了现有的分类标准时,为了避免完全对现有分类标准进行重构,以及基类的修改可能对其他现有子类造成的不可预料的影响,开发者倾向于将大部分可能公用的功能往父类迁移,以确保所有子类都可以共享父类的实现,这称为"冒泡效应"冒泡效应也能解决多重继承的问题,因为多重继承本质上就是对分类学标准进行重建。冒泡效应其实是破坏分类学的,它最后倾向于无分类。例如在极端情况下,顶级的基类是一个很奇怪的对象,它具有任何游戏对象的行为。

最后低层子类变成一个选择器,它通过只暴露基类中自己感兴趣的行为来定义一个游戏对象类型,而将另一些自己不感兴趣的行为屏蔽掉。

冒泡效应使原本希望借助分类学层次结构带来的直观和易于理解的好处全部丧失,并且每个对象都可能在内存中占据着大量不必要的数据内存占用,每个对象在内存布局当中的粒度很大,这将导致 CPU 更高的缓存命中失败率。

5.无止境地重构
以对象为中心的设计由于依赖于分类学标准,而设计需求变更倾向于破坏分类学标准,所以通常会导致比较频繁的重构,
尤其分类标准的粒度越细,越容易导致重构;而分类标准粒度越粗,就会倾向于冒泡效应,失去了分类学应有的好处。

当有需求变更时,几乎不可避免地会对层次结构进行一定程度的重构。实际上,游戏关注角色的行为,我们并不需要去为游戏对象建立一种分类关系,这仅仅是帮助程序按面向对象的方式组织逻辑。如果有另一种方法能够很好地帮助组织游戏对象的行为那么我们便不必花费很多时间成本去建立分类学标准。这正是组件模型和以属性为中心的设计所能够实现的。

6.会含有大量无用的属性

新建的一个类想要拥有其他类一些功能,如果通过继承来实现,那么新建的这个类会把父类的所有属性都继承下来,而有一部分属性是无用的,浪费了内存空间。

组件对象模型

两个对象之间可以有两种关系:继承(Inheritance)和合成(Composition,其中继承是一种"是一个(is-a)"的关系,例如B继承于A,可以说B是一个A类型;而合成是一种"有一个(has-a)"的关系,例如图16.3中汽车有一个气缸,池塘里有许多鸭子。

其中对于合成,根据两个对象之间的生命周期的不同又可分为合成与聚合(Aggregation)。对于图16.3 中的例子,汽车由气缸组成,气缸是汽车的一一个必不可少的部分,它的生命周期同汽车一致,是一种合成关系。在实践中,Carburetor 通常由Car负责构造和销毁,以使它们的生命周期保持一致。而对于池塘,它只是持有鸭子类,在池塘的生命周期内,鸭子在某些时段可能是不

存在的。鸭子通常并不由池塘构造,并且鸭子可能被其他对象持有,因此池塘和鸭子是一种聚合关系。在实践中,通常鸭子表现为一一个指针或者引用。

造成面向对象庞大层次结构的原因或许就是,在面向对象设计中过度使用"是一个"的关系,如果我们将其转化为"有一个"的关系,也许就能降低层次结构的复杂度。例如一个英雄"是一个"渲染对象",可以认为这个英雄"有一个"渲染对象,这样就可以将渲染对象从层次结构中独立出来。

1.把继承改为合成

那么怎样将一个继承的层次结构改为合成的层次结构呢?图16.4 所示是我们假想的某个继承的层次结构,GameObject是所有游戏对象的基类: MoveableObject 实现了游戏对象的坐标变换的信息,它的子类可以在3D坐标系中执行坐标变换;RenderableObject定义了一个游戏对象的可视部分,它可能是-一个网格数据,并可以将这些网格数据绘制到屏幕上;CollidableObject使游戏对象可以参与碰撞检测;AnimatingObject类给予其实例一个通过骨骼关节结构播放动画的能力; PhysicalObject

让游戏对象可以参与物理模拟。

这是一个典型的以对象为中心的架构设计,在使用的时候会遇到16.2.1 节讲述的那

些常见的问题。例如想让某个对象使用物理模拟,必须从PhysicalObject 继承,即使它

可能并不需要使用动画系统;另外,如果要对某些中间层级的类进行修改,也许需要重

构整个层次结构。

此时,如果我们将每个功能分离成独立的类,每个类负责单一、相互独立的功能,然后使用合成的方式组合一个对象, 就可以避免上述提到的这些问题。这样的相互独立、单一功能的类有时候称为组件。组件化的设计可以令游戏对象只选择那些感兴趣的功能,每个功能都可以单独地维护和重构而不需要影响其他类,因为这些组件之间没有耦合,更易于理解。

图16.4 右图所示是将继承改为合成之后的结构,在此设计中,GameObject 不再称

为整个层次的祖父类,而是变成一个枢纽中心,含有多个可选的指针。Transform 组件

用来承担游戏对象的坐标变换功能;而Mesh给游戏对象添加一些网格,使它可以绘制

在屏幕上; Animation 组件使游戏对象可以执行骨骼动画;而RigidBody可以使游戏对

象参与碰撞检测,也可以用来对游戏对象进行物理模拟。
通过类的合成,使得游戏对象的设计更富有弹性,比如可以通过合成不同的组件来
形成一个新的类型,它甚至免除了分类学的烦恼。

2.组件的创建及拥有权

在这种设计中,枢纽类"拥有"其组件,也就是说它管理其组件的生命周期 ,是图16.3中的合成而并非聚合关系。那么这就需要GameObject必须知道在初始化的时候要创建哪些组件。一种可行的设计方案是让每个游戏对象都继承自GameObject, 然后每个子类在构造函数中初始化自己感兴趣的组件,通常GameObject在析构函数中可以清理子类创建的全部组件实例 。另一种更通用的做法是让所有组件继承自同一个基类,例如Component, 而GameObject拥有一个组件的链表或者容器。

实际上这正是Unity3D游戏引擎的游戏对象设计架构,如图16.5 所示。在Unity3D中,每一个游戏对象都由一个GameObject表示,它本身只是一个组件的容器,可以向它任意添加不同的组件来形成一个不同的游戏对象,系统提供的组件大多与某个引擎底层系统相关,而开发者也可以自定义自己的组件来组织应用程序逻辑。

在这样的架构设计中,开发者可以使用数据驱动的方式来组织游戏对象,例如引擎

在初始化的时候读取文本文件所定义的游戏对象类型,决定为游戏对象创建哪些组件。

新的组件类型可以在不影响现有组件的情况下加入,实际上在Unity3D中组件通过脚本

定义,新的组件类型也可以动态被使用。

以属性为中心的架构:

组件模型实际上仍然是以对象为中心的设计,它只是用合成的方式来组合一个对

象,而不是使用继承的方式来构造对象。在以对象为中心的设计中,对象的内存布局

大概如下。

●对象1:

➢位置=(0,3,15)

➢定向=(10,5,30)

●对象2:

➢位置=(10,4,20)

➢血 量=20

●对象3

定向=(0,100,-90)

由此可知,内存中存储的实际上是各个对象的属性,那么我们也可以换个角度以属性为中心而不是以对象为中心来分配内存布局。在新的内存布局中,对象只不过是属性的索引值,表明该属性实例属于哪个对象,因此只要以对象为键对属性建表即可。

●位置

➢对象1=(0,3,15)

➢对象2=(10,4,20)

●定向

➢对象1=(10,5,30)

➢对象3=(0,100,-90)

●血量

对象2=20

相对于对象模型,以属性为中心的设计更类似于关系数据库。每个属性是一张表,而对于属性表的每个实例,它的主键是对象的唯一标识符, 通常是一个字符串名称,或者该名称的一个Hash值。

游戏对象必须同时包含属性和行为,那么以属性为中心的架构设计怎样定义对象的行为呢?有两种做法:在属性本身实现,或者使用脚本来实现。以下分别讨论这两

种方法。

1.通过属性类实现

在这种方法中,每种属性实现为一个属性类,它包含属性数据的定义,同时包含处理属性数据的行为。例如想象一个表示血量的属性,它定义了表示血量的数值,同时通过成员函数来表示该属性的一些行为。例如它可以成为攻击目标,当游戏对象被攻击时扣减血量,并在血量小于或者等于0时通知动画系统或者其他系统处理对象的死亡事件。当属性类本身包含行为时,它在功能上和一个组件类似,同时定义某个行为的数据和逻辑方法。它们的区别是所有相同的属性实例在内存中是连续布局的,而每个组件实例则分布于各个游戏对象之内,属性实例能够更有效地使用内存。

2.通过脚本实现

在这种方法中,属性值仅用来存储属性数据,而通过脚本来实现对象的行为,每个属性可能对应一个脚本函数,如果游戏对象包含某个属性,系统就运行该脚本来处理游戏对象的行为。此外,脚本中也可以响应事件,这可以用来处理属性之间的通信。在一些以属性为中心的引擎中,核心属性是由工程师硬编码的类,但是引擎会提供一种机制, 让游戏设计师或者程序员通过脚本来扩展属性行为。

Genius-x框架正是完全基于这种思想来设计的游戏性系统。
以属性为中心设计的优缺点:

相比于对象模型,以属性为中心的设计有许多优点,例如没有复杂层级结构的问题、没有多重继承的问题、没有分类学的烦恼等,属性之间没有依赖更便于扩展和修改,当定义新的属性时根本不需要修改游戏对象类的定义,通常属性可以通过脚本系统来添加。除此之外,以属性为中心的设计还有另外几方面的优势。首先,它能更有效地使用内存。每个对象占用的内存实际上就是它必须要定义的属性,而不会像继承那样会导致一些子类含有大量无用的属性。其次,在内存布局上,所有相同类型的属性实例在内存中是连续存储的,把数据连续存储于内存中,能够减少或消除缓存命中失败,因为当存取数据数组的某元素时,其附近的大量元素也会被载入相同的缓存线之中。此数据布局的设计方式也称为数组化的结构(Struct of Array, SoA),而传统的对象模型的数据布局称为结构化的数组(Array ofStruct, AoS)。

最后,相比于对象模型,以属性为中心的设计更容易实现数据驱动。实际上,ScottBilas提出使用该种架构的目的就是为了实现数据驱动,在属性化的对象中,设计师能够通过定义属性列表,或者通过脚本添加新的属性类型来减少对程序员的依赖。这在游戏开发中很关键,能大大提供工作效率,让设计和美术都能参与游戏开发,而不是把所有工作都让程序员来执行。以属性为中心的设计也存在一些缺点。 首先,当游戏对象由大量的属性构成时,属性之间的关系可能变得比较混乱。

其次,单凭一些细粒度的属性很难实现比较复杂的行为,因此,根据实际需求,有些属性的粒度通常会比较大;最后,由于属性存在于单独的属性表中,仅保存一个游戏对象的唯一 标识符,很难整体了解游戏对象而进行有效的调试 ,因此在Genius-x中每个属性保存着游戏对象的指针,可以查看其他属性以方便调试。

相关推荐
半盏茶香10 小时前
关于我重生到21世纪学C语言这件事——三子棋游戏!
c语言·开发语言·c++·算法·游戏
辜月廿七13 小时前
C#中日期和时间的处理
开发语言·游戏·unity·c#
Koishi_TvT17 小时前
“2048”游戏网页版html+css+js
前端·javascript·css·vscode·游戏·html
大大大反派18 小时前
AIGC在游戏设计中的应用及影响
游戏·aigc
sheng12345678rui19 小时前
电脑中丢失 vcruntime140.dll 的五种解决方法
游戏·microsoft·电脑·dll修复工具·1024程序员节
神仙别闹1 天前
基于MFC实现的赛车游戏
c++·游戏·mfc
DisonTangor1 天前
微软的新模拟器将为 Windows on Arm 带来更多游戏
arm开发·游戏·microsoft
Footprint_Analytics2 天前
Footprint Analytics 助力 Sei 游戏生态增长
游戏·web3·区块链
半盏茶香2 天前
【C语言】分支和循环详解(下)猜数字游戏
c语言·开发语言·c++·算法·游戏
PandaQue2 天前
《怪物猎人:荒野》游戏可以键鼠直连吗
游戏