Skip to content
云风 edited this page Aug 1, 2024 · 5 revisions

虚拟文件系统

通常,一个游戏引擎都会有一套资产管理方案,用于管理游戏用到的模型、贴图、声音、特效、脚本等等数据。在 Ant 中,它对应的是虚拟文件系统(下面简称 vfs)。vfs 对于 Ant 不仅仅是资产管理,它不仅包含了游戏脚本,还包括了引擎本身的代码管理。Ant 的引擎内核代码有很大比例是用 Lua 编写的,这些代码的源文件也放在 vfs 内。

动机

设计这样一个 vfs 的动机是:Ant 专注于在移动设备运行。而开发人员通常不在运行游戏的移动设备上做开发,这些设备本身也缺乏开发调试环境。对于一个开发和运行分离的环境,我们最好有一个通过网络连接映射在一起的文件系统。在开发机上修改 vfs 中的文件,在运行设备上直接读取这些文件。这样,Ant 就可以做到大多数游戏引擎做不到的事情:开发机上的修改立刻反应到运行设备上,而不需要做繁琐费时的资源打包上传工作。在 iOS 等环境开发时,也并不需要专门的 Mac 开发机。又因为 Ant 的大部分开发工作是用 Lua 完成,日常开发时,代码可以自动同步到运行设备,不需要经过编译流程。

设计

vfs 的接口看起来就像普通的文件系统:用 / 分割的路径名,以 utf8 编码,大小写敏感。


vfs 在开发机上由本地文件系统映射,但并不是简单的将一个本地文件系统的目录映射为 vfs 的根目录。映射做了三件事:

虚拟路径映射

第一:可以将不同的本地路径映射为不同的虚拟路径。这个映射可以是多对一的,即把多个不同的本地路径映射到同一个 vfs 路径上。这些本地目录如果包含了不同名字的文件,在虚拟路径上会合并在一起;如果包含了相同名字的文件,则有明确的优先级决定最终映射哪个文件。如果存在相同名字的子目录,则递归这个合并过程。

这一点可以用来实现 Mod 机制,在开发中,如果需要引用一个过去已经完成的模块以及相关的多媒体资产,直接利用 vfs 将它映射过来;对于新的应用场景,可能需要修改其中的某些数据,则可以在本地文件系统上创建一个 mod 目录,放入修改过的几个文件,以更高优先级映射到同一个 vfs 路径就可以了。

Ant 引擎仓库本身就有许多提供给游戏使用的模块,都是以这种形式映射到游戏运行时的 vfs 中的。或者说,游戏更像是引擎搭建的基础框架的一个 mod 。

多媒体资产的编译

第二:游戏用到的多媒体资产通常是由外部工具制作的。比如贴图可能是用制图软件制作的 png 文件;模型是用建模软件制作的 gltf 文件。引擎则有自己对这些多媒体资产的数据组织方式。对于很多游戏引擎来说,通常都会有一个叫做资产仓库的东西,由引擎的开发工具导入仓库后才能被引擎使用。

Ant 没有专门的导入工具,也没有独立的资产仓库的概念,只有 vfs 。把相关文件通过 vfs 从本地文件系统映射过来即可。但数据其实也做了转换,这个转换过程发生在游戏开发阶段的第一次运行时。通常,转换是多个文件对多个文件的。这有点有点像代码的编译过程,输入源文件和它所依赖的文件,生成编译目标。以后缀名区分的不同类型的资产文件,会被 vfs 自动编译为一组目标文件。从 vfs 使用者的角度看,是把一个源文件变成了一个目录,目录中有多个编译出来的目标。但这个源文件可以依赖多个其它数据源。

所以,对于存在于本地文件系统中特定后缀名的文件,从 vfs 的角度看,是一个目录。以贴图为例,后缀名为 .texture 的文件是引擎可以使用的贴图。它在本地文件系统上其实是一个文本描述的贴图数据的元信息,这些元信息中还会引用真正的贴图数据:png/jpg 等通用图片文件。经由 vfs 映射后,在运行期间,引擎看到的对应路径上其实是一个子目录,里面元数据以及贴图的图片数据分存在子目录下不同的文件中。其中,图片数据还会根据运行所属平台的硬件不同而有所不同。

注:由于一些历史原因,多媒体资产对应的虚拟路径过去是用 | 而不是 / 区隔,和真正的路径有所区分。但现在可以完全用 / 区分,只是功能上保留了 | 以做兼容。

本地文件过滤器

第三,在从本地文件系统映射时,添加了一些过滤器,过滤掉一些不应进入 vfs 的文件。比如对于贴图,运行时只应看到变成目录 .texture ,而不应看到本地目录下的 .png 文件。这些过滤器可以以后缀名忽略,也可以指定具体文件名作为黑名单,也可以反过来给一个本地目录提供一个白名单。


对于运行设备,vfs 在设备本地创建了一个机制上类似 git 的数据仓库。它和 git 一样,带有版本控制机制。这个仓库是一个 cache ,缓存了运行需要的数据。当运行时需要的文件不存在于本地 cache 中时,可以通过网络从开发机上同步过来。也可以通过 patch 机制下载数据包更新。我们一般主张在开发机使用实时按需同步机制,它采用的是自定义协议;发布后使用 patch 包更新,它采用 http 协议下载。

多媒体资产的编译过程永远在开发设备上进行,运行设备只存在最终运行的结果。

实现

vfs 在运行期存在两模式。

C/S 模式

最终运行在移动设备上的是一种客户端服务器模式。移动设备是客户端,它在本地创建了一个机制类似 git 的仓库。所有运行时需要的文件被缓存在这个仓库中。仓库中的所有文件都以文件内容的 hash 值(sha1 的十六进制表示)为文件名。虚拟目录是一个文本文件,记录有目录下所有文件的文件名以及它们的 hash ,这个文本文件被看作是虚拟目录。所以,本质上 vfs 保存了一个 Merkle tree 。而根目录的 hash 可以看作当前整个仓库的版本号。

内容相同的文件在本地仓库中会被合并为同一个文件(因为 hash 值相同),文件的不同的版本混装在同一个仓库中,仓库的每个版本都有唯一的 hash 。

客户端切换版本的开销是极小的。在开发期,多媒体资产的编译过程是惰性的,只在开发机,也就是服务器运行的地方,第一次触碰时才开启编译流程,这些资产对应的虚拟目录如果没有被编译过就是不存在的。它类似文件系统中的软连接,软连接可以是无效的。当发生编译时,开发机上就会产生 vfs 仓库的一个新版本(因为多出了一些数据),并同步给运行机。

我们也可以在开发稳定后,将所有资产文件一次性全部编译,并打包为一个 zip 文件。客户端可以用这个 zip 文件取代本地仓库 cache 。但这个 zip 包的优先级低于本地 cache ,也就是说,在打包后,依然可以通过 C/S 模式继续更新。你也可以选择第二种更新方式:把新增的文件打包在一个新的 zip 包内,让游戏下载这个 patch 包放到自己的本地 cache 内。

当运行期失去网络连接后,游戏可以处于离线模式。在离线模式下,有可能缺失某些数据文件,数据缺失的问题需要开发者自己处理。

注:vfs 的文件服务器由 Lua 编写,放在 tools/fileserver 下,可以直接用引擎的主程序启动。

本地模式

当你的开发环境和运行环境在同一台开发机时,通常在 Windows 下(也支持 Mac),可以启动 vfs 本地模式。

本地模式不需要额外的 fileserver 进程支持。vfs 直接从本地文件系统中读取文件,但多媒体资产的编译、mod 机制、过滤器这些依然工作。只是实现有所不同,不再有类似 git 的仓库。

你也可以在开发机上使用 C/S 模式运行,这会更接近真实运行环境。使用

luamake runtime

可以构建出对应的引擎程序。在这个模式下,只需要一个执行文件即可运行,但运行需要连接文件服务器,所以还需要一个额外的 fileserver 进程,这个进程可以运行在同一台机器上,也可以运行在其它机器。

编辑器模式

还有一个历史遗留的第三模式,专供编辑器使用。将来很可能会取消。

这是因为,vfs 本身被设计为只读。一旦程序运行,整个仓库的数据被锁定在一个特定版本上,不可以新增、删除、修改文件。而编辑器则天然有修改 vfs 的需求。所以,我们过去为编辑器在 vfs 上开了一些后门。

最近,开发团队试图解决这个遗留问题,这项工作还在进行中:细节可以参考这篇 blog

一些细节

vfs 的实现大部分都不算复杂,但依然有一些难点:

最麻烦的在于 vfs 的自举过程。vfs 本身也是用 Lua 编写的,所以它自己的代码也是 vfs 的一部分。历史上,我们实现了一套完善但复杂的自举机制。先用一个预编译到执行文件中的固定版本完成最小功能的启动,然后检查 vfs 中自身的代码看是否有变化,当其发生变化时,重启这个自举过程。

后来,我们意识到这部分实现的过于复杂,容易滋生 bug ,便简化了它。不再支持自更新。

vfs 的所有 IO 访问都是通过一个独立线程(准确的说是 ltask 的一个独立服务)完成的。它是 Lua 实现的,在一个独立的 Lua 虚拟机内,通过消息和其它模块通讯。如果这个模块无法自更新会带来很多不便。虽然它是 Lua 实现,但源代码被编译到执行文件中,而不是从 vfs 中加载。好在 Lua 提供了很多运行时的动态性,我们可以让 IO 服务一开始只提供最小的功能集,在运行时再把新的功能注入到这个服务里。目前,游戏客户端内置的一个调试用 web 服务器就借助了这个手段在 IO 服务上扩展了一些特性。

另一个复杂点也和自举有关。

由于游戏的运行框架基于 ltask ,这是一个类似 skynet 的 Actor 框架。IO 服务就是 ltask 中的一个服务,而 ltask 本身也基于大量的 Lua 代码,而源代码存在于 vfs 中。这又是一个先有鸡还是先有蛋的问题。vfs 运行时依赖 ltask ,ltask 的实现依赖 vfs 。所以,vfs 的启动被分为两个不同的阶段,在自举阶段,IO 线程可以不通过 ltask 和外部交换信息,而有独有的一套专门为它服务的 channel 通讯机制;到了正常运行阶段,它被自己切换为一个 ltask 服务,再通过常规的 ltask 消息工作。

虽然 vfs 的这些部分相当复杂,可能还有潜在的 bug 。但一般使用者并不需要了解细节。

api

通常我们并不需要直接从 vfs 中读取文件,大多数情况应该使用 Asset 的抽象层。但有时,还是需要从 vfs 中直接读取文件。这需要:

local vfs = require "vfs"

详细 API 见 FileSystem

vfs 模块在 引擎初始化阶段 就被加载,最常用的 api 是 vfs.read(path) ,它会返回一个 lightuserdata ,引用一个内存文件对象。它需要用 fastio 模块进一步读取其中的数据。

如果需要遍历 vfs 的目录结构,则可以使用 filesystem 模块:

local fs = require "filesystem"

具体 api 可以 参考源码,或参考引擎 内置的 webserver 的实现

配置 VFS

在项目本地路径的根上可选配置一个 .mount 文件,它可以用来配置本地路径到 vfs 路径的对应关系。例如:

mount:
    /engine/ %engine%/engine
    /pkg/    %engine%/pkg
    /        %project%

这个配置文件会将

  • 引擎目录(%engine%)下的 engine 子目录映射为 vfs 的 /engine/ 。
  • 引擎目录下的 pkg 子目录映射为 vfs 的 /pkg/ 。
  • 项目目录(%project%)的根目录映射为 vfs 的 / 。

vfs 有三组控制选项,分别为 whitelist block 和 ignore 。

  • whitelist 是一组后缀名,只有带有这些后缀名的文件才能进入 vfs 。
  • block 是一组路径,这些路径不会进入 vfs 。
  • ignore 是一组路径,这些路径将忽略 vfs 的 whitelist 规则,让其下所有文件都进入 vfs ,且不会进行资产编译转换流程。

预设的 whitelist 有 ant prefab ecs lua html css efk ttf otf ttc bank state varyings atlas 。可以在项目本地路径根上的 .vfsignore 文件中增加配置。在 .vfsignore 中还可以配置 block 和 ignore 路径。

Clone this wiki locally