UE在打包时默认使用Zlib
作为资源的压缩算法,但是从压缩率和解压速度来看它并不是最好的选择,可以从Squash Compression Benchmark 去看各种压缩算的效率对比,我选择了facebook开源的ZStandard 作为替换Zlib的压缩实现,因为ZSTD在保证压缩比的同时还具有不错的解压效率。 本篇文章并不只是讲怎么在UE里集成一个压缩算法,还会简单介绍一下UE里的一些功能的模块化组织方式——ModularFeature
,使用这种方式可以比较方便地替换某些功能的实现,本文中的替换压缩算法是一个实践。
我在UE中集成ZSTD 的方式是写了一个插件,源码集成,开源在Github上:hxhb/ue-zstd ,支持Android和Windows、IOS以及MacOS,欢迎Star。
写在前面 其实ZStandard 在考虑压缩速度的情况下和zlib的压缩比是差不多的(zstd的compression level为10
左右),但是zstd解压更快。zstd的压缩level为1~22
,默认情况下为3,我写的插件中默认Level为10
,可以通过启动引擎时指定-zstdlevel=
参数来指定(如果编译了UnrealPak也可以通过修改项目设置的Pacakge
-PakFileCompressionCommandlineOptions
的-zstdlevel=
参数来指定)。
ModularFeature UE中所有的ModularFeature的实现都要继承自IModularFeature
,它内部没有任何成员,只是作为一个通用类型。可以从ModularFeature
的组织方式由IModularFeatures
类来管理(默认实现为FModularFeatures
),它提供了注册/取消注册/根据名字获取IModularFeature
的实例,它本质上实现的功能是把一个Name与一堆实现对应起来,比如UE内的压缩算法都是属于COMPRESSION_FORMAT_FEATURE_NAME
的,它对应这一个数组,里面的每一个元素都是一个压缩算法的实现,这样就可以根据我们的需求来随意指定使用哪一个压缩算法。
1 2 3 4 5 6 7 void FModularFeatures::RegisterModularFeature ( const FName Type, IModularFeature* ModularFeature ) { ModularFeaturesMap.AddUnique ( Type, ModularFeature ); ModularFeatureRegisteredEvent.Broadcast ( Type, ModularFeature ); }
可以看到注册一个ModularFeature
就是往一个Map里添加元素,注意ModularFeaturesMap
不是普通的TMap
,它是TMiltiMap
,允许一个key对应多个value,它是FCompression
的static数据成员。
具体的流程为:
在UE的Module加载时把我们所写的ModularFeature
注册到IModularFeatures
中;
在使用的时候就可以根据我们的ModularFeature
类别的名字来查找某个ModularFeature
的所有实现;
之后就可以通过自己定义的接口来调用ModularFeature
实现的功能了。
注意IModularFeatures::GetModularFeature
是通过模板实现的,可以做到直接获取到某个Feature类别的具体类型。
指定压缩算法 首先先来看一下UE中怎么替换打包时使用的压缩算法:
打开Project Settings
找到Packing
-Pak File Compression Format(s)
: 它是一个FString类型,可以输入一串字符串,使用逗号分隔,如果指定了多个,则该列表将按优先级顺序排列,并在格式错误或不可用(未启用插件等)的情况下回退到其他格式。
该字符串用于传递给UnrealPak
的-compressionformats=
参数。
1 2 3 4 5 6 string CompressionFormats = "" ;if (PlatformGameConfig.GetString("/Script/UnrealEd.ProjectPackagingSettings" , "PakFileCompressionFormats" , out CompressionFormats)){ CompressionFormats = " -compressionformats=" + CompressionFormats; }
在模块PakFileUtilities
中,默认使用的是ZLib
压缩算法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 FString DesiredCompressionFormats; if (FParse::Value (CmdLine, TEXT ("-compressionformats=" ), DesiredCompressionFormats) || FParse::Value (CmdLine, TEXT ("-compressionformat=" ), DesiredCompressionFormats)){ TArray<FString> Formats; DesiredCompressionFormats.ParseIntoArray (Formats, TEXT ("," )); for (FString& Format : Formats) { FName FormatName = *Format; if (FCompression::IsFormatValid (FormatName)) { CmdLineParameters.CompressionFormats.Add (FormatName); break ; } } } CmdLineParameters.CompressionFormats.AddUnique (NAME_Zlib);
如果指定了其他的算法会通过ICompressionFormat* FCompression::GetCompressionFormat(FName FormatName, bool bErrorOnFailure)
来获取到真正执行压缩的算法。可以看它的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ICompressionFormat* FCompression::GetCompressionFormat (FName FormatName, bool bErrorOnFailure) { ICompressionFormat** ExistingFormat = CompressionFormats.Find (FormatName); if (ExistingFormat == nullptr ) { TArray<ICompressionFormat*> Features = IModularFeatures::Get ().GetModularFeatureImplementations <ICompressionFormat>(COMPRESSION_FORMAT_FEATURE_NAME); for (ICompressionFormat* CompressionFormat : Features) { if (CompressionFormat->GetCompressionFormatName () == FormatName) { ExistingFormat = &CompressionFormats.Add (FormatName, CompressionFormat); break ; } } if (ExistingFormat == nullptr ) { if (bErrorOnFailure) { UE_LOG (LogCompression, Error, TEXT ("FCompression::GetCompressionFormat - Unable to find a module or plugin for compression format %s" ), *FormatName.ToString ()); } else { UE_LOG (LogCompression, Display, TEXT ("FCompression::GetCompressionFormat - Unable to find a module or plugin for compression format %s" ), *FormatName.ToString ()); } return nullptr ; } } return *ExistingFormat; }
可以看到是从IModularFeatures::Get().GetModularFeatureImplementations<ICompressionFormat>
获取所有的FeatureImplementation
的,如果想要自己添加压缩算法,可以写个插件加到里面,就可以通过名字来使用压缩算法了。
添加的方法最前面已经提到过了,要通过IModularFeature::RegisterModularFeature
来注册一个Feature
:
1 IModularFeatures::Get ().RegisterModularFeature (COMPRESSION_FORMAT_FEATURE_NAME,ICompressionFormatPointer)`
这里COMPRESSION_FORMAT_FEATURE_NAME
是一个宏,就是一个字符串CompressionFormat
,标识一组Feature
的类型。ICompressionFormatPointer
就是自己想要添加的压缩算法的封装对象的指针,该对象需要继承自ICompressionFormat
。
1 2 3 4 5 6 7 8 9 10 #define COMPRESSION_FORMAT_FEATURE_NAME "CompressionFormat" struct ICompressionFormat : public IModularFeature, public IModuleInterface{ virtual FName GetCompressionFormatName () = 0 ; virtual bool Compress (void * CompressedBuffer, int32& CompressedSize, const void * UncompressedBuffer, int32 UncompressedSize, int32 CompressionData) = 0 ; virtual bool Uncompress (void * UncompressedBuffer, int32& UncompressedSize, const void * CompressedBuffer, int32 CompressedSize, int32 CompressionData) = 0 ; virtual int32 GetCompressedBufferSize (int32 UncompressedSize, int32 CompressionData) = 0 ; };
另外,在项目设置中还可以使用PakFileCompressionCommandlineOptions
来控制压缩算法的参数,在CopyBuildToStagingDirectory.Automation.cs
中有把这个参数传递给UnrealPak
,这个暂时按下不表,后面会讲这个东西怎么用。
集成ZSTD 上面写到了,如果想要自己添加一个压缩算法的实现则需要自己实现一个继承自ICompressionFormat
的类,然后注册给IModulelarFeatures
,那么以ZSTD为例,来示范一下怎么真实地添加一个压缩算法。
首先还是要来介绍一下ICompressionFormat
中提供的四个接口函数的语义要求:
1 2 3 4 5 6 7 8 9 10 11 struct ICompressionFormat : public IModularFeature, public IModuleInterface{ virtual FName GetCompressionFormatName () = 0 ; virtual bool Compress (void * CompressedBuffer, int32& CompressedSize, const void * UncompressedBuffer, int32 UncompressedSize, int32 CompressionData) = 0 ; virtual bool Uncompress (void * UncompressedBuffer, int32& UncompressedSize, const void * CompressedBuffer, int32 CompressedSize, int32 CompressionData) = 0 ; virtual int32 GetCompressedBufferSize (int32 UncompressedSize, int32 CompressionData) = 0 ; };
然后开干,集成ZSTD首先需要去facebook/zstd 上把代码拉取下来,然后把代码提取出来(Lib目录下除了dll
目录外都可以拷贝过来),放到插件的Souce/ThirdParty
下,并在插件的*.build.cs
中将其添加至PublicIncludePaths
中。
首先先要对ZSTD
的代码进行修改,因为UE的编译环境和警告等级的关系是没办法把代码拷过来就可以直接编译过的,常见的操作为忽略某些警告,但是ZSTD
有一点特别的地方在于它里面具有XXHash
的代码在编译时会与LiveCoding
中的有冲突会有重定义错误,所以需要对ZSTD
代码中的XXHash
进行改名。
当把ZSTD的代码在UE中能够顺利的编译过的时候可以进入下一个流程,创建并实现ZSTD
的ICompressionFormat
实现。
1 2 3 4 5 6 7 8 9 struct FZstdCompressionFormat : public ICompressionFormat{ virtual FName GetCompressionFormatName () override ; virtual bool Compress (void * CompressedBuffer, int32& CompressedSize, const void * UncompressedBuffer, int32 UncompressedSize, int32 CompressionData) override ; virtual bool Uncompress (void * UncompressedBuffer, int32& UncompressedSize, const void * CompressedBuffer, int32 CompressedSize, int32 CompressionData) override ; virtual int32 GetCompressedBufferSize (int32 UncompressedSize, int32 CompressionData) override ; static int32 Level; };
只是继承了ICompressionFormat
然后添加了一个Level
的static数据成员,用于记录在ZSTD中使用哪个压缩级别。
剩下的事情就是从ZSTD的代码里找到能够实现ICompressionFormat
接口语义的函数:
1 2 3 ZSTDLIB_API size_t ZSTD_compress ( void * dst, size_t dstCapacity,const void * src, size_t srcSize,int compressionLevel) ;ZSTDLIB_API size_t ZSTD_decompress ( void * dst, size_t dstCapacity,const void * src, size_t compressedSize) ;ZSTDLIB_API size_t ZSTD_compressBound (size_t srcSize) ;
通过查看ZSTD的代码可以发现这三个函数组合起来就可以实现ICompressionFormat
中的所有功能,比较简单,都是转发调用: PS:通过实现GetCompressionFormatName
来指定该压缩Feature的名字,我这里给了zstd
,在项目设置里指定的时候就要使用这个名字。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 FName FZstdCompressionFormat::GetCompressionFormatName () { return TEXT ("zstd" ); } bool FZstdCompressionFormat::Compress (void * CompressedBuffer, int32& CompressedSize, const void * UncompressedBuffer, int32 UncompressedSize, int32 CompressionData) { UE_LOG (LogTemp, Log, TEXT ("FZstdCompressionFormat::Compress level is %d" ), FZstdCompressionFormat::Level); int32 Result = ZSTD_compress (CompressedBuffer, CompressedSize, UncompressedBuffer, UncompressedSize, FZstdCompressionFormat::Level); if (Result > 0 ) { if (Result > GetCompressedBufferSize (UncompressedSize, CompressionData)) { FPlatformMisc::LowLevelOutputDebugStringf (TEXT ("%d < %d" ), Result, GetCompressedBufferSize (UncompressedSize, CompressionData)); return false ; } CompressedSize = Result; return true ; } return false ; } bool FZstdCompressionFormat::Uncompress (void * UncompressedBuffer, int32& UncompressedSize, const void * CompressedBuffer, int32 CompressedSize, int32 CompressionData) { int32 Result = ZSTD_decompress (UncompressedBuffer, UncompressedSize, CompressedBuffer, CompressedSize); if (Result > 0 ) { UncompressedSize = Result; return true ; } return false ; } int32 FZstdCompressionFormat::GetCompressedBufferSize (int32 UncompressedSize, int32 CompressionData) { return ZSTD_compressBound (UncompressedSize); }
到这里ZSTD
的的集成工作就完毕了,只剩下最后一步,那就是把这个Feature添加到IModularFeatures
中,可以供引擎使用。
因为我是创建了一个插件,所以可以把注册的逻辑写到模块的StartupModule
中,反之卸载模块时取消注册。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #define ZSTD_LEVEL_OPTION_STRING TEXT("-ZstdLevel=" ) void FlibzstdModule::StartupModule () { FString CommandLine = FCommandLine::Get (); if (CommandLine.Contains (ZSTD_LEVEL_OPTION_STRING, ESearchCase::IgnoreCase)) { int32 level; FParse::Value (FCommandLine::Get (), *FString (ZSTD_LEVEL_OPTION_STRING).ToLower (), level); FZstdCompressionFormat::Level = FMath::Clamp (level, ZSTD_minCLevel (),ZSTD_maxCLevel ()); } ZstdCompressionFormat = new FZstdCompressionFormat (); IModularFeatures::Get ().RegisterModularFeature (COMPRESSION_FORMAT_FEATURE_NAME, ZstdCompressionFormat); } void FlibzstdModule::ShutdownModule () { IModularFeatures::Get ().UnregisterModularFeature (COMPRESSION_FORMAT_FEATURE_NAME, ZstdCompressionFormat); delete ZstdCompressionFormat; }
与前面讲的注册方法一致,我这里还添加了一个引擎启动时的命令行参数-ZstdLevel=
可以用来传递使用ZSTD
进行压缩的的压缩等级。
打包Pak使用ZSTD
算法,使用我的HotPatcher 可以在UnrealPakOptions
中添加-compressionformats=zstd,zlib
参数:
使用UnrealPak
检测压缩格式:
注意,因为没有编译引擎,所以是不能直接通过UnrealPak.exe
来解压使用ZSTD
压缩的Pak的。
如何使用 如果是在UE的编辑器中直接调用PakFileUtilities
模块中的ExecuteUnrealPak
函数,则只需要工程启用这个插件即可,类似于我HotPatcher 的方式。
但是UE在编辑器中直接打包(包括使用ProjectLauncher
)都是启动另外的进程来执行的,不会加载这个插件,会有问题,所以也写一下解决办法。
运行时使用 运行时使用zstd
的方法就是在引擎中拿到我们注册的zstd
的对象,然后使用其调用ICompressionFormat
的那几个接口。
可以通过FCompression
来获取:
1 ICompressionFormat* ZstdCompressionFormat = FCompression::GetCompressionFormat (TEXT ("zstd" ),true );
拿到之后就可以调用Compress
/Uncompress
等接口来实现压缩/解压了。
修改UnrealPak 当我们在编辑器中使用File
-Package Project
-PLATFORM
时,其中的过程时分了几个阶段:
编译工程及所依赖插件的代码
Cook工程中的资源
使用UnrealPak.exe对Cook的资源进行打包
拷贝打包结果
因为我集成了zstd
就是要在对Cook资源进行打包时使用的,项目设置中的Package
-PakFileCompressionFormat(s)
就是在这个阶段起的作用。
但是 ,我创建的插件是放在工程中的,UnrealPak作为一个独立的程序并不会加载这个插件,那么十分尴尬,是不能直接使用的,要对UnrealPak进行修改,让UnrealPak在启动时会去加载libzstd
插件并注册到IModularFeature
中,因为UnrealPak
是属于Programs
的,所以编译它需要源码版引擎 。
具体做法如下:
将插件放入引擎的Engine\Plugins\Runtime
目录下
运行引擎目录下的GenerateProjectFiles.bat
编辑UnrealPak的代码,添加libzstd
模块依赖,并在主函数中动态加载模块;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include "UnrealPak.h" #include "RequiredProgramMainCPPInclude.h" #include "PakFileUtilities.h" #include "IPlatformFilePak.h" #include "libzstd.h" IMPLEMENT_APPLICATION (UnrealPak, "UnrealPak" );INT32_MAIN_INT32_ARGC_TCHAR_ARGV (){ GEngineLoop.PreInit (ArgC, ArgV); FlibzstdModule& LibZstdModule = FModuleManager::LoadModuleChecked <FlibzstdModule>(FName ("libzstd" )); double StartTime = FPlatformTime::Seconds (); int32 Result = ExecuteUnrealPak (FCommandLine::Get ())? 0 : 1 ; UE_LOG (LogPakFile, Display, TEXT ("Unreal pak executed in %f seconds" ), FPlatformTime::Seconds () - StartTime ); GLog->Flush (); FEngineLoop::AppPreExit (); FEngineLoop::AppExit (); return Result; }
也可以看我修改过之后的UnrealPak的代码:UnrealPak_422Source.7z
然后将UnrealPak编译为Development
,编译完成之后的UnrealPak.exe
就是可以使用zstd
压缩/解压功能了。
也可以可以将这个UnrealPak.exe
放入安装版引擎,同样可以使用。
附上我编译好的支持ZSTD的UnrealPak(UE4.22.3):UnrealPak422_ZSTD
然后就可以在项目设置中Package
-PakFileCompressionFormat(s)
中指定zstd
了:
给zstd传递参数 还记得前面按下不表的项目设置中Package
-PakFileCompressionCommandlineOptions
吗?
这个参数就是可以传递给UnrealPak.exe
的命令行参数,在这里填写的内容都会转发给UnrealPak.exe
,而我修改之后的UnrealPak
在启动时会去加载libzstd
模块,那么我们就可以在libzstd
模块的StartupModule
通过分析命令行的输入来控制压缩的各种参数(如压缩等级):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #define ZSTD_LEVEL_OPTION_STRING TEXT("-ZstdLevel=" ) void FlibzstdModule::StartupModule () { FString CommandLine = FCommandLine::Get (); if (CommandLine.Contains (ZSTD_LEVEL_OPTION_STRING, ESearchCase::IgnoreCase)) { int32 level; FParse::Value (FCommandLine::Get (), *FString (ZSTD_LEVEL_OPTION_STRING).ToLower (), level); FZstdCompressionFormat::Level = FMath::Clamp (level, ZSTD_minCLevel (),ZSTD_maxCLevel ()); } UE_LOG (LogTemp, Log, TEXT ("FZstdCompressionFormat::Compress level is %d" ), FZstdCompressionFormat::Level); ZstdCompressionFormat = new FZstdCompressionFormat (); IModularFeatures::Get ().RegisterModularFeature (COMPRESSION_FORMAT_FEATURE_NAME, ZstdCompressionFormat); }
这样就可以在项目设置中的PakFileCompressionCommandlineOptions
填写-zstdlevel=22
来控制要使用的压缩等级了。
注意,Package
-PakFileCompressionCommandlineOptions
在默认情况下是没用的。当没有添加其他的压缩算法并使用我上面写的这种获取参数方式时,这里填写的东西不会产生任何作用。
后记 基本上用这种方式可以自己集成其他的压缩算法,如lz4 等。有时间再来写ZSTD在打包时的压缩比和性能分析。
UPDATE 2021.04.01,UE宣布要在UE4.27和UE5中加入RAD的Oodle压缩算法集成至UE,虽然4.27还未发布,但已经有代码和链接库上传,我把Oodle抽离了出来,并且修复了在低版本引擎中的错误,可以直接在低版本引擎中使用。
详细的内容请看:
使用方法可以参考本篇文章中的内容,通过命令行参数-compressionformats=Oodle
来指定Oodle,以及-compresslevel=Leviathan
来指定压缩级别,经过测试,一个使用zlib压缩之后有295M的pak文件使用Oodle压缩降低到了282M,但是Oodle的解压速度完胜zlib。