事件与消息传递
Events & Message Passing
游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。
静态类型函数绑定的问题
最简单的通知方式是直接调用对象的方法:爆炸时查询伤害半径内的所有对象,对每个调用虚函数 OnExplosion()(卷 II 17.8.1)。
void Explosion::Update() {
if (ExplosionJustWentOff()) {
GameObjectCollection damagedObjects;
g_world.QueryObjectsInSphere(GetDamageSphere(), damagedObjects);
for (each object in damagedObjects)
object.OnExplosion(*this);
}
}
函数绑定是确定某个调用点调用哪个实现的过程。虚函数是后期绑定(编译期不知道调用哪个实现,运行时按目标对象类型决定),又是静态类型的(给定对象类型,编译器知道调用哪个实现,例如目标是 Tank 就调 Tank::OnExplosion())。问题是不灵活:所有对象必须继承同一个基类,基类必须声明 OnExplosion(),哪怕很多对象对爆炸没反应;推而广之,基类要为游戏中所有可能的事件声明虚函数,新增事件很难,也无法在世界编辑器里以数据驱动方式创建事件,还没有机制让某些类型或某些实例只关心部分事件。每个对象实际上"知道"所有事件,哪怕只是实现一个空处理函数。
我们需要的是动态类型的后期绑定。有的语言原生支持(GEA 举例 C# 的委托),其他语言要手工实现,多数方案归结为数据驱动:把"函数调用"这个概念封装进对象,在运行时传递它。
把事件封装成对象
事件由两部分组成:类型(爆炸、友军受伤、发现玩家、捡起血包……)和参数(爆炸造成多少伤害?哪位友军受伤?在哪发现玩家?血包有多少血?)。GEA 的简化示例(卷 II 17.8.2):
struct Event {
const U32 MAX_ARGS = 8;
EventType m_type;
U32 m_numArgs;
EventArg m_aArgs[MAX_ARGS];
};
原书问题:
MAX_ARGS被写成非静态的const U32数据成员(已核对排版页卷 II p.617)。非静态数据成员不能出现在常量表达式里,因而不能作数组m_aArgs[MAX_ARGS]的边界,照抄无法编译;应写成static constexpr U32 MAX_ARGS = 8;(或static const)。依据:cppreference "Constant expressions" 与 "static members"(只有静态常量成员可在类内用作常量表达式)。GEA 自己也说这段代码是"相当过度简化的"示例。
有的引擎称之为消息或命令,强调通知事件本质上等于向对象发消息或命令。实际中事件对象通常更复杂:不同事件类型可以从根事件类派生;参数可以是能容纳任意数量、各种类型参数的链表或动态数组。好处:
- 单一事件处理函数:事件内部编码了类型,一个类(或继承层次的根类)的实例就能表示任意多种事件,只需一个虚函数处理所有事件,如
virtual void OnEvent(Event& event);; - 持久性:函数调用返回后参数就离开作用域,事件对象则把类型和参数存为数据,可以放进队列以后处理,也可以复制并广播给多个接收者;
- 盲转发:对象不必了解事件就能转发它,例如载具收到 Dismount 事件可以转发给所有乘客让他们下车,载具本身对"下车"一无所知。
这一思想在 GUI、分布式通信等领域都很常见,四人帮《设计模式》称之为命令模式(见 命令模式)。
事件类型
(卷 II 17.8.3)
- 全局枚举:每种事件对应一个唯一整数,如
EVENT_TYPE_LEVEL_STARTED、EVENT_TYPE_PLAYER_SPAWNED、EVENT_TYPE_ENEMY_SPOTTED、EVENT_TYPE_EXPLOSION、EVENT_TYPE_BULLET_HIT。简单高效(整数读写比较都极快)。问题:(1) 全游戏所有事件类型的知识集中在一处,可视为破坏封装(见仁见智);(2) 硬编码,难以数据驱动地新增事件类型;(3) 枚举值只是索引、依赖顺序,有人不小心在中间插入一个新类型,后面所有事件 id 都会变,如果事件 id 存在数据文件里就会出问题。所以适合小演示和原型,难以扩展到真实游戏。
原书问题:原文说枚举方案"有两个问题 (two problems)",随后却列出 First、Second、Third 三条(已核对排版页卷 II p.618)。
- 字符串:完全自由,想个名字就能新增事件类型。问题:事件名冲突的可能性大,一个拼写错误就会让事件失效,字符串本身占内存,比较字符串比比较整数贵。用哈希字符串 id(见 字符串与唯一标识符)可以消除性能和内存问题,但解决不了名字冲突和拼写错误。尽管如此,包括 Naughty Dog 在内的许多团队认为字符串或字符串 id 事件系统的极度灵活和数据驱动值得冒这个险。
- 工具辅助:维护所有事件类型名的中央数据库,提供添加新事件的界面,自动检测命名冲突、禁止重复;选择已有事件时用排好序的下拉框,而不是让用户凭记忆手打;数据库还能存每种事件的元数据(用途与用法文档、支持的参数个数和类型)。效果可以很好,但搭建和维护成本不低。
UE 的对应:Gameplay 标签深入 中的层级标签由集中注册的名字组成,编辑器里从下拉列表中选择,与 GEA 描述的"中央事件名数据库 + 下拉框"思路相近(对比归纳)。
事件参数
事件参数通常像函数的参数列表,向接收者提供有用的信息(卷 II 17.8.4):
- 每种事件派生一个类,参数硬编码为数据成员,如
class ExplosionEvent : public Event { Point m_center; float m_damage; float m_radius; }; - Variant 集合:Variant 是能容纳多种类型数据的对象,通常同时记录当前类型和数据本身:
struct Variant {
enum Type { TYPE_INTEGER, TYPE_FLOAT, TYPE_BOOL, TYPE_STRING_ID, TYPE_COUNT /* 类型数 */ };
Type m_type;
union {
I32 m_asInteger;
F32 m_asFloat;
bool m_asBool;
U32 m_asStringId;
};
};
(已核对排版页卷 II p.620,原书代码本身无误;layout 模式的提取文本曾把类型列和成员名列拆开。)
Variant 集合可以是小的固定上限数组(例如 4、8 或 16 个元素):限制了参数个数,但避免了为每个事件的参数动态分配内存,这在内存紧张的主机游戏中好处很大;也可以是 std::vector 式的动态数组或 std::list 式的链表,更灵活但有动态分配的代价;若每个 Variant 大小相同,用池分配器效果很好。
- 键值对参数(卷 II 17.8.4.1):按索引排列的参数有顺序依赖问题,发送者和接收者都必须"知道"参数顺序,容易漏掉必需参数或多加一个。用键值对实现参数,每个参数由键唯一标识,可以任意顺序出现,可选参数可以完全省略。可以实现为闭散列或开散列的哈希表,也可以是键值对的数组、链表或二叉搜索树。GEA 表 17.1 的示例(键 / 类型 / 值):
"event"/ stringid /"explosion";"radius"/ float / 10.3;"damage"/ int / 25;"grenade"/ bool / true。GEA 说具体实现基本无关紧要,只要高效满足游戏需求即可。
事件处理器
对象收到事件、消息或命令后的响应称为处理事件,通常由一个称为事件处理器的函数或脚本片段实现。常见的是一个能处理所有类型的原生虚函数或脚本函数,内部用 switch 或级联 if/else-if(卷 II 17.8.5):
virtual void SomeObject::OnEvent(Event& event) {
switch (event.GetType()) {
case SID("EVENT_ATTACK"): RespondToAttack(event.GetAttackInfo()); break;
case SID("EVENT_HEALTH_PACK"): AddHealth(event.GetHealthPack().GetHealth()); break;
// ...
default: break; // 不认识的事件
}
}
也可以为每种事件写一个处理函数(OnThis()、OnThat()……),但如前所述处理函数泛滥会有问题。Windows GUI 工具包 MFC 的消息映射允许在运行时把任何 Windows 消息绑定到任意虚或非虚函数,既不必在根类声明所有消息的处理函数,又避免了非 MFC 消息处理里常见的大 switch;但 GEA 认为这样的系统大概不值得折腾,switch 简单清晰、工作得很好。
补充(并列记录):按 Microsoft Learn《Message Maps (MFC)》的说明,MFC 消息映射是在源码中用
DECLARE_MESSAGE_MAP/BEGIN_MESSAGE_MAP/ON_COMMAND等宏静态声明的表,运行时框架只是沿类层次查表分发;GEA 说的"在运行时绑定"应理解为"运行时查表分发",而不是程序运行中可以动态改绑。
解包参数(卷 II 17.8.6):上例略过了如何类型安全地取参数。event.GetHealthPack() 意味着根 Event 类"知道"血包,进而知道游戏中所有参数类型,大概不现实。真实引擎中可能由派生 Event 类提供 GetHealthPack() 之类的便捷 API,或由处理器手工解包并强制转换类型;后者有类型安全隐患,但实际上通常不是大问题,因为解包时总是知道事件类型。
职责链
对象之间几乎总以各种方式相互依赖:变换层次(物体放在另一物体上或拿在角色手里)、多组件对象(星形拓扑或松散的组件"云")、体育游戏中每队的角色列表。这些关系可以看成一张或多张关系图(列表和树只是图的特例,GEA 图 17.21 画了附着图、组件图和队伍图),任何一张都可以充当事件的分发通道:载具把事件传给乘客,乘客再传给背包中的物品;多组件对象把事件传给所有组件;体育游戏角色把事件传给队友(卷 II 17.8.7)。
在对象图中转发事件是面向对象事件驱动编程的常见模式,有时称为职责链 (Chain of Responsibility)。顺序通常由工程师预先决定;事件先交给链上第一个对象,处理器返回布尔值或枚举码表示是否认识并处理了它;被"消费"就停止转发,否则交给下一个接收者:
virtual bool SomeObject::OnEvent(Event& event) {
if (BaseClass::OnEvent(event)) return true; // 先调基类处理器
switch (event.GetType()) {
case SID("EVENT_ATTACK"):
RespondToAttack(event.GetAttackInfo());
return false; // 可以继续转发给其他对象
case SID("EVENT_HEALTH_PACK"):
AddHealth(event.GetHealthPack().GetHealth());
return true; // 我消费了这个事件,不再转发
// ...
default:
return false; // 不认识这个事件
}
}
派生类重写处理器时,如果只是增强基类响应,适合同时调用基类实现;如果完全替换,就不应调用基类处理器。这是另一种职责链。转发还可以用来多播:例如爆炸时利用世界的对象查询机制找出影响半径内的所有对象,再把事件转发给它们。
注册对事件的兴趣
多数对象只关心少数几种事件,多播或广播时却要遍历所有对象并调用处理器,效率不高(卷 II 17.8.8)。办法是让对象注册兴趣:为每种事件维护一个感兴趣对象的链表,或者每个对象维护一个位数组,每一位表示是否关心某种事件,这样就不必调用不关心该事件的对象。
更好的是在最初的对象查询里就只包括感兴趣的对象:爆炸时向碰撞系统查询伤害半径内并且能响应 Explosion 事件的对象。是否划算取决于查询机制的实现,以及在查询中过滤与在多播遍历中过滤的相对成本。注册与通知的通用结构见 观察者模式。
排队还是不排队
多数引擎提供发送即处理的机制,有的还允许把事件排队、在未来任意时刻处理。排队有吸引人的好处,但显著增加事件系统的复杂度,也带来独特的问题(卷 II 17.8.9)。GPP 的同主题讨论见 事件队列。
排队的好处(卷 II 17.8.9.1):
- 控制处理时机:子系统和对象需要按特定顺序更新,有些事件也对在游戏循环中何时处理高度敏感。全部立即处理时,处理器会在整个循环中以难以预测和控制的方式被调用;用队列延迟事件,工程师可以确保只在安全、合适的时候处理。
- 向未来投递事件:发送者可以指定投递时间,如本帧稍后、下一帧或几秒后。可以用来实现闹钟;也可以实现周期任务,如每两秒闪一次灯:处理器执行任务,然后再向一个周期之后投递同类事件。实现方法是入队前给每个事件打上期望投递时间,只有游戏时钟达到或超过它才处理;简单的做法是按投递时间升序排序,每帧查看队首,若在未来就立即停止(后面的都在未来),否则取出处理,直到遇到未来的事件:
// 每帧至少调用一次,分发所有投递时间不晚于现在的事件
void EventQueue::DispatchEvents(F32 currentTime) {
Event* pEvent = PeekNextEvent(); // 只看不取
while (pEvent && pEvent->GetDeliveryTime() <= currentTime) {
RemoveNextEvent();
pEvent->Dispatch(); // 交给接收者的事件处理器
pEvent = PeekNextEvent();
}
}
- 事件优先级:按投递时间排序后,两个以上事件投递时间完全相同时顺序仍不确定。这比想象的更常见,因为投递时间常被量化为整数帧(例如两个发送者都要求"本帧""下一帧"或"七帧后")。可以给事件分配优先级:时间相同时高优先级先处理,即先按投递时间升序排序,再把同一时间的每组按优先级降序排序。可以用无符号 32 位整数支持多达四十亿个优先级,也可以只有低中高两三级。每个引擎都存在一个能消除所有实际歧义的最少优先级数,最好尽量接近这个最小值,级别太多时要搞清在某种情况下哪个事件先处理会成为小噩梦;不过各游戏需求不同。
排队的问题(卷 II 17.8.9.2):
- 复杂度增加:需要更多代码、数据结构和更复杂的算法,开发时间更长,维护和演进成本更高。
- 深拷贝事件及其参数:立即处理时参数只需在处理函数(及其调用的函数)期间存在,事件和参数可以放在任何地方,包括调用栈上;排队时参数必须活过发送函数的作用域,所以入队前要深拷贝整个事件,包括参数负载及其指向的数据,确保不存在指向只存在于发送函数作用域内数据的悬垂引用,事件也能无限期存储。GEA 的例子里发送代码看起来基本一样(在栈上构造事件,
event.SetArgFloat("Damage", damage)等;原书排队版本把Event event("Explosion");印成了缺少右引号和右括号的Event event("Explosion,属于排版页卷 II p.628 上的原书问题),只是event.Send(receiver)(立即调receiver.OnEvent(event))换成了event.Queue(receiver)(内部先DeepCopy(event)再receiver.EnqueueEvent(pEventCopy))。 - 排队事件的动态内存分配:深拷贝意味着动态分配,而引擎中的动态分配既可能慢又容易产生碎片。最好选快速、无碎片的分配器:事件对象大小相同、参数元素也都同样大小(例如都是 Variant)时可以用池分配器;大小不一时可以用小内存分配器(为几种预定的小尺寸各维护一个池,见 游戏世界的加载与流送 与 自定义内存分配器)。Naughty Dog 把排队事件分配为可重定位内存块(卷 I 6.2.2.2)。
- 调试困难:处理器不是由发送者直接调用的,调用栈不能告诉你事件从哪来,无法在调试器里沿调用栈向上查看发送者的状态或发送时的情形;事件在对象间转发时更难。有的引擎记录事件在系统中流转的"书面痕迹"作为调试信息,但无论怎样,没有排队时事件调试通常容易得多。
- 竞争条件与一帧延迟:可能需要在游戏循环中多处分发事件,既要避免多余的一帧延迟,又要保证对象在帧内按正确顺序更新。例如动画更新中检测到某个动画播完,发出事件,其处理器想播放新动画;为了让两段动画之间没有一帧空隙,必须先更新动画时钟(检测到结束并发事件),再分发事件(让处理器请求新动画),最后开始动画混合(处理并显示新动画的第一帧):
while (true) {
g_animationEngine.UpdateLocalClocks(dt); // 可能检测到片段结束,发出 EndOfAnimation 事件
g_eventSystem.DispatchEvents(); // EndOfAnimation 处理器可以在本帧启动新动画
g_animationEngine.StartAnimationBlending(); // 混合所有正在播放的动画,包括本帧刚开始的
}
立即发送的问题
不排队也有问题:立即处理会导致极深的调用栈。A 给 B 发事件,B 在处理器里又发事件,层层下去(卷 II 17.8.10)。GEA 给出的调用栈示例中,Car::Update() 经过多次 Event::Send()、Car::OnEvent()、Character::OnEvent()、AnimationEngine::PlayAnimation()、HandleSoundEffect() 一路到 ShoulderAngel::OnEvent()。极端情况下(尤其是事件发送陷入无限循环)会耗尽栈空间,但问题的关键是每个事件处理器都必须完全可重入,即被递归调用也没有副作用。GEA 的人为例子:一个给全局变量加一的函数,若该变量应每帧只加一次,这个函数就不可重入,因为多次递归调用会加多次。
原书问题:该调用栈示例中有一处 "Characer::OnEvent()",应为 "Character::OnEvent()"(已核对排版页卷 II p.630)。
数据驱动的事件/消息系统
事件系统比 C/C++ 的静态类型函数调用灵活得多,但收发逻辑仍是硬编码、只由工程师掌控;若把事件系统数据驱动化,就能把它的能力交给策划(卷 II 17.8.11)。
- 简单可配置:在世界编辑器里选中对象,打开它可能收到的所有事件的滚动列表,对每个事件用下拉框和复选框从一组硬编码的预定义选项中选择是否以及如何响应。例如 "PlayerSpotted" 事件可以把 AI 角色配置为逃跑、攻击或忽略。一些真实的商业引擎基本就是这样实现的。问题是严重限制策划在没有程序员帮助时能做的事。
- 脚本:另一端是给策划一门简单的脚本语言(见 游戏脚本系统),策划可以写代码定义某类对象如何响应某类事件,甚至定义新事件类型、发送事件、以任意方式接收处理。这时策划实际上就是程序员(用能力稍弱但更易用、希望更不易出错的语言),一切皆有可能,Naughty Dog 就这样做。问题是许多策划不是受过训练的软件工程师,学习使用脚本语言可能令人生畏;除非已经练习脚本或编程一段时间,策划比工程师更容易引入 bug,可能在 alpha 阶段带来令人不快的意外。
- 中间路线:图形化:用复杂的 GUI 提供很大灵活性,但不提供完全自由的脚本语言。一种做法是流程图式的图形化编程语言:给用户一组有限、受控的原子操作,但可以自由连线。例如对 "PlayerSpotted" 事件,策划可以连一个流程图:角色撤退到最近的掩体点、播放动画、等 5 秒、然后攻击。GUI 还可以做错误检查和校验。Unreal 的蓝图就是这样的系统。
数据通路通信系统
把类似函数调用的事件系统改成数据驱动时,一个问题是不同事件类型往往互不兼容(卷 II 17.8.11.1)。例如玩家有一把电磁脉冲 (EMP) 枪:脉冲让灯和电子设备关闭、吓跑小动物、冲击波让附近植物摇摆。这些对象可能已有相应的事件响应:小动物对 "Scare" 逃跑,电子设备对 "TurnOff" 关机,植物对 "Wind" 摇摆;但 EMP 枪和这些处理器都不兼容,结果只能新建 "EMP" 事件,再为每种对象写自定义处理器。
解法是把事件类型从等式中拿掉,只考虑从一个对象向另一个对象发送数据流:每个对象有一个或多个可连接数据流的输入端口,和一个或多个向其他对象发送数据的输出端口;只要有办法把端口连起来(例如在 GUI 里用橡皮筋线连接),就能构造任意复杂的行为。EMP 枪有一个发送布尔信号的 "Fire" 输出端口,平时输出 0,开火时发出持续一帧的 1;动物有 "Scare" 输入,电子设备有 "TurnOn" 输入,植物有 "Sway" 输入;把 Fire 连到这些输入即可触发相应行为。注意要让 Fire 先经过一个取反节点再连到电子设备的 TurnOn,因为希望开火时它们关闭(GEA 图 17.22)。
flowchart LR G["EMP 枪<br/>Fire 输出"] --> A["动物<br/>Scare 输入"] G --> F["植物<br/>Sway 输入"] G --> I["取反节点<br/>In → Out"] --> R["收音机<br/>TurnOn 输入"]
上图按 GEA 图 17.22 的连线关系绘制:同一个布尔输出驱动三种对象,只有电子设备那一路经过取反。
- 程序员决定每种对象有哪些端口,策划用 GUI 以任意方式连线;程序员还提供图中可用的其他节点,如取反、产生正弦波、输出当前游戏时间(秒)。
- 数据通路上可以传送多种数据:布尔、单位浮点、三维向量、颜色、整数等。必须保证只连接类型兼容的端口,或者提供自动类型转换,例如单位浮点输出连到布尔输入时,小于 0.5 视为 false,大于等于 0.5 视为 true。GEA 说这就是 UE 蓝图这类基于 GUI 的事件系统的本质(图 17.23 为蓝图编辑器截图)。
图形化编程的利弊(卷 II 17.8.11.2):
- GUI 相对文本脚本的好处:易用、学习曲线平缓、可以有工具内帮助和提示、大量错误检查;
- 流程图式 GUI 的坏处:开发、调试、维护成本高;额外复杂度可能带来恼人甚至毁掉进度的 bug;策划能做的事有时受工具限制;
- 文本编程语言的优势:相对简单(更不易出 bug)、可以方便地在源码中搜索替换、每个人可以用自己最顺手的文本编辑器。
与 UE5 的对照
- GEA 提到的"动态类型后期绑定"在 UE 中由委托提供:单播、多播和动态委托,动态委托可序列化、可在蓝图中绑定,见 委托与 Tick 机制。
- 蓝图在本库中的定位、性能与 C++ 分工见 蓝图与 C++ 协作。
出处
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.8 节