游戏引擎的事件系统

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

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

1 事件系统的原理

1.1 把事件封装成对象

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

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

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

  • 仅需单个事件处理函数:任何数量的事件类型都可以表示为单个类的实例,仅需要单个虚函数处理所有事件类型(如virtual void OnEvent(Event& event)
  • 持久性:事件对象把其类型及参数储存为数据,因此具有持久性,可用于储存队列稍后处理,或者复制及广播至多个接收者等
  • 盲目地转发事件:对象可以转发事件至另一对象,而不需要知道事件的内容

1.2 事件类型

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

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

1.3 事件参数

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

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

1.4 注册事件与事件处理器

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

当游戏对象接收到一个事件,需要以某种方式做出回应,此过程称为事件处理,并通常实现成称为事件处理器(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的事件处理器又发出另一个事件,如此反复。在逻辑有误或使用不当的情况下,极深的调用堆栈有可能会用尽堆栈空间(尤其是造成无限循环的事件发送)。关键还是要遵循一些编码原则,并把事件处理器实现为完全可重入函数,即以递归方式调用事件处理器并不会有任何不良副作用。

2. 事件排队机制

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

  • 控制事件处理的时机:让开发者多一道措施确保事件在安全及合适的时机获得处理
  • 往未来投递事件的能力:可以设置事件的触发时间(例如下一帧、数秒后),这样就相当于实现了一个定时器。具体实现方式:把队列中的事件按送达时间排序,在每帧中先检查队列中首个事件的送达时间,若还未到送达时间,就可立即终止处理(排序保证了之后的事件也是未到时间的)
  • 处理同时刻事件的优先次序:事件的送达时间通常会量化为整数帧,因此存在同一帧处理多个事件而无法确定顺序的问题。解决方法是为事件设置优先次序(根据需要用整型或若干档枚举表示),当同帧多事件触发时按优先级排序。

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

  • 增加事件系统的复杂度:给系统加上此功能会增加开发时间和维护成本
  • 深度复制事件及其参数:若事件是触发后即时处理的,事件参数所占用堆栈内存在事件消费完即销毁。但如果使用事件队列,则需要将整个事件对象(包括参数)深度复制到队列,这样才能确保没有仅对发送者作用域数据的悬垂引用,并且容许事件无限期储存
  • 为队列中的事件做动态内存分配:要注意考虑深度复制导致的动态内存分配开销,可以考虑快速且不会造成碎片的池分配器或其他小型内存分配器
  • 调试困难:不能在调试器的调用堆栈看出事件从何而来,以及检查发送者的状态和发送时的环境情况。调试延时事件会变得棘手,若事件会被对象转发的话调试会更加困难

参考文献:电子工业出版社《游戏引擎架构》第14.7节

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器