« July 2019 | Main

August 16, 2019

游戏引擎中的资源生命期管理问题

最近我们开发中的游戏引擎在修理资源管理模块中的 bug 时,我提出了一些想法,希望可以简化资源对象的生命期管理。

其实这个模块已经被重构过几次了。我想理一下它的发展轨迹。

最开始,我们不想太考虑资源的生命期问题,全部都不释放。当然,谁都明白,这种策略只适合做 demo ,不可能用在产品中。

因为我们整个引擎的框架是用 lua 搭建,那么,最直接的想法就是利用 lua 自带的 gc 来回收那些不被引用的资源对象。我不太喜欢这个简单粗暴的方法。因为首先, gc 不会太及时,其次 gc 方法触发的时机很难控制,容易干扰正常的运行流程。图形显示模块是时间敏感的,如果因为资源释放占用了 cpu 的话,很容易变成肉眼可查的卡顿。

另一个促使我们认真考虑资源管理模块的设计的原因是,当我们从 demo 过渡到现实世界的大游戏场景时,过多的资源量触发了 bgfx 的一个内部限制:如果你在一个渲染帧内调用了过多资源 api (例如创建新的 buffer texture 等),会超出 bgfx 的多线程渲染内部的一个消息管道上限,直接让程序崩溃。

所以我们不得不比计划提前实现资源的异步加载模块,它属于资源管理模块的一部分,所以也就顺理成章的考虑整个资源管理模块的设计。

我们一开始实现了一个中规中矩的引用计数方案。资源永远都被 ECS 中的 C 引用,且永远没有对外引用,所以并没有循环引用的问题。引用计数一定可以正确的管理资源的生命周期。一旦引用计数为 0 ,把课回收资源放到一个集合里,交给一个 system 处理即可。

但我直觉上不喜欢在一个基于 lua 这样自带 gc 的语言构建的框架中使用一个蹩脚的引用计数机制。而且,移动设备是一个内存受限的环境,我认为基于业务上不再对资源引用与否来觉得是否可以释放资源不是最好的管理方式。

我认为可以把资源分为两类:一,从 IO 获取的资源,它们有唯一的名字(文件名)。这类资源即使从内存释放,也可以重新加载回内存。二,根据其它数据由代码生成的资源,如果销毁不太容易重建。

第一类资源是大头,我认为它们实际上可以随时从内存销毁,释放内存供其它使用。部分资源类型还有替代方案:例如贴图,我们可以用统一空白贴图临时顶替使用。

针对第一类资源,生命期管理就不必基于它是否在内存中还有引用决定,而应该由是否很久没有使用决定。一个长期未使用的资源对象,无论在 ECS 中是否还有 C 对其引用,资源管理模块都有权销毁它,直到下次使用它时再通过异步加载模块读回。

而第二类资源就麻烦一些。如果我们随意删除,就很难重建(因为失去了当初创建它的上下文)。我们决定在内存富裕的情况下,永远保留这类资源。在迫不得已的时候,再在条件允许时删除它。最早的实现又用回了引用计数方案,但很快又去掉了。我们的 ECS 框架很容易遍历所有的资源,所以在必要的时候确定一个资源对象是否还有 C 在引用并不复杂。所以不必额外做繁杂的加减引用操作。

后来,在经过一些讨论后。我又从 imgui 的设计中得到了新的灵感:

其实,当初我们也考虑过给每种第二类资源提供一个回调函数,在销毁后调用一下就能重建回来。但并不是总能简单的写出这个创建函数。例如,如果一张贴图是用场景上的一个摄像机渲染出来的,那么这个回调函数就涉及相关场景对象了。一个简单的闭包函数很可能破坏掉 ECS 的设计原则。

但是,如果我们反过来想,如果每帧都主动创建这种资源呢?好比 imgui 并不保存控件的状态,每帧都去画一下那个控件一样。这样就不会影响 ECS 的设计原则,可以用自然的方式去创建动态资源。之后,我们就可以再这个基础上 cache 上一帧的结果,而避免每帧都创建。

这样设计后,第二类资源对象就和第一类对象一样,可以在任意时刻销毁。资源管理模块只需要按 LRU 算法淘汰超出内存阙值的资源就够了。

August 02, 2019

资源文件的转换问题

我们上周在游戏引擎上面的工作中遇到一些 bug ,涉及到过去的一些设计问题。维持讨论了几天解决该问题的方案。今天终于把最终方案确定了下来,值得做一个记录。

bug 出在游戏资源文件的转换上面。

游戏里用到的资源通常需要一个导入资源库的过程,例如你的原始贴图是一个 png 文件,但是引擎需要的是对应运行平台的压缩格式,windows 上是 dxt ,手机上是 ktx 等等。这个过程,在 Unity 等商业引擎中,是放在资源导入流程中。

我们的引擎把这个转换过程放在虚拟文件系统这个层次。这个设计决策是因为,我感觉统一导入资源是个痛点,用的人通常需要等待导入过程。Unity 用了 cache server 来解决这个痛点,但我认为 cache server 也存在一些设计问题 ,这个会在后面再展开一次。

我更希望转换过程是惰性的,直到最终运行需要的资源才需要转换。

在我们的设计中,所有需要转换的资源,都有一个后缀为 .lk 的同名文件放在文件系统中。它描述了怎样加工原始素材,它的作用和 Unity 的 .meta 文件基本一致。

我们的虚拟文件系统在发现一个文件有 .lk 时,会在请求该文件的时候调用构建模块转换源文件。

我们一开始假设的前提是,一个源文件加对应的 lk 文件,在加平台参数,三者的内容就决定了最终生成的文件是什么。所以前三者的 hash 就能用于转换过程的 cache 。最近发现,这个前提是不成立的,导致了 bug 的产生。

原因是:对于 shader 文件,它其实是一种代码,类似 c/c++ 代码。编译一个 shader 其实是依赖很多文件的。所以光有一个 shader 源文件无法准确的 cache 结果:例如,我们修改了 shader 中 include 的另一个文件,但是 fileserver 并不知道,虚拟文件系统返回了 cache 结果,而没有重新编译。


一开始想解决这个问题时,我想放弃惰性构建这个机制。即,把资源转换和 fileserver 分离。这样,修改了源文件,就由资源转换模块去构建这个资源文件。客户端永远认为在运行时资源已经是构建好的(和 Unity 一致)。

这个方案最为简单,但在组内讨论的时候很快被否决了。因为这样又倒退回去了,并没有解决原先想解决的痛点。经过几个方案的讨论,我们最终找到了比较合理的方法。

以 shader 为例,假设有一个 shader 文件叫 a.sc ,有一个 a.sc.lk 指明了 sc 该如何构建。我们的 fieserver 在收到 build 请求的时候,会无条件的重新编译 a.sc 在指定平台上的结果,并把结果文件的 hash 返回。这一步是不做任何 cache 的。

但是,新的方案中,如果你请求了 a.sc 在 ios 上的版本,那么,构建模块会在虚拟文件系统中添加一个叫做 a.sc.lk.ios 的构建脚本文件,详细记录了 a.sc 在 ios 上的构建方法,和构建过程中的依赖关系,包括依赖文件的路径,和当前这些依赖文件的 hash 。

那么,这个 a.sc.lk.ios 文件,其实就唯一确定了一个编译好的目标文件。因为任何一个依赖文件的修改,都会导致文件内容的变更(hash 值变了)。

这个文件在当此运行会话中,对 fileserver 的客户端是不可见的。这是因为我们假定运行过程中,所有文件在一颗 merkle tree 上,是不可变更的。但是新的会话就能看见这个新增的 .ios 文件了。

一旦客户端看得到 .ios 文件,它就可以用这个 .ios 文件的 hash 去请求编译的结果。由于每个 .ios 文件的内容都能唯一确定一个编译结果,这样,fileserver 就能对结果做 cache 。

.ios 文件不是资源编译的结果,而是编译的参数。它在数据仓库里就可以有多份,而编译的结果只需要保存最终的一份。大多数情况下,用户只会请求最新的一份编译结果,但万一用户请求过期的版本,fileserver 也可以重新生成出来。

这有点像 github 的大文件储存方案,不在 git 主仓库里保存大的二进制文件,而保存了一个唯一的 url ,把文件放在了另一个服务中。在这里,这另一个服务就是编译资源的模块。


这个机制做到了惰性编译资源,又可以合理的 cache 资源的编译结果。和 Unity 的 cache server 不同,它用来索引 cache 的 key 其实是编译资源的完整过程,包括了编译的依赖关系。

所以,编译模块,完全可以跨项目的 cache 这个过程。即,如果你一张贴图用在一个项目中,被编译过一次;当你把这张贴图复制到新项目使用时,是不需要重新编译的。而 Unity 中同一张贴图即使在同一个项目中换个位置,都会导致 guid 变化,从而让 meta 文件变化,致使资源重新编译。

文件服务还知道所有的需求,所以它完全有能力在你请求 ios 版本的同时,预期你还会在以后请求 andriod 版本。所以 fileserver 还有能力利用闲置时间去提前生成那些尚未请求的版本。而 Unity 的 cache server 则是一个纯粹的 key / value 服务,完全不可能做到这些。