一个大型的商业游戏包含很多资源,如图像、声音、文本、脚本和其他各种类型的数据,为游戏提供一个完整和高效的资源管理系统(包括SDK与编辑器)是游戏引擎开发商必须完成的工作。我们在Numen Game Engine 2.0中实现了一个底层资源打包接口,在接口的基础上实现了纹理编辑器、模型编辑器、单位编辑器和建筑物编辑器等工具,整个系统采用32位的ID来标识资源,具有一级分类的能力,支持资源包的各种常用操作。
但是在Numen Game Engine 3.0中,我们为了实现一个完美的资源打包系统,完全放弃了以前的所有接口,采用更多新的技术,最终构建了一个功能强大,性能高效的资源管理接口,这篇文章中,我们将从资源编辑器的特性简介、关键技术和资源编辑器功能三个方面,介绍构建一个商业级资源打包系统需要掌握的关键技术和实现细节。
一、特性简介
Numen Pack Editor 2.0支持以下主要功能特性:
1、创建、打开和关闭资源包。
2、压缩资源包。消除数据添加、修改和删除带来的数据碎片,扩展索引表容量,重设资源包密钥。
3、合并资源包。将外部的资源包整合进当前打开的资源包。
4、释放资源文件。将资源包内的任意文件释放到磁盘。
5、创建文件夹。
6、(批量)添加文件。可以指定文件的独立密钥与压缩选项。
7、从文件夹添加数据(包括文件夹与文件)。可以指定文件的独立密钥与压缩选项。
8、(批量)更新文件。可以选择按列表顺序与按文件名两种方式进行数据更新。
9、(批量)复制、剪切、粘贴、删除和重命名单元。
10、查看和修改资源属性。
资源编辑器的功能实现主要依托于底层SDK提供的接口,同时,细致周到的用户界面设计和大量方便的组合功能,是编辑器使用层次提高的关键。 Numen Pack Editor 2.0在设计中,仅界面的设计就花费了大量时间,期间主要是对各种细节进行反复测试和修改。
二、关键技术
总的来说,为了确保资源管理系统的高效,需要实现散列表、目录树、存储空间管理、加密/解密和数据压缩五个主要的接口,但是要实现完整的资源系统,还需要其他大量的基础函数,Numen Game Engine 2.0升级到3.0后,只需要实现前三项技术即可完成整个系统的构建。
(一)散列表
众所周知,散列表的出现就是为了解决字符串或非简单数据类型(如32位整数)的索引。一般来说,首先将需要索引的数据进行计算,产生一个32位的索引值,好的算法可以使这个索引值分布比较平均,减少产生冲突的可能性;插入操作时,用这个索引值与索引表大小进行模运算,获得它在索引表的位置,如果计算出的位置为空,则直接插入元素,否则将进行冲突处理;查询操作时,先在计算出的第一个索引表位置进行查找,发现元素则返回,否则继续进行查找;删除操作同查询操作,只是找到元素后会给元素打一个删除标记。
我们有很多的散列算法可用,这里推荐暴雪在MPQ中开发的索引算法,其实该算法最大的好处在于不在散列表中存储要索引的数据,这样在比较操作时就不需要进行长数据的比较,也可以节约存储空间(后面会说到这个只对索引表有直接益处),举例来说:我们要插入一个字符串”e\world\texture\char\tank.png”(长度29字符),如果我们在索引表中存储完整的字符串,最理想的情况下我们也需要比较29个字符长度的数据,如果发生冲突,还将进行更多的比较。
在MPQ中引入了两个额外的32位索引值来提供比较,这样在任何情况下都不会进行长数据的比较,如上面那个字符串,我们得到索引值dwHash,计算两个参考值dwCheck1和dwCheck2,这样每次元素比较最多只需进行3次32位整数值的数据比较。值得一提的是,这个算法也并不是万能的,在实际应用中,如果我们不需要大量长索引,那么普通算法的劣势将不明显,同时还将节约dwCheck1和dwCheck2的计算时间。
另一个关键就是如何在索引表中放置冲突元素,一种方法是用链,另一种方法使用顺序,在MPQ中使用的是顺序存储,这样做的好处是实现简单,但是在最坏情况下,查找一个元素可能将遍历一次索引表,但是在正常游戏中,一般不会查询错误的单元,所以这样的情况很少出现。顺便提一下,在将冲突数据进行链的处理时,由于索引表需要存储在文件中,所以这里链需要改造成索引链(如下一个位置是15),而不是采用内存链表。
(二)目录树
在资源包中我们对每个单元的全路径进行索引,如e\texture \tank.png,那么在资源管理器中我们不可能通过遍历索引表来构建整个目录树(在1万个以上项目时速度将会明显变慢),而且我们的索引表也不存储任何名称信息。这就需要我们实现一个类似文件树的接口,它将保存所有的目录信息。
在Numen Game Engine 3.0中,我们用链表实现了目录树,目录树的单元数据结构如下:
struct FILETREEITEM
{
FILETREEITEM* pPrevItem; // 前一个单元
FILETREEITEM* pNextItem; // 后一个单元
FILETREEITEM* pChildItem; // 子单元
FILETREEITEM* pParentItem; // 父单元
DWORD dwHashIndex; // 索引序号
int nNameLength; // 名称长度
WCHAR strItemName[1]; // 单元名称,不包含路径.(可变长度)
};
采用“上下左右”四个单元指针的目的是为了提高插入的效率和使单元访问更容易,Numen引擎中用了一个小技巧,就是每个单元的第一个子单元(包括根单元),它的pPrevItem指向的是本级最后一个单元,这样做是为了使插入操作能进行后序插入,普通情况下,要实现这个目的,需要提供一个额外的 pLastItem指针。
设计目录树时的难点在于指针的正确处理,如果说单向链表很简单,双向链表并不难,那第一次设计这种属性链表时一定不会一帆风顺的。另外还需要注意一些操作影响的不仅仅是一个单元,还将影响所有的子单元。
使用可变长度的单元名称将节约大量内存空间。
为了使各种操作有更多的可定制性,我们还需要在对目录树的各种操作中加入回调函数,如下所示:
// 文件操作参数
#define FILETREEOP_RENAMEITEM 0x00000001 // 重命名单元
#define FILETREEOP_COPYITEM 0x00000002 // 复制单元
#define FILETREEOP_MOVEITEM 0x00000004 // 移动单元
#define FILETREEOP_REMOVEITEM 0x00000008 // 删除单元
#define FILETREEOP_OVERWRITE 0x00000010 // 覆盖操作
#define FILETREEOP_INTERNALCREATE 0x00000020 // 内部创建
// 文件树操作回调函数
// 参数: -strSrcName 源单元名称
// -strDstName 目标单元名称
// -dwFlags 文件操作参数,参考FILETREEOP宏定义.
// -lpUserData 用户自定义数据
// 返回: 0: 执行单元操作
// 其他: 放弃单元操作
typedef LRESULT (CALLBACK *LPCBFILETREEOP)( LPCWSTR strSrcName, LPCWSTR strDstName, DWORD dwFlags, LPVOID lpUserData );
有了这些回调函数,这样我们就可以非常灵活的实现我们的资源管理器了。
最后值得一提的是,目录树也是实现采用检验值方式的索引表进行重构的基础,因为没有存储索引信息的索引表,是不可能改变自身大小的,必须通过外部记录的索引信息,将索引表扩容后,进行索引重建。
(三)存储空间管理
对操作系统比较熟悉的人都知道,文件系统在长时间使用后会产生大量数据碎片,资源管理器也将面临这个问题。在Numen Game Engine 2.0中,我们只是简单的把任何新增的数据加入到资源包的结尾处,可以定期通过压缩资源包来消除空间浪费。
在 Numen Game Engine 3.0中,我们实现了一个空间管理类,这样任何空间的申请和释放都通过这个类来管理,这个类建立了32个单向链表,每个链表记录一定容量的空闲空间信息,如我们先后释放了4096,89,12589大小的存储空间,然后我们添加大小为70,4000,10000大小的数据,我们将可以申请到重复利用的空间,而不需要增加资源包的大小,同时未用完的空间将被加入到链表中等待分配。
正是由于存储空间管理器的引入,大大降低了数据的删除和修改操作对资源包大小产生的影响,在配合资源压缩功能,完全不必担心数据碎片带来的空间浪费。
(四)数据加密/解密
这个方面的内容可参考的教程和代码较多,算法的强度和效率也各有不同,所以开发商可以根据引擎定位和项目需求进行具体定制。
资源包中的加密分为文件头加密、索引表加密、目录树加密、数据块信息加密和数据加密,这些加密方式为资源包提供了完整和灵活的保护方案,用户可以根据数据的重要程度和读取效率需求自由定制加密方案。
(五)数据压缩
通过提供各种压缩算法,如Huffman,RLE,ZIP,LZW等,为引擎数据提供压缩服务,目前Numen Game Engine 3.0的资源包支持Huffman,RLE和LZW三种压缩算法选择,用户可以自己选择使用他们中的一种或多种对数据进行压缩。
三、资源编辑器功能
这里将对Numen Pack Editor 2.0中的部分功能进行图文说明:
(一)压缩数据包
这里为用户提供了改变索引表大小和资源包加密的机会,资源包索引将会对这个资源包进行重构,花费的时间根据资源包的数据大小,加密强度和压缩等级而定。
(二)合并资源包
合并资源包是为了团队配合制作资源而产生,比如美工进行分工后,可以自行制作自己的图形资源,并保存在自己的文件夹中,当资源需要进行整合时,可以统一汇总,而且每个资源包可以保持自己独立的密钥,使团队合作的层次得以体现。
(三)添加文件
如果不能批量添加文件到指定文件夹,那添加文件将会变成一件浪费人力的工作,同样我们也需要为一批添加的文件同时指定加密和压缩方案。
(四)添加文件夹的文件
这是一个十分重要的功能,当用户有一大批已经制作好的资源需要进行添加时,我们不需要手工建立所有的文件夹,只需要点击几次鼠标,便可将所有资源一次性加入到资源包中。
(五)更新文件
如果一个资源编辑器不具备强大和灵活的更新能力,那就不叫资源编辑器了。用户首先选择需要更新的文件,然后选择按序号或者命名更新,就可以一次性完成一批数据的更新操作,而且编辑器提供了更新确认对话框,所以用户不必担心误操作覆盖掉重要的数据。
(六)文件属性
如果文件加入资源包后,不能随时查看和编辑它的属性,这将是不可想象的灾难,因为我们根本无法预知未来的需求变化,比如添加时我们希望数据能快速的读取,所以没有给它加入加密和压缩,但是游戏发布后,我们希望保护我们的资源,这是利用文件属性对话框就可以轻易的实现这个目的。
四、结束语
Numen Pack Editor 2.0 已经比较完善的支持了资源包管理系统应具备的各种功能,而且在用户接口设计上非常注重细节,为用户进行资源处理提供了高效的手段。但是一切技术都是不断发展和完善的,现在的编辑器也需要在更多的项目实践中不断完善和发展,关于Numen Game Engine和Numen Software的更多信息请随时访问www.numenstudio.com进行查询。
最近实现了一个简单的文件打包格式。
一向很喜欢简单高效的东西,所以非必要的加密,压缩功能一概欠奉。反正也开源了,用户很容易自行添加。
文件以文件名的hash作为检索,为了防止hash冲突的情况,除了用来建立hash table的主hash以外,索引表里还另外保存了两个用不同算法算出来的hash值。这样一来,读取包内文件时并不需要原始的文件名信息,只要根据输入的文件名算出来的3个hash值都和hash table里保存的相应值一致,就基本可以认定是用户所需要的。当然,对于32bit的hash来说冲突的情况实际上很少,而计算hash也有一定开销(虽然和磁盘io相比基本可以忽略),如果包内文件又很少,可以只比较两个,甚至一个hash值。
包内文件采用简单的首尾相连的形式连续存放,索引表放在文件最后,这样在包内文件变化时能最小化写操作。删除文件时,并不把后面的数据前移,而是简单的留下空洞;添加文件时会优先利用之前留下的空洞。这样在多次添加/删除操作后可能会在包内留下一些无法再利用的小碎片,当用户觉得有必要进行整理时(例如碎片空间达到总大小的一定比例),可以调用zpak提供的defrag函数来整理。
虽然包文件看似具有目录结构(见上图),但实际上包内文件并没有以树状形式存储,目录信息只存在于文件名中。为方便起见,在zpack核心代码之外,提供了ZpExplorer类让用户可以以目录形式进行操作,比如删除目录,添加目录,解压目录等。
最终基本上达到了设计目的,无论从功能,接口,代码量上看都不大容易再简化了。原来计划只提供一个命令行工具的,后来也不能免俗,做了一个图中所示类似Windows Explorer的东西。
后记:
很多人向我提出以下几个问题(建议),这里统一解答一下
- 希望提供一个patch更新机制,方便用在网络游戏中
毫无疑问,网络游戏更新补丁时经常会需要添加或修改资源包里的文件,但这并不意味着patch机制应该成为打包格式的一部分。所谓更新,无非就是添加文件(目录),删除文件(目录),用新的文件代替旧的文件,这些zpack都已经提供了接口,还需要更多的功能吗?
另外有用户提出希望添加包文件合并的功能,也是为了补丁方便。我提出的替代方案是把一个包(比较小的那个)先释放到硬盘上,然后把所有文件/目录添加到另一个包里。虽然直接将包合并或许能快一些(因为可以节省一步中转),但我认为节省的这点时间和下载补丁的时间相比简直是九牛一毛。况且zpack还没有提供压缩功能,补丁包压成zip下载似乎更合算
不是。一个IPackage(以及打开的IFile)不能在多个线程同时使用,即使是只读的也不行。因为STL的fstream不是线程安全的。
如果需要多线程同时读取包内文件,可以每个线程分别调用zp::open,打开一份独占的IPackage实例,这样就不会互相影响了
- 用开源的打包格式,岂不是资源包里的东西都随便让人看了
没错。暴雪的mpq你都随便看了,你的游戏有什么需要藏着掖着的?
退一步说,如果希望不要太容易被用户看到包里的东西,可以自己改一下包格式。开源嘛,代码全都在你手里了。
我比较偏向的方案是,如果有需要保密的文件,比如比较敏感的脚本,数据表什么的,可以在打到包里之前就做好加密。
Comments
其实目前来说硬盘空间已经不是问题了,所以保留旧文件也未尝不可,采用版本机制,高版本的文件可以代替低版本文件,这样不需要对原有的压缩包进行变化。只是游戏资源并不是一个压缩包,而是一堆压缩包。
Posted by: none | (7) September 25, 2010 12:16 AM
以前网易的游戏就是这样一个特点,解包的时间比下载时间要长,不知道从什么时候开始,解包的时间几乎不用考虑了。
Posted by: liy | (6) August 26, 2010 05:39 PM
请问你们的数据压缩是用什么方法?zip吗?我不知道zip速度怎么样。我们的游戏数据太大了,普通的zip压缩能压倒20%大小,如果解压算法速度快的话,使用压缩数据肯定能提高一些装载速度
Posted by: 大脚 | (5) August 26, 2010 02:36 PM
因为以前正好做过类似的东西,我顺面说一下:
1.MPQ其实也可以支持将文件名嵌入包中,(仅仅使用一个特殊的文件名标志,以普通数据文件的方式保存)而在发布时将文件名从包中删除。
2.只要有合理的文件系统,按照独立打包发布的更新文件,叫什么名字其实无所谓,只要在检索的时候将更新的包放在前面就行。大的更新包通过这种方式更新,最后仅仅时读取文件时多浪费一次hash查找的时间(在mpq中就是3次hash一次查找)
3.内置的压缩在MPQ中早就有了,而且不是针对每个文件的,而是针对2/4K的一块数据,所以即使时要求随机读取数据(如文中说的地图),也是可以满足要求,只需要寻址到那个位置,同时解压当前块的数据即可。。。。。需要的话,继续解压下一块。。。
4.我做文件更新格式的时候就是按照文中说的抽小文件的方式,不过后来感觉效率的确比较低。。。。。。针对每个小文件的diff方式实在是好。。。呵呵
Posted by: JTianLing | (4) August 26, 2010 01:39 PM
95年OLE2的结构化存储就是用此类机制,内部实现了一个mini FAT
http://en.wikipedia.org/wiki/Compound_File_Binary_Format
Posted by: flier | (3) August 26, 2010 01:18 PM
学习了,谢谢博主分享
Posted by: 刘永 | (2) August 26, 2010 11:46 AM
太好了,前几天正在做这个呢,很好的参考