快速理解Entity Component System (ECS)实现

Number of views 104

自从第一次听说实体组件系统(Entity Component Systems)及其在游戏开发中的应用以来,我就想为自己使用和学习的目的构建一个。网上已经有一些人们构建并发布的例子(1, 2),也有一些可以用来构建真实游戏的成熟系统(3, 4)。

我研究了其中的每一个,喜欢它们各自不同的方面,但我想构建自己的系统,并以自己的方式解决问题,融合不同示例中的各种元素。这就是我的成果。

诚然,这个系统设计得很简单。它旨在作为一个起点,供那些对此感兴趣的开发者们探索。

什么是ECS?

在传统游戏开发中,通常会采用继承的方式来解决问题。比如,哥布林(Goblin)继承自怪物(Monster),而怪物又继承自角色(Actor)。店员(Shopkeeper)继承自人类(Human),而人类同样继承自角色(Actor)。角色类包含一个名为Render()的函数,该函数知道如何渲染一个角色,因此对于每一个哥布林,你可以调用Goblin.Render(),对于每一个店员,你可以调用Shopkeeper.Render()。

这种方法存在两个主要问题。首先是灵活性的问题。如果你决定在游戏中访问一个友好的哥布林村庄,并且有哥布林店员,那么你的继承树就会变得混乱。所有店铺管理的功能都放在了店员类中(如销售、以物易物等),但你的哥布林店员不能从店员类继承,因为这会使哥布林店员变成一个人类。毫无疑问,继承在软件开发中有其地位,但在游戏玩法编程中可能会引发问题。

第二个问题是缓存的误用。在游戏里,你经常需要每秒多次遍历一组对象,并在每一帧上运行它们的方法。例如,你的物理系统可能会遍历所有受物理影响的对象并调用Object.Integrate(dt),以更新它们的位置、速度和加速度。因此,传统上你会有一个大对象,它包含了所有状态,包括物理所需的那些,并对每个需要更新的对象调用integrate函数。在每个对象的Integrate()方法中,你会访问对象的位置、速度和加速度成员变量。当你访问位置时,它会连同附近的成员变量一起被拉入缓存行。其中一些附近的成员变量是有用的(如速度和加速度),而其他的则不是。这是对缓存的巨大浪费,在这个数据从主内存传输到CPU内存所需时间成为性能瓶颈的时代,这个问题尤为重要。

潮流已经转向基于组件的设计以解决第一个问题。以Unity为例,所有的游戏对象都是基于组件的。你从一个只有默认必需的Transform组件的空白对象开始,并添加更多的组件来赋予对象功能。但这并没有解决第二个问题。

第二个问题通过将所有需要经常迭代的数据紧密地打包在内存中来解决,这样一次就可以加载整个缓存行的数据,当下一个项目被迭代时,它的数据已经在缓存中了。这通过将组件定义为普通的旧数据(Plain Old Data, POD)来实现,基本上就是一个只包含相关数据的简单结构体。继续以物理为例,你可能会有一个包含位置的Transform,一个包含速度和加速度的Rigidbody,以及一个包含重力常数g的Gravity组件。

然后,物理系统会遍历所有“包含”这三个组件的“对象”,只将它关心的数据拉入缓存中进行处理。

随着Unity引入了自己的ECS实现,以及其Jobs系统和Burst编译器,Unity正朝着这个方向前进。事实上,观看Unity的首席程序员Mike Acton(领导ECS开发)的一个演讲是我对这些东西感兴趣的最初原因。

实际上,传统的“对象”概念已经不复存在。取而代之的是实体(Entity),它只是一个ID。它本身并不“包含”任何东西。相反,这个ID被用作索引访问组件数组。数组在内存中是连续的,这使其成为理想的数据结构选择。因此,物理系统可能有一个包含所有具有Transform、RigidBody和Gravity组件的实体列表,并使用实体的ID作为索引访问Transform数组、RigidBody数组以及Gravity数组。

因此,从概念上讲,这其实相当简单。实体(Entity)是一个ID。组件(Component)是一组数据的结构体。系统(System)是对组件进行操作的逻辑。这篇文章的重点将是如何以一种简单、易于理解和使用的方式来实现这三个要素。

我设计我的系统时设定了以下目标:

  • 相对简单且易于理解
  • 使用现代C++特性
  • 最少量的if条件(以避免分支预测错误)
  • 最少量的虚继承(以避免虚函数查找和分支预测错误)

实体(Entity)

如前面提到的,实体非常简单:

// A simple type alias
using Entity = std::uint32_t;

// Used to define the size of arrays later on
const Entity MAX_ENTITIES = 5000;

当然,你可以选择让实体(Entity)的大小为任意值,MAX_ENTITIES也同样可以按你的需求设定。

组件(Component)

组件几乎和实体一样简单。它只是一个包含一小块功能相关数据的结构体。以Transform为例,它可能看起来像这样:

struct Transform
{
	Vec3 position;
	Quat rotation;
	Vec3 scale;
}

每个组件类型(如Transform、RigidBody等)也有一个唯一的ID赋予它(原因将在后面解释)。

// A simple type alias
using ComponentType = std::uint8_t;

// Used to define the size of arrays later on
const ComponentType MAX_COMPONENTS = 32;

同样,您可以为ComponentType和MAX_COMPONENTS选择任何大小。

签名(Signature)

由于实体只是一个ID,我们需要一种方法来跟踪某个实体“拥有”哪些组件,同时我们也需要一种方法来跟踪系统关心哪些组件。

我选择了使用std::bitset(现代C++中位字段的等价物)的非常简单的方法,称之为签名(Signature)。每个组件类型都有一个唯一的ID(从0开始),用于表示签名中的一个位。

例如,如果Transform的类型是0,RigidBody的类型是1,Gravity的类型是2,那么拥有这三个组件的实体将有一个为0b111的签名(位0、1和2被设置)。

系统也会将其对某些组件的兴趣注册为另一个签名。然后通过简单的按位比较即可确保实体的签名包含系统的签名(实体可能拥有比系统要求更多的组件,这没有问题,只要它包含了系统所需的所有组件即可)。

// A simple type alias
using Signature = std::bitset<MAX_COMPONENTS>;

实体管理器(Entity Manager)

实体管理器(The Entity Manager)负责分发实体ID,并记录哪些ID正在使用,哪些没有被使用。

我选择使用一个简单的std::queue,在启动时,队列初始化为包含从0到MAX_ENTITIES之间的所有有效实体ID。当创建一个实体时,它会从队列的前端取出一个ID,而当一个实体被销毁时,它会将这个已销毁的ID放到队列的末尾。

class EntityManager
{
public:
	EntityManager()
	{
		// Initialize the queue with all possible entity IDs
		for (Entity entity = 0; entity < MAX_ENTITIES; ++entity)
		{
			mAvailableEntities.push(entity);
		}
	}

	Entity CreateEntity()
	{
		assert(mLivingEntityCount < MAX_ENTITIES && "Too many entities in existence.");

		// Take an ID from the front of the queue
		Entity id = mAvailableEntities.front();
		mAvailableEntities.pop();
		++mLivingEntityCount;

		return id;
	}

	void DestroyEntity(Entity entity)
	{
		assert(entity < MAX_ENTITIES && "Entity out of range.");

		// Invalidate the destroyed entity's signature
		mSignatures[entity].reset();

		// Put the destroyed ID at the back of the queue
		mAvailableEntities.push(entity);
		--mLivingEntityCount;
	}

	void SetSignature(Entity entity, Signature signature)
	{
		assert(entity < MAX_ENTITIES && "Entity out of range.");

		// Put this entity's signature into the array
		mSignatures[entity] = signature;
	}

	Signature GetSignature(Entity entity)
	{
		assert(entity < MAX_ENTITIES && "Entity out of range.");

		// Get this entity's signature from the array
		return mSignatures[entity];
	}

private:
	// Queue of unused entity IDs
	std::queue<Entity> mAvailableEntities{};

	// Array of signatures where the index corresponds to the entity ID
	std::array<Signature, MAX_ENTITIES> mSignatures{};

	// Total living entities - used to keep limits on how many exist
	uint32_t mLivingEntityCount{};
};

组件数组(Component Array)

我们需要创建一个本质上是简单数组的数据结构,但它始终是一个紧凑的数组,意味着它没有任何空洞。如果实体只是组件数组的一个索引,那么为实体获取相关组件是很简单的,但是当一个实体被销毁时会发生什么呢?指向数组的那个索引不再有效。

记住,ECS的全部意义在于将数据紧密地存储在内存中,这意味着你应该能够迭代数组中的所有索引,而不需要任何形式的“if(有效)”检查。当一个实体被销毁时,它“拥有”的组件数据仍然存在于数组中。如果系统尝试遍历这个数组,它会遇到没有关联实体的过期数据。因此,我们需要始终保持数组中充满有效的数据。

我选择通过维护从实体ID到数组索引的映射来解决这个问题。在访问数组时,你使用实体ID查找实际的数组索引。然后,当一个实体被销毁时,你将数组中的最后一个有效元素移到被删除实体的位置,并更新映射,使得实体ID指向正确的位置。还有一个从数组索引到实体ID的映射,这样在移动最后一个数组元素时,你就知道哪个实体正在使用那个索引并可以更新它的映射。

在展示代码之前,让我用图表的形式演示这个过程,因为尽管我尽可能让代码变得易于理解,但以图片形式呈现依然更加清晰。

假设MAX_ENTITIES设置为5。数组最初是空的,映射中没有任何内容,大小为0。

Start
------
Array: []

Entity->Index: []

Index->Entity: []

Size: 0

然后我们为实体0添加一个值为A的组件。

实体0映射到索引0,同时索引0也映射回实体0。

Add A to Entity 0
------
Array: [A]

Entity->Index:
[0:0] Entity 0's data (A) is at Index 0

Index->Entity:
[0:0] Index 0 holds Entity 0's data (A)

Size: 1

然后我们为实体1添加一个值为B的组件。

实体1映射到索引1,同时索引1也映射回实体1。

Add B to Entity 1
------
Array: [A, B]

Entity->Index:
[0:0] Entity 0's data (A) is at Index 0
[1:1] Entity 1's data (B) is at Index 1

Index->Entity:
[0:0] Index 0 holds Entity 0's data (A)
[1:1] Index 1 holds Entity 1's data (B)

Size: 2

然后我们为实体2添加一个值为C的组件。

实体2映射到索引2,同时索引2也映射回实体2。

Add C to Entity 2
------
Array: [A, B, C]

Entity->Index:
[0:0] Entity 0's data (A) is at Index 0
[1:1] Entity 1's data (B) is at Index 1
[2:2] Entity 2's data (C) is at Index 2

Index->Entity:
[0:0] Index 0 holds Entity 0's data (A)
[1:1] Index 1 holds Entity 1's data (B)
[2:2] Index 2 holds Entity 2's data (C)

Size: 3

然后我们为实体3添加一个值为D的组件。

实体3映射到索引3,同时索引3也映射回实体3。

Add D to Entity 3
------
Array: [A, B, C, D]

Entity->Index:
[0:0] Entity 0's data (A) is at Index 0
[1:1] Entity 1's data (B) is at Index 1
[2:2] Entity 2's data (C) is at Index 2
[3:3] Entity 3's data (D) is at Index 3

Index->Entity:
[0:0] Index 0 holds Entity 0's data (A)
[1:1] Index 1 holds Entity 1's data (B)
[2:2] Index 2 holds Entity 2's data (C)
[3:3] Index 3 holds Entity 3's data (D)

Size: 4

目前为止一切顺利,所有数据都紧密地存储在内存中。但是接着我们从实体1中删除了值B。为了保持数组的紧凑性,我们将最后一个元素D移动到B原来的位置,并更新映射。

现在实体3映射到索引1,同时索引1也映射回实体3。

Delete B (which was at Index 1 and was the data of Entity 1)
------
Array: [A, D, C]

Entity->Index:
[0:0] Entity 0's data (A) is at Index 0
[3:1] Entity 3's data (D) is at Index 1
[2:2] Entity 2's data (C) is at Index 2

Index->Entity:
[0:0] Index 0 holds Entity 0's data (A)
[1:3] Index 1 holds Entity 3's data (D)
[2:2] Index 2 holds Entity 2's data (C)

Size: 3

接着我们从实体3中删除值D,将最后一个元素C移动到D原来的位置。

现在实体2映射到索引1,同时索引1也映射回实体2。

Delete D (which was at Index 1 and was the data of Entity 3)
------
Array: [A, C]

Entity->Index:
[0:0] Entity 0's data (A) is at Index 0
[2:1] Entity 2's data (C) is at Index 1

Index->Entity:
[0:0] Index 0 holds Entity 0's data (A)
[1:2] Index 1 holds Entity 2's data (C)

Size: 2

最后我们为实体4添加值E。

实体4映射到索引2,同时索引2也映射回实体4。

Add E to Entity 4
------
Array: [A, C, E]

Entity->Index:
[0:0] Entity 0's data (A) is at Index 0
[2:1] Entity 2's data (C) is at Index 1
[4:2] Entity 4's data (E) is at index 2

Index->Entity:
[0:0] Index 0 holds Entity 0's data (A)
[1:2] Index 1 holds Entity 2's data (C)
[2:4] Index 2 holds Entity 4's data (E)

Size: 3

就是这样,在保持紧凑性的同时移除了或添加了组件。

// The one instance of virtual inheritance in the entire implementation.
// An interface is needed so that the ComponentManager (seen later)
// can tell a generic ComponentArray that an entity has been destroyed
// and that it needs to update its array mappings.
class IComponentArray
{
public:
	virtual ~IComponentArray() = default;
	virtual void EntityDestroyed(Entity entity) = 0;
};


template<typename T>
class ComponentArray : public IComponentArray
{
public:
	void InsertData(Entity entity, T component)
	{
		assert(mEntityToIndexMap.find(entity) == mEntityToIndexMap.end() && "Component added to same entity more than once.");

		// Put new entry at end and update the maps
		size_t newIndex = mSize;
		mEntityToIndexMap[entity] = newIndex;
		mIndexToEntityMap[newIndex] = entity;
		mComponentArray[newIndex] = component;
		++mSize;
	}

	void RemoveData(Entity entity)
	{
		assert(mEntityToIndexMap.find(entity) != mEntityToIndexMap.end() && "Removing non-existent component.");

		// Copy element at end into deleted element's place to maintain density
		size_t indexOfRemovedEntity = mEntityToIndexMap[entity];
		size_t indexOfLastElement = mSize - 1;
		mComponentArray[indexOfRemovedEntity] = mComponentArray[indexOfLastElement];

		// Update map to point to moved spot
		Entity entityOfLastElement = mIndexToEntityMap[indexOfLastElement];
		mEntityToIndexMap[entityOfLastElement] = indexOfRemovedEntity;
		mIndexToEntityMap[indexOfRemovedEntity] = entityOfLastElement;

		mEntityToIndexMap.erase(entity);
		mIndexToEntityMap.erase(indexOfLastElement);

		--mSize;
	}

	T& GetData(Entity entity)
	{
		assert(mEntityToIndexMap.find(entity) != mEntityToIndexMap.end() && "Retrieving non-existent component.");

		// Return a reference to the entity's component
		return mComponentArray[mEntityToIndexMap[entity]];
	}

	void EntityDestroyed(Entity entity) override
	{
		if (mEntityToIndexMap.find(entity) != mEntityToIndexMap.end())
		{
			// Remove the entity's component if it existed
			RemoveData(entity);
		}
	}

private:
	// The packed array of components (of generic type T),
	// set to a specified maximum amount, matching the maximum number
	// of entities allowed to exist simultaneously, so that each entity
	// has a unique spot.
	std::array<T, MAX_ENTITIES> mComponentArray;

	// Map from an entity ID to an array index.
	std::unordered_map<Entity, size_t> mEntityToIndexMap;

	// Map from an array index to an entity ID.
	std::unordered_map<size_t, Entity> mIndexToEntityMap;

	// Total size of valid entries in the array.
	size_t mSize;
};

unordered_map确实存在性能损失,因为当你想要获取组件的ID以便从连续数组中获取它时,你必须从非连续的unordered_map中请求它。另一种方法是使用数组。

但是unordered_map有一个很好的特性,即支持find()、insert()和delete()函数,这使得可以在不进行“if(有效)”检查的情况下断言其有效性,并且比将数组元素设置为某个“无效”值更清晰。

IComponentArray的虚拟继承虽然不太理想,但据我所知是不可避免的。正如后面看到的,我们将拥有每个组件类型的一个ComponentArray列表,在实体被销毁时我们需要通知所有这些数组,以便在数据存在的情况下删除实体的数据。保持多个模板类型的列表的唯一方法是维护它们共同接口的列表,这样我们就可以对所有这些接口调用EntityDestroyed()。

另一种方法是使用事件机制,让每个ComponentArray都能订阅一个实体销毁事件并据此响应。这原本是我的处理方式,但我决定保持ComponentArray相对简单。

还有一种方法是使用一些复杂的模板技巧和反射,但我为了自己的理智考虑,希望尽可能保持简单。调用虚拟函数EntityDestroyed()的成本应该是最小的,因为它并不是每帧都会发生的事情。

组件管理器(Component Manager)

现在我们可以实现组件管理器(Component Manager),它负责在需要添加或移除组件时与所有不同的 ComponentArray进行通信。

如前所述,我们需要为每种类型的组件拥有一个唯一的ID,以便它能在签名中占据一位。为了轻松实现这一点,我让组件管理器拥有一个ComponentType变量,每当有组件类型注册时该变量就递增1。我见过一些不需要任何RegisterComponent功能的实现,但我发现这是最简单的方法。缺点是每次你向游戏中添加一种新的组件类型并想要使用它时,首先需要调用RegisterComponent。

C++提供了一个方便的函数,可以返回指向类型T的const char数组表示的指针。该指针(只是一个整数)可以用作映射ComponentTypes中唯一键。

同样的键也用作映射IComponentArray指针的唯一键,因此每种ComponentType都有一个ComponentArray实例化对象。

class ComponentManager
{
public:
	template<typename T>
	void RegisterComponent()
	{
		const char* typeName = typeid(T).name();

		assert(mComponentTypes.find(typeName) == mComponentTypes.end() && "Registering component type more than once.");

		// Add this component type to the component type map
		mComponentTypes.insert({typeName, mNextComponentType});

		// Create a ComponentArray pointer and add it to the component arrays map
		mComponentArrays.insert({typeName, std::make_shared<ComponentArray<T>>()});

		// Increment the value so that the next component registered will be different
		++mNextComponentType;
	}

	template<typename T>
	ComponentType GetComponentType()
	{
		const char* typeName = typeid(T).name();

		assert(mComponentTypes.find(typeName) != mComponentTypes.end() && "Component not registered before use.");

		// Return this component's type - used for creating signatures
		return mComponentTypes[typeName];
	}

	template<typename T>
	void AddComponent(Entity entity, T component)
	{
		// Add a component to the array for an entity
		GetComponentArray<T>()->InsertData(entity, component);
	}

	template<typename T>
	void RemoveComponent(Entity entity)
	{
		// Remove a component from the array for an entity
		GetComponentArray<T>()->RemoveData(entity);
	}

	template<typename T>
	T& GetComponent(Entity entity)
	{
		// Get a reference to a component from the array for an entity
		return GetComponentArray<T>()->GetData(entity);
	}

	void EntityDestroyed(Entity entity)
	{
		// Notify each component array that an entity has been destroyed
		// If it has a component for that entity, it will remove it
		for (auto const& pair : mComponentArrays)
		{
			auto const& component = pair.second;

			component->EntityDestroyed(entity);
		}
	}

private:
	// Map from type string pointer to a component type
	std::unordered_map<const char*, ComponentType> mComponentTypes{};

	// Map from type string pointer to a component array
	std::unordered_map<const char*, std::shared_ptr<IComponentArray>> mComponentArrays{};

	// The component type to be assigned to the next registered component - starting at 0
	ComponentType mNextComponentType{};

	// Convenience function to get the statically casted pointer to the ComponentArray of type T.
	template<typename T>
	std::shared_ptr<ComponentArray<T>> GetComponentArray()
	{
		const char* typeName = typeid(T).name();

		assert(mComponentTypes.find(typeName) != mComponentTypes.end() && "Component not registered before use.");

		return std::static_pointer_cast<ComponentArray<T>>(mComponentArrays[typeName]);
	}
};

系统(System)

系统是指任何对具有特定组件签名的实体列表进行迭代的功能。

每个系统都需要一个实体列表,我们希望在系统外部(以管理器的形式维护该列表)有一些逻辑,所以我使用了一个只有std::set实体集合的系统基类。

我选择使用std::set而不是std::list有几个原因:

首先,每个实体都是唯一的,而集合定义为每个元素都是唯一的,所以在逻辑上非常匹配。

其次,因为每个实体都是整数,在插入或移除集合时便于比较。从列表中移除特定实体的时间复杂度是O(n),因为你必须从头开始,可能要遍历到末尾;而从集合中移除的时间复杂度是O(log n),因为它是二叉树结构。然而,插入到列表中的时间复杂度是O(1),而插入到集合中也是O(log n)。

第三,这使得代码更容易理解和阅读。使用列表时,你必须使用std::find来检查实体是否在列表中,但使用std::set可以直接调用insert()和erase(),无需任何检查。如果尝试插入已存在的实体,则不会有任何操作;如果尝试移除不存在的实体,同样也不会有任何操作。

第四,我测试了使用列表和使用集合的情况,结果发现使用集合更快。

class System
{
public:
	std::set<Entity> mEntities;
};

每个系统可以继承自这个基类,这使得系统管理器(见下一节)能够维护一个指向这些系统的指针列表。这里使用了继承,但没有使用虚函数。

然后,一个系统可以像这样操作:

for (auto const& entity : mEntities)
{
	auto& rigidBody = GetComponent<RigidBody>(entity);
	auto& transform = GetComponent<Transform>(entity);
	auto const& gravity = GetComponent<Gravity>(entity);

	transform.position += rigidBody.velocity * dt;

	rigidBody.velocity += gravity.force * dt;
}

对于遍历的当前实体以及组件数组中与其当前实体邻近的其他实体,RigidBody、Transform和Gravity将会被预加载到缓存中,这些数据很可能在处理列表中的下一个实体时被用到。

系统管理器(System Manager)

系统管理器负责维护已注册系统的记录及其签名。当一个系统被注册时,它会被添加到一个使用与组件相同的typeid(T).name()技巧的映射中。同样的键也被用于系统指针的映射中。

与组件一样,这种方法要求对添加到游戏中的每种新系统类型调用RegisterSystem()。

每个系统都需要为其设置一个签名,以便管理器可以将合适的实体添加到每个系统的实体列表中。当一个实体的签名发生变化(由于组件的添加或移除),则需要更新系统跟踪的实体列表。

如果系统正在跟踪的一个实体被销毁了,那么它也需要更新其列表。

class SystemManager
{
public:
	template<typename T>
	std::shared_ptr<T> RegisterSystem()
	{
		const char* typeName = typeid(T).name();

		assert(mSystems.find(typeName) == mSystems.end() && "Registering system more than once.");

		// Create a pointer to the system and return it so it can be used externally
		auto system = std::make_shared<T>();
		mSystems.insert({typeName, system});
		return system;
	}

	template<typename T>
	void SetSignature(Signature signature)
	{
		const char* typeName = typeid(T).name();

		assert(mSystems.find(typeName) != mSystems.end() && "System used before registered.");

		// Set the signature for this system
		mSignatures.insert({typeName, signature});
	}

	void EntityDestroyed(Entity entity)
	{
		// Erase a destroyed entity from all system lists
		// mEntities is a set so no check needed
		for (auto const& pair : mSystems)
		{
			auto const& system = pair.second;

			system->mEntities.erase(entity);
		}
	}

	void EntitySignatureChanged(Entity entity, Signature entitySignature)
	{
		// Notify each system that an entity's signature changed
		for (auto const& pair : mSystems)
		{
			auto const& type = pair.first;
			auto const& system = pair.second;
			auto const& systemSignature = mSignatures[type];

			// Entity signature matches system signature - insert into set
			if ((entitySignature & systemSignature) == systemSignature)
			{
				system->mEntities.insert(entity);
			}
			// Entity signature does not match system signature - erase from set
			else
			{
				system->mEntities.erase(entity);
			}
		}
	}

private:
	// Map from system type string pointer to a signature
	std::unordered_map<const char*, Signature> mSignatures{};

	// Map from system type string pointer to a system pointer
	std::unordered_map<const char*, std::shared_ptr<System>> mSystems{};
};

协调者(The Coordinator)

我们现在积累了相当多的功能。我们有由实体管理器(Entity Manager)管理的实体,有由组件管理器(Component Manager)管理的组件,还有由系统管理器(System Manager)管理的系统。这三个管理器之间也需要相互通信。

实现这一点的方法有几种,比如将它们都设为全局变量,或者使用事件系统,但我选择将它们打包到一个名为协调者(Coordinator,欢迎提出替代名称建议)的单一类中,作为中介者。这使我们可以拥有协调者的单一实例(可以是全局实例或其他形式),并能通过它与所有管理器进行交互。这也使得使用起来更加方便,因为你能够替换这样的代码:

Entity player = entityManager.CreateEntity();
componentManager.AddComponent<Transform>(player);
RenderSystem renderSystem = systemManager.RegisterSystem<RenderSystem>();

替换为:

Entity player = coordinator.CreateEntity();
coordinator.AddComponent<Transform>(player);
RenderSystem renderSystem = coordinator.RegisterSystem<RenderSystem>();

协调器有指向每个管理器的指针,并在它们之间进行协调管理。

class Coordinator
{
public:
	void Init()
	{
		// Create pointers to each manager
		mComponentManager = std::make_unique<ComponentManager>();
		mEntityManager = std::make_unique<EntityManager>();
		mSystemManager = std::make_unique<SystemManager>();
	}


	// Entity methods
	Entity CreateEntity()
	{
		return mEntityManager->CreateEntity();
	}

	void DestroyEntity(Entity entity)
	{
		mEntityManager->DestroyEntity(entity);

		mComponentManager->EntityDestroyed(entity);

		mSystemManager->EntityDestroyed(entity);
	}


	// Component methods
	template<typename T>
	void RegisterComponent()
	{
		mComponentManager->RegisterComponent<T>();
	}

	template<typename T>
	void AddComponent(Entity entity, T component)
	{
		mComponentManager->AddComponent<T>(entity, component);

		auto signature = mEntityManager->GetSignature(entity);
		signature.set(mComponentManager->GetComponentType<T>(), true);
		mEntityManager->SetSignature(entity, signature);

		mSystemManager->EntitySignatureChanged(entity, signature);
	}

	template<typename T>
	void RemoveComponent(Entity entity)
	{
		mComponentManager->RemoveComponent<T>(entity);

		auto signature = mEntityManager->GetSignature(entity);
		signature.set(mComponentManager->GetComponentType<T>(), false);
		mEntityManager->SetSignature(entity, signature);

		mSystemManager->EntitySignatureChanged(entity, signature);
	}

	template<typename T>
	T& GetComponent(Entity entity)
	{
		return mComponentManager->GetComponent<T>(entity);
	}

	template<typename T>
	ComponentType GetComponentType()
	{
		return mComponentManager->GetComponentType<T>();
	}


	// System methods
	template<typename T>
	std::shared_ptr<T> RegisterSystem()
	{
		return mSystemManager->RegisterSystem<T>();
	}

	template<typename T>
	void SetSystemSignature(Signature signature)
	{
		mSystemManager->SetSignature<T>(signature);
	}

private:
	std::unique_ptr<ComponentManager> mComponentManager;
	std::unique_ptr<EntityManager> mEntityManager;
	std::unique_ptr<SystemManager> mSystemManager;
};

我见过一些实现创建了一个实体类,该类作为ID的包装器,其中的方法直接调用EntityManager和ComponentManager(例如,entity.RemoveComponent()),这种方式使用起来更加直观,但我发现它会使代码变得更加复杂且难以理解。我尝试过多次以这种方式实现,但每次都遇到了递归头文件包含的问题。最终,我选择了更简洁但不如前者直观的Coordinator方案。

Demo

现在让我们看看如何在一个演示中使用所有这些内容,该演示实例化了10,000个立方体,然后让它们在重力的影响下掉落。我们将忽略渲染和数学类的细节,因为这并不是本文的主题,但请记住,还有一个渲染系统和一个Vec3类。

我们有以下组件:

struct Gravity
{
	Vec3 force;
};

struct RigidBody
{
	Vec3 velocity;
	Vec3 acceleration;
};

struct Transform
{
	Vec3 position;
	Vec3 rotation;
	Vec3 scale;
};

一个用于基础物理集成的系统:

extern Coordinator gCoordinator;

void PhysicsSystem::Update(float dt)
{
	for (auto const& entity : mEntities)
	{
		auto& rigidBody = gCoordinator.GetComponent<RigidBody>(entity);
		auto& transform = gCoordinator.GetComponent<Transform>(entity);
		auto const& gravity = gCoordinator.GetComponent<Gravity>(entity);

		transform.position += rigidBody.velocity * dt;

		rigidBody.velocity += gravity.force * dt;
	}
}

main函数的逻辑处理:

Coordinator gCoordinator;

int main()
{
	gCoordinator.Init();

	gCoordinator.RegisterComponent<Gravity>();
	gCoordinator.RegisterComponent<RigidBody>();
	gCoordinator.RegisterComponent<Transform>();

	auto physicsSystem = gCoordinator.RegisterSystem<PhysicsSystem>();

	Signature signature;
	signature.set(gCoordinator.GetComponentType<Gravity>());
	signature.set(gCoordinator.GetComponentType<RigidBody>());
	signature.set(gCoordinator.GetComponentType<Transform>());
	gCoordinator.SetSystemSignature<PhysicsSystem>(signature);

	std::vector<Entity> entities(MAX_ENTITIES);

	std::default_random_engine generator;
	std::uniform_real_distribution<float> randPosition(-100.0f, 100.0f);
	std::uniform_real_distribution<float> randRotation(0.0f, 3.0f);
	std::uniform_real_distribution<float> randScale(3.0f, 5.0f);
	std::uniform_real_distribution<float> randGravity(-10.0f, -1.0f);

	float scale = randScale(generator);

	for (auto& entity : entities)
	{
		entity = gCoordinator.CreateEntity();

		gCoordinator.AddComponent(
			entity,
			Gravity{Vec3(0.0f, randGravity(generator), 0.0f)});

		gCoordinator.AddComponent(
			entity,
			RigidBody{
				.velocity = Vec3(0.0f, 0.0f, 0.0f),
				.acceleration = Vec3(0.0f, 0.0f, 0.0f)
			});

		gCoordinator.AddComponent(
			entity,
			Transform{
				.position = Vec3(randPosition(generator), randPosition(generator), randPosition(generator)),
				.rotation = Vec3(randRotation(generator), randRotation(generator), randRotation(generator)),
				.scale = Vec3(scale, scale, scale)
			});
	}

	float dt = 0.0f;

	while (!quit)
	{
		auto startTime = std::chrono::high_resolution_clock::now();

		physicsSystem->Update(dt);

		auto stopTime = std::chrono::high_resolution_clock::now();

		dt = std::chrono::duration<float, std::chrono::seconds::period>(stopTime - startTime).count();
	}
}

输出结果如下:

image1738847647254.png

如果你感兴趣的话,以下是使用Valgrind的cachegrind工具得到的输出结果:

==15445== I   refs:      3,632,270,619
==15445== I1  misses:       87,147,982
==15445== LLi misses:           26,599
==15445== I1  miss rate:          2.40%
==15445== LLi miss rate:          0.00%
==15445==
==15445== D   refs:      1,583,125,924  (1,045,689,190 rd   + 537,436,734 wr)
==15445== D1  misses:       11,968,989  (    7,776,523 rd   +   4,192,466 wr)
==15445== LLd misses:          505,598  (      270,649 rd   +     234,949 wr)
==15445== D1  miss rate:           0.8% (          0.7%     +         0.8%  )
==15445== LLd miss rate:           0.0% (          0.0%     +         0.0%  )
==15445==
==15445== LL refs:          99,116,971  (   94,924,505 rd   +   4,192,466 wr)
==15445== LL misses:           532,197  (      297,248 rd   +     234,949 wr)
==15445== LL miss rate:            0.0% (          0.0%     +         0.0%  )

这当然是一个非常简单的例子,但它仍然很有趣。

回到开头关于组件如何使复杂行为变得更简单的讨论,我们可以通过不给立方体添加RigidBody或Gravity组件,而是将它们添加到摄像机上来轻松翻转我们的演示示例。

image1738847818290.png

这是摄像机因为物理作用下落时的输出画面。

结论

如果你对ECS(实体组件系统)的概念持怀疑态度,我希望我已经让你相信它有其优点。而如果你像我曾经一样困惑于如何实现一个ECS,我希望我已经帮助你找到了一种方法。

源码

您可以在这里找到所有的源代码

所有与ECS相关的源代码仅存在于头文件中有两个原因。首先,存在大量的模板类和函数,这些必须放在头文件中以便编译器能够正确处理。其次,这样做有可能增加编译器进行内联优化的机会,从而可能提高性能。

作者:austinmorlan

转载请注明出处:点识成金AI @端一碗 翻译整理

0 Answers