UE热更新:Create Shader Patch

之前的热更新系列文章中介绍了UE热更新的流程和打包细节,其实有一些热更补丁优化的工程实践我觉得也可以详细介绍。

本篇文章从生成Shader的Patch入手,目的减少每次热更新时的Shader的大小,并会对引擎内部的实现细节做一些分析,解决引擎中的Shader Patch的相关问题,并基于HotPatcher的实现自动化的Shade Patch流程。

2021.11.02 UPDATE:HotPatcher已支持对Patch中所有Cook资源编译的Shader收集起来打包为Shader Library,可以替代Shader Patch的机制。详情见文章:UE热更新:Shader更新策略

shaderbytecode的生成与加载

UE在Project Settings-Packaging中提供了Share Material Shader Code的选项,可以控制Shader的共享,存储为单独的文件中,减少包体的大小。

By default shader code gets saved inline inside material assets, enabling this option will store only shader code once as individual files This will reduce overall package size but might increase loading time.

当开启这个选项之后打包项目,包内的Content目录下会生成以下两个ushaderbytecode文件:

打包的Pak中的Mount Point为:

1
../../../PROJECT_NAME/Content/

而且ushaderbytecode文件的命名根据以下规则:

Runtime/RenderCore/Private/ShaderCodeLibrary.cpp
1
2
3
4
5
6
7
8
static FString ShaderExtension = TEXT(".ushaderbytecode");
static FString StableExtension = TEXT(".scl.csv");
static FString PipelineExtension = TEXT(".ushaderpipelines");

static FString GetCodeArchiveFilename(const FString& BaseDir, const FString& LibraryName, FName Platform)
{
return BaseDir / FString::Printf(TEXT("ShaderArchive-%s-"), *LibraryName) + Platform.ToString() + ShaderExtension;
}

ushaderbytecode在引擎启动时会自动加载,但是注意Global和项目两者加载时机的区别:

Runtime/Launch/Private/LaunchEngineLoop.cpp
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
37
38
39
40
41
42
43
44
45
46
47
48
49
int32 FEngineLoop::PreInitPreStartupScreen(const TCHAR* CmdLine)
{
// ...
{
bool bUseCodeLibrary = FPlatformProperties::RequiresCookedData() || GAllowCookedDataInEditorBuilds;
if (bUseCodeLibrary)
{
{
SCOPED_BOOT_TIMING("FShaderCodeLibrary::InitForRuntime");
// Will open material shader code storage if project was packaged with it
// This only opens the Global shader library, which is always in the content dir.
FShaderCodeLibrary::InitForRuntime(GMaxRHIShaderPlatform); // 加载Global的ushaderbytecode
}

#if !UE_EDITOR
// Cooked data only - but also requires the code library - game only
if (FPlatformProperties::RequiresCookedData())
{
SCOPED_BOOT_TIMING("FShaderPipelineCache::Initialize");
// Initialize the pipeline cache system. Opening is deferred until the manual call to
// OpenPipelineFileCache below, after content pak's ShaderCodeLibraries are loaded.
FShaderPipelineCache::Initialize(GMaxRHIShaderPlatform);
}
#endif //!UE_EDITOR
}
}
// ...
}

int32 FEngineLoop::PreInitPostStartupScreen(const TCHAR* CmdLine)
{
// ...
//Handle opening shader library after our EarlyLoadScreen
{
LLM_SCOPE(ELLMTag::Shaders);
SCOPED_BOOT_TIMING("FShaderCodeLibrary::OpenLibrary");

// Open the game library which contains the material shaders.
FShaderCodeLibrary::OpenLibrary(FApp::GetProjectName(), FPaths::ProjectContentDir()); // 加载项目的ushaderbytecode
for (const FString& RootDir : FPlatformMisc::GetAdditionalRootDirectories())
{
FShaderCodeLibrary::OpenLibrary(FApp::GetProjectName(), FPaths::Combine(RootDir, FApp::GetProjectName(), TEXT("Content")));
}

// Now our shader code main library is opened, kick off the precompile, if already initialized
FShaderPipelineCache::OpenPipelineFileCache(GMaxRHIShaderPlatform);
}
// ...
}

综上所述,引擎打包对于Shader的处理需要注意以下两点:

  1. 打包项目是会只把当前打包的资源的Shader编译到ushaderbytecode中
  2. 引擎启动时会自动加载

所以,如果我们进行热更新资源时有shader的变动或者新增了,如果不把shaderbytecode打包进来,会导致有些资源没有效果,如图:

Log中的错误:

这就需要我们去处理更新Shader的情况。

Shader的热更新

根据前面一小节的介绍,我们知道了打包时会自动把所打包资源的Shader生成到ushaderbytecode文件中,当我们热更时新增了材质,需要把新的shader文件给打到pak中,在运行时加载,不然会丢失材质效果。

HotPatcher中提供了包含shaderbytecode的选项,会把Cook之后最新生成的ushaderbytecode打包到pak中:

并且Monut Point与基础包的相同。

当我们挂载热更的pak时,需要pak order大于基础包中的pak,这样我们热更的pak中的ushaderbytecode就是优先级最高的,但是因为前面也已经提到了,引擎启动时就已经自动加载了基础包中的shaderbytecode,当程序运行起来之后挂载的pak中的shaderbytecode就不会被自动加载,这需要在挂载pak之后自己执行:

1
2
3
4
5
void UFlibPatchParserHelper::ReloadShaderbytecode()
{
FShaderCodeLibrary::OpenLibrary("Global", FPaths::ProjectContentDir());
FShaderCodeLibrary::OpenLibrary(FApp::GetProjectName(), FPaths::ProjectContentDir());
}

调用FShaderCodeLibrary::OpenLibrary函数即可。

综上所述,我们热更shader时的流程如下:

  1. 执行Cook生成包含最新资源的ushaderbytecode文件
  2. 打包ushaderbytecode到pak中
  3. 手动加载ushaderbytecode

重新生成ushaderbytecode可以直接使用以下cook命令:

1
UE4Editor-cmd.exe PROJECT_NAME.uproject -run=cook -targetplatform=WindowsNoEditor -Iterate -UnVersioned -Compressed

其实它会rebuild metadata,AssetRegistry之类的都会重新生成。
执行完毕之后 Saved/Cooked下的 AssetRegistry.bin以及Metadate目录 /Content/ShaderArchive-*.ushaderbytecode以及 Ending/GlobalShaderCache*.bin等文件都是生成之后最新的了,可以在之后通过 HotPatcher 来打包他们了。

Shader Patch的生成

但是,上一节的介绍其实是每次热更都需要完整地把shaderbytecode更新,其实是有点浪费的,因为基础包中的shader已经存在了,没必要我们每次更新还要把已有的包含进来,而且到项目后期Shader的占用会很大,甚至有的能达到几百M,如果每次资源变动的热更都要包含几百M这肯定是不行的,所以也需要对Shader进行Patch,实现增量更新的目的。

UE中在4.23+中开始提供了创建ShaderPatch的方法,需要提供Old Metadata和New Metadata的目录,Metadata必须要具有以下目录结构:

1
2
3
4
5
6
7
8
9
10
11
D:\Unreal Projects\Blank425\Saved\Cooked\WindowsNoEditor\Blank425\Metadata>tree /a /f
卷 Windows 的文件夹 PATH 列表
卷序列号为 0C49-9EA3
C:.
| BulkDataInfo.ubulkmanifest
| CookedIniVersion.txt
| DevelopmentAssetRegistry.bin
|
\---ShaderLibrarySource
ShaderArchive-Global-PCD3D_SM5.ushaderbytecode
ShaderArchive-Blank425-PCD3D_SM5.ushaderbytecode

需要在打基础包时备份好当时的Metadata目录,把最新的工程在执行Cook之后的Metadata目录作为New Metadata,基础包的作为Old Metadata,调用引擎中的FShaderCodeLibrary::CreatePatchLibrary函数,但是这个函数在不同的引擎版本中原型有差异,可以实现一层封装:

1
2
3
4
5
6
7
8
9
10
11
12
bool UFlibShaderPatchHelper::CreateShaderCodePatch(TArray<FString> const& OldMetaDataDirs, FString const& NewMetaDataDir, FString const& OutDir, bool bNativeFormat)
{
#if ENGINE_MINOR_VERSION > 25
return FShaderCodeLibrary::CreatePatchLibrary(OldMetaDataDirs,NewMetaDataDir,OutDir,bNativeFormat,true);
#else
#if ENGINE_MINOR_VERSION > 23
return FShaderCodeLibrary::CreatePatchLibrary(OldMetaDataDirs,NewMetaDataDir,OutDir,bNativeFormat);
#else
return false;
#endif
#endif
}

FShaderCodeLibrary::CreatePatchLibrary内部的实现原理是,从Old Metadata序列化出旧的Shader数据,与New Metadata的做比对,有差异的部分作为Patch中的Shader。

HotPatcher中提供了Shader Patch的配置和管理方法:

可以同时指定多个平台以及Metadata的目录参数,并且支持commandlet。

4.25+ ShaderPatch Crash

注意,引擎中提供的FShaderCodeLibrary::CreatePatchLibrary在4.25中有bug,会导致生成Patch时的Crash,下面写一下解决方案。

在4.25引擎版本中调用FShaderCodeLibrary::CreatePatchLibrary来创建ShaderCode Patch会触发check抛异常:

这是因为FEditorShaderCodeArchive的构造函数中调用了ShaderHashTable的Initialize,并给了默认值0x1000

1
2
3
4
5
6
7
8
9
10
FEditorShaderCodeArchive(FName InFormat)
: FormatName(InFormat)
, Format(nullptr)
{
Format = GetTargetPlatformManagerRef().FindShaderFormat(InFormat);
check(Format);

SerializedShaders.ShaderHashTable.Initialize(0x10000);
SerializedShaders.ShaderMapHashTable.Initialize(0x10000);
}

导致在后续的流程中(FSerializedShaderArchive::Serialize)调用Initialize的时候check失败了(因为HaseSize已经有值了,并不是0,对其再调用Initialize就触发了check):

查了下FEditorShaderCodeArchive构造函数中调用Initialize的代码是在4.25之后的引擎版本才有的,所以影响到的之后4.25+的版本。
代码对比:

解决方案:把FSerializedShaderArchive::SerializeShaderMapHashTableInitializeShaderHashTableInitialize在Editor下注释掉,因为FEditorShaderCodeArchive的代码只在Editor下有效,并且是只在生成ShaderPatch时有用。

这就造成了以下几个问题:

  1. FEditorShaderCodeArchive的构造只有Eidotor并且ShaderPatch是才有用,也就意味着这里写的ShaderMapHashTableInitializeShaderHashTableInitialize只有在创建ShaderPatch时才会执行
  2. 在打基础包时执行Cook会编译shader,但是不会执行FEditorShaderCodeArchive的构造,ShaderMapHashTableInitializeShaderHashTableInitialize也就不会执行,就需要在使用的地方来调用它们的初始化

这也是UE中没有管理好这两个状态的地方:在FEditorShaderCodeArchiveFSerializedShaderArchive::Serialize中都做了Initialize的操作,在打基础包时造成了ShaderMapHashTableShaderHashTableInitialize已经被FEditorShaderCodeArchive初始化的情况下又被FSerializedShaderArchive::Serialize执行了一遍,导致Crash,但是我们又不能粗暴地把任何一处的初始化操作去掉,只能通过检测ShaderMapHashTableShaderHashTableInitialize是否已经被执行,来选择性的跳过。

阅读代码可以知道ShaderMapHashTableShaderHashTableInitialize只应该执行一次,并且初始化之后HashSize和IndexSize应该具有非0值:

Runtime/Core/Public/Containers/HashTable.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FORCEINLINE void FHashTable::Initialize(uint32 InHashSize, uint32 InIndexSize)
{
check(HashSize == 0u);
check(IndexSize == 0u);

HashSize = InHashSize;
IndexSize = InIndexSize;

check(HashSize <= 0x10000);
check(FMath::IsPowerOfTwo(HashSize));

if (IndexSize)
{
HashMask = (uint16)(HashSize - 1);

Hash = new uint32[HashSize];
NextIndex = new uint32[IndexSize];

FMemory::Memset(Hash, 0xff, HashSize * 4);
}
}

Initialize时会检测当前的HashSizeIndexSize是否为0,并在之后进行赋值。所以,我们只要获取FHashTableHashSizeIndexSize检测它们是否为0即可判断当前的HashTable对象是否已经被Initialize过,但是,UE里的FHashTable里这两个成员都是protected的,只能修改引擎来实现了:

添加获取FHashTableHashSizeIndexSize属性的成员函数:

1
2
3
4
5
6
7
8
class FHashTable
{
public:
// ...
FORCEINLINE uint32 GetHashSize()const{return HashSize;};
FORCEINLINE uint32 GetIndexSize()const{return IndexSize;};
// ...
};

然后在FSerializedShaderArchive::Serialize进行检测,如果已被初始化则跳过Initialize逻辑:

Runtime/RenderCore/Private/ShaderCodeArchive.cpp
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void FSerializedShaderArchive::Serialize(FArchive& Ar)
{
Ar << ShaderMapHashes;
Ar << ShaderHashes;
Ar << ShaderMapEntries;
Ar << ShaderEntries;
Ar << PreloadEntries;
Ar << ShaderIndices;

check(ShaderHashes.Num() == ShaderEntries.Num());
check(ShaderMapHashes.Num() == ShaderMapEntries.Num());

if (Ar.IsLoading())
{
// ++[SHADER_PATCH][lipengzha]
auto ShaderHashInitialized = [](const FHashTable& HashTable)->bool
{
return HashTable.GetHashSize() || HashTable.GetIndexSize();
};
// ++[SHADER_PATCH][lipengzha]
{
const uint32 HashSize = FMath::Min<uint32>(0x10000, 1u << FMath::CeilLogTwo(ShaderMapHashes.Num()));

// ++[SHADER_PATCH][lipengzha]
if(!ShaderHashInitialized(ShaderMapHashTable))
{
ShaderMapHashTable.Initialize(HashSize, ShaderMapHashes.Num());
}
// ++[SHADER_PATCH][lipengzha]

for (int32 Index = 0; Index < ShaderMapHashes.Num(); ++Index)
{
const uint32 Key = GetTypeHash(ShaderMapHashes[Index]);
ShaderMapHashTable.Add(Key, Index);
}
}
{
const uint32 HashSize = FMath::Min<uint32>(0x10000, 1u << FMath::CeilLogTwo(ShaderHashes.Num()));

// ++[SHADER_PATCH][lipengzha]
if(!ShaderHashInitialized(ShaderHashTable))
{
ShaderHashTable.Initialize(HashSize, ShaderHashes.Num());
}
// ++[SHADER_PATCH][lipengzha]

for (int32 Index = 0; Index < ShaderHashes.Num(); ++Index)
{
const uint32 Key = GetTypeHash(ShaderHashes[Index]);
ShaderHashTable.Add(Key, Index);
}
}
}
}

这样可以统一ShaderPatch和Runtime的HashTable的Initialize流程。

Shader Patch的自动化流程

HotPatcher中支持了Shade Patch的配置化Commandlet功能,可以通过配置文件执行:

1
UE4Editor.exe PROJECT.uproject -run=HotShaderPatch -config="export-shaderpatch-config.json"

-config 参数所接收的文件都可以从编辑器中通过插件导出。

并且也支持在命令行上的参数替换,与HotRelease和HotPatcher的Commandlet功能类似,这里不再赘述,可以直接去看HotPatcher的文档介绍:

只需要管理好项目每次版本的Metadata目录,就可以编辑一份通用的配置文件导出,在每次HotPatcher的Patch任务前执行生成Shader的Patch文件,以外部文件的形式添加至HotPatcher的配置中即可。剩下的事情就是运行时加载Shader Patch的文件了,插件中同样做了函数库的支持:

1
bool UFlibPatchParserHelper::LoadShaderbytecode(const FString& LibraryName, const FString& LibraryDir);

注意事项

在Shader Patch的使用中需要注意的是:生成出来的ShaderPatch的ushaderbytecode文件是与基础包内的文件名一致的,所以不能使用引擎启动时的默认挂载(会导致基础包内的ushaderbytecode文件无法被加载,从而crash)。

应该按照前文的介绍在挂载之后自己处理ShaderPatch的ushaderbytecode文件的加载。

并且:ShaderPatch的更新不直接支持Patch的迭代,如:1.0 Metadata + 1.1的ShaderPatch,并不能生成1.2的ShaderPatch,必须要基于1.1的完整Metadata才可以,即每次Patch必须要基于上一次完整的Metadate数据(Project和Global的ushaderbytecode文件),在工程管理上每次打包都需要把完整的Metadata收集起来。

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

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

本文标题:UE热更新:Create Shader Patch
文章作者:查利鹏
发布时间:2021年03月12日 09时49分
本文字数:本文一共有5.1k字
原始链接:https://imzlp.com/posts/5867/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!