上篇谈了一下我们游戏引擎的虚拟文件系统(vfs)。我觉得这个系统中,游戏资产的管理部分还是个满有意思的设计,值得写一下。
VFS 的设计动机是方便把开发机磁盘上的数据同步到运行设备(通常是手机)中。传统游戏引擎的做法通常是建一个叫做资产仓库的东西,在开发期间不断添加维护这个仓库。需要把游戏部署在运行设备时,再打包上传过去。因为传统游戏引擎在开发期间一般直接在开发机上运行,所以打包上传(从开发机转移游戏资产)并不频繁。
而我们的游戏引擎特别为手机游戏开发而设计,我们不可能直接在手机设备上开发,所以开发机一是和运行机分离的。为了提高开发效率,所以我们设计了 VFS 系统。可以通过网络同步资源仓库。
为了方便持续开发,vfs 被设计成带版本管理的。这里,我们借鉴了 git 的做法:所有仓库中的文件都以其内容的 hash 值为索引。当文件发生改变时,就产生了一个新索引项。而内容相同的文件,无论它的文件名是什么,放在什么目录下,在仓库中都只有唯一一份。
vfs 可以把本地文件系统的任意目录嫁接在一个虚拟目录树中。这里 采用了 mod 机制 ,可以把两个目录按优先级合并从 vfs 上的同一个虚拟目录。
vfs 的目录结构是一颗 Merkle tree 。仓库中的每个目录是一个文本,内容是它所包含的所有文件的真实文件名以及文件 hash 值的列表。因为 vfs 仓库在游戏运行期间是不变的。整个 vfs 仓库根目录的 hash 就可以看成整个仓库的版本号。vfs 中的任意改动,都会产生一个新的版本,这可以方便我们做差异同步(类似 git),开发期可以随时通过网络把本地数据差异同步到手机上,也可以直接 patch 包,一次把手机上的仓库更新到最新版。
和 git 不同,对于游戏引擎,我们面临着一个难题:游戏仓库中有两类数据,一种是静态数据,它们直接存放在本地文件系统中;另一类是资源,例如贴图、模型等,开发目录中保存的是它们的源文件,而运行时需要先离线处理一下才可以使用。以贴图为例,开发目录下可能是一个 png 文件,但运行时,我们需要根据最终设备,把它做有损压缩转换为 ASTC 格式。
许多传统的引擎是这样解决这个问题的:开发期间开发者通过编辑器把源文件导入仓库,这时就产生了一组在开发机上运行期最终可以使用的数据。这些数据组织在本地的仓库数据库中。而等发布到手机时,再针对手机设备做一次转换,这就是所谓的打包流程。这个过程通常很耗时,实际开发时,往往需要专门用一台机器定期打包(每日构建)。
我不喜欢这样的方案。因为这样的资源仓库很难做版本管理。发布流程很费时,开发时虽然把资源编译过程分散到小段时间,但其耗时有时也会影响开发。如果我们直接对源文件做版本管理,那么每次导入游戏运行仓库的时间开销就更难以接受了。
所以,我们的 vfs 选择了惰性编译资源的方案:即在运行时需要某个资源,才由 vfs 的 fileserver 触发编译过程。fileserver 可以不在特定开发机上,所以也能方便的横向扩展。
资源源文件和运行时数据一般是 n:m 的关系。一组源数据可以对应为多个目标文件。而一组源文件可以用单个源文件里写清引用了外部哪些文件。很多标准化的数据文件也是这样干的,例如,gltf 格式中,可以把依赖的贴图文件放在外部。这样,源数据和运行时文件就是 1:n 的关系。
在 vfs 中,本地数据源的资源文件可以看成是一个软连接,它对应到另一个目录树(的 n 个文件)。所谓编译资源,就是根据这个源文件生成这个目录树。但如果我们真的把资源看成一棵子树(硬连接)的话,就很难计算整个 Merkle tree 了。因为计算 Merkle tree 需要每个节点的 hash 。当资源在根据需要才编译时,编译之前是无法得到其 hash 的。这是游戏资源管理的一个难题。有过传统引擎打包经验的开发者都知道,全量打包少则几分钟,多则几小时。完全不适合开发期边改边用。
我们的 vfs 目前的做法是在结构上把静态文件和资源文件分在两个平级的子树上,同属一个总的根。静态文件中可以有软连接,软连接是一个路径字符串,指向资源文件子树的一个结点。在开发期,没有用过的资源节点在资源子树上是不存在的。运行时发现一个软连接不存在时,可以向 vfs 的 fileserver 提一个请求,要求它生成对应的资源子树。新的资源树增加或修改了节点后,虽然整个树发生了变化,但是静态文件的那颗子树是不会变的。运行期会切换到仓库新的根,但静态文件部分不需要刷新,资源部分已存在的部分也不会变化,只需要通过软连接取到新增加的结点就可以了。
长话短说:vfs 虽然是不变的,但可以通过生成新的版本修改它。我们把资源和静态文件分开,通过软连接关联起来。静态文件仓库是不变的,资源仓库版本会在开发期的运行时变化。这个变化仅限于子树节点的增减,而单组数据本身的数据版本在进程退出前保持不变。