实体-组件-系统 - C++ 中一个很棒的游戏设计模式(第 1 部分)
本文是关于实体-组件-系统(ECS)的。它是一种设计模式,可以让你在设计整体软件架构时拥有极大的灵活性。
引言
在本文(原文 博文)中,我想讨论实体-组件-系统(ECS)。你可以在网上找到大量关于此事的资料,所以我不会在这里深入解释,但我会更多地讨论我自己的实现。
首先。你可以在我的 github 存储库中找到我的 ECS 的完整源代码。
实体-组件-系统——主要在视频游戏中遇到——是一种设计模式,可以让你在设计整体软件架构时拥有极大的灵活性[1]。像 Unity、Epic 或 Crytek 这样的大公司将此模式纳入其框架,为开发者提供了一个非常丰富的工具来构建他们的软件。你可以查看这些博文,了解关于此事的广泛讨论[2,3,4,5]。
如果你阅读了我上面提到的文章,你会注意到它们都拥有相同的目标:在实体、组件和系统之间分配不同的关注点和任务。这三个是该模式中的重要组成部分,并且相当松散耦合。实体主要用于提供唯一标识符,让环境意识到单个个体的存在,并充当一种根对象,该对象捆绑了一组组件。组件不过是没有任何复杂逻辑的容器对象。理想情况下,它们是简单的纯旧数据对象(POD)。每种类型的组件都可以附加到一个实体上,以提供某种属性。例如,一个“生命值组件”可以附加到一个实体上,通过给它一个生命值(在内存中不过是一个整数或浮点值)来使其具有生命。
到目前为止,我遇到的绝大多数文章都同意实体和组件对象的作用和用途,但对于系统,意见有所不同。有些人认为系统只了解组件。此外,有些人认为每种类型的组件都应该有一个系统,例如,“碰撞组件”有一个“碰撞系统”,“生命值组件”有一个“生命值系统”等等。这种方法有点僵化,并且没有考虑到不同组件之间的相互作用。一种不太严格的方法是让不同的系统处理它们应该关心的所有组件。例如,“物理系统”应该知道“碰撞组件”和“刚体组件”,因为两者都可能包含有关物理模拟的必要信息。在我看来,系统是“封闭的环境”。也就是说,它们不拥有实体或组件的所有权。它们通过独立的管理器对象访问它们,而这些管理器对象又会负责实体和组件的生命周期。
这就引出了一个有趣的问题:实体、组件和系统之间如何相互通信,如果它们彼此独立的话?答案因实现而异。对于我将要展示的实现,答案是事件溯源[6]。事件通过“事件管理器”分发,任何对事件感兴趣的都可以收听管理器在说什么。如果实体、系统甚至组件有重要的状态变更需要沟通,例如“位置已更改”或“玩家已死亡”,它都可以告知“事件管理器”。他将广播事件,所有订阅者都会收到通知。这样,一切都可以相互关联。
好吧,我想上面的介绍比我原计划的要长,但我们还是做到了。:) 在我们深入研究代码(顺便说一句,这是 C++11)之前,我将概述我架构的主要功能。
- 内存效率 - 为了能够快速创建和移除实体、组件、系统对象以及事件,我无法依赖标准 new/delete 管理的堆内存。解决方案当然是自定义内存分配器。
- 日志记录 - 为了了解正在发生的事情,我使用了 log4cplus[7] 进行日志记录。
- 可扩展 - 轻松实现新类型的实体、组件、系统和事件,除了系统的内存之外,没有预设的上限
- 灵活 - 实体、组件和系统之间没有依赖关系(实体和组件当然有一种依赖关系,但它们之间没有任何指针逻辑)
- 简单的对象查找/访问 - 通过 `EntityId` 或组件迭代器轻松检索实体对象及其组件,以迭代特定类型的所有组件
- 流程控制 - 系统有优先级,并且可以相互依赖,因此可以建立一个拓扑顺序来执行它们
- 易于使用 - 该库可以轻松集成到其他软件中;只需一个头文件。
下图描绘了我的实体-组件-系统(ECS)的整体架构
可以看到,图中分为四个不同颜色的区域。每个区域定义了架构的一个模块化部分。最底部——实际上在上面的图中是最顶部的;应该是颠倒的——是我们拥有内存管理和日志记录(黄色区域)。这些第一层模块处理非常低级的任务。它们由实体-组件-系统(蓝色区域)和事件溯源(红色区域)的第二层模块使用。这些家伙主要处理对象管理任务。最顶层是第三层模块,即 `ECS_Engine`(绿色区域)。这个高级全局引擎对象协调所有第二层模块,并负责初始化和销毁。好了,这是一个简短且非常抽象的概述,现在让我们进入更多细节。
内存管理器
让我们从 内存管理器 开始。它的实现基于我在 gamedev.net 上找到的一篇文章[8]。其思想是将堆内存分配和释放减到最低。因此,只有在应用程序启动时,才会使用 malloc 分配一大块系统内存。这块内存将由一个或多个自定义分配器管理。有许多类型的分配器[9](线性、堆栈、空闲列表...),它们各有优缺点(我在此不讨论)。但即使它们内部工作方式不同,它们都共享一个通用的公共接口。
class Allocator
{
public:
virtual void* allocate(size_t size) = 0;
virtual void free(void* p) = 0;
};
上面的代码片段不完整,但概述了每个具体分配器必须提供的两个主要 `public` 方法。
- `allocate` - 分配一定数量的字节并返回此块的内存地址,以及
- `free` - 使用地址释放先前分配的内存块
说完了这些,我们就可以做一些很酷的事情,比如像这样将多个分配器链接起来。
可以看到,一个分配器可以从另一个(父)分配器获取它将要管理的内存块,而后者又可以从另一个分配器获取内存,以此类推。这样,你就可以建立不同的内存管理策略。对于我的 ECS 的实现,我提供了一个根堆栈分配器,它获取 1GB 系统内存的初始分配块。第二层模块将从这个根分配器分配所需的内存量,并且只在应用程序终止时才释放它。
图-03 显示了全局内存如何分配给第二层模块:“全局内存用户 A” 可能是实体管理器,“全局内存用户 B” 是组件管理器,而“全局内存用户 C” 是系统管理器。
日志记录
我不会过多谈论日志记录,因为我只是使用了 log4cplus[7] 来完成这项工作。我所做的只是定义了一个 Logger 基类,它包含一个 `log4cplus::Logger` 对象和几个包装方法,用于转发简单的日志调用,如“`LogInfo()`”、“`LogWarning()`”等。
实体管理器、IEntity、Entity 等。
好的,现在让我们来谈谈我架构的核心;图 01 中的蓝色区域。你可能已经注意到所有管理器对象及其相关类之间的相似设置。例如,看看 EntityManager、IEntity 和 Entity 类。`EntityManger` 类负责在应用程序运行时管理所有实体对象。这包括创建、删除和访问现有实体对象等任务。`IEntity` 是一个接口类,提供了实体对象的基本特征,例如对象标识符和(静态)类型标识符。它是静态的,因为它在程序初始化后不会改变。此类型标识符在多次应用程序运行中也是一致的,只有在源代码被修改时才可能改变。
class IEntity
{
// code not complete!
EntityId m_Id;
public:
IEntity();
virtual ~IEntity();
virtual const EntityTypeId GetStaticEntityTypeID() const = 0;
inline const EntityId GetEntityID() const { return this->m_Id; }
};
类型标识符是一个整数值,对于每个具体的实体类都不同。这允许我们在运行时检查 `IEntity` 对象的类型。最后是 `Entity` 模板类。
template<class T><class t="">
class Entity : public IEntity
{
// code not complete!
void operator delete(void*) = delete;
void operator delete[](void*) = delete;
public:
static const EntityTypeId STATIC_ENTITY_TYPE_ID;
Entity() {}
virtual ~Entity() {}
virtual const EntityTypeId GetStaticEntityTypeID() const override
{ return STATIC_ENTITY_TYPE_ID; }
};
// constant initialization of entity type identifier
template<class T> const EntityTypeId Entity<T>::STATIC_ENTITY_TYPE_ID =
util::Internal::FamilyTypeID::Get();</class>
这个类的唯一目的是初始化具体实体类的唯一类型标识符。我利用了两个事实:一是静态变量的常量初始化[10],二是模板类的工作方式。模板类 `Entity` 的每个版本都有其自己的 `static` 变量 `STATIC_ENTITY_TYPE_ID`。而它保证在任何动态初始化发生之前被初始化。术语“`util::Internal::FamilyTypeID::Get()`”用于实现一种类型计数机制。它会在每次使用不同的 `T` 调用时内部递增计数器,但每次使用相同的 `T` 调用时总是返回相同的值。我不确定这种模式是否有特殊的名称,但它确实很酷。:) 在这一点上,我也摆脱了 `delete` 和 `delete[]` 运算符。这样,我就确保没有人会意外调用这些函数。同时,这也(只要你的编译器足够智能)会在你尝试使用实体对象的 `new` 或 `new[]` 运算符时发出警告,因为它们的对应项已经消失了。这些运算符并非设计用于此,因为 `EntityManager` 类将负责所有这些。好了,让我们总结一下我们刚才学到的。管理器类提供基本功能,如创建、删除和访问对象。接口类充当根基类,并提供唯一的对象标识符和类型标识符。模板类确保类型标识符的正确初始化,并删除 `delete`/`delete[]` 运算符。这种管理器、接口和模板类的模式也用于组件、系统和事件。唯一但重要的一点是,这些组的区别在于 `manager` 类存储和访问它们对象的方式。
让我们先看看 `EntityManager` 类。图 04 显示了事物的存储方式的整体结构。
创建新实体对象时,会使用 `EntityManager::CreateEntity
- 类型为 **T** 的实体对象的 `ObjectPool`[12] 将被获取,如果此池不存在,则会创建一个新的。
- 将从此池中分配新内存;刚好够存储 T 对象。
- 在实际调用 `T` 的构造函数之前,会从管理器获取一个新的 `EntityId`。此 ID 将与之前分配的内存一起存储在一个查找表中,这样,我们就可以稍后通过此 ID 查找实体实例。
- 接下来,使用转发的 `args`... 作为输入调用 C++ 的放置 new 运算符[13],以创建 T 的新实例。
- 最后,该方法返回实体的标识符。
在创建了实体对象的新实例之后,可以通过其唯一的**对象**标识符(`EntityId`)和 `EntityManager::GetEntity(EntityId id)` 来访问它。要销毁实体对象的实例,必须调用 `EntityManager::DestroyEntity(EntityId id)` 方法。
`ComponentManager` 类的工作方式相同,外加一个扩展。除了用于存储所有类型组件的对象池外,它还必须提供一个额外的机制来将组件链接到其拥有的实体对象。这个约束导致了第二个查找步骤:首先,我们检查给定 `EntityId` 是否有条目,如果有,我们将通过在组件列表中查找来检查此实体是否附加了某种类型的组件。
使用 `ComponentManager::CreateComponent(EntityId id, args...)` 方法允许我们将特定组件添加到实体。使用 `ComponentManager::GetComponent(EntityId id)`,我们可以访问实体的组件,其中 `T` 指定了我们要访问的组件类型。如果组件不存在,则返回 `nullptr`。要从实体中删除组件,可以使用 `ComponentManager::RemoveComponent(EntityId id)` 方法。但等等,还有更多。访问组件的另一种方法是使用 `ComponentIterator`。这样,你就可以迭代特定类型 `T` 的所有现有组件。当某个系统(如“物理系统”)想将重力应用于所有“刚体组件”时,这可能会很有用。
`SystemManager` 类在存储和访问系统方面没有任何花哨的附加功能。它使用一个简单的映射来存储系统及其类型标识符作为键。
`EventManager` 类使用一个线性分配器来管理内存块。这块内存用作事件缓冲区。事件被存储到该缓冲区中,稍后进行分派。事件的分派会清除缓冲区,以便存储新事件。这至少每帧发生一次。
我希望到此为止,你对我的 ECS 的工作原理有了一定的了解。如果没有,不用担心,看看 图-06,让我们回顾一下。你可以看到 `EntityId` 非常重要,因为它将用于访问具体的实体对象实例及其所有组件。所有组件都知道它们的所有者,也就是说,有一个 `component` 对象在手,你可以通过给定该组件的所有者 ID 向 `EntityManager` 类询问来轻松获取实体。要传递实体,你永远不会直接使用其指针,但你可以将事件与 `EntityId` 结合使用。你可以创建一个具体的事件,例如“`EntityDied`”,这个事件(它必须是一个纯旧数据对象)有一个 `EntityId` 类型的成员。现在要通知所有事件侦听器(`IEventListener`)——它们可以是实体、组件或系统——我们使用 `EventManager::SendEvent(entityId)`。接收者就可以使用提供的 `EntityId` 并询问 `EntityManager` 类来获取实体对象,或者询问 `ComponentManager` 类来获取该实体的特定组件。绕过这一步的原因很简单,在应用程序运行的任何时候,实体或其组件都可能被某些逻辑删除。因为你不会用额外的清理工作来充斥你的代码,所以你依赖于这个 `EntityId`。如果管理器为该 `EntityId` 返回 `nullptr`,你就知道一个实体或组件已不再存在。顺便说一句,红色方块对应于 图-01 中的方块,并标记了 ECS 的边界。
引擎对象
为了让事情更方便一些,我创建了一个引擎对象。引擎对象确保了在客户端软件中的轻松集成和使用。在客户端,只需包含“ECS/ECS.h”头文件并调用 `ECS::Initialize()` 方法。现在将初始化一个静态全局引擎对象(`ECS::ECS_Engine`),并在客户端使用它可以访问所有管理器类。此外,它提供了一个 `SendEvent` 方法用于广播事件,以及一个 `Update` 方法,它会自动分派所有事件并更新所有系统。在退出主程序之前应调用 `ECS::Terminate()`。这将确保所有获取的资源都将被释放。下面的代码片段演示了 ECS 全局引擎对象的非常基本的使用。
结论
本文所述的实体-组件-系统是功能齐全且可供使用的。但一如既往,肯定还有一些需要改进的地方。以下列表仅包含我想到的一些想法。
- 使其线程安全
- 根据拓扑顺序,在线程中运行每个系统或一组系统
- 重构事件溯源和内存管理,并将它们作为模块包含
- 序列化
- 分析
- ...
我希望这篇文章有所帮助,并且你喜欢阅读它的程度和我写它一样!:) 如果你想看看我的 ECS 的实际应用,请看这个演示。
《赏金猎人》演示大量使用了 ECS 并展示了该模式的优势。如果你想知道如何做到,可以看看这篇 博文。
到目前为止……
参考文献
- [1]https://en.wikipedia.org/wiki/Entity-component-system
- [2]http://gameprogrammingpatterns.com/component.html
- [3]https://www.gamedev.net/articles/programming/general-and-gameplay-programming/understanding-component-entity-systems-r3013/
- [4]https://github.com/junkdog/artemis-odb/wiki/Introduction-to-Entity-Systems
- [5]http://scottbilas.com/files/2002/gdc_san_jose/game_objects_slides.pdf
- [6]https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing
- [7]https://sourceforge.net/p/log4cplus/wiki/Home/
- [8]https://www.gamedev.net/articles/programming/general-and-gameplay-programming/c-custom-memory-allocation-r3010/
- [9]https://github.com/mtrebi/memory-allocatorshttps://www.gamedev.net/articles/programming/general-and-gameplay-programming/c-custom-memory-allocation-r3010/ [10]https://cppreference.cn/w/cpp/language/constant_initialization
- [11]https://en.wikipedia.org/wiki/Variadic_template
- [12]http://gameprogrammingpatterns.com/object-pool.html
- [13]https://cppreference.cn/w/cpp/language/new