游戏引擎的虚拟文件系统
目前我们游戏用的引擎早在 2018 年就开始了。因为一开始,它就定位为一个主要用于手机平台的游戏引擎,要方便手机开发。因为我们不太可能直接在手机设备上编写代码、制作资源,所以开发机一定是和游戏运行环境分离的。从一开始,我们就设计了一个虚拟文件系统,它可以通过网络,把开发机上的文件系统映射到手机设备上,同时兼有版本管理的功能。这样,才可以做到在开发期间,开发机上所做的大多数修改,都能立刻反映到手机上。
我们的游戏引擎的大部分是用 Lua 开发的,这也意味着文件系统中不光有游戏用的资源素材,还包括了代码本身。甚至包括了虚拟文件系统自身的实现。这个东西比一开始想的要麻烦,我们这几年不断地修改它,直到最近。比如一开始认为最麻烦的自举部分 ,在去年就去掉了,为的就是减少系统的复杂度。
简单说两句自举的问题:
常规的考虑是有一个自更新的机制,在发现需要增加需求或修补已有的 bug 时,可以不经过 app 重新打包的流程将新版本更新并持久化在外存中,并可以在自举过程中完成更新顺利初始化新版本的系统并无缝启动引擎(文件系统一定要先于整个引擎完成初始化)。但由于 iphone 手机这样的特殊环境,我们很难启用多个进程完成这个这个工作,甚至难以重启自身进程,所以做起来非常麻烦。另外,一旦更新失败,还需要一定的自恢复机制,保证在不重新安装 app 的前提下,回滚到上一个版本。
我们最初的实现虽然基本完成了任务,但在启动阶段引入如此繁杂的过程总是让人不安。所以我们在去年一次重构中去掉了这些,换成了一个无法自更新的简单版本。但是,更新问题依然需要解决。大多数情况下,我们选择重新打包和发布 app ;但也可以选择在内存中更新虚拟文件系统(而不是永久性的更新磁盘上的版本)。这得益于 Lua 的动态性,即使启动完毕,我们以后可以在后面的业务代码中,修改已在内存中的 api 实现。另外,虚拟文件系统工作在 ltask 这个多任务框架上,它的核心部件是一个独立的 IO 线程,在独立的 Lua 虚拟机内,这也方便了在内存中单独更新它。一旦有问题,因为不涉及外存,也就不必回滚。
btw, ltask 固然用独立虚拟机增进了 vfs (虚拟文件系统)的健壮,同时也带来了一些麻烦:因为 ltask 的很大一部分也是 lua 实现的,它们的代码本身是放在 vfs 中。这个麻烦也是我们简化 vfs 的自举部分的动机。
今天这篇 blog 想展开另外一个问题:我们设计 vfs 的初衷是为游戏引擎服务,而游戏引擎在使用文件系统时基于了一个假设:这个文件系统是只读的,如有存档等需求,会把这些文件保存在 vfs 之外。也就是说,对游戏来说,vfs 仓库在游戏启动那一刻,所有的文件结构都已经确定了。基于这点,我们才能用简单的方法实现它,并给它加上类似 git 的版本管理机制。在 vfs 中,目录结构是一颗 Merkle tree ,我们可以通过 hash 值获取到任何一个版本的文件或目录。但这都基于目录树在运行过程中不会发生变化。
但在开发过程中,我们发现,有不少基于这个引擎开发的应用程序会打破这个规则:编辑器就是一个典型的例子。编辑器开起来一定会修改本地文件,而这些文件最终又会变成引擎需要读取的资源。另一个例子是,vfs 是一个 C/S 结构,引擎的运行时部分是 C ,它会把 vfs 的文件 cache 在本地的物理文件系统中,但仅仅是一个 cache ;这些文件的源头是 S 通过网络同步过来的。我们为这个 S 开发了一个叫 fileserver 的工具。看起来,这个 fileserver 也会打破:在运行过程中 vfs 仓库不会变化,这条约定。
由于这样的现状,我们只好开发了两个版本的 vfs 模块,一个是上面提到的 C/S 结构中的 C ,另外一个不需要 S 而直接访问本地文件系统,它们的 api 是几乎一致的(后者多一些修改本地文件的方法)。虽然,两个版本已经相安无事的同时运行了几年,但在前几年,我们主要在 PC 上做开发,贪图方便,大家均使用后一版本(它更方便调试);只有在手机上,才运行前一个完整版本(需要 fileserver 支持)。这两者在实现上毕竟有所不同,所以,经常会出现两个版本的某些行为不一致。
我为这个问题苦恼了很久。一直在致力于统一成同一个版本。但如何解决 vfs 需要读写这个问题呢?我并不想为了游戏运行时不需要的写特性,而放弃那个简单的仓库不变的设计。
让我们来看看编辑器的具体需求:
编辑器其实是对一个可编辑实体(通常被我们成为 prefab 预制件)的修改操作。在编辑过程中,我们还会为 prefab 导入新的外部模型、贴图、编写新的 shader 等等。这些新增的资源原本是在 vfs 中不存在的。而引擎的底层,需要通过 vfs 拿到这些资源以做渲染。
重新审视编辑器需求,我发现其实向文件系统写入文件和目录其实并不是必须的。虽然看起来我们需要修改和增加它们,再让渲染底层把它们读出来。在此之前,我们已经把游戏中用到的资源:模型、贴图、着色器等等分门别类的在独立服务中管理起来了。应用层不在直接持有它们的底层 handle 。这样,可以实现透明的异步加载。渲染层持有一个间接 handle ,背后对应的是一个 vfs 路径。如果实体不在内存中,根据不同的类型,底层会有不同的替代方案,例如,贴图会有一张统一的替代图。这样,实体不必等待所依赖的资源完全加载到内存就可以创建出来,不会因为 IO 阻塞住程序。
所以,这些资源模块所依赖的是一个 vfs 路径,而不是 vfs 系统本身。要解决编辑器难题,最简单粗暴的方法是:所有要编辑的东西,都放在内存里。如果一个 vfs 路径对应的资源数据永远在内存,我们就不再关心在文件系统中的那个文件是否真的存在、是否被修改了。而保存编辑结果这个操作,仅仅只是对使用者编辑成果的备份,我们完成了备份即可,而用户并不关心怎样写入磁盘、到底写去了哪里。
如此,我们就把编辑器所依赖的资源分成了两部分:引擎本身和正在编辑的东西;前者走标准的 vfs 只读镜像,后者只存在于内存中,准确说,在专门的资源管理服务的 lua 虚拟机中。对于在内存中的 vfs 路径,就永远不再从外部读取。这样就没有破坏 vfs 在启动那一刻就不再变化的约定。
另一个有趣的问题是 fileserver 。它本身就是为 C 端提供数据的。看起来它作为 S 端,无法将自己运行在 C 端,也就是它本身的源码无法放在 vfs 仓库里。但细想却也未必。
因为我们的游戏运行时(vfs 的 C 端)本身是可以离线工作的。如果它无法连接到 fileserver ,那么它会完全使用上一次的 cache ,那里面通常有一份仓库的快照。所以,只有我们用另外一个 fileserver 为主 fileserver 提供服务,让它把自身用到的代码快照下来放在自己的 cache 中,后续就不再需要第二个 fileserver 了,直到 fileserver 自身的实现需要更新。