https://zhuanlan.zhihu.com/gu-yu
前文回顾:The world at your fingertips — 天涯明月刀幕后15(得失)
差不多是时候可以体系的讨论一下游戏中的资源问题。
引擎虽然是整个游戏的核心,但真正海量的开发内容,都在素材。整个引擎、工具团队的存在意义,就是制作流程,让更多的策划、美术、逻辑程序等能高效制作游戏的内容。这些数据需要被妥善的管理。除去基本的贴图格式,其他数据的格式,一般都是自定义格式。用什么形式表现,都在程序员的一念之间。如何组织数据,方便多人工作,如何提高工作效率或者加载效率,如何让数据有更强的表现能力,都是引擎需要考虑的。
首先考虑的是,数据是否对合并(Merge)友好。
这是一个大型多人游戏中非常重要的因素。大型开发团队必然有很多人需要同时工作在一个版本中,编辑修改文件中的造成冲突是在所难免的。程序员们常用的编程语言,一般都是对Merge友好的,但资源文件就不一定了。
Merge友好有几个先决条件。最基本的自然是
Diff友好。所谓的Diff友好,就是一个文件的两个不同版本,我们要可以方便的知道它们有没有不同,有什么地方不同。如果两个版本的文件,都没有办法Diff,自然就无法Merge了。大多数文本文件都能比较好的满足这个条件,基于文本行之间的差异,就能方便的做到上述要求。
但光Diff友好还不够,还需要对
编辑友好。比如图像文件,通过一些辅助工具,也可以做到图像Diff,通过图形化的界面显示出两幅图像的差别,用高亮的形式把像素差别显示在了图像上。这类文件,虽然可以Diff了,但并没有太容易的方法可以轻易地编辑它,来合并大家的不同改动,所以本质上还是Merge不友好的。只有文本文件,才是编辑友好的,显示出不同,可以方便的合并和修改,解决多人工作中的修改冲突。
最后需要的,是可读性,或者说,是
理解友好。举个例子,一个Mesh文件,如果用文本表达,它既是Diff友好,又是编辑友好的,但整个文件里面大段大段都是顶点坐标。即使有了Diff,你也很容易编辑它,但由于你无法轻易理解这个顶点的含义,对你来说,Merge这个文件无异于碰运气。
有了Diff友好、编辑友好,和理解友好,我们就可以解决第一个问题,合并友好问题了。
下一个要考虑的问题,是
编辑和运行时刻的效率问题。
编辑效率问题比较简单,如果文件有好的工具可以修改,效率就比较高。配合合并友好性问题一起考虑,那么我们还需要加上一个维度,即这个资源文件是否有利于合并。即使我们可以高效的修改,但如果不能高效的合并,我们依然会在工作文件有编辑冲突时面临相当大的困境。总体来说,引擎要提供高质量的编辑工具,以及保证文件的可合并性。
加载效率问题是另一个需要考虑的问题。游戏运行时必然需要高效的加载文件,减少Loading时间。加载效率也分成IO效率以及解析效率两个环节。
所谓的
IO效率,就是文件被读进引擎的速度有多快。IO一直是现代电脑的最大瓶颈,现代电脑在CPU、内存、网络速度都有成百上千倍提升,但IO速度始终是系统瓶颈。直到SSD硬盘的出现,IO才有了巨大的提升,但即使是SSD硬盘,依然需要开发者认真考虑IO问题,因为它比内存速度,依然有数量级上的差距。
回到我们的问题,IO效率需要考虑文件大小,这个通常不是最大的问题,因为IO系统最大的问题在零星小文件的读取,不在于持续读取速度。只要读取文件没有数量级上的差异,多几百KB并不从根本上影响IO效率。
另一个问题便是寻道问题,这个需要重点解决。零散小文件是IO效率的杀手。以前做主机游戏的时候,这个问题更突出,因为那些游戏使用光盘来读取文件,而不是硬盘,随机寻道基本是不可能的事情。一般通过一些称之为Linear loading的手段加以解决,即有相当多的文件总是按照指定的顺序被读取的,如果我们能合理的分组,将它们一次全部读入,就可以大大减少寻道时间。说起来容易,做起来非常琐碎,因为这个改动很可能会影响上层的加载逻辑。当然有很多巧妙地办法可以解决这个问题,比如上层加载的时候,都通过统一的管理接口。管理接口底层其实是顺序读文件的,但在上层看来是透明的,上层只管不停的提出加载需求即可。这样的系统通常会有一个预先的记录环节,在正常加载过程中,录下所有需要读取的小文件,把这些小文件顺序输出到一个大文件中。然后把这个大文件烧录进DVD版本。后续正式加载游戏的时候,高层逻辑正常加载这些小文件,但底层的IO接口,其实是顺序读那个预先生成的大文件。如果上层和下层的读文件顺序严格一致,那就可以非常快速的读完这个文件。用这类方法,如果用在随机寻道比较多的游戏引擎中,比如Unreal,可以提升读盘速度10-100倍,非常惊人。更进一步可以做的,就是在最后DVD刻录的时候,把这些大文件,尽量放在DVD的外道,因为外道的读盘速度会更快。
在我们的PC硬盘上,这个解决方法依然是非常有效的,但这个方法有几个缺点。一是它牺牲了读盘的安全性,我们的录制读盘顺序过程,和实际加载游戏过程中,读盘顺序必须完美一致,如果有不一致,游戏通常就会有不可知问题,至少也会crash。二是这个模式的开发流程更复杂,预先录制读盘顺序,需要遍历每个地图,耗时很长,稍有逻辑改动,又要重新做一遍,严重影响出版本的效率。三是这个模式应用受限,无法用在可变顺序的加载上。比如我们网游随时要按照地图块来进行streaming,我们很难预测玩家会如何移动,需要加载怎么样的场景块,这就大大限制了他的应用范围。
当然整个思路还是可用的,如果我们能把加载数据分割成更小的块,通过合适的管理,也能在效率和灵活性上达成一定的妥协。
IO效率只是加载效率的一部分问题。另一个就是
解析处理的效率。
对于文本类型的数据文件,比如XML、Json,读进来只是很小的一部分工作,当内容到了内存,就要考虑如何处理。这些文件都要在内存中被处理,建立DOM树,不同的处理库的快慢,显然对效率有极大的影响。
简单的库处理解析内容,对每一个需要生成的节点,都会用动态内存分配空间,或是动态分配字符串内容。这虽然简单,但并不高效。高端一点的库,比如Rapidxml,大量用了in placement的new,直接在需要解析内容的内存块上直接构建内容,对程序员来说,需要了解,这块内存已经被DOM树使用了,内存管理也特别小心,某种意义上,整个实现变得更dirty,高层也需要知道这些内存管理的细节。但效率的确极高。游戏开发,往往需要打破常规,做更多违反良好软件工程实践的优化,提高效率。
Milo受到了Rapidxml的启发,在周末写了一个RapidJson,速度极快,号称只需要strlen的一倍时间就可以解析完同等长度的Json文件。封装成公共组件后给大家用,有些同学会抱怨说这个API用起来不太顺手,需要先分配好内存才能在内存上构建这个DOM树,无法直接扔进去stream,从而得到新生成的DOM树。然而这是没有办法的选择,in placement的生成内容,自然需要上层帮助管理这块内存,为了运行效率,自然带来了额外的开发复杂度。
解析完对象,还需要实际在引擎中生成相应的对象。根据不同的引擎,生成效率也各不相同。最简单的方法可以在多线程加载的时候直接生成相关的对象,管理逻辑非常简单清晰。但这个方式有几个缺点,多线程的时候,生成一个新对象未必是线程安全的,需要很多保护,有些时候甚至是不可能做到的。另一个原因是不容易控制生成的效率,如果某一刻突然有大量的对象需要生成,就会block住加载逻辑非常久。
好一点的做法是把所有需要生成的对象放进一个队列。然后另外在主线程写处理代码,在线程安全的时候从队列里面拿出对象一一处理。这样我们就解决了线程安全问题。至于另一个生成效率问题,既然我们已经把对象解析和生成的逻辑分开了,那么什么时候生成对象已经变得相当灵活,我们完全可以给定一个固定的时间budget,每一帧只花2ms生成对象,所有来不及处理的对象放到下一帧处理即可。分割问题到更可控的小规模,是解决问题百试不爽的好办法。
下一回我们聊聊资源的组织管理方式,以及资源的格式问题,最后简单介绍一下天刀的一些资源管理取舍。