文章

PAK热更新

PAK 热更新

文件名的内置逻辑规则

  • 该命名为内置的逻辑,非必要规范,但需注意闭坑

  • name_VERSION_P.pak

    • 大小写不敏感
    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
    
    if (PakFilename.EndsWith(TEXT("_P.pak")))
    {
        // Prioritize based on the chunk version number
        // Default to version 1 for single patch system
        uint32 ChunkVersionNumber = 1;
        FString StrippedPakFilename = PakFilename.LeftChop(6);
        int32 VersionEndIndex = PakFilename.Find("_", ESearchCase::CaseSensitive, ESearchDir::FromEnd);
        if (VersionEndIndex != INDEX_NONE && VersionEndIndex > 0)
        {
            int32 VersionStartIndex = PakFilename.Find("_", ESearchCase::CaseSensitive, ESearchDir::FromEnd, VersionEndIndex - 1);
            if (VersionStartIndex != INDEX_NONE)
            {
            VersionStartIndex++;
            FString VersionString = PakFilename.Mid(VersionStartIndex, VersionEndIndex - VersionStartIndex);
            if (VersionString.IsNumeric())
            {
            int32 ChunkVersionSigned = FCString::Atoi(*VersionString);
            if (ChunkVersionSigned >= 1)
            {
                // Increment by one so that the first patch file still gets more priority than the base pak file
                ChunkVersionNumber = (uint32)ChunkVersionSigned + 1;
            }
        }
            }
        }
        PakOrder += 100 * ChunkVersionNumber;
    }
    

挂载后,文件加载的访问次序由什么决定的

FPakEntry.ReadOrder(uint32) 决定

  • 使用 < 的稳定排序算法

    • 数值越大的,优先使用

    • 数值相同的,先挂载的优先

ReadOrder = PakType

  • 若文件名以 _p.pak 结尾,则根据 PAK 文件名规则提取 ChunkVersionSigned,即 ReadOrder = + (100 + (ChunkVersionSigned >= 1?(uint32)ChunkVersionSigned + 1):1)
    • 其中 ChunkVersion 默认值为1;

挂载细节

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
int32 FPakPlatformFile::MountAllPakFiles(const TArray<FString>& PakFolders, const FString& WildCard)
{
	// ...
	// Sort in descending order.
    FoundPakFiles.Sort(TGreater<FString>());
	// ...
	for (int32 PakFileIndex = 0; PakFileIndex < FoundPakFiles.Num(); PakFileIndex++)
    {
        // ...
        uint32 PakOrder = GetPakOrderFromPakFilePath(PakFilename);
		// ...
        for (int32 PakFileIndex = 0; PakFileIndex < FoundPakFiles.Num(); PakFileIndex++)
        {
            // ...
            if (Mount(*PakFilename, PakOrder))
			{
				++NumPakFilesMounted;
			}
        }
    }
}

int32 FPakPlatformFile::GetPakOrderFromPakFilePath(const FString& PakFilePath)
{
	if (PakFilePath.StartsWith(FString::Printf(TEXT("%sPaks/%s-"), *FPaths::ProjectContentDir(), FApp::GetProjectName())))
	{
		return 4;
	}
	else if (PakFilePath.StartsWith(FPaths::ProjectContentDir()))
	{
		return 3;
	}
	else if (PakFilePath.StartsWith(FPaths::EngineContentDir()))
	{
		return 2;
	}
	else if (PakFilePath.StartsWith(FPaths::ProjectSavedDir()))
	{
		return 1;
	}

	return 0;
}

bool FPakPlatformFile::Mount(const TCHAR* InPakFilename, uint32 PakOrder, const TCHAR* InPath /*= NULL*/, bool bLoadIndex /*= true*/)
{
    // ...
    PakFiles.StableSort();
    // ...
}

MountPoint » 决定了业务访问路径

» 那么注意是否需要在PAK里有版本信息的标识,否则容易被hack回滚

  • 可能可以在 Pak的文件头位置写入版本信息
  • 使用手动挂载 指定 pakorder的方式更符合设计

UE 默认情况下自动挂载的PAK路径

把打出来的 Pak 直接放到这三个目录下,在没有开启 Signing 的情况下,是会默认加载这三个路径下的所有 Pak 的。

1
2
3
4
5
# relative to Project Path
Content/Paks/
Saved/Paks/
# relative to Engine Path
Content/Paks

手动构建 Pak 文件

  • 精细化控制 不同pak的压缩算法 以 优化包体、加载;
  • 解耦 cook 与 package 环节独立,便于分布式构建
  • 精准控制进包的资源
1
2
3
4
5
6
7
8
9
10
11
Output from:
  C:\Users\junqiangzhu\Downloads\MyProject\MyProject.uproject
  C:\Users\junqiangzhu\Downloads\MyProject\Saved\StagedBuilds\Android_ASTC\MyProject\Content\Paks\MyProject-Android_ASTC.pak
  -create=E:\ug_UnrealEngine\Engine\Programs\AutomationTool\Saved\Logs\PakList_MyProject-Android_ASTC.txt
  -cryptokeys=C:\Users\junqiangzhu\Downloads\MyProject\Saved\Cooked\Android_ASTC\MyProject\Metadata\Crypto.json
  -secondaryOrder=C:\Users\junqiangzhu\Downloads\MyProject\Build\Android_ASTC\FileOpenOrder\CookerOpenOrder.log
  -patchpaddingalign=0
  -platform=Android
  -compressionformats=Oodle -compressmethod=Kraken -compresslevel=4
  -multiprocess
  -abslog=E:\ug_UnrealEngine\Engine\Programs\AutomationTool\Saved\Logs\UnrealPak-MyProject-Android_ASTC-2022.07.14-10.45.11.txt

加载 加密的Pak文件

1
FCoreDelegates::GetPakEncryptionKeyDelegate().BindUObject(this, &UUGVersionMonitorStage::InitEncrypt);
1
2
3
4
5
6
7
8
9
10
11
12
13
void UUGVersionMonitorStage::InitEncrypt(uint8* Key)
{
	FString KeyStr = TEXT("OqsU2kHaC38dsjmBbVssat3uNcwDAV07HzOla/lX24ifHU=");
	TArray<uint8> KeyBase64Ary;
	if (FBase64::Decode(KeyStr, KeyBase64Ary))
	{
		FMemory::Memcpy(TCHAR_TO_UTF8(*KeyStr), KeyBase64Ary.GetData(), FAES::FAESKey::KeySize);
	}
	else
	{
		UE_LOG(LogTemp, Warning, TEXT("InitEncrypt Decode result invalid."));
	}
}

怎么加载指定Pak中的文件?

打包时的mountPoint是怎么确定的

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
FString GetCommonRootPath(TArray<FPakInputPair>& FilesToAdd)
{
	FString Root = GetLongestPath(FilesToAdd);
	for (int32 FileIndex = 0; FileIndex < FilesToAdd.Num() && Root.Len(); FileIndex++)
	{
		FString Filename(FilesToAdd[FileIndex].Dest);
		FString Path = FPaths::GetPath(Filename) + TEXT("/");
		int32 CommonSeparatorIndex = -1;
		int32 SeparatorIndex = Path.Find(TEXT("/"), ESearchCase::CaseSensitive);
		while (SeparatorIndex >= 0)
		{
			if (FCString::Strnicmp(*Root, *Path, SeparatorIndex + 1) != 0)
			{
				break;
			}
			CommonSeparatorIndex = SeparatorIndex;
			if (CommonSeparatorIndex + 1 < Path.Len())
			{
				SeparatorIndex = Path.Find(TEXT("/"), ESearchCase::CaseSensitive, ESearchDir::FromStart, CommonSeparatorIndex + 1);
			}
			else
			{
				break;
			}
		}
		if ((CommonSeparatorIndex + 1) < Root.Len())
		{
			Root.MidInline(0, CommonSeparatorIndex + 1, false);
		}
	}
	return Root;
}

其他API

  • PakPlatformFile->Unmount() 可卸载
  • PakPlatformFile->GetMountedPakFilenames() 获取已加载的pak,可用于检测,避免重复加载

待验证

  • PIE模式下,MountPoint 使用绝对路径
  • 打包模式下,MountPoint 使用相对路径

加载代码例子

1
2
3
4
TSharedPtr<FPakPlatformFile> PakPlatformFile;
IPlatformFile* InnerPlatformFile;
UFUNCTION(BlueprintCallable)
bool LoadPak(const FString& PakPath);
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
55
56
57
58
59
void ALoadPakActor::BeginPlay()
{
	Super::BeginPlay();
	//获取当前使用的平台
	InnerPlatformFile = &FPlatformFileManager::Get().GetPlatformFile();
	UE_LOG(LogTemp, Warning, TEXT("InnerPlatformFile: %s"), InnerPlatformFile->GetName());
	//初始化PakPlatformFile
	PakPlatformFile = MakeShareable(new FPakPlatformFile());
	PakPlatformFile.Get()->Initialize(InnerPlatformFile, TEXT(""));
}
bool ALoadPakActor::LoadPak(const FString& PakPath)
{
	bool Result = false;
	// 切换到 pak平台
	FPlatformFileManager::Get().SetPlatformFile(*PakPlatformFile.Get());
	// 获取pak文件
	TSharedPtr<FPakFile> PakFile = MakeShareable(new FPakFile(InnerPlatformFile, *PakPath, false));
	FString MountPoint = PakFile->GetMountPoint();
	UE_LOG(LogTemp, Warning, TEXT("Default Mount Point: %s"), *MountPoint);
#if WITH_EDITOR
	// PIE模式下,MountPoint 使用绝对路径
	// 打包模式下,MountPoint 使用相对路径
	MountPoint = FPaths::ConvertRelativePathToFull(MountPoint);
	UE_LOG(LogTemp, Warning, TEXT("Default Mount Point Full Path: %s"), *MountPoint);
	// 设置pak文件的Mount点,因为在制作pak的时候已在文本中设定 mount point,故省略此步骤
	MountPoint = FPaths::ProjectContentDir() + TEXT("DLC/");
	// 可在此处检测 默认MountPoint的绝对路径释放和本条语句执行结果是否一致
	MountPoint = FPaths::ConvertRelativePathToFull(MountPoint);
	PakFile->SetMountPoint(*MountPoint);
	UE_LOG(LogTemp, Warning, TEXT("New Mount Point Full Path: %s"), *MountPoint);
#endif
	// 对pak文件进行挂载
	if (PakPlatformFile->Mount(*PakPath, 1, *MountPoint))
	{
		// 加载 pak 里的资源
		UClass* BP_PakTestClass = LoadClass<AActor>(nullptr, TEXT("Blueprint'/Game/DLC/BP_PakTest1.BP_PakTest1_C'"));
		if (BP_PakTestClass)
		{
			GetWorld()->SpawnActor<AActor>(BP_PakTestClass, FVector::ZeroVector, FRotator::ZeroRotator);
			Result = true;
		}
		else
			UE_LOG(LogTemp, Error, TEXT("Load BP_PakTest1 Class Failed"));
		// 遍历 pak 里的资源
		TArray<FString> AssetList;
		PakFile->FindPrunedFilesAtPath(AssetList, *PakFile->GetMountPoint(), true, false, true);
		for (FString itemPath : AssetList)
		{
			UE_LOG(LogTemp, Warning, TEXT("%-30s\t%s"), *FPackageName::GetShortName(itemPath), *itemPath);
			// 此处可异步加载资源
		}
	}
	else
		UE_LOG(LogTemp, Error, TEXT("Mount Pak Failed"));
	// 设置回原来的PlatformFile, UE4.26
	// 不加该条语句,本测试崩溃,报错:Pure Virtual function being called while application was running
	FPlatformFileManager::Get().SetPlatformFile(*InnerPlatformFile);
	return Result;
}
  • 使用 FCoreDelegates 挂载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool ALoadPakActor::LoadPak(const FString& PakPath)
{
	bool Result = false;
	if (FCoreDelegates::OnMountPak.IsBound())
	{
		if (FCoreDelegates::OnMountPak.Execute(PakPath, 0, nullptr))
		{
			UClass* BP_PakTestClass = LoadClass<AActor>(nullptr, TEXT("Blueprint'/Game/DLC/BP_PakTest1.BP_PakTest1_C'"));
			if (BP_PakTestClass)
			{
				GetWorld()->SpawnActor<AActor>(BP_PakTestClass, FVector::ZeroVector, FRotator::ZeroRotator);
				Result = true;
			}
			else
				UE_LOG(LogTemp, Error, TEXT("Load BP_PakTest1 Class Failed"));
		}
		else
			UE_LOG(LogTemp, Error, TEXT("OnMountPak.Execute() Failed"));
	}
	else
		UE_LOG(LogTemp, Error, TEXT("OnMountPak.IsBound() Failed"));
	return Result;
}

Cook产物一致性

  • Shader Cache
  • 序列化内容确定性

Shader变体的处理

Pak文件大小优化

FPakEntry -> Header 信息可以考虑精简

FPakFooterInfo 脚注信息可以选择精简

  • 脚注的存在目的是什么?

Shader热更

  • 当我们挂载热更的 pak 时,需要 pak order 大于基础包中的 pak

  • 引擎启动时就已经自动加载了基础包中的 shaderbytecode,当程序运行起来之后挂载的 pak 中的 shaderbytecode 就不会被自动加载,这需要在挂载 pak 之后自己执行:

    需要进一步验证,为什么?

    1
    2
    3
    4
    5
    6
    
    void UFlibPatchParserHelper::ReloadShaderbytecode()
    {
        // 调用 FShaderCodeLibrary::OpenLibrary 函数即可。
    	FShaderCodeLibrary::OpenLibrary("Global", FPaths::ProjectContentDir());
    	FShaderCodeLibrary::OpenLibrary(FApp::GetProjectName(), FPaths::ProjectContentDir());
    }
    

综上所述,我们热更 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
  • Enging/GlobalShaderCache*.bin

等文件都是生成之后最新的了。

这里知乎上也有人提到可以通过修改引擎源码来实现部分 Shader的编译,但是可能就需要支持 多 .ushaderbytecode 的情况了

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 函数.

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

ShaderPatch 的更新不直接支持 Patch 的迭代

如:1.0 Metadata + 1.1 的 ShaderPatch,并不能生成 1.2 的 ShaderPatch,必须要基于 1.1 的完整 Metadata 才可以,即每次 Patch 必须要基于上一次完整的 Metadate 数据(Project 和 Global 的 ushaderbytecode 文件)

在工程管理上每次打包都需要把完整的 Metadata 收集起来。

本文由作者按照 CC BY 4.0 进行授权