UE4的导航使用的是RecastDetour组件,这是一个开源组件,主要支持3D场景的导航网格导出和寻路,或者有一个更流行的名字叫做NavMesh。不管是Unity还是UE都使用了这一套组件。
Github上有更为详细的源码、Demo和说明:https://github.com/recastnavigation/recastnavigation
这一篇会阐述UE4是如何划分Tile,并基于Tile构建导航数据的。在后续的文章中,会详细介绍每个Tile如何构建导航数据。
在 Unreal Engine 4 (UE4) 中,Tile(瓦片)是构建导航网格的基本单元。每个 Tile 是一个正方形的格子,具有独立的导航网格数据,这种设计使得导航系统在性能和灵活性上都得到了显著提升。以下是对 Tile 概念及其在导航网格构建中的作用的详细分析。
正方形格子:
独立的导航网格数据:
支持多线程构建导航网格数据:
动态更新导航网格:
以 Tile 为粒度加载导航网格:
在构建导航数据时,每个 Tile 会经历以下步骤:
体素化(Voxelization):
构造连续区域:
计算连通性:
裁剪可行走区域:
划分区域:
竖直方向分层并合并:
写入 rcHeightfieldLayer 类:
rcHeightfieldLayer
类中,以供 Detour(UE4 的寻路系统)使用。这一步骤是将构建的导航数据整合到最终的导航网格中。Tile 的设计在 UE4 的导航系统中起到了至关重要的作用。通过将导航网格划分为独立的 Tile,UE4 能够实现高效的多线程构建、动态更新和按需加载。这种灵活的导航网格管理方式不仅提高了性能,还增强了游戏的可玩性和响应性。后续的文章将深入探讨每个步骤的具体实现和优化策略,帮助开发者更好地理解和利用基于 Tile 的导航数据构建。
在 Unreal Engine 4 (UE4) 中,导航系统使用八叉树(Octree)来高效管理与导航相关的物理数据。八叉树的结构使得空间划分更加高效,能够快速定位和更新与导航相关的对象。以下是关于八叉树如何更新的详细说明。
八叉树是一种空间划分的数据结构,适用于三维空间中的对象管理。在 UE4 的导航系统中,八叉树用于存储和管理与导航相关的 Actor 和 Component 的数据。通过使用 TOctree2
模板类,UE4 能够高效地进行空间查询和更新。
当与导航相关的 Actor 或 Component 的 Transform 发生变化时,导航系统会通过一系列的事件和委托来更新八叉树。以下是更新流程的详细步骤:
USceneComponent
的 Transform 发生变化时,确实会通过 InternalSetWorldLocationAndRotation
方法进行检测和处理。以下是这个过程的详细说明:Transform 变化的调用:
USceneComponent
的位置和旋转时,通常会调用 InternalSetWorldLocationAndRotation
方法。这个方法是设置组件的新位置和旋转的核心逻辑。bool USceneComponent::InternalSetWorldLocationAndRotation(FVector NewLocation, const FQuat& RotationQuat, bool bNoPhysics, ETeleportType Teleport)
{
// ... 其他逻辑
// 检查组件是否注册且可以影响导航
if (IsRegistered() && bCanEverAffectNavigation)
{
PostUpdateNavigationData(); // 调用更新导航数据的方法
}
// ... 其他逻辑
}
条件检查:
PostUpdateNavigationData
之前,首先会检查两个条件:
true
,则表示该组件的变化可能会影响导航网格的生成和更新。调用 PostUpdateNavigationData:
PostUpdateNavigationData
方法将被调用。这个方法的主要作用是通知导航系统该组件的 Transform 已经发生变化。void USceneComponent::PostUpdateNavigationData()
{
SCOPE_CYCLE_COUNTER(STAT_ComponentPostUpdateNavData); // 性能计数器
FNavigationSystem::OnComponentTransformChanged(*this); // 通知导航系统
}
通知导航系统:
PostUpdateNavigationData
方法中,使用 FNavigationSystem::OnComponentTransformChanged
静态方法来通知导航系统。这个方法会触发相应的委托,进而执行导航系统的更新逻辑。通过这种机制,USceneComponent
的 Transform 变化能够被有效地检测到,并且在必要时通知导航系统进行更新。这种设计确保了导航数据的实时性和准确性,使得 AI 和其他依赖导航的系统能够在动态环境中做出正确的决策。开发者可以利用这一机制来确保游戏中的导航系统始终反映当前的场景状态。
USceneComponent
的 Transform 发生变化并调用 PostUpdateNavigationData
方法后,OnComponentTransformChanged
委托会被触发。这一过程是导航系统更新的关键环节。以下是这个过程的详细说明:触发委托:
PostUpdateNavigationData
方法中调用 FNavigationSystem::OnComponentTransformChanged(*this)
,这会触发 OnComponentTransformChanged
委托。这个委托是导航系统用来响应组件 Transform 变化的机制。void USceneComponent::PostUpdateNavigationData()
{
SCOPE_CYCLE_COUNTER(STAT_ComponentPostUpdateNavData); // 性能计数器
FNavigationSystem::OnComponentTransformChanged(*this); // 通知导航系统
}
调用 UpdateNavOctreeAfterMove:
OnComponentTransformChanged
委托的实现中,会调用 UpdateNavOctreeAfterMove
方法。这个方法的主要职责是检查八叉树(Nav Octree)是否需要更新,并在必要时进行更新。void FNavigationSystem::OnComponentTransformChanged(USceneComponent& Component)
{
// 其他逻辑...
UpdateNavOctreeAfterMove(Component);
}
检查八叉树更新的必要性:
UpdateNavOctreeAfterMove
方法会根据组件的新位置和旧位置来判断是否需要更新八叉树。具体来说,它会检查以下几个方面:
void FNavigationSystem::UpdateNavOctreeAfterMove(USceneComponent& Component)
{
// 检查组件的移动是否需要更新八叉树
if (ShouldUpdateNavOctree(Component))
{
// 执行八叉树更新逻辑
UpdateNavOctree(Component);
}
}
执行八叉树更新:
UpdateNavOctree
方法将被调用。这个方法会处理八叉树的更新逻辑,包括:
通过 OnComponentTransformChanged
委托的触发,导航系统能够及时响应组件 Transform 的变化,并通过 UpdateNavOctreeAfterMove
方法检查和更新八叉树。这一机制确保了导航数据的实时性和准确性,使得 AI 和其他依赖导航的系统能够在动态环境中做出正确的决策。开发者可以利用这一机制来确保游戏中的导航系统始终反映当前的场景状态,从而提升游戏的整体体验。
UpdateNavOctreeElement
方法会被调用。以下是这个过程的详细说明:调用 UpdateNavOctreeElement:
UpdateNavOctreeElement
方法会被调用。这个方法负责处理八叉树中元素的注册和注销。void FNavigationSystem::UpdateNavOctreeElement(USceneComponent& Component)
{
// 检查当前元素是否已经存在于八叉树中
if (NavOctree->IsElementRegistered(Component))
{
// 如果存在,注销原有的节点
NavOctree->UnregisterElement(Component);
}
// 注册新的节点
NavOctree->RegisterElement(Component);
}
检查元素是否存在:
UpdateNavOctreeElement
方法中,首先会调用 IsElementRegistered
方法来检查当前的组件(元素)是否已经存在于八叉树中。这是为了避免重复注册。注销原有节点:
UnregisterElement
方法将其从八叉树中注销。这一步是必要的,因为组件的 Transform 发生了变化,原有的节点位置可能不再有效。注册新的节点:
RegisterElement
方法将组件的新位置注册到八叉树中。这一步确保了导航系统能够使用组件的新位置进行路径计算和导航。通过 UpdateNavOctreeElement
方法,导航系统能够有效地管理八叉树中的元素。这个过程确保了当组件的 Transform 发生变化时,导航数据能够及时更新,从而保持导航系统的准确性和实时性。开发者可以利用这一机制来确保游戏中的导航系统始终反映当前的场景状态,提升 AI 和其他依赖导航的系统的表现。
脏区域的定义:
标记脏区域:
RemoveNavOctreeElementId
方法来标记受影响的区域为脏区域。这一过程确保了相关的区域在后续的更新中被重新计算。void FNavigationSystem::RemoveNavOctreeElementId(FNavOctreeElementId ElementId)
{
// 获取元素的边界
FBox ElementBounds = GetElementBounds(ElementId);
// 标记受影响的区域为脏区域
MarkDirtyAreas(ElementBounds);
}
脏区域的存储:
TArray<FBox> DirtyAreas; // 存储脏区域的数组
更新脏区域:
FRecastNavMeshGenerator
)中,会定期检查脏区域,并对其进行更新。更新过程通常包括:
void FRecastNavMeshGenerator::UpdateDirtyAreas()
{
for (const FBox& DirtyArea : DirtyAreas)
{
// 重新生成相应的 Tile
GenerateTile(DirtyArea);
}
// 清空已处理的脏区域
DirtyAreas.Empty();
}
避免不必要的计算:
增量更新:
脏区域的管理机制在导航系统中起着至关重要的作用。通过标记和管理脏区域,导航系统能够确保导航数据的准确性,同时避免不必要的计算。这种机制不仅提高了性能,还使得动态场景中的导航系统能够更有效地响应环境变化,从而提升了游戏的整体体验。开发者可以利用这一机制来优化导航系统的表现,确保 AI 和其他依赖导航的系统能够在不断变化的环境中做出正确的决策。
调用 RemoveNavOctreeElementId:
UpdateNavOctreeElement
方法中,当需要注销原有的节点时,会调用 RemoveNavOctreeElementId
方法。这个方法的主要职责是从八叉树中移除元素,并标记受影响的区域为脏区域。void FNavigationSystem::UnregisterElement(USceneComponent& Component)
{
// 获取当前元素的 ID
FNavOctreeElementId ElementId = GetElementId(Component);
// 调用方法标记脏区域
RemoveNavOctreeElementId(ElementId);
}
标记脏区域:
RemoveNavOctreeElementId
方法中,会根据组件的旧位置和大小来确定受影响的区域,并将这些区域标记为脏区域。这意味着这些区域的导航数据需要在后续的更新中被重新计算。void FNavigationSystem::RemoveNavOctreeElementId(FNavOctreeElementId ElementId)
{
// 获取元素的边界
FBox ElementBounds = GetElementBounds(ElementId);
// 标记受影响的区域为脏区域
MarkDirtyAreas(ElementBounds);
}
更新导航网格生成器:
FRecastNavMeshGenerator
)会在后续的更新过程中检查这些脏区域,并重新生成相应的 Tile。这确保了导航网格的准确性和实时性。void FRecastNavMeshGenerator::UpdateDirtyAreas()
{
// 遍历所有脏区域并更新导航网格
for (const FBox& DirtyArea : DirtyAreas)
{
// 重新生成相应的 Tile
GenerateTile(DirtyArea);
}
}
通过在注销节点时调用 RemoveNavOctreeElementId
方法,导航系统能够有效地标记受影响的区域为脏区域。这一机制确保了导航网格生成器能够在后续的更新中重新计算和生成受影响的 Tile,从而保持导航数据的准确性和实时性。开发者可以利用这一机制来确保游戏中的导航系统始终反映当前的场景状态,提升 AI 和其他依赖导航的系统的表现。
通过上述流程,UE4 的导航系统能够高效地更新八叉树,以反映场景中 Actor 和 Component 的变化。这种机制确保了导航网格的实时性和准确性,使得 AI 能够在动态环境中做出正确的导航决策。八叉树的使用不仅提高了空间管理的效率,还为复杂场景的导航提供了必要的支持。以下是对八叉树更新机制的进一步总结和扩展:
脏区域的管理:
父节点更新:
UpdateNavOctreeParentChain
方法会被调用,以确保所有父节点也得到更新。这是因为在八叉树中,子节点的变化可能会影响到其父节点的状态,因此需要向上更新整个父链。多线程支持:
网络同步:
性能优化:
在实际开发中,开发者可以通过以下方式与导航系统进行交互:
自定义导航行为:开发者可以通过实现 INavRelevantInterface
接口来定义自定义的导航行为,使得特定的 Actor 或 Component 能够影响导航网格的生成和更新。
事件驱动更新:通过监听特定的事件(如 Actor 的注册、注销、移动等),开发者可以主动触发导航系统的更新,确保导航数据的实时性。
调试和优化:UE4 提供了调试工具,可以帮助开发者可视化导航网格和脏区域,便于发现和解决潜在的问题。
UE4 的导航系统通过八叉树的使用,提供了一种高效的方式来管理和更新导航数据。通过事件驱动的更新机制、脏区域管理、父节点更新等策略,导航系统能够在动态环境中保持高效和准确。这种设计不仅适用于单人游戏,也能很好地支持网络游戏中的多玩家场景。开发者可以利用这些功能来创建复杂的 AI 导航行为,提升游戏的整体体验。
在UE4中,导航系统的分块构建过程是一个复杂而高效的机制,旨在支持动态场景中的导航数据更新。以下是对该过程的详细分析,特别是从UNavigationSystemV1::Build()
开始的全量构建流程。
构建过程的第一步是收集场景中所有的ANavMeshBoundsVolume
包围盒。这一过程在GatherNavigationBounds()
函数中实现,主要通过遍历世界中的所有相关类来完成。
void UNavigationSystemV1::GatherNavigationBounds(TArray<FNavigationBounds>& OutBounds)
{
// 遍历场景中的所有NavMeshBoundsVolume
for (TActorIterator<ANavMeshBoundsVolume> It(World); It; ++It)
{
// 收集包围盒信息
FNavigationBounds NewBounds;
NewBounds.Bounds = It->GetComponentsBoundingBox();
OutBounds.Add(NewBounds);
}
}
对于每个包围盒,使用配置的TileSize
和CellSize
来划分Tile。每个Tile的大小是TileSize * CellSize
,并且每个Tile都是一个轴对齐的包围盒(AABB)。这一过程在MarkDirtyTiles()
函数中实现。
void FRecastNavMeshGenerator::MarkDirtyTiles(const TArray<FNavigationDirtyArea>& DirtyAreas)
{
TSet<FPendingTileElement> DirtyTiles;
for (const FNavigationDirtyArea& DirtyArea : DirtyAreas)
{
FBox AdjustedAreaBounds = DirtyArea.Bounds;
const FRcTileBox TileBox(AdjustedAreaBounds, RcNavMeshOrigin, TileSizeInWorldUnits);
for (int32 TileY = TileBox.YMin; TileY <= TileBox.YMax; ++TileY)
{
for (int32 TileX = TileBox.XMin; TileX <= TileBox.XMax; ++TileX)
{
FPendingTileElement Element;
Element.Coord = FIntPoint(TileX, TileY);
DirtyTiles.Add(Element);
}
}
}
// 将脏区域的Tile记录到PendingDirtyTiles中
PendingDirtyTiles.Empty(DirtyTiles.Num());
for (const FPendingTileElement& Element : DirtyTiles)
{
PendingDirtyTiles.Add(Element);
}
// 按距离主角的远近对Tile进行排序
if (NumTilesMarked > 0)
{
SortPendingBuildTiles();
}
}
在构建过程中,UE4使用多线程来生成导航数据。每个Tile由一个独立的任务(Task)负责,这部分逻辑在FRecastNavMeshGenerator::ProcessTileTasks()
中实现。EnsureBuildCompletion()
函数负责驱动这个过程。
void FRecastNavMeshGenerator::EnsureBuildCompletion()
{
do
{
const int32 NumTasksToProcess = (bDoAsyncDataGathering ? 1 : MaxTileGeneratorTasks) - RunningDirtyTiles.Num();
ProcessTileTasks(NumTasksToProcess);
// 等待所有任务完成
for (FRunningTileElement& Element : RunningDirtyTiles)
{
Element.AsyncTask->EnsureCompletion();
}
}
while (GetNumRemaningBuildTasks() > 0);
}
每个任务负责对其对应的Tile进行完整的体素化、分层、区域划分和构建相邻三角形导航数据等过程。关键函数包括FRecastTileGenerator::GenerateCompressedLayers()
和FRecastTileGenerator::GenerateNavigationData()
。
bool FRecastTileGenerator::GenerateCompressedLayers(FNavMeshBuildContext& BuildContext)
{
// 创建高度场,准备构建体素
if (!CreateHeightField(BuildContext, RasterContext))
{
return false;
}
// 将三角形光栅化到体素上
ComputeRasterizationMasks(BuildContext, RasterContext);
RasterizeTriangles(BuildContext, RasterContext);
// 根据边界剔除边界之外的体素
if (TileConfig.bPerformVoxelFiltering && !bFullyEncapsulatedByInclusionBounds)
{
ApplyVoxelFilter(RasterContext.SolidHF, TileConfig.walkableRadius);
}
// 构建压缩高度场
if (!BuildCompactHeightField(BuildContext, RasterContext))
{
return false;
}
// 生成导航层
if (!GenerateNavigationData(BuildContext, RasterContext))
{
return false;
}
GenerateNavigationData()
函数负责将高度场转换为可用于AI导航的三角形网格。这个过程包括以下几个关键步骤:
bool FRecastTileGenerator::GenerateNavigationData(FNavMeshBuildContext& BuildContext, FRasterContext& RasterContext)
{
// 进行区域划分
if (!BuildRegions(BuildContext, RasterContext))
{
return false;
}
// 构建相邻三角形
if (!BuildAdjacency(BuildContext, RasterContext))
{
return false;
}
// 其他导航数据的生成逻辑...
return true;
}
一旦所有的Tile都完成了生成过程,导航系统会将这些Tile合并到主导航网格中。此时,系统会更新导航数据,以确保AI能够使用最新的导航信息。
void FRecastNavMeshGenerator::FinalizeTileBuilds()
{
// 合并所有生成的Tile到主导航网格
for (const FPendingTileElement& Tile : PendingDirtyTiles)
{
// 将Tile的导航数据合并到主网格
MergeTileToNavMesh(Tile);
}
// 清理临时数据
ClearPendingTiles();
}
整个导航系统的分块构建过程可以总结为以下几个步骤:
ANavMeshBoundsVolume
,收集所有需要构建导航数据的区域。TileSize
和CellSize
,将每个包围盒划分为多个Tile。在实际应用中,开发者可以通过以下方式优化导航系统的性能:
TileSize
和CellSize
,以平衡性能和导航精度。UE4的导航系统通过分块构建和脏区域管理,能够高效地处理动态场景中的导航数据更新。理解这一过程的细节和优化方法,对于开发者在实现复杂AI行为和动态环境中的导航至关重要。
在UE4中,导航系统的Tile加载和使用是一个重要的过程,涉及到如何存储、序列化和反序列化导航数据。以下是对这一过程的详细解析。
在UE4中,FRecastTileData
结构体用于承载Recast导航数据。它包含了Tile的位置信息、数据大小以及实际的导航数据。具体结构如下:
struct FRecastTileData
{
struct FRawData
{
FRawData(uint8* InData);
~FRawData();
uint8* RawData;
};
FRecastTileData();
FRecastTileData(int32 TileDataSize, uint8* TileRawData, int32 TileCacheDataSize, uint8* TileCacheRawData);
// Tile的原始位置
int32 OriginalX; // Tile的X坐标
int32 OriginalY; // Tile的Y坐标
int32 X; // 当前Tile的X坐标
int32 Y; // 当前Tile的Y坐标
int32 Layer; // Tile的层级
// Tile数据
int32 TileDataSize; // Tile数据的大小
TSharedPtr<FRawData> TileRawData; // Recast使用的原始数据
// 压缩的Tile缓存层
int32 TileCacheDataSize; // 缓存数据的大小
TSharedPtr<FRawData> TileCacheRawData; // 压缩的Tile缓存数据
// Tile是否附加到NavMesh
bool bAttached;
};
在UE4中,Persistent Level在加载时会自动创建一个ARecastNavMesh
类的实例,导航数据会直接生成在这个对象中。而对于SubLevel,导航数据则会序列化到ULevel
类的NavMeshChunk
对象中,随场景加载。
在FPImplRecastNavMesh::Serialize()
函数中,导航数据的加载过程如下:
void FPImplRecastNavMesh::Serialize(FArchive& Ar, int32 NavMeshVersion)
{
if (Ar.IsLoading())
{
// 分配导航网格对象
ReleaseDetourNavMesh();
DetourNavMesh = dtAllocNavMesh();
}
// 读取每个Tile的内容,添加到DetourNavMesh中
for (int i = 0; i < NumTiles; ++i)
{
unsigned char* TileData = NULL;
int32 TileDataSize = 0;
// 读取每个TileData的内容
SerializeRecastMeshTile(Ar, NavMeshVersion, TileData, TileDataSize);
if (TileData != NULL)
{
// 将每个Tile数据添加到DetourNavMesh中
dtMeshHeader* const TileHeader = (dtMeshHeader*)TileData;
DetourNavMesh->addTile(TileData, TileDataSize, DT_TILE_FREE_DATA, TileRef, NULL);
// 处理CompressedData序列化
}
}
}
一旦Tile数据被加载到DetourNavMesh
中,AI系统就可以使用这些数据进行寻路。具体来说,A*算法会利用这些预生成的导航数据来计算路径。
在寻路时,AI会根据当前的位置和目标位置,使用A*算法在DetourNavMesh
中查找路径。这个过程通常涉及以下步骤:
DetourNavMesh
中的数据,执行A*算法来计算路径。在使用Tile进行寻路时,UE4的设计考虑了性能优化。通过将导航数据分块存储,系统可以在需要时快速加载和使用特定的Tile,而不是每次都加载整个导航网格。这种方法不仅提高了性能,还减少了内存占用。
UE4中的Tile加载和使用机制通过FRecastTileData结构体和FPImplRecastNavMesh::Serialize()函数实现了高效的导航数据管理。这种设计使得游戏开发者能够在复杂的场景中实现灵活且高效的AI寻路系统。
这一篇作为系列第一篇,从整个框架层,来分析了UE4是如何划分导航的Tile,如何分Tile构建导航网格,Tile的存储方式,以及加载上来如何使用Tile,并简单介绍了UE4如何支持动态更新导航。