中EN
概念页

事件与消息传递

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++ 协作。
  1. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.8 节

相关页面

游戏开发事件队列Event Queue事件队列 (Event Queue) 用一个先进先出队列,把“发送消息或事件”和“处理它”在时间上解耦:发送方入队后立即返回,接收方在自己方便的时候再取出处理。它比观察者和命令模式多做的一件事是时间上的解耦,适合“推”与“拉”两端节奏不同的场合(例如音频请求),代价是全局可见、世界状态可能已经改变,以及可能出现隐蔽的反馈回环。游戏开发观察者模式Observer Pattern观察者模式 (Observer Pattern) 让一段代码宣布“发生了某件事”,而不必关心谁会收到通知:主体 (Subject) 维护一个观察者列表,在事件发生时逐个同步调用它们。它适合连接彼此几乎无关的子系统(例如物理与成就),但要注意同步阻塞、观察者的注销,以及运行时才能确定的通信关系难以推理。游戏开发命令模式Command Pattern命令模式 (Command Pattern) 把一次方法调用“物化”成对象,从而可以存储、传递、延迟执行、排队或撤销。游戏里最典型的三种用法是:可配置的按键映射、让玩家输入和 AI 共用同一套“驱动角色”的接口、以及撤销/重做。游戏开发 · UE5委托与 Tick 机制Delegates & TickUE 有两种驱动游戏逻辑的方式:事件驱动——委托 (Delegate) 在某事发生时回调绑定的函数;轮询驱动——Tick 每帧(或按最小间隔)调用 Actor/组件的更新函数,并按 Tick 组 (Tick Group) 与物理模拟排序。官方建议能用定时器、委托就不用 Tick,必须 Tick 时用 Tick 组和 Tick 依赖保证顺序,避免“差一帧”问题。游戏开发 · UE5蓝图与 C++ 协作Blueprint & C++ InteropEpic 官方建议:多数项目混用蓝图 (Blueprint) 与 C++,以 C++ 作为基础、在其上构建蓝图类。C++ 适合“编程”——定义系统;蓝图适合“脚本”——在已有系统上定义具体行为。两者通过反射说明符互相调用:C++ 用 BlueprintCallable/BlueprintReadWrite 等暴露给蓝图,用 BlueprintImplementableEvent/BlueprintNativeEvent 让蓝图实现或覆盖 C++ 声明的函数。游戏开发游戏脚本系统Game Scripting脚本语言是主要用来让用户控制和定制软件行为的编程语言;在游戏引擎中,它是高层、相对易用、能方便访问引擎大多数常用功能的语言,程序员和非程序员都能用它开发新游戏或给现有游戏做 mod。GEA 先区分数据定义语言和运行时脚本语言,梳理语言分类与游戏脚本的典型特征(解释执行、轻量、快速迭代、方便),介绍 QuakeC、UnrealScript、Lua、Python、Pawn,列出从"脚本回调"到"脚本驱动整个游戏"的六种架构,再以 Naughty Dog 的 DCScript 为例讲虚拟机与原生函数接口,最后是对象引用、事件收发、面向对象脚本、脚本状态机和协作式多线程脚本(GEA4 卷 II 17.游戏开发游戏对象的实时更新Updating Game Objects in Real Time游戏对象更新就是根据上一时刻状态 Si(t − Δt) 求出当前状态 Si(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发字符串与唯一标识符Strings & Unique Identifiers字符串在 C/C++ 中只是字符数组,变长意味着要么硬编码长度上限、要么动态分配;比较和复制都是 O(n)。引擎内部大量用字符串做资源名和对象 ID,处理不当会严重拖慢游戏。GEA 的建议:运行时代码尽量避免字符串类,若用则了解其成本并按引用传递;路径类是值得专门做的例外;对象与资产的唯一标识用哈希字符串 ID(string id),兼得字符串的可读性与整数的比较速度。游戏开发游戏对象引用与查询Game Object References & Queries游戏对象需要唯一 id 来区分、查找和充当通信目标;找到对象后还要能引用它,引用的寿命可以从一次函数调用到许多分钟。GEA 比较三种实现:裸指针最快最简单,但有孤儿对象、悬垂指针、非法指针三类问题,且不能配合内存重定位;智能指针可以检测对象已删除、做引用计数,但很难写对,GEA 个人建议远离它,非用不可就用 C++11 标准库或 Boost 的成熟实现;句柄是全局句柄表的整数索引,加上唯一 id 防止槽位复用后指向错误对象,简单而安全。对象查询则不追求通用数据库,而是为最常用的查询建专门的数据结构(GEA4 卷 II 17.5)。游戏开发 · UE5Gameplay 标签深入Gameplay Tags In DepthGameplay 标签 (Gameplay Tags) 是用 . 分层的用户定义标签,必须登记到标签字典(项目设置 .ini、数据表或 C++ 原生标签宏)。对象用 FGameplayTagContainer 持有多个标签并以 HasTag/HasAny/HasAll 判断(注意空集合语义),复杂条件用 FGameplayTagQuery 的叶/根表达式组合并配置在资产里。它是 GAS、AI 和各类玩法状态的通用词汇表;大项目可限制标签编辑权限,并用 IGameplayTagAssetInterface 免 Cast 取标签。游戏开发游戏世界与游戏对象Game World & Game Objects游戏由玩法 (gameplay) 而不是技术定义;游戏机制 (game mechanics) 是支配游戏中各实体交互的规则,以及目标、成败标准、玩家能力、非玩家实体和整体流程(GEA 引用的论文称实现玩法的软件系统为游戏的 G 因子)。大多数游戏世界由静态元素和动态元素组成,划分为若干世界块,并由高层游戏流程组织玩家目标;动态元素通常实现为游戏对象 (GO),引擎提供的建模设施称为游戏对象模型,且分为工具侧和运行时两套(GEA4 卷 II 16、16.1–16.2)。游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。游戏开发游戏世界的加载与流送Loading & Streaming Game Worlds世界加载系统负责两件事:把世界块和所需资源从磁盘读进内存的文件 I/O,以及这些资源的内存分配与释放;另外还要管理游戏对象的生成与销毁。GEA 按演进顺序介绍:一次只载一个关卡的栈式加载(LSR 资源压栈底),用小块气闸 (air lock) 区域实现基本无缝的过渡,再到把所有资产切成等大块、用块池分配器按关卡加载区域流送的细粒度方案(Naughty Dog);对象生成的内存问题有"不许运行时分配"、按尺寸分池、小内存分配器和内存重定位四种对策;存档则分检查点和随处存档(GEA4 卷 II 17.4)。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原