我们游戏引擎的 IO 模块其实一直在修改。最初的版本到现在有四年多了 。
一直没有定稿的一部分原因是因为我们给引擎设定了一个比较高的需求:引擎本身也是可以从网络自更新的。而更新引擎本身必然依赖 IO ,包括 IO 模块自身。而我们引擎又基于一个多线程版本的 Lua 框架 ltask ,ltask 本身也是需要依赖 IO 模块启动的。这些后续的设计要晚于最初 IO 模块的设计,反复重构也就是必然了。
一开始我们为 IO 单独分配了一个线程,它负责和文件服务器通讯以及文件的读写。后来因为整合 ltask ,又花了许多功夫把它从独立线程变为 ltask 的一个服务。但这个服务又有点特殊:因为它还需要负责 ltask 自身的加载。所以它前生是一个 native os thread ,中途再转换为 ltask 的独占服务。
最近,我反思整个模块的演化历程或许被引入了不必要的复杂度。重新考虑把所有的 IO 请求都放到一个单独的线程/服务 中是否是过度设计、似乎应该做一些退让,维护很少几个全局状态,似乎能简化很多的设计工作。
这里需要解决的另一个难题是一些第三方库需要注册一个 IO 回调接口,再需要读写文件时调用框架提供的接口加载文件。这些第三方模块都是把文件操作看作是同步调用,而我们已经实现的 IO 模块本质上提供的是异步接口。
如果一个模块本身是 Lua 实现的,那么同步接口和异步接口的差异很小;但如果回调发生在 C side 中时,就很难把异步接口转换为同步的。因为我们无法从 C side 直接 yield 线程。这也是我们前段时间实现 多线程串行运行 Lua 虚拟机 的动机。实际上,这个特性被实现后,我们最终并没有使用它,改为修改 IO 模块的设计。
当我们回顾游戏引擎本身的需求时,会发现,按需同步加载文件往往并不多见。因为那样一定会阻塞程序的运行。即使在 30fps 的游戏中,超过 33ms 的停顿给玩家的体验也很糟糕,而读文件甚至从网络下载文件,33ms 实在是不够用。
以我们这些年的经历来看,按需加载最大的应用场合是在开发期:开发人员不必忍受反复的 loading 时间,可以做到快速启动、快速复现问题,可以提高开发体验;至于游戏体验被不时的 IO 加载打断倒是次要的。
所以,我想我们应该同时提供两种模式,并可以随时切换。
另一个面临的大问题是资源的生命期管理。如果我们把文件对应的资源逐个交于上层,几乎很难作到精细化管理。要么是直到需要时忍受一下加载停顿加载,等暂时不用就立刻释放;要么就是一开始把需要用到的资源全部加载(并伴随一个 loading 过场)。这两个方案上层都不会花太多精力做精细化管理。
所以,我在资源文件之上增加了一个 asset bundle 的概念。这个词是从 Unity 中借来的,别的引擎应该也类似。
bundle 是一系列资源文件的集合。我们可以在开发期给 bundle 中的每个文件标记上针对这个文件的管理策略:可以是必须在初始化时加载、也可以是按需加载;鉴于我们引擎的特性,按需加载也分为从网络下载以及加载到内存。
和 Unity 的具体实现不同,我们的文件是不打包的(或是说不按 bundle 打包),bundle 仅仅是描述引用了哪些文件以及应如何管理这些资源。不同的 bundle 允许引用重复的文件,但管理策略可以不同。
游戏上层逻辑不用精细的去管理每个具体文件,而只需要针对 bundle 进行管理:可以打开或关闭某个特定 bundle 。在引用资源的时候,也不从 bundle 开始索引文件,即,不必去打开具体某个 bundle 中的某个文件;所有 bundle 中的文件总集还是摊在一个文件树上的。所以打开具体资源文件的 api 的参数还是文件路径而不是 bundle + 路径。只不过,如果一个 bundle (在特定服务中)未曾被打开,那么其中包含的文件也会打开失败。
在当前的实现中,我把文件树实现为全局共享,而不再通过服务间消息传递。这样在集成第三方库时要简单的多。估计性能也有所提高。但是每个服务所打开的 bundle 是独立管理的,这样一定程度上还有一些隔离。这可以帮助我们发现资源管理逻辑上的 bug 。
另外,一些特定的资源不在文件层面管理。例如贴图,是基于贴图的 handle 进行管理的。这可以方便我们在贴图缺失时可以先用替代贴图顶替。从业务层面看,贴图永远可以以同步模式加载,即使内部机制是异步加载的(这由 bundle 的参数控制)。
而其他一些资源,如果需要做异步加载,则需要上层逻辑配合异步 IO 的接口实现,而不是把细节藏起。