UE Hot Update: Shader update strategy

UE热更新:Shader更新策略

In some previous articles, the issue of UE hot updates missing shaders and using default materials has been introduced, along with a Shader Patch solution. For details, please refer to the articles:

The fundamental reason for the runtime loss of materials is that the shaders dependent on newly added or modified resources were not packaged, leading to reading failures at runtime. This article will introduce the strategy and pros and cons of shader updates, analyze the mechanisms within the engine, and provide an optimized solution that combines the advantages of Shader Patch and Inline Shader Code, which has been implemented in HotPatcher.

UE has two serialization strategies for shaders during cooking:

  1. Share Material Shader Code: This will serialize the required shaders for the project into a separate Shader Code Library file (ushaderbytecode or metailib), which can reduce package size and avoid shader redundancy, allowing shaders to be searched from shaderbytecode during read.
  2. Inline Shader Code: This serialization method includes shaders in the material uasset, avoiding the management of Shader Code Library. Each material, after cooking, itself contains the required shaders, leading to redundancy within the entire package and increasing package size.

In the default project configuration of a new project, Project Settings-Project-Packaging-Shader Material Shader Code is set to off.

However, in order to reduce the package size, it is generally enabled during packaging, and the resulting package contains ushaderbytecode:

If we subsequently update the material without updating the shaders that have changed within the package, it will lead to the material loss issue mentioned earlier. Thus, material loss is determined by two prerequisites:

  1. Share Material Shader Code is enabled.
  2. The shader library ushaderbytecode is not updated after the material update.

Based on these two prerequisites, a shader update strategy can be formulated:

  1. Update the latest Shader Code Library every time an update is made.
  2. Implement inline Shader Code cooking for the updated materials.

However, these two strategies also lead to their respective issues:

Shader Code Library:

  1. The complete Shader Code Library is excessively large.
  2. Shader Patch requires the management of additional metadata files.
  3. Each patch must be based on the complete shaderbytecode.

Inline Shader Code:

  1. UE’s default Cook Commandlet does not provide a method to cook a single resource.
  2. Inline Shader Code increases package size.

Shader Patch

The process of performing Shader Patch in UE has been previously described in my article: UE Hot Update: Create Shader Patch .

Shader Patch

Here, I will discuss the mechanisms for creating the Shader Code Library not mentioned in the previous article. In the material resource, there is an attribute bShareCode marking whether the current shader exists in the Shader Library or is 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;
// ...
}

The implementation of FShaderLibraryCooker::IsShaderLibraryEnabled (prior to UE4.26, it was FShaderCodeLibrary::IsEnabled):

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

The creation of FShaderCodeLibraryImpl::Impl takes place in the following two functions:

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

This essentially divides into two phases: when cooking for storage, and when reading, it is essential to determine whether the currently dependent shader is stored in the shader library or inlined.

By default, these two functions will not be executed during the editor’s startup because, theoretically, one is required during packaging and the other during non-Editor runtime. They are respectively used during cooking as follows:

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);
// ...
}
// ...
}

And in FEngineLoop::PreInitPreStartupScreen (Non-Editor runtime):
FShaderCodeLibrary::InitForRuntime

Moreover, for situations where Share Shader Library is enabled, UE has also provided a mechanism (4.26+) to forcibly specify that certain resources’ shaders should be inline, configured using the following method:

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

During the execution of cooking, there is a check for ShouldInlineShaderCode:

To realize forced inlining.

Inline Shader Code

Inline Shader Code

In the introduction of the previous section, it has been mentioned whether shaders are serialized to the Shader Library during cooking based on the prerequisite: FShaderCodeLibrary::IsEnabled (4.25-) or FShaderLibraryCooker::IsShaderLibraryEnabled (4.26+). To achieve Inline Shader Code:

  1. Turn off bShareMaterialShaderCode in project settings for UE’s CookCommandlet.
  2. Implement a custom cooking process that does not execute FShaderLibraryCooker::InitForCooking.

For the first method, every time you want to cook inline, you need to manually modify the project settings, which is inconvenient, so the second method is recommended. However, UE does not provide an interface for cooking a single resource. I have implemented a method to cook a single resource in HotPatcher, supporting selection of resources and directories within the editor, as well as cooking resources within patches:

It is also encapsulated in code:

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*>{}
);

By using this interface, you can perform cooking in the editor, and when the resource being cooked is a material, it will be serialized using Inline Shader Code.

Better Way

As mentioned earlier, using Shader Patch and Inline Shader Code each has its own strengths and weaknesses:

  1. Shader Patch requires complete cooking, doing patches on two complete pieces of shader code can be inefficient for large projects and incurs additional management costs.
  2. Inline Shader Code: where no additional files need to be managed, the downside is that shaders cannot be shared, resulting in redundancy.

Thus, I provide a plan that combines the strengths of both in HotPatcher:

  1. Keep the Share Shader Code Library enabled during cooking.
  2. Only gather all compiled shaders from the resources cooked in the current patch and generate a separate Shader Library.

This eliminates the need to manually execute Shader Patch and removes concerns about package size increases from using Inline Shader Code. It is a solution that leverages the advantages of both. Thanks to the process mechanisms of HotPatcher, it can collect all the shaders for the current platform’s cooked resources during the cooking phase (as each packaging platform may have multiple shader formats, like Android’s GLSL_ES or Vulkan).

Once all resources are done cooking, all shaders from this cook will be collected, generating a Shader Code Library to be directly packaged into pak. This implementation does not require new and old version patches for incremental shaders, thus, there is no need for complete cooking, and no need to manage shader code versions.

All functions in HotPatcher can be simply toggled on or off:

If bSharedShaderLibrary is not enabled, then all cooked shaders in the current patch are inline shader codes. Conversely, the shaders will be shared, reducing shader redundancy.

There are also several other options:

  • bNativeShader: Same as the project setting SharedMaterialNativeLibraries. Once enabled, the iOS platform will compile to metallib.
  • ShaderNameRule: Naming for the Shader Library generated for the current patch, providing three modes: the current patch version, project name, or custom.
  • ShaderLibMountPoint specifies the path where the shader library is stored in the pak for loading during runtime.

Note: By default, the pak includes the Global and project name’s Shader Library, and care should be taken to avoid naming collisions.

After enabling bSharedShaderLibrary in the plugin, both Windows and Android will generate ushaderbytecode and package it into pak:

Windows

Android

If bNativeShader is enabled for iOS, it will compile metallib and metalmap:

Due to iOS’s loading mechanism for Native Shader, metallib and metalmap cannot be packaged into pak. They must be loaded directly from disk, not through UFS.

For the Shader Library packaged for the patch, it must be called after mounting for use. I have similarly encapsulated a loading function in the plugin:

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

The parameters to be passed here are derived from the ShaderNameRule. If it is the version number, pass the version number as a string. The second parameter is the Shader Library path in the pak, passing the value of ShaderLibMountPoint.

If Shared Shader Library is enabled but only mounted without calling LoadShaderbytecode, it will still result in material loss effects.

Cook Shader Format

During the cooking phase, HotPatcher will use the Shader formats configured in project settings for each platform. Each platform can have multiple Shader Formats; we take the most commonly used Windows/Android/iOS platforms as examples.

Windows

When packing for Windows, it retrieves GEngineIni‘s TargetRHIs, which defaults to PCD3D_SM5, and can also include ES3.1:

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

The engine’s code relevant to acquiring Windows Shader Formats can be found in: GenericWindowsTargetPlatform.h#L229

Android

Android is also configured in the engine (UE dropped support for ES2 in version 4.25 and later):

The engine’s code relevant to acquiring Android Shader Formats can be found at: AndroidTargetPlatform.cpp#L390

iOS

iOS uses Metal, and whether to compile as Native can be toggled within project settings and HotPatcher with the bNativeShader option. Additionally, it should also depend on whether compiled on Mac or if the Windows environment has a Metal Compiler (4.26+).

Project Settings-Package
Override location of Metal Toolchain

Note: Accessing the path of Metal Toolchain in the engine for Windows is C:\Program Files\Metal Developer Tools. By default, it will be installed there; if it is located elsewhere, it needs to be modified under Project Settings-IOS-Build in Override location of Metal Toolchain.

HotPatcher

By enabling bNativeShader in HotPatcher, it will compile metallic/metalmap in supported environments.

The relevant engine code for acquiring Shader Formats on the iOS platform can be found at: IOSTargetPlatform.cpp#L581

Native Metal Shader

As mentioned in previous notes, Apple has introduced a Metal Shader Compiler for Windows, which can compile Metal Native Shader on Windows: Windows Metal Shader Compiler for iOS. However, UE only provided engine-level support after version 4.26; before version 4.25, metal could only be packaged using a Mac, and using remote build on Windows would only allow for Text Shader, which is ushaderbytecode, performing lower than Native. During runtime, it generates the following log:

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

The engine’s code can be found at: Engine/Source/Runtime/Apple/MetalRHI/Private/Shaders/Types/Templates/MetalBaseShader.h

In an environment that supports compiling Native Metal (Mac/Win), HotPatcher also supports compiling Patch resource shaders in Native format.

Although there is an option to enable Native Shader during packaging in project settings, this switch does not affect the code at runtime after packaging. Regardless of whether the base package has Native enabled, all three shader update methods can be utilized:

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

For the loading of OpenLibrary, on the Metal platform, it will prioritize loading Native first, and only if that fails will it load ushaderbytecode.

  1. Attempt to load metalmap; if it fails, FMetalDynamicRHI::RHICreateShaderLibrary returns nullptr (Apple\MetalRHI\Private\MetalShaders.cpp)
  2. If loading the metalmap fails, then load 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));
//...
}
// ...
}
};

Thus, regardless of the type of shader library within the base package, the shader update method can be employed.

There is a necessary point to note when loading Native Metal Shader: the names of metallib/metalmap files must be all lowercase, or else loading will fail. Native Shader Libraries packaged by HotPatcher have this processed.

I created a test environment where both Native and ushaderbytecode can be loaded properly:


Loaded Shader
Unload Shader

End

This article has explored the advantages and disadvantages of various methods for hot-updating shaders in UE, and based on HotPatcher has implemented incremental shader updates without the need for patching, providing a perfect alternative to Shader Patch and Inline Shader Code solutions.

With this functionality, shaders that depend on a specific map or module can be contained within an independent package, allowing the shaders to be decoupled and downloaded as needed without existing fully within the base package.

The article is finished. If you have any questions, please comment and communicate.

Scan the QR code on WeChat and follow me.

Title:UE Hot Update: Shader update strategy
Author:LIPENGZHA
Publish Date:2021/09/13 10:24
World Count:11k Words
Link:https://en.imzlp.com/posts/15810/
License: CC BY-NC-SA 4.0
Reprinting of the full article is prohibited.
Your donation will encourage me to keep creating!