《游戏引擎架构》笔记十四

时间:2017-11-12 21:25:56   收藏:0   阅读:392

运行时游戏性基础系统

游戏性基础系统的组件

如果可以合理地画出游戏与游戏引擎的分界线,那么游戏性基础系统就是刚刚位于该线之下。理论上,我们可以建立一个游戏性基础系统,其大部分是各个游戏皆通用的。实际上不同引擎之间有许多共有模式,以下列出一些常用组件,后续的文章就会逐渐记录这些组件的功能和设计方法。

运行时对象模型分为

 各种运行时对象模型架构

以对象为中心的架构

这种架构中每个逻辑游戏对象会实现为类的实例,或一组互相连接的实例。然而单纯使用继承和多态会导致一系列类层次结构的问题。

使用面向对象架构的问题

类层次结构逐渐变得单一庞大。如下图①实现《吃豆人》(PacMan)的一种简单类结构,随着功能增长,该结构会同时往纵、横方向发展,并出现以下问题:

技术分享

使用“合成”来简化层次结构

面向对象设计中过度使用“是一个(is-a)”关系,会限制了我们创造新游戏类型的设计选择,而且难以扩展现存类的功能。若像下图左边的继承结构,希望一个游戏对象类有碰撞功能,它必须要继承自CollidableObject ,即使它可能是隐形的而并不需要RenderableObject的功能。若把不同的功能分离为独立的“组件”类,它们互不相干,由一个轻量的GameObject采用“有一个(has-a)”关系持有并管理,如下图右边,则可以大大简化。Unity便是运用这种思想的例子。

技术分享

对于GameObject管理其组件声明周期的具体实现,具体的做法是GameObject持有所有可能组件的指针并默认为空,而具体的游戏对象继承GameObject后,自行初始化所需的基本组件,并实现自己的特殊组件。但是当需要扩展新组件时,都要修改GameObject类,不符合开闭原则,因此更好的做法是以下这种GameObject持有Component链表的结构。

技术分享

以属性为中心的架构

以对象为中心,会自然地关注对象属性和行为。以属性为中心,则是先定义所有属性,再为每个属性键表存储关联该属性的对象,像数据库表就是这种设计

这种设计的优点是趋向更有效地使用内存,因为只需储存实际上用到的属性;也更容易使用数据驱动的方式来建模。最后是比以对象为中心的模型更加缓存友好,因为有些游戏硬件的内存存取成本远高于执行指令和运算。把数据连续储存于内存之中,能减少或消除缓存命中失败。这种数据布局方式称为数组的结构(struct of array)。以下代码展示了与传统结构之数组(array of struct)的对比。

static const U32 MAX_GAME_OBJECTS = 1024;
// 传统结构的数组方式
struct GameObject
{
    U32 m_uniqueId;
    Vector m_pos;
    Quaternion m_rot;
};
GameObject g_AllGameObjects[MAX_GAME_OBJECTS];

// 对缓存更友好的数组的结构方式
struct AllGameObjects
{
    U32 m_UniqueId[MAX_GAME_OBJECTS];
    Vector m_Pos[MAX_GAME_OBJECTS];
    Quaternion m_Rot[MAX_GAME_OBJECTS];
}
AllGameObjects g_allGameObjects;

这种设计的缺点是单凭凑齐一些细粒度的属性去实现一个大规模的行为,并非易事。这种系统也可能更难以除错,因为程序员不能一次性地把游戏对象拉到监视视窗中检查它的属性。

世界组块的数据格式

游戏世界的加载和串流

对象引用与世界查询

对象引用方法

指针

每个游戏对象通常需要某种唯一标识符以便互相区分,并且能在运行时或工具方(世界编辑器)找到所需的对象,也可用该标识符作为对象间通信的目标。当通过查询找到一个游戏对象时,需要以某种方式引用它。C/C++中最常见的做法就是使用指针,因为指针是实现对象引用最快、最高效并最容易使用的方式。但使用指针很容易出现孤立对象、过时指针、无效指针等问题,所以开发引擎的团队制定严格的编程惯例,或使用安全的约束方法如智能指针。

智能指针是一个小型对象,行为与指针非常接近,但其扩展了规避原始C/C++指针所衍生的问题。关于智能指针可参考C++的一些高级书目,此处不赘述,仅建议尽量不要在项目中尝试自己实现恶心的智能指针,如果必须使用,尽量选用像Boost这样的成熟实现。

句柄

句柄就是某全局句柄表的整数索引,而句柄表则储存指向引用对象的指针。下图说明了此数据结构。

技术分享

虽然句柄可以实现为原始整数,但句柄表的索引通常会包装成一个简单类,以提供更方便创建句柄和解引用的接口。以下是一种简单实现(省略其他与句柄无关的实现)。

/* GameObject类储存了它的句柄索引,当要创建新句柄时就不用以地址搜寻句柄表了 */
class GameObject
{
private:
    GameObjectId m_uniqueId;  // 对象唯一标识符
    U32 m_handleIndex;  // 供更快地创建句柄
    friend class GameObjectHandle;  // 让它访问id及索引
public:
    GameObject()
    {
        m_uniqueId = AssignUniqueObjectId();
        m_handleIndex = FindFreeSlotInHandleTable();
    }
}

// 定义句柄表的大小,以及同时间的最大对象数目
static const U32 MAX_GAME_OBJECTS = ...;
// 全局句柄表,只是简单的数组,储存游戏对象指针
static GameObject* g_apGameObject[MAX_GAME_OBJECTS];

/* 句柄封装类 */
class GameObjectHandle
{
private:
    U32 m_handleIndex;
    GameObjectId m_uniqueId;
public:
    explicit GameObjectHandle(GameObject& object) :
        m_handleIndex(object.m_handleIndex),
        m_uniqueId(object.m_uniqueId) {}
    // 句柄解引用
    GameObject* ToObject() const
    {
        GameObject* pObject = g_apGameObject[m_handleIndex];
        if (pObject != NULL && pObject->m_uniqueId == m_uniqueId)
            return pObject;
        return NULL;
    }
}

对象查询方法

取决于具体的游戏设计,开发者需要根据业务来查询不同种类的对象,例如找出玩家视线范围内的所有敌人角色,找出所有血量少于80%的可破坏游戏对象等等。游戏团队通常要判断,在游戏开发过程中哪些是可能最常用到的查询类型,并实现专用的数据结构加速查询。以下列举了一些可用于加速某类游戏对象查询的专门的数据结构。

实时更新游戏对象

一种最简单但不可行的实现方式是,每个游戏对象都有一个虚函数virtual void Update(float dt),游戏主循环在每一帧遍历全体游戏对象集合并逐一调用Update。每个Update所做的事情大致是更新对象自身的逻辑数据,然后逐个更新其组件(如动画、渲染、粒子、声音组件)。

性能限制与批次式更新

低阶引擎系统都有极严竣的性能限制,把多个游戏对象的同个子系统更新组合起来批次处理,要比上述多个游戏对象交错更新子系统更高效,如下图所示。像渲染引擎就是使用批次式更新的典型例子。

技术分享

批次式更新带来很多性能效益,包括但不限于:

性能优势并不是使用批次式更新的唯一原因,一些引擎子系统从根本上不能以对象单位进行更新。例如,若一个动力学系统里有多个刚体进行碰撞决议时,孤立地逐一考虑对象,一般不能找到满意的解。

对象及子系统的相互依赖

要正确运行游戏,游戏对象更新的次序是重要的(例如计算某物体的局部坐标需要先计算其父节点的世界坐标)。除了对象之间有依赖关系,各子系统也有依赖关系,而且不是简单的先后关系,例如布娃娃物理模拟系统须与动画系统协同更新。可以在主循环中明确编写各个子系统的更新顺序。

主循环通常不能简化成每帧每对象调用一次Update,游戏对象可能需要使用多个引擎子系统的中间结果。很多游戏引擎容许游戏对象在1帧中的多个时机编写对应的虚函数“挂钩”进行更新,像Unity GameObject的Update、FixedUpdate、LateUpdate等。游戏对象可按需增加更多更新阶段,但要小心带来多余的调用空的虚函数开销可能很高。

桶式更新

当存在对象间的依赖时,可能会抵触更新次序的规则,有时要轻微调整上述的批次式更新技巧。即不要一次性批处理所有游戏对象,而是把对象按依赖关系分为若干群组(或称为桶bucket),即没有任何依赖关系的对象(依赖树的根)放到第1个桶,依赖树第2层的所有对象放到第2个桶……然后按依赖次序更新每个桶,桶中使用批次式更新,如下图所示。游戏引擎可以明确为依赖树林的深度设限,这样就可以使用固定数目的桶以提高性能。

技术分享

对象状态及“差一帧”延迟

更新游戏对象可视为这样一个过程:每个对象根据t1时刻的状态决定t2t2 t1+Δt= t1 + Δt)时刻的状态。理论上,所有游戏对象的状态是瞬间及并行地从时刻t1t1更新至t2t2的。但实际上主循环会逐个更新对象,在一轮循环中间中断时则有一些对象处于部分更新的状态(例如某个对象可能已执行姿势动画混合,却未计算物理及碰撞决议)。

游戏对象在两帧之间状态不一致是混淆和bug的主要来源。当有对象依赖时(如对象B需要根据对象A的速度来决定当前帧自身的速度),程序员必须弄清楚需要的是对象A的之前的状态还是新状态。若需要新状态,而对象A却未更新,就会产生一个更新次序问题,会导致一类称为“差一帧”延迟的bug。解决这个问题通常有以下做法:

事件与消息泵

游戏本质上是事件驱动的。事件是游戏过程中发生、希望关注的事情,例如发生爆炸、玩家被敌人看见、拾取补血包等等。游戏通常需要一些方法做两件事——当事件发生时通知关注该事件的对象,以及让那些对象回应所关注的事件。事件系统采用的设计模式便是知名的观察者模式,本文将介绍事件系统的一些基本原理,以及事件排队的扩展机制。

为了通知游戏对象一个事件己发生,最简单的方法是调用该对象的方法,更进一步的是调用欲通知对象的虚函数。虚函数的后期绑定在某种程度上降低了实现的弹性,实际上,使用静态类型的虚函数作为事件处理程序,会导致GameObject基类需要声明游戏中所有可能出现的事件!这样会令创建新事件变得困难,也阻止了以数据驱动方式产生事件,也违背了让某些类仅注册自己希望关注的事件的初衷。

把事件封装成对象

事件实质上由两个部分组成:类型及参数,其中参数为事件提供细节。因此可以把这两个部分封装成事件对象,伪代码如下所示。有些游戏引擎称这种事件结构为消息(message)或命令(command),这些名称强调了本质上,把事件通知对象等于向对象发送消息或命令。

struct Event
{
    const U32 MAX_ARGS= 8;
    EventType m_type;
    U32 m_numArgs;
    EventArg m_aArgs[MAX_ARGS];
};

把事件封装为对象有这些好处:

事件类型

最简单的方法是使用一个全局的枚举,把每个事件类型映射至一个唯一整数。此方法的优点在于简单及高效,缺点是游戏中所有事件类型都要集中在一起(有点破坏封装的意味,见仁见智);事件类型是硬编码的,意味着新的事件类型不可通过数据驱动的方式来定义;枚举是索引,有时在中间插入新类型可能会引起一些次序相关的问题。

另一个事件类型编码方法是使用字符串。此方法是完全自由形式,但问题是有较大机会产生事件名称冲突,也有机会因拼错字而导致不能正常运作,字符串所消耗的内存也较多。不过可以做一些辅助工具来规避字符串带来的风险。在实际项目中,以上两种方法都有被使用,关键还是要权衡其利弊及项目的实际情况。

事件参数

事件的参数通常与函数的参数很相似,而且理论上可以支持任意种类和任意数量的参数。例如上面的代码的EventArg,如果是在C#/Java中,可以将任意类型参数封箱为object发送。但如果是在C/C++中,则只能使用void*指针来模拟,或者使用C++的template模拟。书中还描述了一种用C/C++ union实现的可以容纳多种类型的Variant数据结构,但通用性较弱,此处不赘述。

事件参数采用以索引为基础的集合,有个问题是参数的意义取决于储存的次序,发送方及接受方都必须理解事件以什么次序储存参数,这可能会导致混淆及bug。可以采用键值对的数据结构来封装一系列事件参数,并通过有实际意义命名的key来提取参数。

注册事件与事件处理器

大部分游戏对象只会关注很小的事件集合,每次都多播或广播事件是很低效的事情。为了提高事件处理的效率,可以让对象注册它们所关注的事件。例如,每个事件类型维护一个链表,内含关注该事件类型的对象,当特定事件触发时只需遍历列表逐个通知即可。

当游戏对象接收到一个事件,需要以某种方式做出回应,此过程称为事件处理,并通常实现成称为事件处理器(event handler)的函数。在一些高级语言中,可以通过存储函数指针(C/C++)或委托(C#)来注册回调函数,并在收到特定事件时调用。随后,取出EventArg并拆箱还原为原来的参数类型,对其进行处理。

游戏对象之间经常有依赖性,事件有时需要沿着依赖链传递下去。通常,事件传递的次序是预先由开发者决定的,在事件处理器中通过返回一个布尔值以表示该对象是否处理了该事件,以及是否继续往下转发。支持职责链的事件处理器大概如下所示:

virtual bool SomeObject::OnEvent(Event& event)
{
    // 先调用基类的处理器
    if (BaseClass::OnEvent(event))
        return true;  // 基类处理器已处理了事件,返回true表示不再转发
    switch (event.GetType())
    {
        case EVENT_ATTACK:
            ResponseToAttack(event.GetAttackinfo());
            return false; // 可以转发事件给其他对象
        case EVENT_HEALTH_PACK:
            AddHealth(event.GetHealthPack().GetHealth());
            return true; // 消化了事件,不再转发
        // ......
        default:
            return false;  // 无法识别该事件,转发给其他对象
    }
}

即时事件处理器可能导致非常深的调用堆栈,例如对象A向对象B发送一个事件,然后B的事件处理器又发出另一个事件,如此反复。在逻辑有误或使用不当的情况下,极深的调用堆栈有可能会用尽堆栈空间(尤其是造成无限循环的事件发送)。关键还是要遵循一些编码原则,并把事件处理器实现为完全可重入函数,即以递归方式调用事件处理器并不会有任何不良副作用。

事件排队

上述的事件机制都是在发送事件时便马上被处理,有的引擎也会容许把事件排队留待未来某刻才进行处理。事件排队有以下好处:

使用事件队列需要考虑的问题:

 

原文:http://www.cnblogs.com/yeqluofwupheng/p/7717379.html

评论(0
© 2014 bubuko.com 版权所有 - 联系我们:wmxa8@hotmail.com
打开技术之扣,分享程序人生!