« 场景层次结构的排序 | 返回首页 | 游戏引擎中预制件的设计 »

资源模块的重构

这篇是对 游戏引擎中的资源生命期管理问题 的延续。

最近对我们游戏引擎的资源模块做了一次重构,大概花了一周的时间,其中核心模块的代码实现花了 2 天。比之前的方案简洁很多。新方案的设计是基于以下原则来实现的:

  1. 引擎应该围绕数据来设计。ECS 更是数据驱动的模型。
  2. 数据全部都用一致的数据结构来表达,方便统一处理。因为我们采用 lua 做开发,所以,一切数据都是 lua table 。我们的引擎与其说是基于 lua 开发,不如说是基于 lua 的数据结构开发。即使某些模块因为性能因素用 C/C++ 实现,操纵的还是 lua table 。读写 lua table 的性能和读写 C struct / array 相比,并无显著的劣势。
  3. 在使用上尽量不区分外部不可修改的静态数据和运行期动态修改的数据。
  4. 惰性加载,延迟异步加载,替代资源,这些尽可能的隐藏起来,不必对外透露细节。尽可能的减少外部干预。
  5. lua 虽然有 metatable 这个神器,可以帮助我们抹平不同数据、不同策略之间的差异。但不要过多依赖语言特性。

我们的 Component 本质上就是树状的数据结构表达的一组数据。对 Lua 来说,就是一个带或不带层次的 table 。同样,我们也可以把一切外部数据都视为相同的树状表。

所以,一个外部资源文件的加载器,就可以写成:通过一个资源文件名初始化的状态机,产出一张 lua 表。lua 表中可以是 lua 支持的数据类型,如 number string 这些,也可以是引擎再加工的 handle ,如 texture, vb, ib, framebuffer 等。

运行时任何的数据都可以表示为一个普通的 lua 表,也可以是一个资源文件名+字符串路径引用的子树。通过 metatable 的机制,在使用上可以是一致的。但这个 metatable 的行为,可以根据数据的状态:在内存,不在内存,等进行不同的处理。

资源文件我倾向于把类型信息编码进去,也就是不采用额外的 schema 描述。这样,任何保存在文件中的数据,都可以用一致的表达方法。但是,根据不同的资源类型,可以实现不同的加载器。加载器更多的作用是进行数据到运行时的翻译工作:比如,把一块贴图数据变成贴图的 handle 。

因为引擎是围绕数据工作的。所以创建一个 Entity 就是从一个 prefab 文件实例化出来的。prefab 文件本质上就是 Entity 的初始化数据集。在引擎的使用层面,我们甚至不必提供用纯代码构造 Entity 的 API ,唯一的 API 就是从文件实例化,然后允许用户进一步修改。

这样,产生 prefab 文件的工具,就成了日常工作流的一部分。我们之前的开发流程并不是这样做的,我们之前尽可能的用人去写代码来做底层的开发,测试,脱离了工具。导致工具的开发从引擎开发中剥离了出去。正在做引擎的人不依赖正在做工具的人,虽然解开了依赖性,也造成了不吃自己的狗粮。毕竟,最终引擎的用户,面对的就是一个开发工具,而不仅仅是引擎的库。这次对工作流的调整,修改希望终结这种 现象,让工具变得更好用。

之前一直没有这样做的原因之一是我们的脚手架还没有搭建稳定。这里有一个先有鸡还是先有蛋的问题。脚手架没有完工,会让工具无法正确工作,如果库的开发过多依赖尚未稳定工作的工具,则会影响开发本身。这个结就无法解开。而经过了一年多的开发,是时候拆脚手架了。


回到资源模块这个话题。

我们对运行期的数据,如果引用的是资源文件的一部分,就将其实现为一个代理对象。这些代理对象按所属资源文件分类,分别管理。我为每个资源文件中的每个子树都生产一个代理。理论上,整个游戏所引用的这样的数据块的总量是固定的,这个代理对象本身不一定包含真正的数据,它的内存占用是有限的,所以我永远不清除这些代理对象。减少管理的复杂度。

当代理对象代理的数据在内存中时,它指向加载器加载出来的数据表。我们可以通过代理对象的元方法监测数据的使用情况。虽然监测本身有成本,但可以随时将元方法更换为对数据表的直接引用,提高性能。

注:lua metatable 的 index 如果指向一张普通 table 时,性能大大高于指向一个函数。

如果我想知道一张贴图最近是否有人使用,只需要把这张贴图的代理对象的监测方法,以一定周期性的抽查,比如在过去 5 分钟,抽查 100 次。如果没有使用,就可以认为它可以暂时清理出内存。

当代理对象代理的数据不在内存中时,可以把数据所属的资源文件下所有相关的代理对象共用同一个元表,这样,就可以用 O(1) 的代价修改所有对这个文件的引用的代理对象的行为。针对不同类型的资源,可以有不同的策略。

有些小文件,加载起来并不慢,我们可以采用直接阻塞式的惰性加载方案。一旦应用层访问到不在内存的数据,就阻塞住线程,把数据加载进来。

对于某些大文件,例如贴图,可以采用异步加载的方式。先用一张已经在内存的替代贴图顶上。并把加载操作提交到 IO 线程。待加载完毕后,再统一替换。

如果有一些特殊的大文件,即想做异步加载,又无法用替代物顶替,还提供的主动查询的 api 查询一个 lua table 背后的数据是否在内存。不过这需要上层业务了解更多细节,暂时只是在资源管理模块的 api 中预留了这个能力,并没有真的使用。


为了抹平普通运行数据和资源数据的区别,并允许运行时修改那些原本引用的是外部静态资源的对象,提供了一个叫 patch 的 api 。

我们规定,对 Component 的全部或部分的修改,必须通过 patch 进行,即 data.foo = patch(data.foo, { ... } ) 这样的形式。第二个参数就是要修改的内容,用 lua 表的形式提供(允许有树层次)。为了减少使用者的失误(例如拼写错误),不允许通过 patch 增加或删除原有数据中不存在的项。

如果想更自由的修改数据,也可以用 data.foo = patch(data.foo, {}) 先打一个空的 patch ,这样会把 data.foo 的第一层转换为运行期的普通表,然后就可以用标准 lua 语法对这一层自由修改了(删除已有项,或增加新项目)。

Comments

云大的引擎啥时候能跟大家见面啊, 很是期待
期待看到你们的引擎,感觉会彻底解放生产力。国内需要一个自己的Unity这样的一站式移动平台3D引擎。
这个引擎啥时候开源?

Post a comment

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