« ECS 的 entity 集合维护 | 返回首页 | 是时候启动一个为移动设备设计的 3d 引擎项目了 »

Lua 实现 ECS 框架的一些技巧

最近在用 Lua 实现一个 ECS 框架,用到了一些有趣的 Lua 语法技巧。

在 ECS 框架中,Component 是没有方法只有数据的,方法全部写在 System 中。Entity 本身仅仅是 Component 的组合,通常用一个 id 表示。

但实际写代码的时候,使用面向对象的语法(用 Lua 的冒号 这个语法糖)却是比较自然的写法。比如我们在操作一个 Component 数据的时候,用 component:foobar() 比用 foobar(component) 要舒服一些。好在 Lua 是一门非常动态的语言,我们有一些语法技巧在保持上面 ECS 原则的前提下,兼顾编码的书写体验。

之所以不在 Component 上绑定方法,是因为在不同的 System 下,对 Component 的操作方法是不固定的。A system 可能对某种组件的数据有一组操作方法;而 B system 用不到这些,但有另外的方法。我们需要的是把对 Component 的操作方法分类,按 System 去组织,而不是拒绝用面向对象的语法去处理 Component 的数据。

如果是 C++ 这种静态语言,我们可以仅在 Component 的基类中保存数据,而把方法都添加在 Component_System 的派生类中,派生类之添加方法不准增加成员变量;在 Lua 这种动态语言中,能有更灵活的方式来解决这个问题。后面我会介绍这种手法。

另一方面,一个 System 可以关注 Entity 上多个 Component 的组合。如果我们的方法需要同时操作 Component A,B,C ,那么这个方法组织在 A 下或 B,C 下都看起来不是很合适。这类方法还是属于 System 的,也可以由多个 System 共享。在守望先锋的框架中,把它们归类为 Util 方法。

大部分 System 还需要一个单件来储存相关数据。看起来这违背了 System 只有方法没有状态的原则,但实践中单件却大量存在。比如说,空间裁剪器是一个 System ,但我们必须有一个单件来保存空间信息;输入设备管理器也是这样,必须有个位置储存输入状态。所以,我们不妨给所有 System 都绑定一个单件数据结构,这个数据结构的方法仅供该 System 调用(改变状态),但数据可以被其它 System 读取(不改变状态)。


理解了 ECS 的设计和需求后,最后谈谈 Lua 的语法技巧。

我们可以用 lua table 来实现 entity 对象,但这个对象只能由 System 访问,在做引用的时候都必须使用 entity id 。只有 table 对象才好附加操作的方法,数字 id 是不行的。

Component 放在 Entity 对象中,是纯数据结构,不需要方法。所有针对 Component 操作的方法都由 Entity 来发起。

例如,有一类 Component 叫 transform ,它可以有一个方法叫 rotate ,用来旋转。我们在实现 rotate 方法时候,通常会写成:

function transform:rotate(deg)
   ...
end

但我们不必真的把这个方法通过 metatable 绑定到 component 对象上,设置给 Entity 其实是一样的。比如在使用的时候,可以要求使用的人这样写:

entity = world[entity_id]  -- 通过 entity id 取得 entity 对象。
entity:transform_rotate(30)  -- 旋转 entity 30 度,这里调用的是上面的 transform.rotate 方法。

entity:transform_rotate(30) 调用的是 entity.transform_rotate(entity, 30) ;我们只需要让框架生成这个 transform_rotate 函数,让它实际调用 return transform.rotate(self.transform, 30) 就可以了。这对有闭包支持的 lua 来说,生成这个 proxy 函数是小菜一碟。甚至我们可以用 self[1] 来访问 self.transform ,只需要把 .transform 组件固定在 entity 的一号位, 或许能略微提升一点性能。

每个 system 都可以有一组专属的 entity 操作方法,这组方法(包括动态构造出上述的转发函数)是框架在初始化阶段完成的。我们怎样做到在不同的 system 中 entity 的行为不同(可以用到的方法不一样)呢?这就是 Lua 的 metatable 巧妙之处了。

我们不必为每个 entity 对象都产生若干代理对象交给不同的 System 使用,而仅需要在 System 切换的时候,修改一下 entity 类的 metatable 中的 __index 就够了。因为 entity:method() 其实是调用的 getmetatable(entity).__index.method(entity) 。对于所有 entity 对象,getmetatable(entity) 是一致的,共享同一张表,我们只需要一行代码就可以切换当下所有 entity 的行为。

如果有必要,我们还可以在这个 __index 中作进一步的运行时检查,检查当前 System 有没有访问没有声明的 Component 数据。这种运行时检查也可以方便的开关。

ECS 框架中,System 是依次运行的,System 相互之间也禁止调用。所以 System 的切换是完全可控,且发生频率很低的。这种通过切换 __index 的方式来改变不同 System 下 Entity 行为的方法简单可行。


和传统的 OOP 方式,定义类的数据结构和方法不同。对于 ECS 系统,我们需要定义的是:

  1. Component 的数据结构。
  2. System 绑定的 Singleton 的数据结构。
  3. System 的 Update 方法,如果需要,还有 System 监控某类 Component 变化的 Notify 方法。
  4. System 的私有方法,用来分解 System 的行为。
  5. System 相关的 Component 类别,和针对这些 Component 数据的操作方法。
  6. System 自定义的操作 Entity (Component 组)的方法。

这里 4,5,6 定义的方法集,只能在 3 提到的 Update/Notify 函数中调用。

Comments

web就是tree搞的太恶心了,父子组件通信被逼得只能单向流动. --- ecs完全没必要用tree的结构吧,想不到有需要的场景,没做过游戏,能列举一下么
用树形或者其他结构是为了更快的做Culling,渲染对象是不是平躺还不好说。ECS很流行,比如LumixEngine和你的技术选型一样,商业的有Stingray等,但是成功的没有,个人觉得这个技术更多的是炒作,不实用。ECS相比传统Entity Component模式的优点是数据的连续存储,这点传统方法其实可以用定制分配器的方法解决。数据的访问,实际情况下是无序的,连续存储的优势也不大了。而且ECS的使用起来非常不方便。
可像是裁剪、蒙版或者一些混合状态是需要类似树的方式才能有正确的效果,如果不用树的话感觉这部分不怎么好实现?
我不打算用 tree 来组织场景。渲染对象是平坦的,只是空间矩阵会被某些东西影响而已。从逻辑结构上来说,没有必要把对象保存在层次树上。 比如说,你把一个杯子放在桌子上,只在空间结构上,需要让杯子的位置跟随桌子,但是再其它方面,他们两者都没有父子关系,(甚至你不想让杯子跟随桌子缩放)。仅仅仅仅为了这个空间关系就用树结构不划算。 又比如骨骼系统,从身体到手指,可能在空间关系上有很多层的结构。但是在处理业务逻辑上,手指和脚趾是平级的,和空间结构是两个东西。 我想另外设计一个层次结构的对象/组件,它本身是有空间层次结构的,但是没有实体,只有空间关系和插槽。其它物件都是附着在插槽上的。 写一个 system 去这类组件的计算空间位置,更新插槽就可以了。
不知道云风大大对parent有什么好的实现方法,就像是drawtree之类的,筛选出render组件的实体出来是个数组而非树(我目前的实现方式是如此)。我有想过一种方案,在遍历筛选出来的数组实体的时候,每个实体都往上遍历,然后渲染过的做个记录如果再次访问到的就跳过,不过感觉这种实现有点蠢…
云凤大神准备把数据也放在lua的里吗?
如果在服务端用这种方法写逻辑的话,热更新时应该就不用处理upvalue的问题了吧!

Post a comment

非这个主题相关的留言请到:留言本