UE5热更新实战:Pak包加流关卡实现无损新关卡加载
发布时间:2026/9/15 14:10:15 作者:尧图编辑部 阅读量:1,286

UE5项目要做到“无损更新的新关卡”Pak包 流关卡是目前最实用的落地方案。我最近在项目里刚把这套跑通打好的新地图资源打进Pak启动后下载、挂载再把关卡动态塞进当前世界整个过程不需要重新打包整个客户端也不用重启App。这篇把从Cook到Mount再到AddStreamingLevel的完整链路、命令行、C示例和踩过的坑一次性说清楚适合UE5客户端、联机项目、以及给项目做版本增量更新的同学直接参考。1. 热更新方案选型为什么选Pak包加流关卡1.1 先搞清楚要更新的到底是代码还是资源UE5的“热更新”其实分两派。资源类内容包括贴图、模型、音频、关卡地图、数据表格都可以放进Pak包在运行时挂载到虚拟文件系统这是引擎原生支持的路径。C代码不行Pak只能装二进制资源不能像脚本语言那样直接跑逻辑代码更新必须要走平台原生更新机制Android是上传AAB或单独APKWindows是走安装包更新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 -runCook -targetplatformWindows -cookall -outputdirC:/CookedOutput参数解释-runCook启动Cook流程。-targetplatformWindows目标平台如果还要Android就换成Android或者直接指定多个平台。-cookallCook整个项目的所有资源。如果你只想打单个地图可以用-MapNewMap方式只对这个地图以及它依赖的资源做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 -CreatePakFileList.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_castFPakPlatformFile*(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::LoadModuleCheckedFAssetRegistryModule(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返回truePak也确认挂载了但LoadLevelInstance返回的bSuccess是false或者关卡一直处在Loading状态。绝大多数情况都出在AssetRegistry扫描路径和Pak内部路径不匹配。比如你Pak里的地图在/Game/Maps/NewMap.umap但扫描时写了/Game/NewMap索引对不上自然加载不到。排查办法是先用UnrealPak列表工具查看Pak内部文件路径再对照扫描路径UnrealPak.exe NewMap.pak -List看到Pak里真实的路径后扫描路径就按它所在目录来。还有一个更隐蔽的问题Cook时如果用的不是-cookall而是用了-MapNewMap有些依赖资源可能没进Pak导致地图加载中途卡住解决办法是把关卡的全部依赖收集到Pak文件列表里。4.2 关卡能加载但进图后一堆白模和红网格白模和红色网格的通用含义是“贴图丢失”或“材质缺失”。这不一定是加载逻辑的问题更可能是Pak打包时依赖资源漏了。有一个很实用的排查方法在加载前开启引擎的日志输出或者直接在游戏运行时的Output Log里搜索LogStreaming和LogPakFile会看到具体是哪个资产加载失败。我后来的习惯是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.pak和NewMap_v1.1.pak挂载顺序和优先级由启动脚本控制。这样遇到资源问题一键切回上一个版本不用重新下载整包。做完这套方案我个人最大的体会是热更新本身不是难点难点全在“资源依赖完整性”和“版本状态管理”。只要Cook时把依赖管好Mount路径和扫描路径严格按/Game和../../../项目名/Content/对齐流加载的代码其实非常薄。后续如果再往深做还可以考虑GameFeature插件体系它相当于是官方把这套Pak、AssetRegistry、插件状态管理都封装好了一层换皮和增量功能上线会更快但内核仍然是Pak挂载和流关卡加载原理完全一致。