UE热更新:Shader更新策略

在之前的一些文章中,介绍了UE热更新丢失Shader使用默认材质的处理问题,以及Shader Patch的方案。
详情可见文章:

其实运行时丢失材质的根本原因是新添加或修改的资源所依赖的Shader没有被打包,运行时读取失败导致的错误。本篇文章来介绍一下Shader更新的策略和优缺点,以及对引擎内机制的分析,并提供一个结合Shader Patch与Inline Shader Code优点的优化方案,已经在HotPacther中实现。

UE在Cook时有两种Shader的序列化策略:

  1. Share Material Shader Code:会把项目所需的Shader单独的序列化为Shader Code Library文件(ushaderbytecodemetailib),能够降低包体大小,避免Shader的冗余,读取shader时会从shaderbytecode中查找。
  2. Inline Shader Code:将Shader序列化至材质uasset,避免管理Shader Code Library,每个材质在Cook之后它本身就包含了所需的shader,整个包内具有冗余,会增加包体的大小。

在新建工程的默认项目配置中,Project Settings-Project-Packaging-Shadre Material Shader Code为关闭状态。

但是为了降低包体的大小,通常在打包时会开启,打出来的包就包含了ushaderbytecode:

但是,如果我们后续更新了材质,没有更新包内变动之后的shader,就会导致前文中介绍的材质丢失的问题。
所以材质丢失是有两个前提决定的:

  1. 开启了Share Material Shader Code
  2. 更新材质后没有更新ushaderbytecode库

根据这两个前提,可以制定Shader更新的策略:

  1. 每次更新之后就更新最新的Shade Code Library
  2. 对更新的材质实现inline Shader Code的cook

但是,这两个策略又引出了各自的问题:

Shader Code Library:

  1. 完整的Shader Code Library太过巨大
  2. Shader Patch需要管理额外的Metadata文件
  3. 每次Patch必须基于完整的shadebytecode

Inline Shader Code:

  1. UE默认Cook Commandlet没有提供Cook单个资源的方法
  2. Inline Shader Code会增大包体

Shader Patch

在UE里进行Shader Patch的流程,我之前已经写了一篇文章:UE热更新:Create Shader Patch

Shader Patch

这里讲一些上篇文章中没提到的创建Shader code library机制的内容。在材质资源中有一个属性bShareCode标记当前的Shader是存在于Shader Library中还是Inline:

Source\Runtime\RenderCore\Private\ShaderMap.cpp
1
2
3
4
5
6
7
8
9
10
bool FShaderMapBase::Serialize(FArchive& Ar, bool bInlineShaderResources, bool bLoadedByCookedMaterial, bool bInlineShaderCode)
{
// ...
bool bShareCode = false;
#if WITH_EDITOR
bShareCode = !bInlineShaderCode && FShaderLibraryCooker::IsShaderLibraryEnabled() && Ar.IsCooking();
#endif // WITH_EDITOR
Ar << bShareCode;
// ...
}

FShaderLibraryCooker::IsShaderLibraryEnabled的实现(在UE4.26之前是FShaderCodeLibrary::IsEnabled):

ShaderCodeLibrary.cpp
1
2
3
4
bool FShaderCodeLibrary::IsEnabled()
{
return FShaderCodeLibraryImpl::Impl != nullptr;
}

FShaderCodeLibraryImpl::Impl的创建在以下两个函数中:

1
2
3
4
// for Cooking
void FShaderCodeLibrary::InitForCooking(bool bNativeFormat);
// for Runtime
void FShaderCodeLibrary::InitForRuntime(EShaderPlatform ShaderPlatform);

其实是分了两个阶段:Cook存储时和读取时,都需要获取到当前资源依赖的shader是存储在shader libraray中还是inline了。

默认情况下,这两个函数在启动编辑器时都不会被执行,因为毕竟理论上他们一个是在打包时一个是在非Editor运行时才被需要,他们分别被用在Cook时:

Editor/UnrealEd/Private/CookOnTheFlyServer.cpp
1
2
3
4
5
6
7
8
9
10
11
void UCookOnTheFlyServer::InitShaderCodeLibrary(void)
{
const UProjectPackagingSettings* const PackagingSettings = GetDefault<UProjectPackagingSettings>();
bool const bCacheShaderLibraries = IsUsingShaderCodeLibrary();
if (bCacheShaderLibraries && PackagingSettings->bShareMaterialShaderCode)
{
FShaderLibraryCooker::InitForCooking(PackagingSettings->bSharedMaterialNativeLibraries);
// ...
}
// ...
}

以及FEngineLoop::PreInitPreStartupScreen中(Non-Editor运行时):
FShaderCodeLibrary::InitForRuntime

并且,对于开启了Share Shader Library的情况,UE也提供了一个机制(4.26+),可以强制制定某些资源的Shader是Inline的,使用一下方式配置:

DefaultEngine.ini
1
2
3
[ShaderCodeLibrary]
bEnableInliningWorkaround_Windows=True
+MaterialToInline=/Game/StarterContent/Materials/M_Brick_Clay_Beveled

在执行Cook时有ShouldInlineShaderCode的检测:

实现强制inline。

Inline Shader Code

Inline Shader Code

在上一节的介绍中,已经提到了在Cook时是否将Shader序列化到Shader Library的前提条件:FShaderCodeLibrary::IsEnabled(4.25-)或FShaderLibraryCooker::IsShaderLibraryEnabled(4.26+),想要实现Inline Shader Code的方法:

  1. UE的CookCommandlet,在项目设置中关闭bShareMaterialShaderCode
  2. 自己实现Cook流程,不执行FShaderLibraryCooker::InitForCooking

对于第一种方式,每次想要Cook Inline的时候,都需要手动去修改项目设置,较为不便,所以推荐第二个方式。但是UE没有提供Cook单个资源的接口,我在HotPacther中实现了Cook单个资源的方式,支持编辑器中选中资源、目录,以及在Patch中对资源进行Cook:

在代码中也做了封装:

FlibHotPactherEditorHelper.h
1
2
3
4
5
6
7
static bool CookPackage(
const FAssetData& AssetData,
UPackage* Package,
const TArray<FString>& Platforms,
TFunction<void(const FString&)> PackageSavedCallback = [](const FString&){},
class TMap<FString,FSavePackageContext*> PlatformSavePackageContext = TMap<FString,FSavePackageContext*>{}
);

使用这个接口就能实现在编辑器中的Cook,当Cook资源是材质时,会使用Inline Shader Code的方式序列化。

Better Way

前面已经提到了,使用Shader Patch和Inline Shader Code的方式各有各的优缺点:

  1. Shader Patch需要完整Cook,对两份完整的Shader code做Patch,项目巨大时执行效率不高,并且有额外的管理成本。
  2. Inline Shader Code:无需管理任何额外文件,缺点是无法共用shader,具有冗余。

所以,我为HotPacther提供一个结合两者优点的方案:

  1. Cook时依然开启Share Shader Code Library
  2. 仅把当前Patch中Cook资源时所有编译的Shader收集起来,生成单独的Shader Library

这样不用再手动执行Shader Patch,也不用担心Inline Shader Code的方式导致包体增大的问题,是一种结合了两者优点的方案。
得益于HotPatcher的流程机制,能够在插件的Cook阶段中把当前平台所有Cook资源的shader收集起来(每个打包的平台可能会具有多种shader格式,如Android的GLSL_ESVulkan)。

在所有的资源都Cook完毕之后,采集到了本次Cook所有Shader,将其生成Shader Code Library并直接打包至pak中。是一种无需进行新旧版本patch的增量shader方案,所以,也无需完整的Cook,更无需管理Shader code的版本。

所有的功能在HotPatcher中只需简单地开关即可实现:

若不开启bSharedShaderLibrary,则当前Patch中的Cook的shader全部是inline shader code。反之Shader则是Shared的,降低Shader的冗余。

还有一些其他的选项:

  • bNativeShader:与项目设置中SharedMaterialNativeLibraries相同,开启之后IOS平台会编译成metallib
  • ShaderNameRule:为当前Patch生产的Shader Library的命名,提供了三种模式:当前Patch的版本号、项目名、自定义
  • ShaderLibMountPoint则是将shader library打包至Pak中所存储的路径,在运行时加载Shader Library需要指定。

注意:打包时默认具有Global和项目名的Shader Library,注意不要有重名。

在插件中开启bSharedShaderLibrary后,Windows和Android会生成ushaderbytecode,并打包至Pak中:

Windows

Android

IOS如果开启bNativeShader,则会编译出metallib和metalmap:

由于IOS的Native Shader的加载机制,不能把metallib和metalmap打包到pak中,不能通过UFS,需要直接从磁盘加载。

为Patch打包出的Shader Library,需要在Mount之后调用加载才能使用,我在插件中同样有封装函数:

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

这里需要传递的参数,就是ShaderNameRule中的规则,如果是版本号,就传递版本号的字符串,第二个参数就是Shader Library在Pak中的路径,传递ShaderLibMountPoint值即可。

如果开启了Shared Shader Libraty,但是只进行了Mount,没有LoadShaderbytecode,同样会出现材质丢失的效果。

Cook Shader Format

HotPatcher在Cook阶段,会使用项目设置中配置的各个平台的Shader格式进行Cook,每个平台可以具有多种Shader Format,这里以最常用的Windows/Android/IOS三个平台为例。

Windows

Window打包时是获取GEngineIni下的TargetRHIs,默认是PCD3D_SM5,也可以添加ES3.1

1
2
3
4
5
[/Script/WindowsTargetPlatform.WindowsTargetSettings]
Compiler=Default
+TargetedRHIs=PCD3D_ES31
+TargetedRHIs=PCD3D_SM5
DefaultGraphicsRHI=DefaultGraphicsRHI_Default

引擎中Windows获取Shader Formats的相关代码:GenericWindowsTargetPlatform.h#L229

Android

Android是也是在引擎中配置的(UE在4.25及之后的版本上舍弃了ES2的支持):

引擎中Android获取Shader Formats的相关代码:AndroidTargetPlatform.cpp#L390

IOS

IOS则是Metal,但是否编译为Native,项目设置和HotPatcher中都有bNativeShader的选项,并且也要根据是否在Mac上编译或者Win上具有Metal Compiler(4.26+)。

Project Settings-Package
Override location of Metal Toolchain

注意:引擎中访问Windows Metal Toolchin的路径为C:\Program Files\Metal Developer Tools,默认安装会在这个位置,若不在这个路径下,就需要在Project Settings-IOS-Build下修改Override location of Metal Toolchain

HotPatcher

HotPatcher中勾上bNativeShader就会在支持的环境上编译出metallic/metalmap。

引擎中IOS平台获取Shader Formats的相关代码:IOSTargetPlatform.cpp#L581

Native Metal Shader

之前笔记中提到,苹果推出了Windows上的 Metal Shader Compiler,可以在Windows上编译出Metal Native Shader:Windows Metal Shader Compiler for IOS,但是UE在4.26之后才提供了引擎层面的支持,在4.25及之前的引擎版本中,metal只能通过Mac来打包,使用Windows远程构建就只能使用Text Shader,也就是ushaderbytecode,性能低于Native,在运行时有以下log:

1
LogMetal: Display: Loaded a text shader (will be slower to load)

引擎中的代码:Engine/Source/Runtime/Apple/MetalRHI/Private/Shaders/Types/Templates/MetalBaseShader.h

在支持Compile Native Metal的环境上(Mac/Win),HotPatcher也支持了对Patch中资源的Shader编译Native格式。

虽然项目设置中有打包时启用Native Shader的选项,但是这个开关并不会对打包后运行时的代码有任何影响。不管基础包是否开启了Native,在运行时三种shader的更新方式都可以:

  1. ushaderbytecode
  2. inline shader code
  3. Native(metallib/metalmap)

对于调用OpenLibrary的加载,在Metal平台会优先加载Native的,若加载失败才会加载ushaderbytecode。

  1. 尝试加载metalmap,若加载失败,则FMetalDynamicRHI::RHICreateShaderLibrary返回nullptr (Apple\MetalRHI\Private\MetalShaders.cpp)
  2. 加载metalmap失败,则加载ushaderbytecode
Runtime\RenderCore\Private\ShaderCodeLibrary.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class FShaderLibraryInstance
{
public:
static FShaderLibraryInstance* Create(EShaderPlatform InShaderPlatform, const FString& ShaderCodeDir, FString const& InLibraryName)
{
FRHIShaderLibraryRef Library;
if (RHISupportsNativeShaderLibraries(InShaderPlatform))
{
Library = RHICreateShaderLibrary(InShaderPlatform, ShaderCodeDir, InLibraryName);
}

if (!Library)
{
const FName PlatformName = LegacyShaderPlatformToShaderFormat(InShaderPlatform);
const FString DestFilePath = GetCodeArchiveFilename(ShaderCodeDir, InLibraryName, PlatformName);
TUniquePtr<FArchive> Ar(IFileManager::Get().CreateFileReader(*DestFilePath));
//...
}
// ...
}
};

所以,不管基础包内的shader library是何种类型,这Shader的更新方式都可以用。

加载Native Metal Shader有一个需要注意的问题,metallib/metalmap文件名必须全小写,不然会加载失败。通过HotPatcher打包的Native Shader Library已做处理。

我创建了一个测试环境,不管是Native/ushaderbytecode,都能够正常加载:


Loaded Shader
Unload Shader

End

本文研究了UE中热更Shader几种不同方式的优缺点,并基于HotPacther实现了无需Patch的Shader增量更新,可以完美地替代Shader Patch和Inline Shader Code的方案。

根据该功能,可以实现某个地图或者某种模块依赖的shader全在独立的包中,这样能够让shader也解耦,完全可以按需下载而不用完整的存在基础包中。

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

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

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