手撕ECS #1: Types, Hierarchies and Prefabs

原文链接:Building an ECS #1: Types, Hierarchies and Prefabs

Entity Index

首先来看几个概念:

  • Entities 是运行时中独一无二的“东西”,通常与一个独一无二的整数标示。
  • Type(或 Archetype)来区分 Entity 所拥有组件列表的异同,通常用一个数组标示。比如一个 FPS 游戏中 Player 对象拥有两个组件,分别是“位置”和“血量”,则它的 Type 就是[Position, Health]

我们可以通过这两个概念来构造 ECS 中最基础的数据结构,即 Entity Index

1
2
3
4
5
using EntityId = uint64_t;
using ComponentId = EntityId;

using Type = std::vector<ComponentId>;
std::unordered_map<EntityId, Type> entityIndex;

通过 EntityId 我们可以很容易获取该 Entity 所拥有的组件列表,那么实现一个 HasComponent 便易如反掌:

1
2
3
4
5
6
7
bool HasComponent(EntityId entity, ComponentId component) {
vector<ComponentId>& type = entityIndex[entity];
for (auto c : type) {
if (c == component) return true;
}
return false;
}

针对 C++ 的特性,在添加组件时可以用模板的形式绑定就再好不过了:

1
2
Player.add<Position>();
Player.add<Health>();

在这两个 add 操作之后,entityIndex 中就应该包含一项绑定 Player[Position, Health] 的数组元素。当然,是以 ComponentId,即 uint64_t 的形式定义的,例如:

1
2
ComponentId Position_id = 1;
ComponentId Health_id = 2;

如此一来,Player 这一 Entity 的 Type 便成了 [1, 2]

ECS 其中一种实现方法 SanderMertens/Flecs 提供了一种思路:由于 Entity 与 Component 均使用整形作为 id,可以将它俩统一存入 EntityIndex。虽然这样会降低些许可读性,但在实际开发时,考虑某个 Entity 拥有哪些组件也将会造成同样的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
EcsComponent: [EcsComponent, EcsId],
EcsId: [EcsComponent, EcsId],
Position: [EcsComponent, EcsId],
Health: [EcsComponent, EcsID],
Player: [EcsId, Position, Health]
};
{ // Convert to digital params:
1: [1, 2],
2: [1, 2],
10: [1, 2],
20: [1, 2],
30: [2, 10, 20]
};

“One of the biggest implications of this design is that we can add entities to entities” – Ajmmertens.

Hierarchies

上文已阐述了 Entity 与 Component 的储存方式,当我们将若干 Entity 载入场景中时,需要一个不可或缺的功能:Entity 层级。按照之前 Entity Index 的实现,我们可以将父节点以 Component 的形式绑定至 Entity 中:

1
2
3
4
5
6
{
Parent: []
Child1: [Parent]
Child2: [Parent]
Child3: [Parent]
}

当然这个写法是有问题的,我们无法区分 std::vector<uint_64> 中哪些是父对象,哪些是子对象:

1
Player: [Position, Health, MyPlatoon]

这时可以使用**类型标注(Type Flag)**来对数组元素进行标注,以描述其在数组中的作用。下例使用了一个 CHILDOF 标注修饰 MyPlatoon,令其成为 Player 的父对象。

1
Player: [Position, Health, CHILDOF | MyPlatoon]

只需要进行一次或运算便能得到父对象:

1
2
3
4
5
6
7
8
Type type = entity_index[Player];
for (auto el : type) {
if (el & CHILDOF) {
// ... element is a parent
} else {
// ... element is not a parent
}
}

层级可谓游戏设计中极为常用的功能,牺牲一些内存来储存层级信息优大于劣。当然现在我们只谈到了如何储存方式,如何在系统中运作就是另外一个浩大工程了。

Prefab

重用对象,又称预设(Prefab)这看起来很复杂的功能在游戏开发中也不可或缺。在 Flecs 中将对象扩展了一位信息用作声明实例间的关系,显式定义为INSTANCEOF。利用这一位信息再添加一些结合语义,就能使对象共用一组 Component。

1
2
3
4
5
6
{
Base: [Color],
InstanceA: Position, INSTANCEOF| Base],
InstanceB: Position, INSTANCEOF| Base]
}
// `InstanceA` and `InstanceB` both share all components from `Base`

当我们想获取 InstanceAInstanceBColor 组件时,实际上获取到的是 BaseColor

1
InstanceA.get<Color>(); // returns Base.Color

这里说的共用组件(shared component)对应拥有组件(owned component)。下面看一段 Flecs 的应用代码,观察如何在 API 上去调用:

1
2
3
4
5
6
7
8
9
auto BlueSquare = flecs::entity()
.set<Color>({0, 0, 255})
.set<Square>({50});
auto SquareA = flecs::entity()
.add_instanceof(BlueSquare)
.set<Position>({10, 20});
auto SquareB = flecs::entity()
.add_instanceof(BlueSquare)
.set<Position>({30, 40});

将会生成如下 entity index:

1
2
3
4
5
{
BlueSquare: [Color, Square],
SquareA: [INSTANCEOF| BlueSquare, Position]
SquareB: [INSTANCEOF| BlueSquare, Position]
}

继续优化:假设 SquareA 想要更新自己的 Color 而不去影响其他 BlueSquare 实例,便需要一个复写(override)的功能。就结论来说我们可以增加两条规则:

  • Adding a component to an instance will override the base
  • When overriding a shared component, the value of the base will be copied to the instance
1
2
3
4
5
6
7
8
auto BlueSquare = flecs::entity()
.set<Color>({0, 0, 255})
.set<Square>({50});
auto SquareA = flecs::entity()
.add_instanceof(BlueSquare)
.add<Color>()
.set<Position>({10, 20});
// Code for SquareB ...

注意第 6 行并没有为 Color 组件赋初值。根据规则 2 的定义,新增的 Color 组件内容将拷贝BlueSquare。执行完上面的代码后将得到:

1
2
3
4
{
BlueSquare: [Color, Square],
SquareA: [INSTANCEOF | BlueSquare, Color, Position]
}

功能虽然有了,但每次创建实例时都需要将 Color 重新 add 着实麻烦。我们可以定义一个特殊的 Archetype 来代替这个操作:

1
2
3
4
5
6
7
8
9
auto BlueSquarePrefab = flecs::entity()
.set<Color>({0, 0, 255})
.set<Square>({50});
auto BlueSquare = flecs::type()
.add_instanceof(BlueSquarePrefab)
.add<Color>();
auto SquareA = flecs::entity()
.add(BlueSquare)
.set<Position>({10, 20});

Summary

层级与实例均使用了 EntityId 中的一位来用于标识,即使是如此简单的数据结构,却能实现一个较为复杂的行为。

1
2
3
4
5
6
// Entities, types and entity index
using EntityId = uint64_t;
using Type = vector<EntityId>;
unordered_map<EntityId, Type> entity_index;// Type flags
const EntityId INSTANCEOF = 1 << 63;
const EntityId CHILDOF = 1 << 62;

希望这篇文章能给你提供一些关于如何设计 ECS 的灵感~

Share