UE热更新:资源的二进制补丁方案

先前介绍了一系列UE中热更新工程实践的文章,能够实现基于原始工程的资源的版本比对与差异更新。但默认情况下资源的更新是基于文件的更新,某个资源产生了变动,就要将该资源的文件完整地打包进去,而UE的资源变动在Cook之后并不会造成整个文件的全部更新,序列化时只变动了某些bytes。在这种情况下,基于文件的Patch机制能够大幅度减少补丁的大小,本篇文章对二进制补丁的生成和加载方案做一个概述,以HotPatcher为基础,可以方便地实现二进制补丁的生成。
为了实现这个需求,我将HDiffPatch移植到了UE内,将其作为HotPatcher默认的二进制差异补丁的DIFF/PATCH算法,基于Modular Feature方式也可以方便地扩展其他的算法。

概览

基于资源的二进制差异的补丁实现,在有些游戏中已经经过了大规模地应用,如英雄联盟就在自己的更新机制中实现了二进制补丁的方案。
有些实现方案是基于完整包的差异,这种机制在UE中不合适,因为UE打包出来的资源是Pak,打包Pak时资源的排列顺序并不固定,并不能保证相同的排列方式,所以直接对于Pak的比对不合理,表现也很差,所以我的方案是对资源Cook后的文件,未打包进Pak时进行DIFF,将差异结果再打包至Pak中。

在UE中,我们修改的文件最终更新到玩家设备上的uasset,都是Cook之后的文件。如下图为例:

同一个资源在修改前后,对其进行Cook之后的对比。可以看到Cook之后的文件只是变动了一些数据,但是大部分的数据是一致的,在这种情况下,对整个文件的更新就显得比较浪费,二进制补丁收益巨大。

所以目标是:

  1. 打包时基于对UAsset的Cook结果进行二进制的patch生成
  2. 将patch的文件替换原始的Cooked的资源文件
  3. 加载资源时进行patch

针对这三个需求,需要介入打包的流程以及修改引擎的代码。

资源的二进制补丁

UE打包的uasset,都是Cooked之后的文件,也是游戏打包之后能读取的文件,实现uasset的二进制DIFF/PATCH,就是要获取基础包内的Cooked文件与最新资源Cooked的文件进行差异。

得益于HotPatcher的机制,可以很方便地得到当前Patch的uasset信息,将Patch列表中的文件从基础包的Pak中解包出来,与最新Cooked的文件进行DIFF。

所以整个资源进行DIFF的步骤和流程为:

  1. 获取到当前Patch中的资源列表
  2. Cook Patch中的资源
  3. 从基础包Pak中解包当前Patch列表中的资源
  4. 使用HDiffPatch创建新资源与旧资源的二进制补丁
  5. 将原始的Cooked的文件从Patch信息中剔除,使用patch替代

HotPatcher插件中已经提供了这个机制,可以在Patch配置页面的Binaries Patch中启用。

可以指定某个平台基础包的Pak,以及解密的Key,也可以做一些过滤操作,支持文件规则匹配(大小、类型)等。
补丁大小还是非常可观的:

二进制DIFF/PATCH算法

我移植了HDiffPatch到UE中,以插件的形式实现,可以从github上下载:hxhb/HDiffPatchUE,与HotPacther放入同一个工程中即可在Patch使用。

二进制的DIFF/PATCH算法,我目前选择的是HDiffPatch,相较于BsDiff有性能优势:

我将HDiffPatch的代码移植为了UE的一个Module,并针对跨平台的问题做了一些修正,将其放到游戏工程中,启动HotPatcher就可以在Binaries Patch-Binaries Patch Type中选择。

我将其的代码封装了两个函数,可以方便地调用进行DIFF/PATCH的行为。

1
2
3
4
UFUNCTION(BlueprintCallable)
static bool CreateCompressedDiff(const TArray<uint8>& NewData, const TArray<uint8>& OldData, TArray<uint8>& OutPatch);
UFUNCTION(BlueprintCallable)
static bool PatchCompressedDiff(const TArray<uint8>& OldData, const TArray<uint8>& PatchData, TArray<uint8>& OutNewData);

当然,前面也提到了,如果不想使用HDiffPatch作为创建二进制的算法,也可以自己实现其他算法的集成,因为是基于Modular Feature的实现,可以非侵入式地实现扩展,只需要添加一个实现以下接口的模块,在StartupModule中注册至BINARIES_DIFF_PATCH_FEATURE_NAME的Modular Features中即可。

1
2
3
4
5
6
7
struct IBinariesDiffPatchFeature: public IModularFeature
{
virtual ~IBinariesDiffPatchFeature(){};
virtual bool CreateDiff(const TArray<uint8>& NewData, const TArray<uint8>& OldData, TArray<uint8>& OutPatch) = 0;
virtual bool PatchDiff(const TArray<uint8>& OldData, const TArray<uint8>& PatchData, TArray<uint8>& OutNewData) = 0;
virtual FString GetFeatureName()const = 0;
};

运行时PATCH

注意:改造PakFile模块的实现,并没有支持PakCache,所以需要将其关闭。

经过前面的步骤,已经实现了二进制的补丁,并且将其打包成了Pak。

对于创建了二进制补丁的资源,在运行时的加载和使用也需要通过Patch之后才能正常访问。本节提供一个实现思路,但不是完整的方案,这部分内容涉及过多并且需要修改引擎,我提供一个粗暴的实践方案,目的是验证二进制补丁的可行性,在实际的生产中还需要根据项目需求进行大量的优化。

UE在加载Pak中的文件时,根据PakOrder进行优先级排序,这也是UE热更新实现的基础,从Pak中读取文件的实现是在IPlatformPakFile.cpp中,它位于UE的PakFile模块。

原始的Pak加载流程:

  1. 读取A文件
  2. 从Pak中读取A的原始Cooked的文件
  3. 返回文件Handle

但是我们经过创建了Patch之后,Pak内并不包含原始的Cooked之后的文件,所以就需要先在旧资源的基础上执行Patch操作,将最新的文件恢复出来:

  1. 读取A文件
  2. 从Pak中读取旧的A文件+A的Patch文件
  3. 运行时Patch,恢复出源文件
  4. 返回源文件Handle

Patch的操作通常是Pre Patch的流程,因为在运行时在内存中实时Patch的话也能实现,但是会有很大的性能损失。所以,可以在进入游戏之前,在热更流程中,对所有下载的资源执行Patch操作,这样在运行时只是读取,只是从Pak读还是从磁盘读的区别。

想要实现这个流程,需要改造UE的PakFile模块。不过,建议不要直接在PakFile模块上改,可以实现一个继承自FPakPlatformFile的类:

1
2
3
4
5
6
7
class PATCHPAKFILE_API FPatchPakPlatformFile: public FPakPlatformFile
{
public:
virtual IFileHandle* OpenRead(const TCHAR* Filename, bool bAllowWrite = false) override;
virtual IAsyncReadFileHandle* OpenAsyncRead(const TCHAR* Filename)override;
virtual const TCHAR* GetName() const override { return TEXT("PatchPakFile"); }
};

从Pak中读取文件,就是通过调用OpenReadOpenAsyncRead两个接口实现的,在这两个接口中可以实现上面的流程。

核心流程伪代码如下:

1
2
3
4
5
6
7
8
9
IFileHandle* FPatchPakPlatformFile::OpenRead(const TCHAR* Filename, bool bAllowWrite)
{
IFileHandle* Handle = NULL;
if(HasPatch(Filename))
{
Handle = GetLowerLevel()->OpenRead(GetPatchedAssetPath(Filename),bAllowWrite);
}
return !Handle ? FPakPlatformFile::OpenRead(Filename, bAllowWrite) : Handle;
}

异步也是相同的思路。

在实现完毕FPatchPakPlatformFile之后,可以将其放入引擎的Runtime中,需要修改引擎的代码,将引擎中的PakFile模块替换成PatchPakFile,这样才能使我们的代码生效。

需要修改Launch模块LaunchEngineLoop.cpp文件中的LaunchCheckForFileOverride函数:

1
2
3
4
// From
IPlatformFile* PlatformFile = ConditionallyCreateFileWrapper(TEXT("PakFile"), CurrentPlatformFile, CmdLine);
// To
IPlatformFile* PlatformFile = ConditionallyCreateFileWrapper(TEXT("PatchPakFile"), CurrentPlatformFile, CmdLine);

这样就能在从Pak中读取文件时能走到自己实现的读取流程,实现Patch的行为。

结语

本篇文章介绍了在UE中创建二进制资源补丁的一个方案,在HotPatcher的基础上实现基于HDiffPatch的二进制资源补丁,并且能够在运行时实时、或PrePatch的方法,验证了可行性,应用于实际的项目中还需要优化,因为恢复出来的文件是Cooked之后的原始文件,并没有经过加密,会有资源安全问题,也需要改造处理。有时间的话我会再详细补充关于Patch的性能提升、Patched的资源加密等实现。

为了方便测试,我提供了一个ThirdPerson的Demo,可以点击链接下载:CompressionLab_WindowsNoEditor.7z

红框中的两个文件,一个是经过了BinariesPatch的,一个是原始Cooked文件,他们可以实现相同的作用,放入CompressionLab/Content/Paks即可启动测试。

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

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

本文标题:UE热更新:资源的二进制补丁方案
文章作者:查利鹏
发布时间:2021年09月06日 16时27分
本文字数:本文一共有3k字
原始链接:https://imzlp.com/posts/25136/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!