ModularFeature:为UE4集成ZSTD压缩算法

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
// Runtime/Core/Private/Features/ModularFeatures.cpp
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数据成员。

具体的流程为:

  1. 在UE的Module加载时把我们所写的ModularFeature注册到IModularFeatures中;
  2. 在使用的时候就可以根据我们的ModularFeature类别的名字来查找某个ModularFeature的所有实现;
  3. 之后就可以通过自己定义的接口来调用ModularFeature实现的功能了。

注意IModularFeatures::GetModularFeature是通过模板实现的,可以做到直接获取到某个Feature类别的具体类型。

指定压缩算法

首先先来看一下UE中怎么替换打包时使用的压缩算法:

打开Project Settings找到Packing-Pak File Compression Format(s)

它是一个FString类型,可以输入一串字符串,使用逗号分隔,如果指定了多个,则该列表将按优先级顺序排列,并在格式错误或不可用(未启用插件等)的情况下回退到其他格式。

该字符串用于传递给UnrealPak-compressionformats=参数。

1
2
3
4
5
6
// Programs/AutomationTool/Scripts/CopyBuildToStagingDirectory.Automation.cs
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
// Developer/PakFileUtilities/Private/PakFileUtilities.cpp
FString DesiredCompressionFormats;
// look for -compressionformats or -compressionformat on the commandline
if (FParse::Value(CmdLine, TEXT("-compressionformats="), DesiredCompressionFormats) || FParse::Value(CmdLine, TEXT("-compressionformat="), DesiredCompressionFormats))
{
TArray<FString> Formats;
DesiredCompressionFormats.ParseIntoArray(Formats, TEXT(","));
for (FString& Format : Formats)
{
// look until we have a valid format
FName FormatName = *Format;
if (FCompression::IsFormatValid(FormatName))
{
CmdLineParameters.CompressionFormats.Add(FormatName);
break;
}
}
}
// make sure we can always fallback to zlib, which is guaranteed to exist
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)
{
// is this format the right one?
if (CompressionFormat->GetCompressionFormatName() == FormatName)
{
// remember it in our format map
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
// Runtime/Core/Public/Misc/ICompressionFormat.h
#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中能够顺利的编译过的时候可以进入下一个流程,创建并实现ZSTDICompressionFormat实现。

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); /*!< maximum compressed size in worst case single-pass scenario */

通过查看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));
// we cannot safely go over the BufferSize needed!
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时,其中的过程时分了几个阶段:

  1. 编译工程及所依赖插件的代码
  2. Cook工程中的资源
  3. 使用UnrealPak.exe对Cook的资源进行打包
  4. 拷贝打包结果

因为我集成了zstd就是要在对Cook资源进行打包时使用的,项目设置中的Package-PakFileCompressionFormat(s)就是在这个阶段起的作用。

但是,我创建的插件是放在工程中的,UnrealPak作为一个独立的程序并不会加载这个插件,那么十分尴尬,是不能直接使用的,要对UnrealPak进行修改,让UnrealPak在启动时会去加载libzstd插件并注册到IModularFeature中,因为UnrealPak是属于Programs的,所以编译它需要源码版引擎

具体做法如下:

  1. 将插件放入引擎的Engine\Plugins\Runtime目录下
  2. 运行引擎目录下的GenerateProjectFiles.bat
  3. 编辑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
// Engine\Source\Programs\UnrealPak\Private\UnrealPak.cpp
#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()
{

// start up the main loop
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。

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:ModularFeature:为UE4集成ZSTD压缩算法
文章作者:查利鹏
发布时间:2020年04月20日 21时52分
本文字数:本文一共有5.1k字
原始链接:https://imzlp.com/posts/8470/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!