接上一篇,我们首先来看一下基本的文件IO操作。
对于C语言标准库提供了两种文件io的API。一种是C标准库当中提供的f*系列的,带有缓存功能的API,如fopen/fclose;一种是POSIX标准的,不带有缓存功能的API,如open/close。不过,如同我们之前说过,windows并不是POSIX系统,因此windows所提供的POSIX类型的API并不是系统调用,而是win32 API的简单封装。
由于我们目前主要是进行游戏资源文件的加载。这种加载基本上是只读性质的,而且大部分是顺序读取,所以带有缓存的f*系列API就比较适合我们的需要了。而且f*系列的API是属于C标准库的一部分,可移植性较好。
我们当然可以在场景管理模块里面直接使用f*系列API来加载资源。但是这么做有以下几个问题:
- 虽然f*系列API是C的标准库的一部分,具有良好的可移植性,但是在一些平台上它并不是系统原生的API,所提供的功能十分有限且优化不足。比如在windows平台上有功能更为强大的win32 API,而在PS4上有可以将多个文件的读写进行统一调度优化的库。如果在资源管理模块当中直接调用f*系列的API,那么如果之后我们希望直接使用平台原生的API,会变得困难;
- 各个平台对于文件路径的要求和处理有微妙的区别。比如windows平台有盘符的概念;而Linux平台整个文件系统在一个树状结构当中;而PSV的文件系统在路径前还有媒体标识符;PS4的文件系统虽然类似Linux系统但是是在一个严格的沙箱当中的虚拟路径,与文件实际存放路径不同。这些细节与场景管理本身并没有太多关系,应该放在一个独立的地方进行处理;
- f*系列API所提供的是同步阻塞型的文件访问。如果我们在场景管理模块当中直接使用这些API,那么场景管理模块在进行文件操作的时候将会失去响应。场景管理模块是与图形渲染模块,动画模块,游戏逻辑模块等紧密协作的一个模块,因此我们应该将其设计为一个快速响应的模块,不能有这样的阻塞;
- 如我们上一篇文章所分析的,场景模块是比较复杂的。为了降低其复杂度,增加其可维护性,我们应该尽量把松耦合的功能从其中剥离出来形成单独的模块。
基于这样的基本设计,我们定义了一个资源加载模块,AssetLoader,专门负责资源文件的加载工作。
namespace My { class AssetLoader : public IRuntimeModule { public: virtual ~AssetLoader() {}; virtual int Initialize(); virtual void Finalize(); virtual void Tick(); typedef void* AssetFilePtr; enum AssetOpenMode { MY_OPEN_TEXT = 0, /// Open In Text Mode MY_OPEN_BINARY = 1, /// Open In Binary Mode }; enum AssetSeekBase { MY_SEEK_SET = 0, /// SEEK_SET MY_SEEK_CUR = 1, /// SEEK_CUR MY_SEEK_END = 2 /// SEEK_END }; bool AddSearchPath(const char *path); bool RemoveSearchPath(const char *path); bool FileExists(const char *filePath); AssetFilePtr OpenFile(const char* name, AssetOpenMode mode); Buffer SyncOpenAndReadText(const char *filePath); size_t SyncRead(const AssetFilePtr& fp, Buffer& buf); void CloseFile(AssetFilePtr& fp); size_t GetSize(const AssetFilePtr& fp); int32_t Seek(AssetFilePtr fp, long offset, AssetSeekBase where); inline std::string SyncOpenAndReadTextFileToString(const char* fileN me) { std::string result; Buffer buffer = SyncOpenAndReadText(fileName); char* content = reinterpret_cast<char*>(buffer.m_pData); if (content) { result = std::string(std::move(content)); } return result; } private: std::vector<std::string> m_strSearchPath; }; }
目前这个定义当中还只是包括了同步版本的API,因此这个类看起来还只是一个wrapper,似乎没有必要将其定义为一个RunTimeModule。但是为了实现上面所说的让文件操作不要阻塞调用线程,我们有两个选项∶
- 使用多线程。我们需要在AssetLoader当中创建工作线程池,将对同步阻塞型f*系列API的调用放到工作线程当中去执行;
- 使用异步文件IO API。实质上这也是多线程,只不过线程是由操作系统创建。
选项1的好处是我们可以拥有更为细致的控制权,可以精细安排这些线程的优先级以及执行方式等。缺点是我们需要写更多的代码去维护这个线程池的管理,并且因为操作系统缺乏关于我们要进行的工作的足够的信息,可能无法提供文件读写整体方面的优化;
选项二的好处是代码比较简洁,不用进行线程的管理,并且如果我们一次将多个文件读写请求发送给操作系统,可能会得到操作系统在读取方式方面深度的优化,比如通过妥善安排读取的顺序减少硬盘寻道的时间。然而缺点首先是我们可能比较难以控制文件读取的顺序,而且异步文件IO的API并没有得到标准化,在各个平台上API长得很不一样,甚至不支持。
但是不管我们支持上述两种选项的哪一种,因为是异步操作,就意味着我们的AssetLoader会以一种不同于资源管理模块的节奏进行工作。所以我们将它也定义为一个RuntimeModule。
另外一个需要注意的点就是AssetFilePtr。它被定义成为一个空指针类型。这是因为在不同的平台API当中文件的描述子的定义是不同的。我们用空指针类型来抽象整合这种不同。不过需要注意的是AssetFilePtr并不是资源的唯一识别子。资源的唯一识别子是在游戏当中用来索引资源的唯一标识,它应该具备平台无关性和时序无关性;而AssetFilePtr显然这两条都不符合。
况且,这里面还有一个隐含的问题值得我们思考,那就是游戏资源(Asset)与游戏资源文件(Asset File)是否一定是一对一的关系。如果我们将场景物体考虑为Asset的单位,比如游戏当中的主角人物,那么因为一个人物会包括模型、材质、贴图、动画、声音等许多素材,而这些素材往往是以不同格式的文件进行存放的,那么显然这不是一种一对一的关系。
另一方面,如同我们在上一篇文章所述,为了加快资源的读取速度,我们往往最终会将资源文件进行打包合并,从而多个资源可能会对应同一个资源文件,这也不是一种一对一的关系。
因此,很显然地,我们需要在某个地方进行资源与资源文件之间的映射。我们甚至需要导入或者自定义一种资源描述语法(比如URI),来唯一描述某项资源,以及资源之间的联系。
不过在此之前,我们还有一些基础性工作需要做。因为我们的AssetLoader目前只能完成文件内容的加载(以文本文件模式或者二进制模式),并不能解析文件的内容。我们下一篇将介绍如何为我们的AssetLoader设计各种文件格式的解析器(decoder)。
本文当中涉及到的AssetLoader的实现代码在GitHub的article_25当中。
参考引用
- http://man7.org/linux/man-pages/man7/aio.7.html
- https://msdn.microsoft.com/en-us/library/aa365683.aspx
- https://isocpp.org/wiki/faq/serialization#serialize-text-format
本作品采用知识共享署名 4.0 国际许可协议进行许可。