网页版
https://yekdniwue.blogspot.com/2020/07/AutoBuildNavMesh.html
Build Static Navigation Mesh in World Composition
简介
先前有提到因为地图被细切成很多张的关系,想要使用static nav mesh,
有几个困难处要解决:
1. 地图范围很大的时候,是没办法一次加载所有地图,
只按一次build path就完成的。
不仅会执行很久,也会遇到build path失败的情况。
失败会有警告讯息并且有部分nav mesh不完整。
2. 如果想要每张地图各别计算,要在编辑器内重复的
读取子地图
build path
子地图存盘
卸载子地图
这样的流程其实更适合用自动化来做。
名词与缩写说明
在开始之前,先介绍一些本篇文章会用到的名词或是缩写。
NBV: Navigation Bounds Volume,用来定义navigation mesh的范围。
P-Level: Persistent Level。在本篇指的是在World Composition模式下的主地图。
Sublevel: 在本篇指的是在World Composition模式下的各个子地图,
可能是透过tiled height map汇入进来的。
前置准备
要能够执行期间读取/卸载存在子地图的静态nav mesh资料,需要以下步骤。
我试过很多方法,下面的步骤缺一不可。
这些步骤都是在开启P-Level的模式下运作。
1. 放置一个NBV在P-Level中,可以不需要跟任何东西交集。
2. 选择P-Level内自动产生的RecastNavMesh Actor。
3. Runtime Generation 设为Static。
4. Fixed Tile Pool size设为true。
5. Tile Pool size有可能需要随着地形大小调大。
6. 在每个子地图放置需要的NBV。
以上的步骤完成之后,就可以手动对一个子地图build path再执行,
确认是不是能够真的动态加载。
如果能够动态加载/卸载,就代表成功了。
要严谨一点的话,最好存盘后重开编辑器再测试,
最严谨的话,甚至要package测试。
基本上我在实验的过程中各种情况都遇到了,例如:
只有编辑器预览正常
或是编辑器重启后不正常
或是package出来才不正常...
但是没有关系,只要照着上面的步骤作,应该是不会有问题的。
注意事项
World Composition模式下的Static Nav Mesh,
似乎只能在主地图开启的模式下build path。
如果你直接打开子地图Build path再存盘的话,
这个子地图的nav mesh反而不会被即时加载。
因为nav mesh直接被存进子地图的RecastNavMesh这个actor内了。
但是在world composition模式主要的RecastNavMesh是放在P-Level内。
UE4可能不支援多个RecastNavMesh actor,所以会有无法加载的问题。
这个细节不一定每个团队人员都会知道,所以会造成开发上的麻烦。
例如可能某个人只单独开启子地图编辑场景,
修改场景内容的同时也按了build path并存盘。
(又或是他的editor设定为自动更新navigation)
这样一作下去这块地图的nav mesh就坏了,如图所示。
[图]
所以引入Commandlet自动化的话,比较不会有这样的疑虑。
NavMesh的部分就一律交给机器更新。
Commandlet Construction
Commandlet可以开启无使用者接口的UE4 editor,
并执行Commandlet内撰写的流程。
这次的目标是开启P-Level,依序加载子地图,计算路径后对子地图存盘。
所以我们需要继承已经有读档/存盘能力的UResavePackagesCommandlet。
不过因为Commandlet是Editor用,直接创在专案内会影响打包流程。
可能会有编译错误的问题,所以制作成Plugin会比较好管理。
创造新的Commandlet流程
1. 在Editor创一个Blank Plugin。(不用是Editor Plugin)
2. 修改.uplugin档Modules内的参数
"Type": "Editor"以及"LoadingPhase": "Default"
3. 修改Build.cs档PrivateDependencyModuleNames内的参数
新增"UnrealEd"来开启相关的功能
4. 在Plugin内新增C++ class 并继承UResavePackagesCommandlet
5. Override PerformAdditionalOperations,需要实作的行为写在这里
[图]
Modify .uplugin.
[图]
Add UnrealEd into build.cs in plugin.
ResavePackagesCommandlet介绍
在这次的环境,我们需要ResavePackagesCommandlet内建的几个重要功能,
包含
1. InitializeResaveParameters
2. LoadAndSaveOnePackage
3. PerformAdditionalOperations
4. CheckoutFile
InitializeResaveParameters
我们需要InitializeResaveParameters来解析输入参数,主要是要撷取Map参数,
让后续的LoadAndSaveOnePackage使用。
LoadAndSaveOnePackage
LoadAndSaveOnePackage算是ResavePackagesCommandlet的主函式。
主要内容就是读档,作事情(存盘,算Lighting资讯等等)
与上传(负责与source control沟通)。
PerformAdditionalOperations
因为LoadAndSaveOnePackage实作了太多事情,
我找到PerformAdditionalOperations有提供virtual可以实作,
除了会把读地图档建立好的World传进来。
函式本身的程式码也非常具有参考价值,尤其是Setup the World的部分,
充分说明了在Commandlet里面要如何读一个地图档并且建立出正确的World,
如同Editor中一样。
CheckoutFile
因为我们是使用Perforce作版本控管,所以还多需要CheckoutFile的功能。
Commandlet Implementation
这边还可以分为几个部分:
Build Navigation必要的程式码
正确的读取子地图必要的程式码
如果是要使用Commandlet计算静态场景(非World Composition)的路径,
就不用了解后者
。
只需要参考Build Navigation必要的程式码就好。
Build Navigation必要的程式码
需要Build Navigation总共需要呼叫两个函式:
FNavigationSystem::AddNavigationSystemToWorld
NavigationSystem::Build
呼叫AddNavigationSystemToWorld的原因,是因为我们需要建立 MainNavData。
call stack 大概是
AddNavigationSystemToWorld
InitializeForworld
ProcessRegisterationCandidates
如果没有呼叫这行,后续要执行NavigationSystem::Build的时候,
会因为无资料而直接结束Build流程。
NavigationSystem::Build
呼叫Navigation build nav mesh的最主要函式,所有的资料
都要准备齐全才会有正确的结果。
我有追查到在编辑器点选Build Path其实是会呼叫
FEditorBuildUtils::EditorBuild
不过在Commandlet使用会因为GUnrealEd是null而当机,所以不能直接呼叫。
正确的读取子地图必要的程式码
Load Sublevel
Initialize Sublevel
Save Sublevel
Unload Sublevel
CollectGarbage
Load Sublevel
首先我们有的是P-Level的UWorld*,从
World->WorldComposition->TilesStreaming
可以拿到子地图的资讯。
利用GetWorldAssetPackageName可以查到子地图的完整路径,
再透过LoadPackage读入内存。
Initialize Sublevel
此时P-Level跟读入的子地图还是没有关连的
所以要透过AddStreamingLevel建立P-Level跟子地图的关系。
然后子地图要呼叫一连串的函式,设定状态,再透过FlushLevelStreaming更新。
但是只有这样还不够,我发现子地图其实是以原点为中心储存的,
Engine会读取Tile资讯的Absolute position,
然后呼叫ApplyWorldOffset将地图内所有actor的座标更新。
Navigation mesh才会计算到对的位置。
Tile的Absolute position的抓取可看下面的程式码
TArray<FWorldCompositionTile>& tileList =
worldComposition->GetTilesList();
TArray<ULevelStreaming*> tilesStreaming =
worldComposition->TilesStreaming;
for (int32 index = 0; index < tilesStreaming.Num(); ++index)
{
auto perLevelStreaming = tilesStreaming[index];
auto tileInfo = tileList[index].Info;
FIntVector levelOffset = tileInfo.AbsolutePosition;
}
在设置的最后一个步骤记得要呼叫EditorLevelUtils::SetLevelVisibility,
宣告Sublevel的显示状态visible。
这样navigation build的时候才抓的到SubLevel的NBV。
详细的程式码可看图。
[图]
Initialize sublevel.
Save Sublevel
在储存Sublevel之前,要先还原刚刚为了正确计算nav mesh的位移操作。
所以要呼叫一次ApplyWorldOffset,但是这次要乘上-1。
储存相关的程式码请参考图。
[图]
Save sublevel.
Unload Sublevel
Unload 相对简单,子地图设MarkPendingKill,P-Level移除StreamingLevel与
RemoveFromWorld就可以了。如图所示。
[图]
Unload sublevel.
CollectGarbage
为了避免持续读取地图造成内存不足,每处理完一个子地图就呼叫
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS)
可以释放内存。
整合以上重点项目后的最后流程
1. AddNavigationSystemToWorld
2. For each Sublevel
Load Sublevel
Initialize Sublevel
NavigationSystem::Build
Save Sublevel
Unload Sublevel
CollectGarbage
结论与未来工作
结论
借由这次的项目,我学到很多新的观念,包含了解navigation mesh,
commandlet,package操作,world composition等系统。
navigation mesh相关的部份,我得知了navigation的build到底是如何运作的;
需要哪些资讯才能正确build path;
程式码在哪边;nav mesh的储存规则;子地图的offset机制。
commandlet则是学到如何在commandlet模式读取一个地图档;
根据地图档产生UWorld;了解ResavePackage执行的项目;
为了能存盘读档,UPackage、ULevel、ULevelStreaming等资料型态也概略的看过,稍微
分辨得出差异。
World Composition系统虽然很大,从子地图资料的抽取;
子地图加载/卸载;如何获得Tile资讯,也都包含在这次的研究范围。
未来工作
以目前的版本来说,已经达到我原先想作的目标了,
不过我依然有注意到一些项目是可以再进一步改进的。
首先就是这个Commandlet储存出来的地图,
在World Composition预览会变成默认图。
而原来在编辑器操作并储存的版本预览图会是正确的。
日后如果真的要使用这套流程的话应该要修正。
commandlet如果遇到checkout失败要怎么办,也是可以再延伸的课题。
另外一个重要的项目就是,大型场景不会那么干净,只有一张地形档,
一次只需要计算一个Sublevel。
实际上可能会再细分企划场景(内含有碰撞会影响nav mesh的Actor),
建筑物,零碎物件等等。
但是在计算nav mesh的时候要将这些地图一起纳入再算,才会是正确的结果。
所以除了开发之前要制定良好地图资料夹规范以外,
commandlet也要随着这个规范稍作修改。
要能支援多阶层的地图结构,并且某一个阶层下的地图会把所有subLevel读取进来,
统一build。
举例来说,地图结构可能如图所描述:
[图]
这时候navigation mesh应该是存于X0_Y0以及X0_Y1内,计算两次就好。
最后,其实这次对World Composition的了解还不够深入;
包含调整地图读取的优先级;
如何确保地形加载后再spawn玩家;Sublevel LOD的产生与影响。
都是还没了解的部份,有实际需求的话是要优先研究的。