先前介绍了一系列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之后的文件只是变动了一些数据,但是大部分的数据是一致的,在这种情况下,对整个文件的更新就显得比较浪费,二进制补丁收益巨大。
所以目标是:
- 打包时基于对UAsset的Cook结果进行二进制的patch生成
- 将patch的文件替换原始的Cooked的资源文件
- 加载资源时进行patch
针对这三个需求,需要介入打包的流程以及修改引擎的代码。
资源的二进制补丁
UE打包的uasset,都是Cooked之后的文件,也是游戏打包之后能读取的文件,实现uasset的二进制DIFF/PATCH,就是要获取基础包内的Cooked文件与最新资源Cooked的文件进行差异。
得益于HotPatcher的机制,可以很方便地得到当前Patch的uasset信息,将Patch列表中的文件从基础包的Pak中解包出来,与最新Cooked的文件进行DIFF。
所以整个资源进行DIFF的步骤和流程为:
- 获取到当前Patch中的资源列表
- Cook Patch中的资源
- 从基础包Pak中解包当前Patch列表中的资源
- 使用HDiffPatch创建新资源与旧资源的二进制补丁
- 将原始的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 | UFUNCTION(BlueprintCallable) |
当然,前面也提到了,如果不想使用HDiffPatch作为创建二进制的算法,也可以自己实现其他算法的集成,因为是基于Modular Feature的实现,可以非侵入式地实现扩展,只需要添加一个实现以下接口的模块,在StartupModule
中注册至BINARIES_DIFF_PATCH_FEATURE_NAME
的Modular Features中即可。
1 | struct IBinariesDiffPatchFeature: public IModularFeature |
运行时PATCH
注意:改造PakFile模块的实现,并没有支持PakCache,所以需要将其关闭。
经过前面的步骤,已经实现了二进制的补丁,并且将其打包成了Pak。
对于创建了二进制补丁的资源,在运行时的加载和使用也需要通过Patch之后才能正常访问。本节提供一个实现思路,但不是完整的方案,这部分内容涉及过多并且需要修改引擎,我提供一个粗暴的实践方案,目的是验证二进制补丁的可行性,在实际的生产中还需要根据项目需求进行大量的优化。
UE在加载Pak中的文件时,根据PakOrder进行优先级排序,这也是UE热更新实现的基础,从Pak中读取文件的实现是在IPlatformPakFile.cpp
中,它位于UE的PakFile
模块。
原始的Pak加载流程:
- 读取A文件
- 从Pak中读取A的原始Cooked的文件
- 返回文件Handle
但是我们经过创建了Patch之后,Pak内并不包含原始的Cooked之后的文件,所以就需要先在旧资源的基础上执行Patch操作,将最新的文件恢复出来:
- 读取A文件
- 从Pak中读取旧的A文件+A的Patch文件
- 运行时Patch,恢复出源文件
- 返回源文件Handle
Patch的操作通常是Pre Patch
的流程,因为在运行时在内存中实时Patch的话也能实现,但是会有很大的性能损失。所以,可以在进入游戏之前,在热更流程中,对所有下载的资源执行Patch操作,这样在运行时只是读取,只是从Pak读还是从磁盘读的区别。
想要实现这个流程,需要改造UE的PakFile
模块。不过,建议不要直接在PakFile模块上改,可以实现一个继承自FPakPlatformFile
的类:
1 | class PATCHPAKFILE_API FPatchPakPlatformFile: public FPakPlatformFile |
从Pak中读取文件,就是通过调用OpenRead
和OpenAsyncRead
两个接口实现的,在这两个接口中可以实现上面的流程。
核心流程伪代码如下:
1 | IFileHandle* FPatchPakPlatformFile::OpenRead(const TCHAR* Filename, bool bAllowWrite) |
异步也是相同的思路。
在实现完毕FPatchPakPlatformFile
之后,可以将其放入引擎的Runtime中,需要修改引擎的代码,将引擎中的PakFile
模块替换成PatchPakFile
,这样才能使我们的代码生效。
需要修改Launch
模块LaunchEngineLoop.cpp
文件中的LaunchCheckForFileOverride
函数:
1 | // From |
这样就能在从Pak中读取文件时能走到自己实现的读取流程,实现Patch的行为。
结语
本篇文章介绍了在UE中创建二进制资源补丁的一个方案,在HotPatcher的基础上实现基于HDiffPatch的二进制资源补丁,并且能够在运行时实时、或PrePatch的方法,验证了可行性,应用于实际的项目中还需要优化,因为恢复出来的文件是Cooked之后的原始文件,并没有经过加密,会有资源安全问题,也需要改造处理。有时间的话我会再详细补充关于Patch的性能提升、Patched的资源加密等实现。
为了方便测试,我提供了一个ThirdPerson的Demo,可以点击链接下载:CompressionLab_WindowsNoEditor.7z。
红框中的两个文件,一个是经过了BinariesPatch的,一个是原始Cooked文件,他们可以实现相同的作用,放入CompressionLab/Content/Paks
即可启动测试。