0%

Lua实现的一个ECS系统

主要是来源于云风系列的文章,还有云风所写的这个模块。关于ECS的一些介绍,可以查看一下 Wiki。这里主要是先了解一下概念,边看明白一下 luaecs 的设计思路和使用的方法。

前言

云风博客系列文章:

ECS 在 WIKI 上的说明

ECS 是一个软件架构模式,大多用来在视频游戏开发中存储游戏世界的对象。一个 ECS 符合 数据的 组件 组成了 实体

ECS 遵守在继承之上组合的规则,这意味着每个实体不是通过一个 类型 来定义,而是通过它所关联的 组件 来定义。 组件 如何与 实体 相关依赖于 ECS 系统如何使用。

特点

ECS 的数据布局可能不同,组件 的定义也可能不同,组件 如何与 实体 关联也可能不同。

Unity 游戏引擎实现了两种最流行的布局:

  • 在 Unity 中最常用的布局中有一个实体叫做 gameObjects,每个 gameObjects 有一系列的组件。
  • 另外一个数据布局和数据库相似,常在 Web 开发中看到。这种布局有很多表,每个表的列是多个组件。在系统中,一个实体的 类型 基于它所拥有的组件。对于每个实体 类型,有一个表(叫做 archetype)的列是一系列的组件,这些组件与实体使用的组件相匹配。为了访问一个具体的实体,首先必须先找到正确的 archetype(表),然后索引某一列来获取那个实体对于的每一个组件。

在一个数据库风格的 ECS 中,被叫做 系统 的过程在有特定组件的实体上进行动作。

例如,对于一个物理系统可能会查询有 mass, velocity 和 position 组件的 archetypes (表),遍历列,然后在每个实体的组件上进行物理计算。

实体的行为在运行时可能会被系统添加、删除或改变(mutate,改变状态)组件而变化。这可避免了在 OOP 中过深和过宽的继承类层次的所产生的难以理解、维护和扩展的问题。通常 ECS 方式高度与 DOD(data-oriented design) 技术相兼容,且常在一起配合使用。尽管一个实体与其组件相关联,但这些组件在物理内存中并不一定需要挨在一起。

讨论

Adam Martin 的一系列博客定义了它所认为的 ECS 应该是怎么样的:

  • Entity,实体:一个通用的对象。通常,它只会由一个唯一的 ID 组成。”他们将每个 gameObject 标记为一个独立的 Item”。实现通常会使用一个整型。
  • Component,组件:对象的一个方面的原始数据及对象如何与世界交互。“给实体打上标签并处理这些特定的方面”。实现上通常会使用 结构体、类或关联数组。
  • System,系统:每个系统会持续运行(尽管每个系统有它自己的私有线程)并每个满足系统要求的实体上的一个或多个组件上进行全局动作。

Martins ECS

一个实体只有 ID,用来访问组件。在组件内没有代码(行为)。组件并不需要物理上在一起,但应该是很容易的通过实体就能访问和查询。常用的实践是为实体使用一个唯一的ID。这不是一个强制需求,但它有几个优点:

  • 实体可以通过ID而不是指针引用。这更健壮,允许实体被销毁而不留下野指针。
  • 这对在外部存在状态有用。当状态再次加载时,不需要将指针进行重新构造。
  • 数据可以在内存中动来动去。
  • 实体ID可以在网络中唯一的表示一个实体。

某些优点也可以通过智能指针来实现

Apparatus ECS

Apparatus 是一个 Unreal Engine 的第三方的 ECS 实现,在常规的 ECS 模式上引入了一些额外的特性。其中之一就是支持组件的 类层次。每个组件可以有一个组件类型(或一个基类),就像和 OOP 中的差不多。一个系统就可以通过这个基类进行查询,并获取到此基类衍生的所有子类的实体。这在需要进行一些通用的逻辑是很有用。

数据库风格的ECS模式

在系统间传递数据的方式是将数据存储在组件中,接着让各系统访问这个组件。如,,对象的位置可能被更新了。然后其他系统可能会使用这个位置。如果,有很多不同的不常见的事件,那组件中可能就需要很多的 标志。然后系统就必须在遍历中监控这些标志,这会变得效率低下。一个方式是使用 观察者模式。所有依赖事件的系统会订阅这个事件。因此,从事件来的动作只需要在发生的时候执行一次,而不需要进行轮询。

ECS 架构不会遇到 OOP 中的依赖问题,因为组件只是简单的数据,没有依赖。每个系统通常会查询实体必须拥有的由组件组成的 *archetype` 。例如,一个渲染系统可能会注册 模型,变换和绘制组件。然后会在实体上查询这些组件,如果实体上有这些组件,就执行逻辑;没有这些组件,就跳过。然而,这里可能也会隐藏BUG,因为从一个系统到另外一个系统通过组件来传递值是很难DEBUG的。ECS 可能被用于在非耦合数据需要绑定到一个给定生命周期的情况。

ECS 使用组合,而不是继承树。一个实体典型的是由一个ID,和一系列的组件组成。任何类型的游戏对象都可以通过添加正确的组件到实体上来建立。这也允许开发者很容易的对某个类型的对象添加特性而不需要担心依赖问题。例如,一个玩家实体可能会有一个 bullet 组件,它可能就满足被某些 bulletHanlder 操纵的要求。

Lua ECS

这篇博客 可以看到设计的思路:

  1. 每个 Entity 都有一个内在 id ,但这个 id 只是实现手段,不对外暴露。不可以用 id 去引用特定 Entity 。
  2. Entity 必须在构建的时候就决定好它由哪些 Component 的构成,它不可以在后续运行过程中任意增减 Component 。除了后面提到的特例。
  3. Tag 是一种特殊的 Component ,它不携带数据,只是用来标记一个 Entity ,用来影响 select 的结果集。Tag 可以被动态的 Enable 或 Disable 。(破坏了规则 2 )
  4. 允许在一个特定的处理过程中动态的给 Entity 添加一个临时的 Component ,这会破坏规则 2 。这么用必须遵循一个原则:这类临时的 Component 必须按 select 的遍历次序依次添加,所以,不可以在多个 System 中反复添加(那样会破坏次序)。它通常用于 System 间临时传递数据,在后续的某个 System 结束后,将全部清除。
  5. select 得到的 Component 次序是稳定的,每次都一致,即这些 Entity 的构建次序。
  6. 如果需要用特定的次序遍历,可以创建一组 sorted tag 的特殊 Tag ,对这个新 Tag 遍历就可以得到特定的次序。但用这种方式遍历时,上面的规则 4 不再适用。且 sorted tag 需要自己维护,没有自动更新的机制。

Lua API

  • ecs.world() 新建一个数据存储结构。
  • meta:register(typeclass) 注册一个用 Lua Table 来描述的 Component 。
  • meta:new(obj) 新建一个 Entity,因为这个方法并不会返回 Entity ID,我们如果真的想知道怎么直接引用它的话,需要在 obj 中给一个 reference 字段:一个空表。然后会返回这个表在 Reference Component 的索引和 Reference 的 ID 回来。
  • meta:context(t) 暂时感觉没有什么用处
  • meta:select(pat) 有一个模式,来遍历数据。注意,这个不会返回某个 Entity,它返回的是一些数据集合。我们在 pat 内指定了多个 Component 的话,只会返回我们指定的 Component 的内容。还可以在每个 Component 后加上对应的条件 in, out, update, new,对于 Tag Component 则可以用 absent, exists
  • meta:sync(pat, iter) 根据一个 iter,将 pat 模式中的数据都同步过来,同时,还会将 iter 中的数据写进 C 结构。 iter 是一个 Component pool 中的索引 ID 和 Component 的组合。这相当于是在一个 Component pool 中来获取到 Entity ID,然后根据这个 ID 在对应的 pat 中读取内容。
  • meta:readall(iter) 读取 iter 所能对应的 Entity 的所有内容。
  • meta:clear(name) 清理一个 Component 类型
  • meta:clearall() 清理所有的 Component
  • meta:dumpid(name) 返回一个 Component pool 中的所有 ID。
  • meta:update() 更新数据,将会删除 REMOVED 的数据。
  • meta:remove_reference(ref) 删除对一个Entity 的引用。与 meta:sync('reference:out',{poolid, componentid, refernce=false}) 相等
  • meta:object(name, index, v) 看起来是对象构造到 Component pool 中。
  • meta:singleton(name, pattern, iter) 单例的。

new 用来动态创建新的 component ,但是有限制:在 select 之前,该类 component 一个都不存在,select 过程中,依次创建出来。 使用和 output 类似。

tag 就是一种特殊的 component ,所以可以用 out 写入,看作是一个 boolean 。可以用 exist absent 在 select 中筛选。

读取 Entity

当我们确实需要知道某个 Entity 的数据时,我们这个 Entity 所拥有的 Component 的索引,在这个组件的池中得到 Entity,然后调用 readall(iter) 就行了。

local w =  ecs.world()
w:register {name='a',type='int'}
w:register {name='b',type='int'}

w:new {a = 1, b = 2}
for v in w:select("a:in") do
print(v.a, v.b);
w:readall(v);
print(v.a, v.b)
end

或者我们可以通过 Reference 来实现:

local ref = {}
w:new{ a = 3, b = 4, reference = ref }
print(ref[1], ref[2])
w:readall(ref)
print(ref.a, ref.b)

关于单例

实际上,默认情况下, Luaecs 的单例,指是一个只有一个实例的 Component,其索引永远是 1。但是,在构造单例的时候我们还可以指定一个 pattern ,表示与此 component 相关的其他 Component 也读取出来。如 namt flag

关于遍历

通常,我们使用云风例子中的 w:select(pat) 来进行遍历数据 ,pat 是一个字符串,它指定了一个或多个查询条件,每个查询条件是这样组成的:

  • 组件名[:?tag]
  • :? 存在的情况下,后面必须有条件;: 代表必须存在,? 代表可选。默认情况下,表示 exists
  • tag 可以是:
    • in 读出
    • out 将组件数据写到 C 内存
    • update 同时读写
    • absent 不存在 针对 TAG
    • exists 存在 针对 TAG
    • new 代表临时的
  • 合法的条件格式为:
    • :{absent|exists}
    • [:?]{update|in|out}
    • [:?]new

对于不同的组件,可以附加的查询条件也是不一样的:

每个查询条件会被解析成一个 Lua 表,表中的键可能有 r, w, opt, exist, absent,还会包含组件信息 name, id, type

{
opt = true, exist = true, r = true, w = true, absent=true
}

多个查询条件会组成一个表,传递给 C API。 C API 会返回一个 Userdata,调用这个 Userdata 就会返回可迭代的三个值:

  • 一个 C 函数,用来获取下一个值,leach_group
  • Group iter 的 Userdata 对象
  • 一个表,保存了当前 pattern 主 KEY 的索引ID和 组件ID

关于遍历的过程:

  1. iter 拥有相关的 key, 第 1 个作为 主 key。
  2. 对于在主 key 组件中找到了一个索引,再通过此索引处的 entity id 来找到在其他 key 组件中的索引位置。这样就拿到了每个 KEY 对应的索引列表
  3. 读取各索引处的值,然后 push 到上面返回的那个表中,更新一下索引ID

数据的修改

标签操作


-- 打标签
for v in w:select('node tag?out') do v.tag = true end

-- 读标签
for v in w:select('node tag?in') do print(v.tag) end

-- 同时读写
for v in w:select('node tag?update') do print(v.tag); v.tag = not v.tag end

这些操作,其他 Component 同样适用。

动态添加 Component

这有一个限制,需要添加的 Component 列必须是空的。添加后,我们无法对某个 Entity 去卸掉临时添加的 Component,只能通过 world.clear(name) 来把某一类的组件全部删除。

local ecs = require 'ecs'
local w = ecs.world()

w:register {name = id, type='int'}
w:register {name = 'node', 'x:int', 'y:int'}

w:new {node = {x= 1, y = 2}}
w:new {node = {x= 3, y = 4}}

注意,这和 TAG 的操作不一样。这么用必须遵循一个原则:这类临时的 Component 必须按 select 的遍历次序依次添加,所以,不可以在多个 System 中反复添加(那样会破坏次序)。它通常用于 System 间临时传递数据,在后续的某个 System 结束后,将全部清除。

for v in w:select('node:in id?new') do
v.id = v.node.x
end

在 C 代码中,有一个概念叫做 Temporary,其判断逻辑如下:

static inline int
is_temporary(int attrib) {
if (attrib & COMPONENT_FILTER)
return 0;
return (attrib & COMPONENT_IN) == 0 && (attrib & COMPONENT_OUT) == 0;
}

也即是:

  • 如果一个查询属性,指定的是 opt,那么不是临时的。
  • 一个属性没有指定 inout,那么就是临时的。

order 标签

这是一个特殊的标签,它在 Component 里面用了一个特殊类型表示。对于 order 标签,可以动态添加或作为主KEY。
作为主 Key 的时候,查询条件必须是存在。