Windows 游戏软件在发布时,通常会把所有数据文件打包。这通常出于两个目的:一是保护数据文件不被最终用户直接查看,二是 Windows 的文件系统一度相对低效。尤其是在处理非常多小文件的时候,无论是安装、分发还是运行时处理都有性能问题。
而游戏软件通常会有大量的资源文件,对数据文件打包的需求更为强烈。一般游戏引擎都会支持至少一种资源打包的形式。
打包数据文件的概念和实现,我最早是对 Allegro 的源码阅读学习来的,算起来也是十多年前的故事了。之后一段时间,我从 Doom/Quake 里又看到了类似的东西。再之后,见过了星际争霸对数据包的处理。大体上,大家都支持一种用法:即可以让数据包和本地文件系统中的数据文件共存。非打包的数据在开发期用起来非常方便,打包的数据用于发行。
一旦一种包管理的模块成型,在一个公司内部就很容易长期用下去。在上面做些修补,但基本会维持下去。我指的不仅仅是数据包的格式,也指相关的实现代码。
前者是因为对包管理的相关工具会在漫长的时间内不断的增加完善。包括了打包解包工具,Patch 的生成,发布工具。btw, 好的包格式会极大的提升 patch 的速度。比如网易的游戏系列,升级补丁的时候,网络下载时间占的整个升级时间的百分比最大(几乎超过 95% 的时间);而暴血的升级补丁时间中,网络下载时间则占用的百分比不大(甚至小于 50% ),大部分时间用户在等待 patching 而不是 downloading 。
后者缘于维护的程序员的更替,大部分人对稳定的代码不太愿意去重写。这个时候,合理的接口设计就突显出其重要性。往往设计接口就圈定了思维。实现倒成了很次要的东西。如果一开始设计有些许问题的话,很多年都不会有人去仔细考究。一开始的概念更重要。
2001 年的时候,我为大话西游设计了一个简陋的数据包格式以及实现了相关的引擎代码。这么多年用在网易游戏各个项目中,再无大的改变。
2006 年开始,为新的 3d engine 构思设计新的这套东西,我刻意避免了和以前相同的思路。希望从零来考虑这个问题。为了让系统支持数据的预读,我增加了表达文件依赖关系的信息在资源文件的结构中。并且把字符串文件名从资源系统中去掉。在数据包中,采用 id 而不是文件名来关联文件。
经过这几年的实际使用,我感觉到,保持一般的惯例更有利于开发。而看似不错的设计,由于人员沟通的成本,会极大的降低原本应该带来的好处。每次有新的程序员进来参与相关的开发,我都需要费很大的气力来解释整个资源系统的工作方式和原理。虽然,精心设计过的结构,会使得资源预读以及多线程的资源管理更流畅。资源数据的加载也会有更高的性能。但这都抵不回偶然的错误使用(来至于对系统理解的偏差)带来的负面效应。
除非你想一个人或两个人搞定所有的底层,提供一个 All in One 的解决方案。不然,还是保持惯例的好。不要自己创建新的概念让程序员学习。这也是我这些年越来越不喜欢 C++ 的原因之一。C++ 的哲学下设计出来的东西,都有 All in One 的倾向;而 C 的哲学设计的出来的东西往往可以更好的相互融洽的工作。如果想一个人做(设计)一整套系统,C++ 可能更方便;如果想创造积木的零件,那还是 C 来的实在。
扯远了。
其实今天想写的是,在上周经过几天的仔细思考,以及和同事的一两次讨论后。我想改写目前在开发的引擎中的数据文件管理的部分。选在这个时候来做,也是因为别的部分已经比较顺当,一起写程序的同事基本都能有条不紊的做不同的模块并协同起来工作。而修改这个底层子模块,几乎不会对别的东西有影响。而经过好几年的时间,我觉得我对需求已经有了清晰的理解,可以把这件事情做好了。
其实我想实现的是一个简易的虚拟文件系统。对外的接口在概念上和每个程序员都熟悉的文件控制操作相同。使用树状结构描述文件的集合,用字符串标识目录和文件。可以支持相对和绝对文件路径的检索,支持文件软连接。
大部分时候,我需要的只是对文件的读操作。这会极大的简化设计。
我需要从不同的介质中读取文件。从操作系统提供的文件系统中访问文件。也可以常见一个内存文件系统,把临时资源放在内存。也可以从数据包中检索文件。数据包可以是自定义格式,也可以是标准的 zip 格式(方便开发期使用)。如有可能,我希望支持嵌套的包结构,即可以将一组文件打包成一个 zip 文件,再将这个 zip 文件打包到另一个 zip 包中。程序可以用多层目录的形式直接访问到内层包内的数据。
按我以前的想法。我定义了一个文件加载器的接口。并实现了不同的加载器。从内存加载的、从文件系统加载的、从数据包加载的…… 然后将不同的加载器注册进系统,每次打开文件时,轮询已注册的加载器,分别尝试打开文件。
但这几天专门考虑过这个问题之后,我的想法有些变化。我发现,其实我需要的是一个和 Linux 的 VFS 几乎相同的东西。只是功能上有所削减而已。
文件树结构的管理应该是独立出来的。可以不依赖任何已经实现好的具体文件系统而管理虚拟文件树。这颗树上的节点对应着真实的文件,且这些文件并不需要统一在一种文件系统下(可从不同的途径操作)。再这个层次,模块管理的是文件名和目录项,以及 cache 。
每个独立的文件系统,可以通过 mount (挂接)操作把自己挂在 VFS 的一个挂接点上,取代其下的子树。只需要按需要展开一级目录项,把特定文件系统中的文件项生成在 VFS 的对应挂接点下。VFS 可以有 cache 机制加速对相同文件的第二次访问。
我觉得这个 VFS 的工作方式可以表现的和 linux VFS 的行为一致,每次挂接一个文件系统在一个挂接点上,就把原来这个位置的子树覆盖掉。在打开 /foo/bar/foobar.txt 这个文件时,如果存在 /foo.zip ,就会尝试在 foo.zip 里去查询文件。这会以自动把 /foo.zip 挂接在 /foo 这个挂接点上来实现。但挂接会使原来的 /foo 下所有文件不可见。
这个形式不满足我的需求:因为我希望在 foo.zip 里查找不到某个文件时,依然会在本地文件系统中尝试查询。
改进的方法是,写一个专门的文件系统,用来自动查找别的文件系统,并在自己的系统下做软连接指向成功打开的文件。
比如一开始在根文件系统下创建 /auto ,并将这个特殊的文件系统挂接上去。创建 /zip 用于包系统,/os 用于本地文件系统,/mem 用于内存文件系统。
当我们试着打开 /auto/foo/bar/foobar.txt 时,auto 文件系统尝试打开 /zip/foo/bar/foobar.txt ,如果成功,则创建一个连接,让 /auto/foo/bar/foobar.txt 指向 /zip/foo/bar/foobar.txt 。如果不成功,则继续尝试 /os/foo/bar.foobar.txt 。
大体上就是这样。等实现完了,再写篇 blog 列一下最终的数据结构定义和 api 定义。