UE5热更新实战:Pak包加流关卡实现无损新关卡加载
2026/9/15 14:10:08 网站建设 项目流程

UE5项目要做到“无损更新的新关卡”,Pak包 + 流关卡是目前最实用的落地方案。我最近在项目里刚把这套跑通:打好的新地图资源打进Pak,启动后下载、挂载,再把关卡动态塞进当前世界,整个过程不需要重新打包整个客户端,也不用重启App。这篇把从Cook到Mount,再到AddStreamingLevel的完整链路、命令行、C++示例和踩过的坑一次性说清楚,适合UE5客户端、联机项目、以及给项目做版本增量更新的同学直接参考。

1. 热更新方案选型:为什么选Pak包加流关卡

1.1 先搞清楚要更新的到底是代码还是资源

UE5的“热更新”其实分两派。资源类内容,包括贴图、模型、音频、关卡地图、数据表格,都可以放进Pak包,在运行时挂载到虚拟文件系统,这是引擎原生支持的路径。C++代码不行,Pak只能装二进制资源,不能像脚本语言那样直接跑逻辑,代码更新必须要走平台原生更新机制,Android是上传AAB或单独APK,Windows是走安装包更新,iOS则是App Store审核更新。

所以做热更新前要先分成两类:新关卡、新皮肤、新活动数据,走Pak包;如果改了C++逻辑,哪怕一行,也要发原生包。项目里能接受一定延迟的内容都塞进Pak,代码更新尽量攒个版本一起发,这是最现实的节奏。

1.2 流关卡比“直接OpenLevel”好在哪

很多人第一次做关卡热更新会问:下载完Pak,直接LoadMap切图不就行了吗?可以,但有个体验问题。OpenLevel会卸载当前整个世界,加载新地图,这期间玩家站在加载画面里,黑屏等待。如果是联机玩法,服务器还要重新同步状态。而流关卡不一样,它是在当前World之上再加载一个子关卡,可以做成无缝,也可以配合加载界面做精确的前后台切换。

另一个更关键的原因是,流关卡天然是“Map + 子Level”的组织方式。你完全可以拿一个空的主关卡当容器,把新下载的关卡作为子Level挂进去,然后再按需加载或卸载。这样子关卡被加载时,它的World Partition、Hierarchical LOD、动态Actor这些都会遵循流关卡的生命周期管理,比手动Spawn一堆Actor要可靠得多。

1.3 整条技术链路长什么样

整个热更新流程,从制作到玩家进图,大概是这样的:先在编辑器里把新关卡和依赖资产Cook成游戏可用的二进制,再用UnrealPak命令把Cook产物打包成.pak文件,然后上传到分发服务器。客户端启动时做版本检查和下载,拿到新Pak后用FPakPlatformFile挂载到引擎的虚拟文件系统,接着扫描AssetRegistry让引擎“知道”Pak里有这个新关卡,最后通过流关卡接口把它加载进当前世界。

这条链路里,最容易出问题的是三步:Cook时漏资产、Mount点路径错误、AssetRegistry没扫描到。后面我会把每一步逐个拆开讲。

2. 出包准备:Cook与Pak打包流程

2.1 用Cook命令只产出需要的资源

打包Pak的第一步是Cook。别直接在Editor里导出地图,那样出来的资源格式不完整。用UE的命令行Cook最稳,直接生成游戏运行时需要的二进制资源。

以Windows平台为例,打开命令行执行:

UE5Editor-Cmd.exe 你的项目.uproject -run=Cook -targetplatform=Windows -cookall -outputdir=C:/CookedOutput

参数解释:

  • -run=Cook启动Cook流程。
  • -targetplatform=Windows目标平台,如果还要Android,就换成Android,或者直接指定多个平台。
  • -cookallCook整个项目的所有资源。如果你只想打单个地图,可以用-Map=NewMap方式,只对这个地图以及它依赖的资源做Cook。
  • -outputdir指定Cook输出目录,后续打Pak会用到。

这里有一个非常关键的细节:Cook是按“依赖图”来打包资源的,如果一个资源没被任何地图或Blueprint引用,它是不会被Cook进去的。所以关卡里用到的Mesh、贴图、材质、音效,如果只是放在Content目录里却没有实际引用,最后打出来的包就会缺资源,运行时表现就是“材质丢失”或者“模型白模”。我一般会额外用Asset Manager或者Primary Asset配置,把动态加载的内容登记为Primary Asset,强制参与Cook。

2.2 UnrealPak命令行打包

Cook完成后,需要写一个文件列表告诉UnrealPak要把哪些Cook产物打进Pak。创建一个文本文件,比如PakFileList.txt,每一行格式是:

"C:/CookedOutput/Windows/你的项目/Content/Maps/NewMap.umap" "../../../你的项目/Content/Maps/NewMap.umap"

格式里左边是源文件路径,右边是Pak内部的挂载路径。右边路径必须长成../../../项目名/Content/...这种形式,这是UE加载资源的默认挂载点格式,写错会导致Pak能挂载但资源无法被引用。

然后执行:

UnrealPak.exe NewMap.pak -Create=PakFileList.txt -Compressed

-Compressed会启用Pak内的LZ4压缩,压缩率不错,加载速度影响也比较小,建议开启。如果你的项目启用了Pak签名或AES加密,还需要补上对应的签名密钥和加密参数,否则运行时挂载会直接失败。

这里要注意依赖问题:引擎在运行Pak里的关卡时,不只是读这个umap文件本身,它还会按引用关系去加载其它UAsset。所以你需要在文件列表里把所有依赖资源都加进去。手工列很容易漏,推荐做法还是走自动化脚本,读取Cook输出目录里的完整文件清单,然后全部写入Pak列表。

2.3 挂载路径与目录规范不能乱写

Pak挂载路径是项目里最隐蔽的坑之一。UE内部寻址都是/Game/xxx.xxx这种ObjectPath,而在Pak的文件系统里,资源路径要映射到../../../项目名/Content/xxx。例如项目名是MyProject,地图路径是/Game/Maps/NewMap,那么Pak内部路径就是../../../MyProject/Content/Maps/NewMap.umap

所以你在制作Pak文件列表时,源文件路径随便填Cook输出位置,目标路径一定要保持和项目Content目录层级一致。不一致的典型后果是:Pak挂载成功了,但LoadObject加载不到,因为虚拟文件系统里找不到与/Game/Maps/NewMap对应的物理文件。

建议所有制作Pak的脚本里,统一用一个小函数生成目标路径:

def make_pak_inner_path(project_name, content_relative_path): return f"../../../{project_name}/Content/{content_relative_path}"

这样谁也不会写错。

3. 运行时加载:从Mount到流关卡

3.1 挂载Pak:核心是一个FPakPlatformFile

挂载Pak在代码层面不复杂,核心就是拿到FPakPlatformFile,调用Mount。我提供一个精简版本:

#include "IPlatformFilePak.h" #include "GenericPlatformFile.h" #include "Misc/Paths.h" bool MountPakFile(const FString& PakFilePath, const FString& ProjectName) { FPakPlatformFile* PakPlatformFile = static_cast<FPakPlatformFile*>(FPlatformFileManager::Get().GetPlatformFile()); if (PakPlatformFile == nullptr) { return false; } FString MountPoint = FString::Printf(TEXT("../../../%s/Content/"), *ProjectName); return PakPlatformFile->Mount(*PakFilePath, 0, *MountPoint); }

Mount的第二个参数是优先级,数字越大优先级越高。默认写0即可,但当你同时挂载多个包,且存在同名资源时,高优先级的Pak会覆盖低优先级的。运营项目里做回滚就靠这个参数,把旧版本包优先级调低,新版本包优先级调高。

实测下来还有一个细节:如果Pak文件放在项目的Saved/Paks目录下,引擎启动时可能已经自动挂载了一部分,你手动再Mount同一路径时可能会遇到返回false,可以先调用IsMounted判断一下。

3.2 扫描AssetRegistry,让引擎知道你Pak里有什么

Pak挂载成功只是第一步,引擎的AssetRegistry还没意识到新关卡存在。如果直接传一个/Game/Maps/NewMap.NewMap路径去加载,大概率会失败,因为资源注册表里根本没有这个Asset的信息。

修正方法是在Mount后,手动扫描AssetRegistry,把Pak对应的Content路径加进去:

#include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/IAssetRegistry.h" void ScanPakAssets(const FString& PakContentPath) { FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); AssetRegistry.ScanPathsSynchronous({PakContentPath}, true); }

注意,你传进去的路径是Content路径,比如/Game/Maps,不是文件系统路径。引擎会遍历这个路径下的所有Asset,并注册到内存中。这个步骤对“打包新地图、动态加载新资源”是必须的。如果漏了,AddStreamingLevel或者LoadObject就找不到目标,因为包体在,但“索引”里查不到。

3.3 把Pak里的关卡Add到流关卡列表

到这里,Pak已经挂载,AssetRegistry也扫描完了,接下来就是把关卡真正加载进当前世界。

推荐直接用ULevelStreamingDynamic::LoadLevelInstance,这是专门为运行时动态加载子关卡准备的接口。C++里这样写:

#include "Engine/LevelStreamingDynamic.h" void LoadPakLevelIntoWorld(UObject* WorldContextObject, const FString& LevelPath) { bool bSuccess = false; ULevelStreamingDynamic* StreamingLevel = ULevelStreamingDynamic::LoadLevelInstance( WorldContextObject, LevelPath, FVector::ZeroVector, FRotator::ZeroRotator, bSuccess ); if (bSuccess && StreamingLevel) { StreamingLevel->SetShouldBeLoaded(true); StreamingLevel->SetShouldBeVisible(true); } }

这里LevelPath直接传/Game/Maps/NewMap这种包路径就行,不需要带.NewMap后缀。内部流程相当于:把这个关卡创建成一个流关卡对象,并添加到当前世界的StreamingLevels数组里,也就是说,它最终跑的还是“往流关卡列表里加一条”的机制。流关卡加载状态变化之后,引擎会照常管理它的Load和Unload。

如果你想更底层的控制,也可以自己创建ULevelStreaming子类实例,再调用UWorld::AddStreamingLevel加入列表。但新项目尽量用LoadLevelInstance,够稳、参数少、官方也在持续维护这条路。

3.4 加载进度回调和卸载

热更新关卡往往需要做“进入新区域”的过渡。监听加载完成的回调,用Delegate就行:

StreamingLevel->OnLevelLoaded.AddDynamic(this, &AMyPlayerController::OnNewLevelLoaded);

建议把SetShouldBeVisible放在加载完成回调里,这样不会出现半截关卡被渲染出来的情况。卸载则更简单,调用:

if (StreamingLevel) { StreamingLevel->SetShouldBeLoaded(false); StreamingLevel->SetShouldBeVisible(false); }

要彻底移除,可以考虑UWorld::RemoveStreamingLevel(StreamingLevel)。但很多时候保留在列表里、只是隐藏掉更稳妥,再次显示时不用重新走一遍加载流程。

4. 常见问题与排查技巧实录

4.1 Pak挂载成功但流关卡一直找不到资源

这是我在项目里遇到最多的问题。现象是Mount返回true,Pak也确认挂载了,但LoadLevelInstance返回的bSuccess是false,或者关卡一直处在Loading状态。

绝大多数情况都出在AssetRegistry扫描路径和Pak内部路径不匹配。比如你Pak里的地图在/Game/Maps/NewMap.umap,但扫描时写了/Game/NewMap,索引对不上,自然加载不到。排查办法是先用UnrealPak列表工具查看Pak内部文件路径,再对照扫描路径:

UnrealPak.exe NewMap.pak -List

看到Pak里真实的路径后,扫描路径就按它所在目录来。还有一个更隐蔽的问题:Cook时如果用的不是-cookall,而是用了-Map=NewMap,有些依赖资源可能没进Pak,导致地图加载中途卡住,解决办法是把关卡的全部依赖收集到Pak文件列表里。

4.2 关卡能加载,但进图后一堆白模和红网格

白模和红色网格的通用含义是“贴图丢失”或“材质缺失”。这不一定是加载逻辑的问题,更可能是Pak打包时依赖资源漏了。有一个很实用的排查方法:在加载前开启引擎的日志输出,或者直接在游戏运行时的Output Log里搜索LogStreamingLogPakFile,会看到具体是哪个资产加载失败。

我后来的习惯是,Cook之后先看一眼Cook输出目录里的文件数量,再和Pak里的文件数量对比,差太多就说明依赖漏了。修复思路是让关卡里动态加载的资源都通过PrimaryAsset系统登记,这样Cook时会把它们完整包含进去。

4.3 同一Pak在编辑器里正常,打包后挂载失败

编辑器里正常、打包后失败,十有八九是平台路径不同导致Mount Point不对。编辑器用的是开发环境的项目路径,打包后的游戏是../../../项目名/Content/,如果你在编辑器里写死了本地绝对路径,到了玩家机器就不存在了。所以MountPoint一定要按引擎虚拟结构写,不要用绝对路径。

另外,打包游戏时,如果用了DirectoryToMount或者自定义文件系统封装,要注意自定义平台文件是否对Pak有限制。比如某些平台对只读目录有要求,Pak文件在只读挂载点也可能失败,这时需要在启动阶段把Pak先复制到可写目录再Mount。

4.4 服务器也要热更新:不能只做客户端

很多联机项目会忽略服务器。玩家客户端加载了Pak里的新关卡,但专用服务器没有挂载这个Pak,结果就是客户端连上服务器后,服务器尝试加载同一个Level,路径一样但资源不存在,连接直接失败。

所以服务端的构建流程里也需要做同样的“下载Pak -> Mount -> 扫描AssetRegistry -> LoadLevel”这四步。区别是服务器一般不关心可见性,SetShouldBeVisible可以保持false,但SetShouldBeLoaded一定要true。发布前记得压一份服务器版Pak做联调,别只在单机模式里测试。

5. 工程落地:自动化脚本与版本管理

5.1 把Cook和打Pak写进自动构建脚本

手工敲命令行做一两次没问题,两周更一次版本后就烦了。建议尽早把整个流程脚本化。我是用Python串起来,几个步骤:先拉取最新工程代码,执行Cook,然后生成Pak文件列表,调用UnrealPak打Pak,最后上传到资源服务器,同时生成一个version.json,里面记录版本号、下载地址、Pak大小、MD5。

粗略的脚本结构如下:

def build_pak(project_path, project_name, map_name, output_dir): cook(project_path, map_name, output_dir) file_list = make_pak_file_list(output_dir, project_name) run_unrealpak(file_list, f"{map_name}.pak") upload_and_write_version(f"{map_name}.pak")

版本号一定要有,而且最好遵循“主版本.次版本.资源版本”的结构,资源版本只对应Pak内容变化,不触发客户端原生更新。

5.2 启动时的版本检查和Pak清理

客户端启动时先拉取version.json,和服务端返回的版本号对比。如果一致,什么都不做;如果不一致,下载新Pak,临时保存成.pak.tmp,完整校验MD5后再改后缀,这样杜绝“下了一半损坏”的情况。旧Pak不要立刻删,等新Pak确认挂载成功后再删,能防止启动流程挂掉后无法恢复。

5.3 资源回滚怎么做

回滚的关键点是Mount的优先级。不要把旧Pak清掉,只要把新Pak优先级压下去,再次LoadLevel的时候,引擎自然会去找旧版本资源。所以版本发布时,文件名最好带上版本号,便于保留多版本Pak。比如NewMap_v1.0.pakNewMap_v1.1.pak,挂载顺序和优先级由启动脚本控制。这样遇到资源问题,一键切回上一个版本,不用重新下载整包。

做完这套方案,我个人最大的体会是:热更新本身不是难点,难点全在“资源依赖完整性”和“版本状态管理”。只要Cook时把依赖管好,Mount路径和扫描路径严格按/Game../../../项目名/Content/对齐,流加载的代码其实非常薄。后续如果再往深做,还可以考虑GameFeature插件体系,它相当于是官方把这套Pak、AssetRegistry、插件状态管理都封装好了一层,换皮和增量功能上线会更快,但内核仍然是Pak挂载和流关卡加载,原理完全一致。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询