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 系统,我们需要定义的是:
- Component 的数据结构。
- System 绑定的 Singleton 的数据结构。
- System 的 Update 方法,如果需要,还有 System 监控某类 Component 变化的 Notify 方法。
- System 的私有方法,用来分解 System 的行为。
- System 相关的 Component 类别,和针对这些 Component 数据的操作方法。
- System 自定义的操作 Entity (Component 组)的方法。
这里 4,5,6 定义的方法集,只能在 3 提到的 Update/Notify 函数中调用。
Comments
Posted by: cxt | (7) April 26, 2019 05:37 PM
Posted by: lastmancoding | (6) February 2, 2018 03:59 PM
Posted by: 零 | (5) January 31, 2018 03:58 PM
Posted by: Cloud | (4) January 31, 2018 09:48 AM
Posted by: 零 | (3) January 30, 2018 06:56 PM
Posted by: 张昊 | (2) December 20, 2017 11:17 AM
Posted by: Mr_Z | (1) December 16, 2017 10:09 PM