什么是ECS?

学习写游戏引擎的时候,做到这一部分的内容,看了很多相关文章,写一些自己的理解在这

Entity-Component-System (ECS) is a software architectural pattern mostly used on video game development for the storage of game world objects. An ECS follows the pattern of “entities” with “components” of data.
An ECS follows the principle of composition over inheritance, meaning that every entity is defined not by a “type”, but by the components that are associated with it. The design of how components relate to entities depend upon the Entity Component System being used.

先不谈ECS架构,先来思考思考,假设我们有一个场景Scene,里面一个是正方体,那么我可以设计这么几个类:

1
2
3
4
5
6
7
8
9
10
11
class Entity
{
float[3] m_Position;
};

class Box : public Enitty {};

class Scene
{
std::vector<Entity> m_Entities;
};

在C++里,public继承是is-a的关系,private继承是has-a的关系,那么如果我需要两个正方体,一个可以播放音频,一个可以播放动画,那么代码是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Entity 
{
float[3] m_Position;
};

class Box : public Enitty {};

class Scene
{
std::vector<Entity> m_Entities;
};

class BoxWithAudio : public Box {};
class BoxWithAnimator : public Box {};

当如果Box既可以播放音频、又可以播放动画,那么一共会有这么些类:

1
2
3
4
5
class Box : public Enitty {};
class BoxWithAudio : public Box {};
class BoxWithAnimator : public Box {};
// 出现了multiple inheritance
class BoxWithAnimatorAndAudio : public BoxWithAnimator, public BoxWithAudio {};

这里很容易就出现了一个类继承于两个类的情况,而BoxWithAnimatorBoxWithAudio 又同时继承于Box,所以这是个菱形继承。Box类才添加了两个功能就会造成这么复杂的类设计,所以说这种写法,很容易造成类混乱,更何况其他的很多语言里根本不支持多重继承。

所以,这里有个更好的设计思路,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Entity 
{
std::vector<Component> m_Components;
float[3] m_Position;
};

class Scene
{
std::vector<Entity> m_Entities;
};

class MeshComponent : public Component {};
class AudioComponent : public Component {};
class AnimatorComponent : public Component {};

// 创建Entity
Entity boxWithAnimatorAndAudio;
boxWithAnimatorAndAudio.AddComponent(new MeshComponent());
boxWithAnimatorAndAudio.AddComponent(new AudioComponent());
boxWithAnimatorAndAudio.AddComponent(new AnimatorComponent());

此时的Entity,它需要什么功能,就会在其m_Components里添加对应的Component组件,这样代码设计就不混乱了。

上面的代码,是ECS架构的雏形,但还远远不够。假设我们的Scene里有一堆Entity,每个Entity都有自己的Mesh和自己的Position,那么我们渲染的时候,大概会这么写代码:

1
2
3
4
5
6
7
8
9
10
11
// 遍历Scene里的entities
for(Entity& e: m_Entities)
{
if(e.GetMeshComponent() ! = null)
{
PrepareToDrawMesh(e.GetMeshComponent().mesh, e.GetPosition());
}
}

// 批处理一起绘制Mesh
DrawMesh();

上面的代码,最大的问题是,由于Mesh是存在每个Entity里的,它们的内存必然是散布于计算机各个位置的,那么计算机必然经常会产生缺页情况。没有遵循计算机的局部访问性的代码,效率是很差的。

为了解决这个问题,更好的思路产生了,Scene里相同Component的数据,应该是连续存储的,大概思路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Scene
{
std::vector<Entity> m_Entities;
// 真正的数据连续的存在于Scene里
std::vector<MeshComponent> m_MeshComponents;
std::vector<AudioComponent> m_AudioComponents;
std::vector<AnimatorComponent> m_AnimatorComponents;
};

// Entity本质就是一个唯一标识符(个人理解)
class Entity
{
uint32_t m_UniqueId;

float[3] m_Position;
};

class MeshComponent : public Component {};
class AudioComponent : public Component {};
class AnimatorComponent : public Component {};

实际绘制的时候,代码就变成了:

1
2
3
4
5
6
7
8
9
10
11
for(int i = 0; i < m_MeshComponents.count; i++)
{
const Entity& entity = FindEntityWhoseMeshIdEquals(i);
if(entity ! = null)
{
PrepareToDrawMesh(m_MeshComponents[i], entity.GetPosition());
}
}

// 批处理一起绘制Mesh
DrawMesh();

一般来说, 一个Entity只会有一个某种类型的Component, 比如Animator、Audio组件等,所以前面的Entity里都是用单个uint32_t记录Component在对应数组里的id,但脚本组件一般可以有多个挂在一个Entity上,不过思路是一样的。

为了方便说明,我在Entity里存了三个float,但这里的Entity.GetPosition可能仍然会产生缺页的情况。实际上应该把Transform信息也作为Component存到Scene里,Entity里不会存储任何实际数据,它的作用是把特定的Components组合起来,然后实际绘制Mesh的时候,内存可能是这样的:

1
Mesh | Transform | Mesh | Transform

Mesh并不是直接连续的,可能是Mesh | Transform 两两一组,分布于Memory里。


什么是EC?

EC frameworks, as typically found in game engines, are similar to ECS in that they allow for the creation of entities and the composition of components. However, in an EC framework, components are classes that contain both data and behavior, and behavior is executed directly on the component.

EC框架跟ECS非常类似,无非ECS框架里,Component只负责存Data,而EC框架里,Component除了存Data,还要存Behavior相关的逻辑代码。

Unity和UE4里都是用的EC架构,而不是ECS

Unity和UE4里的Component都可以写逻辑代码,甚至UE4的Actor本身就可以在内部写逻辑代码(Unity则不可以),而且UE4里的Component可以形成父子层级关系:


结论

所以说,ECS里,Components基本上都是存Data的,对Components处理的逻辑代码都放到System里;而EC里,Components里不仅存Data,也存放代码逻辑,所以没有System这个概念。UE4和Unity里基本都是用的EC架构,而不是ECS系统。

EnTT项目

在HEngine选择的就是使用ECS架构的Entt库,这里是他的Wiki

该库所有的内容,应该都放到一个叫entt.hpp的文件里了,我看了下,这个文件非常大,一共有17600行,500多KB,应该代码都在里面了,就把它当头文件用就行了。

这里把该文件放到vendor/entt/include文件夹下,把liscense文件放到vendor/entt文件夹下

entt相关的内容可以直接看对应的github仓库的介绍,这里看一些例子代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 用于后面的Callback例子, 当Transform组件被创建时调用, 会加到entity上
static void OnTransformConstruct(entt::registry& registry, entt::entity entity){}

// 创建一个TransformComponent类
struct TransformComponent
{
glm::mat4 Transform{ 1.0f };

TransformComponent() = default;
TransformComponent(const TransformComponent&) = default;
TransformComponent(const glm::mat4 & transform)
: Transform(transform) {}

operator glm::mat4& () { return Transform; }
operator const glm::mat4& () const { return Transform; }
};

// 创建一个registry, 可以把它理解为vector<entity>, 也就是包含所有entity的容器
entt::registry m_Registry;

// 创建一个entity, entt::entity其实是uint32_t
entt::entity entity = m_Registry.create();

// emplace等同于AddComponent, 这里给entity添加TransformComponent
m_Registry.emplace<TransformComponent>(entity, glm::mat4(1.0f));

// entt提供的Callback, 当TransformComponent被创建时, 调用OnTransformConstruct函数
m_Registry.on_construct<TransformComponent>().connect<&OnTransformConstruct>();

// 判断entity上是否有TransformComponent
if (m_Registry.all_of<TransformComponent>(entity))
// 从entity上get TransformComponent
TransformComponent& transform = m_Registry.get<TransformComponent>(entity);

// 获取所有带有TransformComponent的entity数组
auto view = m_Registry.view<TransformComponent>();
for (auto entity : view)
{
TransformComponent& transform = view.get<TransformComponent>(entity);
}

// group用来获取同时满足拥有多个Component的Entity数组
auto group = m_Registry.group<TransformComponent>(entt::get<SpriteRendererComponent>);
for (auto entity : group)
{
auto& [transform, spriteRenderer] = group.get<TransformComponent, SpriteRendererComponent>(entity);
}