« November 2017 | Main | January 2018 »

December 20, 2017

资源文件系统的设计

上次说到,我们的引擎打算在 PC 上开发,设备上直接调试。如果是按传统的开发方式:运行前将 app 打包上载然后再运行,肯定是无法满足开发需要的。所以必须建立一套资源的同步机制。

目前,我们已经实现了基本的资源文件系统,大致是这样工作的:

所有的资源文件,包括程序代码本身(基于 Lua),都是放在开发 PC 上的。开发环境会缓存每个文件的 md5 值,文件系统将用此 md5 值为标准,默认 md5 相同的文件,其内容也是一致的,暂不考虑 md5 冲突的问题。

在设备上,用设备的本地文件系统做一个 cache ,cache 中分为两个区间,一是资源文件区,按所有资源文件的 md5 值为文件名(按 md5 的 16 进制码的前三字节散列在不同子目录中,防止单个目录文件数量过多)保存。二是目录结构区,每个子目录用一个递增数字 id 做文件名,内容则是可读文件名以及其内容对应的 md5 值或子目录编号。其中 id 0 是资源根目录。

例如,我们有一个贴图文件叫 foobar.tex ,它的 md5 值是 xxxxxx ,放在 textures/foobar.tex 。那么在 0 号目录文件中,就有一项记录为 textures 1 ,表示有 textures 这么一个子目录,其编号为 1 。

在 1 这个子目录文件中,有 foobar.tex xxxxxx 的记录,表示该目录下有 foobar.tex 这个文件, md5 值为 xxxxxx 。

如果需要打开 textures/foobar.tex 这个文件,需要先读取 0 号根目录,找到 textures 子目录的编号 1 ,再加载 1 号目录,找到 foobar.tex 的 md5 值 xxxxxx ,根据 md5 值从资源文件区加载对应文件。


游戏运行时,底层的 IO 模块负责管理资源文件。每打开文件时,若 cache 中没有需要的文件,则向编辑器发送一个文件请求;若已经有对应文件,则向编辑器发送一个文件查询请求,查询该文件的 md5 值,当 对应 md5 的数据不存在时,重新请求该文件。

ps. 未来可以做一个优化,在目录文件中记录版本号,版本号在启动时同步,再根据版本号减少文件查询请求的数量。

IO 模块在运行时是不负责删除任何文件的,即使主动删文件,也仅仅是在目录文件中把对应条目删除,而不真正删除资源文件区的数据文件。这样做的好处是,如果你需要经常在多个版本间切换:例如把你的开发机上连接的设备拔下去,插在另一台开发机上使用,而两台开发机处于不同的开发分支上;不必频繁的更新资源文件。

cache 可以主动清空,或是从根目录递归遍历,删除没有引用的数据文件。这样可以避免占用过多的空间。

直接采用 md5 值来索引数据文件,还有一个额外的好处。用户可以按更自然的方式组织资源结构。比如,不必因为两个模型需要共享同一张贴图,就把贴图文件放在独立的公共目录中。直接把贴图放在所属的模型目录下即可。只要贴图内容是一致的,最终在设备上就只有一份文件,资源管理模块也绝对不会在内存中重复加载。


补充一些实现细节:

当数据文件依赖远程加载时,如果通讯部分也是用 lua 实现,那么很可能会利用 lua 的 coroutine 做异步加载。而 lua 的 require 函数是在 C 中实现的,loader 无法 coroutine.yield 。

这里可以使用一个小技巧,自定义一个 require 从 lua 代码实现部分逻辑。在 lua 中判断 package.loaded 以及使用 package.searchpath 判断文件有效性。等异步加载完成后,再转入默认的 require 函数。

另外,IO 模块提供了 prefetch 指令,可以按照需要,发送文件请求,而不阻塞程序运行。这可以用于更上层的资源管理。例如,了解了资源的相互依赖关系的话,可以再加载完一个资源文件后,查询到它可能依赖的其它资源,一次性提交所有的依赖请求。特别是,如果这个资源是 lua 文件,我们可以在运行前,分析出里面是否有新的 require 请求,提前把所有 require 的相关文件请求都发送出去。

December 19, 2017

BenQ WiT ScreenBar 试用记录

前段时间,BenQ 的同学送了盏屏幕灯给我,我看着不占桌面空间就收下了。

作为一个从小学开始就整天盯着屏幕的程序员,30 多年一直没有近视,应该和一贯重视用眼时的环境光有关。大部分时间我在晚上作业,所以我对灯光的要求挺高的,办公室装修时还特别关照灯光要足。但面对屏幕的时候还好,在要写写画画的时候,还是感觉光线不太够。一直没特别在办公桌上摆个台灯,主要还是嫌占空间。而且我的办公桌比较大,一般台灯也照不到整个桌面。

收到后发现包装和实物都非常精致,看来挺适合送礼 。 上网查了一下,BenQ 家的灯都是 4 位数的价格,果然这质感是淘宝上几十块的 LED 灯完全所不能相提并论的。分量很沉,感觉是铝合金的,安装在屏幕上方还挺漂亮。操作面板很简洁,大概就是开关、调节色温、亮度等等,摸一下就开了。

第一次用的时候稍微有点不习惯,可能是我习惯了原来的屏幕亮度。平时我都把显示屏调节到最暗,突然补了光觉得眼前一亮。好在这盏灯的光路有过设计,不是简单置入 LED 灯。使用的时候没有感受到光线直射入眼睛的耀眼感。研究了一下灯射出的光路,发现是不对称的,靠近屏幕一侧的光线和屏幕平行,这样避免了内测光线被屏幕反射到眼睛。不像我隔壁同事买的淘宝货,那真是要闪瞎眼了。

当然最舒适的体验是在离开键盘写写画画或是翻书查资料的时候。我有打草稿的习惯,这个时候亮堂堂的书写体验非常棒。特别是光线从前面照过来,不会出现吊灯那种手挡住光的情况。前几天碰上游戏手柄坏了动手修理,这种桌面作业多盏台灯也是舒服很多。

我用了一周之后,眼睛就完全适应了,现在如果不开灯反而觉得有点偏暗不舒服。开启自动调光功能的时候会有一个短暂的自动亮度调节过程,应该是根据环境光线让桌面亮度维持在一个范围。晚上灯会亮一点。我比较喜欢默认偏暖色黄光,这个色温还可以手动再调整。

总的感觉这个屏幕挂灯挺适合长时间在电脑前面工作。很多办公环境比较暗,容易造成眼疲劳,保护眼睛非常重要。

IMG_3311.JPG

本文原载于我的微博,blog 上留个档。配图见 https://weibo.com/ttarticle/p/show?id=2309404183095841531959

December 18, 2017

是时候启动一个为移动设备设计的 3d 引擎项目了

首先,我们在 2011 年底开创的简悦被阿里巴巴文化娱乐集团全资收购了。原来简悦的全套班底转型为阿里大文娱游戏事业群。

当收购的事情尘埃落定,我发现可以从新的视角来看待未来,重新设计制作一款 3d 引擎这件事可以重新启动了。在简悦一直想做而做不了这件事,是因为没有余力,必须优先考虑产品盈利;而对于阿里来说,投入资源来做这样一件短期没有收益,但长远看来却很有意义的事是很自然的。

世面上已经有了很多优秀的 3d 游戏引擎,比如目前最为流行的 Unity 和口碑优异的 Unreal ,还有许多品质精良的开源引擎,再从头做一个又有什么意义?

我是这么看这个问题的。

Unity 和 Unreal 固然优秀,但是它们在设计之初并没有把移动设备作为核心平台来考虑。发展历史悠久,固然细节上的完善是后来者无法比拟的,但也存在很多历史包袱。尤其是移动平台上需要特别考虑内存紧致、节约能耗,更胜过运行的更快、效果更华丽。

另外,就国情而言,我们需要的移动游戏需要有更弹性的资源管理以及更新方案,这一直是 Unity 的弱项。Unity 作为一个闭源引擎,很难让使用者做出根本改进。

我们已经和 Unity 达成了合作,购买了全部源码。现在公司也成立了专门的团队自己维护 Unity 源码对其他产品团队做技术支持。在这种情况下,重新抄一个 Unity 没有意义:有什么需求,我们完全可以在 Unity 源码的基础上做开发。所以我要的是一个全新的东西。

我对这个新引擎做如下构想,其实我已经开干了:

  1. 尽快基于 MIT 开源协议开发,包括引擎的运行时部分和全部的工具链。闭源方式是维持不下去的,开源带来的好处,在 skynet 这个项目中已经得到了充分的验证。另外,由于我们和 Unity 有合作,且签有保密协议;尽快开源,并保持开源模式开发也可以自证清白:我们不会使用 Unity 的一行源代码。

  2. 专门针对移动设备做优化,渲染部分将基于 Opengl ES 3.0 ,面对 2 年以后的市场。提高硬件要求,可以精简掉很多不必要的设计,这犹如 10 年前的引擎会兼顾固定管线和可编程管线两套设计一样,现在已经没有引擎再考虑固定渲染管线了。把下限放在 Opengl ES 3.0 ,我们可以放心的使用统一的 ETC2 贴图(而不用考虑苹果特有的 pvr 格式)、使用 Instance 渲染多个物件、使用 MRT 技术来做延迟渲染的光照模型,等等。

  3. 和 Unity 等引擎最大的不同在于,我一开始就会把引擎的运行时和编辑器设计成 C/S 结构,即编辑器和项目是跑在不同的位置的。开发期间,要求开发者必须把项目运行在真机上,让移动设备真机变成真正的第二块显示窗口,而不是像 Unity 那样,开发在 PC 上,只在必要的时候打包上传到设备上开发。这样,开发者自然在整个开发过程中都时刻在关注游戏在真实设备上运行的状况、是否发热严重、帧率是否够、会不会内存不足、操作是否合理,等等。任何时候,都可以方便快捷的插拔不同的硬件设备做测试,省去繁杂的打包上传流程。

  4. 强调工具的易用性。模仿 Unity 的组件机制,但不照搬。用 ECS 结构来搭建引擎的基础框架。


在实现上,我做了一些技术选型:

  1. 渲染底层使用 bgfx ,而不自己从零开发。因为这是项特别脏累的活,需要有足够经验和能力的人来维护。而我们这个游戏引擎,并不需要在渲染底层抠出多少性能出来,精力放在这个方面不合适。bgfx 花了很长时间做多平台多 api 的整合,作者也有长年的游戏开发经验,非常值得信任。

  2. 整个框架基于 lua 构建,只在性能要求非常高的部分用 C/C++ 来封装成内聚性强的库,供 Lua 调用。不用 C/C++ 实现任何框架代码。bgfx 有完善的 C99 接口,可以完美的封装给 lua 调用,这部分工作我已经做得差不多了。动画模块,我选择了 ozz-animation 这个库,也是符合高内聚性原则,稍做封装就可以在 lua 中使用的。后面的开发都会维持这一原则。

  3. 编辑器开发基于 iup ,这是一个可以驱动原生 UI 控件的 gui 框架,使用起来对于 Lua 程序员来说非常方便。虽然原生控件有时不那么美观,但我认为一个好用的编辑器,美观不那么重要。

  4. 编辑器和游戏项目基于自定义的简单协议通讯。本质上是在移动设备上运行一个纯引擎的 app ,没有任何资源和业务代码,接管了底层的 IO 操作,映射到开发机上。当这个 app 运行时读取程序脚本时,其实是通过 usb 或 wifi 读取的开发机上的代码;资源加载亦然。只需要做好 cache 同步机制,和资源在本地运行几乎没有区别。输入设备也是把开发机的鼠标键盘通过协议映射到移动设备上的,并不需要在开发的时候去点手机的屏幕。我们还可以为游戏项目实现一些调试功能界面,直接显示放在开发机上,比在手机上做一个调试控制台,使用起来要舒适的多。


在开源之前,我会逐步把已完成的工作在 blog 上介绍,等到引擎的原型可以使用了,就在 github 上开放全部源代码。

当然,我一个人来做所有的事情太慢了。游戏引擎是一个巨大的工程,特别是在使用细节上需要大量的人力去完善。这是为什么这次我一反先做再说的惯例,在开源之前就写这篇 blog 的原因:这篇 blog 其实是一则招聘启事。

阿里大文娱游戏事业群创新实验室招聘全职 3d 引擎开发工程师 1-2 人

工作地点 广州 待遇 面议 (按阿里的标准不会太离谱)

要求 :

  • 认同并喜爱游戏引擎开发工作。(有内在动力把引擎做好)
  • 有 Unity 或 Unreal 等流行游戏引擎的项目经验(要知道引擎的用户需要什么)
  • 有 C/C++ 开发经验 (有能力编写高效代码,有能力阅读其它游戏引擎源代码)
  • 有动态语言的开发经验,认同动态语言开发。喜爱 Lua 更佳。(绝大部分的开发工作是基于 Lua 而不是 C/C++ 开发的,C/C++ 仅用来编写高内聚性的库,不用来搭建框架)
  • 对设计模式有一定的理解。(有能力把控程序的结构,不仅仅是搬砖堆代码)
  • 心态开放,乐于学习,懂得妥协。(不想整天为实现细节吵架)

有兴趣的同学,请投一份简历到我的 email (我的 blog 上可以找到地址)。


今天畅游的同学打电话来说我昨天的正文中调侃了他们的开源引擎似乎 “抄” 了 Unity 。我这里解释一下我想表达的意思,并把这句话从正文中去掉。

注意看上下文,我强调的是,按照 Unity 的设计,重新实现一遍没有什么意义。这个 "抄" 指的是结构设计,就好像 linux 最初抄了 minix , git 抄了 bitkeeper 一样;至于是否照搬了 Unity 的代码,我当然无从知晓。畅游的开源引擎设计结构是否和 Unity 一样,应该经得起用户评价。

December 16, 2017

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 函数中调用。

December 10, 2017

ECS 的 entity 集合维护

最近在基于 ECS 模型做一些基础工作。实际操作时有一个问题不太明白,那就是涉及对象 (entity) 集合本身的 System 到底应该怎样处理才合适。

仔细阅读了能找到的关于 ECS 的资料,网上能找到的大多是几年前甚至 10 年前的。关于 ECS 的资料都不断地强调一些基本原则:C 里面不可以有方法(纯数据结构),S 里面不可以有状态(纯函数)。从这个角度看,Unity 其实只是一个 EC 系统,而不是 ECS 系统。从 Unity 中寻找关于 System 的设计模式恐怕并不合适。

重看了一遍暴雪在今年 GDC 上的演讲 Overwatch Gameplay Architecture and Netcode —— 这可能是最新公开的采用 ECS 模式的成功(守望先锋)实践了—— 我想我碰到的基础问题应该在里面都有答案。

从绝大多数资料看来,Entity-Component 是对 C++ 中对象模型的一个反思:基于组合,甚至是运行期组合,而不是继承,去合成对象;System 是对 OO 面向对象设计方法的反思:把方法和数据结构分离,不要把一组方法绑定在对象上,即面向对象所主张的,由对象来处理针对它的不同行为;而是由 System 来处理不同的对象聚合。

采用 ECS 模型是因为过去的 OOP 模型耦合度太高,EC/System 的方式可以用来解耦。

把对象 Entity 拆分为更基础的数据结构单元(Component),让 System 直接作用于 Component 集合而不是对象,的确可以对大部分问题解耦。正如 Wikipedia 页面上的举例:假设有一个绘图 System将迭代所有有物理组件和可视组件的 Entity ,它从可视组件中了解 Entity 的怎样绘制,再从物理组件中了解 Entity 该在哪里绘制。而另一个 System 专门处理碰撞检测,它迭代出所有有物理组件的 Entity ,处理他们的碰撞关系,负责产生碰撞事件,但这个 System 不用关心这些 Entity 是怎么绘制的,也不用知道 Entity 具体是什么东西,碰撞本身会有什么后果。再会有另一个 System 负责 Entity 的血量,血量组件记录了 Entity 的 HP 数据,这个 System 处理碰撞事件,知道当子弹击中怪物后,怪物需要扣血。

这样看都很美好,只是,游戏引擎中,往往还有一个性能相关的需求:剔除。一个 System 需要处理的对象往往是全体对象的一个子集。如果子集远小于全体的话,每帧按 Component 类型去迭代整体就有很大的性能开销。

比如,渲染 System 通常会根据摄像机在场景中的位置,剔除掉场景中的大部分物件。如果我们编写了这么一个剔除的 System ,那么在这个 System 运作之后,后续的其它 System 就不应该在整个世界中迭代可视的 Component ,而应该针对的是剔除后的集合了。

如果忽略 EC 这种基于组合的对象模型和传统 C++ 中基于继承的对象模型的实现上的差异,我们可以把 Component 仅看成是 Entity 身上的一种筛选标签 Label ,ECS 模型其实是为 System 提供了按 Label 筛选出 Entity 集合的能力。那么,我们是不是应该提供一种不太影响处理效率的,动态贴标签和撕标签的能力?空间剔除器可以给 Entity 打上需要渲染的标签,后续的渲染器可以迭代“需渲染”的组件集合。

我在 Overwatch Gameplay Architecture and Netcode 中看不到类似的设计,在我自己的实践中,Label 的想法也有不少问题。所以想了另一种方案。

据说,在守望先锋的引擎中,存在着大量的单件(singleton)组件。相关的 System 只会处理这一个组件,从里面读取数据,或把数据放在其中。我认为、用于 System 间交换数据的事件队列、剔除器的结果、这些都应该是存放在某个单件中。像渲染器这种 System 不必从 Entity 全集中迭代需要渲染的集合,而应该转而从剔除器单件里迭代一个子集。

而剔除器的工作依赖对象本身的状态变化。游戏场景中会有大量的物件,它们的状态几乎不会变化,每帧都迭代一遍是很低效的。最好是只在位置变化时才更新剔除器中的集合。

Wikipedia 页面中也谈到 system 间的通讯问题。某些对象状态改变并不频繁,所以处理需要利用观察者模式来被动触发:

The normal way to send data between systems is to store the data in components. For example, the position of an object can be updated regularly. This position is then used by other systems.

If there are a lot of different infrequent events, a lot of flags will be needed in one or more components. Systems will then have to monitor these flags every iteration, which can become inefficient. A solution could be to use the ovserver pattern. All systems that depend on an event subscribe to it. The action from the event will thus only be executed once, when it happens, and no polling is needed.

我自己在实现的时候给 ECS 框架增加这么一个设施:你可以让一个 System 关注一类 Component 的变化事件。只有这类 Component 变化后,System 才运行 —— 而普通 System 是每帧都运行的。而 Component 的新建和删除都会触发这种变更事件,我还给框架增加了一个方法,可以主动设置一个 Component 变更。同一个 Component 的变更事件在同一帧内只会触发一次。

btw, 如果你有留意 Overwatch Gameplay Architecture and Netcode 演讲,大约在第 4 分钟的时候,他展示了一张系统的结构图,其中 System 有两个方法,一个是 Update ,第二个叫 NotifyComponent ,参数是一个 Component 指针。演讲中并未谈及这个 NotifyComponent 是做什么用的,但我猜想就是做的类似工作。

在我的(基于 Lua 的)实现中,System 每帧都会收到一个变更集合,而不是每个 Component 调用一次 System 的对应函数。这是因为 Lua 中维护集合相对廉价,而函数调用相对昂贵。

这个集合每帧更新。一旦有新建 Component ,或是别的 System 主动触发变更消息,都会添加。一开始,我打算在最后将同帧删除了的 Entity 以及从 entity 中移除了 Component 的部分从集合中去掉,保证 System 遍历集合中的 entity 都是有效的。

后来发现,这种做其实是多余的。因为 System 不仅要关心 component 的变化,更要关心 Component 的消失。比如对于空间管理器来说,一个对象从空间中移除也是重要事件。好在 Entity 我们都用唯一 Id 来引用,即使删除,id 也永不复用。如果只需要在 Entity 删除时也把 id 记在这个集合里,System 自己迭代时,发现一个 Entity id 已经无效了,就说明触发了删除事件。这比单独再设计一个删除事件要简洁的多。

总结:

当 System 需要迭代一组 Entity 对他们中的 Component 做特定操作时,这个集合可以从 EntityAdmin 中用 Component 类别做筛选,也可以从 EntityAdmin 获取一个单件,由单件维护这样的集合。

一个单件中的 Entity 集合由特定的 System 来维护,这个 System 可以订阅指定的 Component 的变更事件。

变更事件包含了 Component 的创建、移除和状态变化,创建和移除事件会随着 Entity 的构建和移除自动产生,状态变化由专有 API 产生;同一帧内,一个 Component 最多只会产生一个变更事件。事件并不区分类别(创建、移除等),由 System 在迭代时自行检查 Entity id 的有效性来判别。

December 03, 2017

Lua 下的 ECS 框架

前段时间,我写了一篇 浅谈《守望先锋》中的 ECS 构架 。最近想试试在 Lua 中实现一个简单的 ECS 框架,又仔细琢磨了一下。

我思考后的结论是:ECS 并不是一个新概念,它的提出其实是和语言相关的。ECS 概念的诞生起于游戏行业,相关框架基本都是基于 C++ 来开发的。它其实是对 C++ 对象模型的一个反思。ECS 针对组件组合对象,而反对 C++ 固有的基于继承的对象模型。对象模型才是 ECS 的设计核心理念。而离开 C++ 的对象模型,ECS 并不是什么新鲜的东西。

我的这个观点也不新鲜,在 ECS 的 Wikipedia 页上也有类似的说法:

In the original talk at GDC Scott Bilas compares C++ object system and his new Custom component system. This is consistent with a traditional use of this term in general Systems engineering with Common Lisp Object System and Type system as examples. Therefore, the ideas of "Systems" as a first-class element is a personal opinion essay. Overall, ECS is a mixed personal reflection of orthogonal well-established ideas in general Computer science and Programming language theory. For example, components can be seen as a mixin idiom in various programming languages. Alternatively, components are just a small case under the general Delegation (object-oriented programming) approach and Meta-object protocol. I.e. any complete component object system can be expressed with templates and empathy model within The Orlando Treaty vision of Object-oriented programming,

抛开理论不谈,如果要在 Lua 中实践,我们到底可以做点什么呢?

我认为需要有这几个方面:

首先应该对 Lua 加强类型系统。Lua 的动态性天然支持把不同的组件聚合在一起,我们把不同的 Component 放在一张表里组合成 Entity 就足够了。但如果 Component 分的很细的话,用很多的表组合成一个 Entity 对象的额外开销不小。不像 C++ ,结构体聚合的额外开销几乎为零。我们完全可以把不同 Component 的数据直接平坦放在一个 table 中,只要键值不冲突即可。但是我们需要额外的类型信息方便运行时从 Entity 中萃取出 Component 来。另外,如果是 C / Lua 混合设计的话,某些 Component 还应该可以是 userdata 。

从节省空间及方便遍历的角度讲,我们甚至可以把同类的 C Component 聚合在一大块内存中,然后在 Entity 的 table 中只保留一个 lightuserdata 即可。ECS 的 System 最重要的操作就是遍历处理同类 Component ,这样天然就可以分为 C System 和 Lua System 。数据的内聚性很高,可以直接区分开 C data 和 Lua Data 。

然后、就是方便的遍历。ECS 的 System 需要做的就是筛选出它关心的 Entity ,针对其中的 Component 做操作。如果需要筛选结果大大少于全体 Entity 数量,遍历逐个判断就会效率很低。好在在 Lua 中,我们可以非常容易地做出 cache ,只需要遍历筛选一次,在监控新的 Component 的诞生就可以方便的维护遍历用的集合了。


我写了一个初步的版本。打算等到实际使用起来再慢慢完善。

它可以实现成一个纯 Lua 版,但我特地尝试把里面的两个函数编写了 C 的等价版,看起来可以提高不少性能。

这里的 API 中, Entity 全部使用唯一数字 id 标识,而不主张直接引用 entity 的 table 。这也是一般 ECS 框架的通用做法。数字 id 可以提高健壮性,还可以避免对已经销毁的 Entity 错误的引用。如果需要 C / Lua 混合编程的话,在 C 中引用 id 也方便的多。

类型系统是这样设计的:

每个 Component 有一个 16bit 的唯一类型 id ,每个 Entity 是由若干 Component 组合而成,我将它们的类型 id 升序排列成一个字符串,作为整个 Entity 的动态类型。然后 Lua 中实现了一个 cache ,可以从这个类型字符串转换成易用的类型对象。类型对象用来对 Entity 做筛选,从里面分离出 Component 。这个类型字符串的拼接我实现的是一个 C 版本,虽然 Lua 也能实现的出来,但是效率会低很多。

大部分 Component 我主张直接平坦的放在 Entity 对象表中,但若需要用 C 结构来承载,或单独用一个子表,也提供了 Component 类型注册方法,可以在 new component 时定义一个专门的构造函数(以及 delete 时的析构函数)。

这里还提供了遍历包含特定 Component 的 Entity 集合 的迭代器。它由一个弱表实现的 cache 来管理。在第一次遍历时,会创建一个集合,收集 Entity 全体集合中符合要求的部分,把筛选出来的 id 记录下来。一旦遍历集合创建好,它还会跟踪新的 Component 的创建,自动加进来。

这里的迭代器,我同时实现了 Lua 版本和 C 版本。C 版本的性能会高一些,我认为这个 C 版本在遍历相关集合时,性能表现不会差于用 C/C++ 实现的原生容器。


以上是对 Entity 和 Component 的支持。System 相关的方法,我还没有想好可以做点什么。等用到了再完善。