UE 补完计划(四) 导航系统-1
前言:基于Tile的导航系统
UE4的导航使用的是RecastDetour
组件,这是一个开源组件,主要支持3D场景的导航网格导出和寻路,或者有一个更流行的名字叫做NavMesh。不管是Unity还是UE都使用了这一套组件。
Github上有更为详细的源码、Demo和说明:https://github.com/recastnavigation/recastnavigation
本文使用的UE4源码版本为4.26.1,2021年2月2日的版本。
这一篇是第一篇,会阐述UE4是如何划分Tile,并基于Tile构建导航数据的。在后续的文章中,会详细介绍每个Tile如何构建导航数据。
Tile 的概念
Tile的概念,就是一个正方形的格子。每个Tile中有自己独立的导航网格数据。在我看来,Tile的作用有三点:
-
更加友好地支持多线程构建导航网格数据。
-
通过标记每个Tile是否需要重新生成(Dirty属性),就能在runtime动态更新单独一个Tile的导航网格
-
使用Static方式的导航网格时,可以以Tile为粒度来加载导航网格
在UE4中,所有的导航网格数据都是基于Tile的。每个Tile有自己独自的FRecastTileGenerator,会对当前的Tile从体素化开始,完整执行一遍导航数据构建过程。
这里简单介绍一下每个Tile在构建导航数据时会经历的步骤:
-
体素化。
-
根据体素中存储的信息,构造连续区域。
-
计算每个cell与周围cell的连通性
-
根据配置的agentRadius裁剪可行走区域
-
划分区域
-
在竖直方向分层并进行合并
-
将所有数据写入到
rcHeightfieldLayer
类中,供detour寻路使用
上述步骤都会在后续的文章中详细说明,本文只会讲述基于Tile的导航数据构建。
下面这个Uml类图,包含了NavigationSystem和NavMesh模块一些常用的类以及介绍。可以对NavigationSystem有一个初步了解
NavigationSystem的八叉树
八叉树如何更新
在UE4中,所有的导航依赖的物理数据,都是其自身通过一颗八叉树来维护的。八叉树底层直接使用了UE4的TOctree2
模板类,并提供了一个FNavigationOctreeController
控制类来更新八叉树。
NavigationSystem提供了非常多的静态函数,供会影响导航网格的事件发生时调用。
1 | void UpdateActorData(AActor& Actor) { Delegates.UpdateActorData.Execute(Actor); } |
上述情况非常多,不过主要就是会影响导航网格的Actor或者Component在添加、删除、Transform变化时来主动调用。
我们在这里用一个例子来举例,详细讲一下当一个物体的Transform发生变化时,导航系统是如何进行更新的。
在每个SceneComponent的Transform发生变化时,都会执行:
1 | bool USceneComponent::InternalSetWorldLocationAndRotation(FVector NewLocation, const FQuat& RotationQuat, bool bNoPhysics, ETeleportType Teleport) |
而在UNavigationSystemV1
的构造函数中,会进行所有Delegate
的绑定
1 | UNavigationSystemBase::OnComponentTransformChangedDelegate().BindLambda([](USceneComponent& Comp) { |
因此,当Delegate
触发时,会直接调用NavigationSystem
的接口,直接调用八叉树的更新接口:
1 | void FNavigationDataHandler::UpdateNavOctreeElement(UObject& ElementOwner, INavRelevantInterface& ElementInterface, int32 UpdateFlags) |
同时,在八叉树的接口中,也会去标记NavigationSystem
的DirtyAreas
,来驱动FRecastNavMeshGenerator
的Tick
逻辑去更新对应的Tile
。
1 | void FNavigationDataHandler::RemoveNavOctreeElementId(const FOctreeElementId2& ElementId, int32 UpdateFlags) |
NavigationSystem分块构建过程
理论上,每个Tile的构建都是自洽的,都是与其它Tile可以完全独立进行。在八叉树更新对某个Area进行标记之后,FRecastNavMeshGenerator
的Tick都会去对每个PendingDirtyArea
来进行更新。
为了比较直观,我们在这里分析其全量Build的整个流程。从UNavigationSystemV1::Build()
入手。
流程并不复杂:
-
收集场景中所有的
ANavMeshBoundsVolume
包围盒,核心代码在GatherNavigationBounds()
中,很简单就是遍历World中的类。 -
对每个包围盒的区域,使用配置的TileSize和CellSize,划分Tile。每个Tile依然是AABB的,直接对坐标进行除法来划分Tile。每个Tile的大小就是配置的TileSize * CellSize。核心代码在
MarkDirtyTiles()
函数中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57// 这个构造函数用来计算一个包围盒换算成recast坐标的最大值和最小值
FRcTileBox(const FBox& UnrealBounds, const FVector& RcNavMeshOrigin, const float TileSizeInWorldUnits)
{
check(TileSizeInWorldUnits > 0);
// 在这里直接通过TileSize和cs来计算每个tile的大小,在这里直接划分。以(0,0)点为中心划分
const FBox RcAreaBounds = Unreal2RecastBox(UnrealBounds);
XMin = FMath::FloorToInt((RcAreaBounds.Min.X - RcNavMeshOrigin.X) / TileSizeInWorldUnits);
XMax = FMath::FloorToInt((RcAreaBounds.Max.X - RcNavMeshOrigin.X) / TileSizeInWorldUnits);
YMin = FMath::FloorToInt((RcAreaBounds.Min.Z - RcNavMeshOrigin.Z) / TileSizeInWorldUnits);
YMax = FMath::FloorToInt((RcAreaBounds.Max.Z - RcNavMeshOrigin.Z) / TileSizeInWorldUnits);
}
// 将外部传入的包围盒,换算成tile坐标,并对其标记为dirty
void FRecastNavMeshGenerator::MarkDirtyTiles(const TArray<FNavigationDirtyArea>& DirtyAreas)
{
// ...
TSet<FPendingTileElement> DirtyTiles;
for (const FNavigationDirtyArea& DirtyArea : DirtyAreas)
{
// ...
// 获取到当前DirtyArea的包围盒
FBox AdjustedAreaBounds = DirtyArea.Bounds;
const FRcTileBox TileBox(AdjustedAreaBounds, RcNavMeshOrigin, TileSizeInWorldUnits);
// 这个包围盒内部的坐标都是一个tile
for (int32 TileY = TileBox.YMin; TileY <= TileBox.YMax; ++TileY)
{
for (int32 TileX = TileBox.XMin; TileX <= TileBox.XMax; ++TileX)
{
// ...
// 记录当前tile坐标
FPendingTileElement Element;
Element.Coord = FIntPoint(TileX, TileY);
// ...
DirtyTiles.Add(Element);
}
}
}
// Dump results into array
PendingDirtyTiles.Empty(DirtyTiles.Num());
for(const FPendingTileElement& Element : DirtyTiles)
{
PendingDirtyTiles.Add(Element);
}
// 这里将所有tile按照距离主角的距离的远近进行排序
if (NumTilesMarked > 0)
{
SortPendingBuildTiles();
}
} -
在Build时使用多线程来生成导航数据。每个Tile由一个Task负责。这部分逻辑核心代码在
FRecastNavMeshGenerator::ProcessTileTasks()
中。如果在Rebuild过程中,这个过程会由FRecastNavMeshGenerator::EnsureBuildCompletion()
来驱动。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void FRecastNavMeshGenerator::EnsureBuildCompletion()
{
// ...
do
{
const int32 NumTasksToProcess = (bDoAsyncDataGathering ? 1 : MaxTileGeneratorTasks) - RunningDirtyTiles.Num();
ProcessTileTasks(NumTasksToProcess);
// Block until tasks are finished
for (FRunningTileElement& Element : RunningDirtyTiles)
{
Element.AsyncTask->EnsureCompletion();
}
}
while (GetNumRemaningBuildTasks() > 0);
}
而每个Task负责的内容,就是对自己Task所负责的Tile,完整运行一遍体素化、分层、区域划分、构建相邻三角形导航数据等过程。
其关键函数在FRecastTileGenerator::GenerateCompressedLayers()
和FRecastTileGenerator::GenerateNavigationData()
中。详细流程将会后续文章中详细阐述。
1 | bool FRecastTileGenerator::GenerateCompressedLayers(FNavMeshBuildContext& BuildContext) |
Tile 的加载和使用
对于UE4来说,每个Tile只是存储当前线下已经预生成的导航数据。生成之后,实际使用A*算法来进行寻路时,直接用生成好的导航数据来进行寻路。
在UE4中,承载recast数据的类是FRecastTileData
。其结构如下:
1 | struct FRecastTileData |
对于UE4来说,对于PersistantLevel
,加载Level时会自动在场景中创建一个ARecastNavMesh
类,导航数据会直接生成在场景中的ARecastNavMesh
对象中。
而对于SubLevel,导航数据会序列化到ULevel类上的NavMeshChunk
对象中。及导航数据会跟随场景加载。
加载上来的数据,将会在FPImplRecastNavMesh::Serialize()
过程中,读取文件中的数据存储至DetourNavMesh
对象上。
1 | void FPImplRecastNavMesh::Serialize( FArchive& Ar, int32 NavMeshVersion ) |
总结
这一篇作为系列第一篇,从整个框架层,来分析了UE4是如何划分导航的Tile,如何分Tile构建导航网格,Tile的存储方式,以及加载上来如何使用Tile,并简单介绍了UE4如何支持动态更新导航。
下一篇将会介绍UE4导航的整个体素化过程。