前言
UE4的导航使用的是RecastDetour组件,这是一个开源组件,主要支持3D场景的导航网格导出和寻路,或者有一个更流行的名字叫做NavMesh。不管是Unity还是UE都使用了这一套组件。不过UE4对其算法做了不小的修改。
Github上有更为详细的源码、Demo和说明:https://github.com/recastnavigation/recastnavigation
本文使用的UE4源码版本为4.24.3,2020年2月25日的版本。
Recast采用了体素化的方式,来生成导航网格。大致分为三个步骤:
将场景体素化。形成一个多层的体素模型。
将不同层的体素模型划分为可重叠的2D区域。不同层的2D区域不同
沿着边界区域剥离出导航凸多边形。
本文将介绍第一部分,将场景体素化,以及后续的可行走层的过滤。
Recast 是一个用于生成导航网格的开源库,它采用体素化的方式来处理复杂的三维场景,以便为游戏中的 AI 寻路提供支持。以下是 Recast 生成导航网格的三个主要步骤的详细说明:
1. 场景体素化
在这一阶段,Recast 将三维场景转换为体素模型。体素化的过程包括以下几个关键步骤:
-
体素化处理:将场景中的几何体(如静态网格)转换为体素(Voxel)。每个体素代表场景中的一个小立方体,通常是一个固定大小的立方体网格。体素化的目的是将复杂的三维形状简化为更易于处理的网格。
-
多层体素模型:Recast 生成一个多层的体素模型,通常是通过将场景划分为多个高度层(或高度图)来实现的。每一层代表不同的高度信息,这样可以更好地处理具有不同高度的地形特征(如台阶、坡道等)。
-
体素标记:在体素化过程中,Recast 会对每个体素进行标记,以指示它们是可行走的(即可以被 AI 角色 traversed)还是不可行走的(如障碍物)。这一步骤对于后续的导航网格生成至关重要。
2. 划分可重叠的 2D 区域
在体素化完成后,Recast 将不同层的体素模型划分为可重叠的 2D 区域。这个过程包括:
-
区域划分:Recast 会分析体素模型,识别出可行走的区域,并将这些区域划分为多个 2D 区域。这些区域可以是多边形,通常是凸多边形,以便于后续的处理。
-
重叠区域处理:不同层的 2D 区域可能会重叠,Recast 会处理这些重叠区域,以确保生成的导航网格能够准确反映场景的可行走区域。
-
区域属性:每个区域可以具有不同的属性,例如不同的成本、类型或其他特征,这些属性可以影响 AI 的寻路决策。
3. 剥离导航凸多边形
在完成区域划分后,Recast 将沿着边界区域剥离出导航凸多边形。这个步骤的关键包括:
-
边界提取:Recast 会分析每个 2D 区域的边界,提取出可行走区域的边界线。这些边界线将用于生成最终的导航多边形。
-
凸多边形生成:通过对边界线的处理,Recast 会生成一组凸多边形,这些多边形代表了可行走的导航区域。凸多边形的优点在于它们易于计算和处理,适合用于寻路算法。
-
导航网格构建:最终,所有生成的凸多边形将被组合成一个完整的导航网格,供 AI 角色在游戏中进行寻路使用。
总结
Recast 的体素化导航网格生成过程通过将复杂的三维场景简化为易于处理的体素模型,并通过划分可重叠的 2D 区域和剥离导航凸多边形,最终生成高效的导航网格。这一过程不仅提高了寻路的效率,也为开发者提供了灵活的工具来处理各种复杂的场景。通过这种方式,Recast 能够支持多种类型的游戏和应用,满足不同的 AI 寻路需求。
体素概念介绍
体素的概念和像素类似,将三维空间分成一个个的小格子,如下图所示:

然后是一个概念span:代表某一方向上连续的格子。

体素化的目的,就是为了将整个场景转换为一个个格子内的体素,并标记每个span的可行走状态。以方便后续做区域划分和寻路。
体素(Voxel)是“体积元素”(Volume Element)的缩写,类似于二维图像中的像素(Pixel),但体素是三维空间中的基本单位。体素可以被视为一个小立方体,代表三维空间中的一个特定位置。以下是体素的几个关键概念和应用:
1. 体素的基本概念
-
三维空间的表示:体素用于表示三维空间中的物体或场景。每个体素都有一个特定的位置和属性(如颜色、密度、材料类型等)。
-
离散化:体素化是将连续的三维空间离散化为一系列体素的过程。这种离散化使得计算机能够更容易地处理和存储三维数据。
-
体素的大小:体素的大小决定了空间的分辨率。较小的体素可以提供更高的细节,但会增加计算和存储的需求。
2. 体素的表示方式
-
网格表示:体素通常以三维网格的形式存储,每个体素在三维坐标系中都有一个对应的坐标(x, y, z)。
-
体素数据结构:体素数据可以使用不同的数据结构来存储,例如三维数组、八叉树(Octree)或体素网格(Voxel Grid),以便于高效的存取和处理。
3. 体素的应用
-
计算机图形学:体素被广泛应用于计算机图形学中,尤其是在体积渲染、医学成像(如CT和MRI扫描)和科学可视化中。
-
游戏开发:在游戏开发中,体素用于创建三维环境和物体,尤其是在沙盒游戏(如《Minecraft》)中,体素化的世界允许玩家自由地构建和破坏。
-
物理模拟:体素可以用于物理模拟,例如流体动力学和碰撞检测,通过将物体分解为体素来简化计算。
-
人工智能:在AI寻路和导航系统中,体素化的场景可以帮助生成导航网格,使得AI能够更有效地在复杂环境中移动。
4. 体素的优缺点
-
优点:
- 简单性:体素化的表示方式相对简单,易于理解和实现。
- 灵活性:体素可以表示复杂的形状和结构,适用于多种应用场景。
- 高效性:在某些情况下,体素化可以提高计算效率,特别是在处理大规模场景时。
-
缺点:
- 存储需求:较高的分辨率会导致大量的体素数据,增加存储和计算的需求。
- 细节损失:体素化可能会导致细节的损失,尤其是在表示复杂的曲面时。
5. 体素与其他表示方式的比较
-
与多边形:传统的三维模型通常使用多边形(如三角形)来表示物体的表面,而体素则关注物体的体积。多边形适合于细致的表面渲染,而体素更适合于体积渲染和物理模拟。
-
与点云:点云是另一种三维表示方式,通常用于表示物体的表面。与体素相比,点云不提供体积信息,但可以更高效地表示稀疏数据。
总结
体素作为三维空间的基本单位,具有广泛的应用和重要性。它们在计算机图形学、游戏开发、物理模拟和人工智能等领域发挥着重要作用。通过体素化,开发者能够更有效地处理和表示复杂的三维场景,为用户提供更丰富的体验。
体素化流程
体素化流程是将三维场景转换为体素模型的关键步骤,通常用于生成导航网格。以下是体素化流程的详细说明,主要分为两个步骤:标记可行走的面和将网格光栅化,以及过滤可行走表面。
1. 标记可行走的面
在这一阶段,Recast 使用 rcMarkWalkableTrianglesCos() 函数来标记场景中可行走的面。这个过程的主要逻辑包括:
-
输入数据:该函数接收场景中所有物体的顶点和三角形数据。这些数据通常来自于场景的几何体。
-
法线计算:函数会计算每个三角形的法线,以确定其朝向。法线的方向对于判断三角形是否可行走至关重要。
-
可行走性判断:通过计算三角形的法线与重力方向之间的夹角,函数可以判断该三角形是否可被视为可行走的表面。通常,夹角小于某个阈值(例如 45 度)被认为是可行走的。
-
标记过程:对于每个被标记为可行走的三角形,函数会在相应的数据结构中进行标记,以便后续处理。
2. 将网格光栅化
在标记完可行走的面后,Recast 使用 rcRasterizeTriangles() 函数将网格光栅化。这个过程的主要逻辑包括:
-
光栅化过程:该函数将标记为可行走的三角形转换为体素。具体来说,它会遍历每个可行走的三角形,并将其投影到体素网格中。
-
体素填充:在光栅化过程中,函数会根据三角形的空间位置和大小,填充相应的体素。这意味着在三维空间中,所有与可行走三角形相交的体素都会被标记为可行走。
-
多层体素模型:光栅化的结果通常是一个多层的体素模型,每一层代表不同的高度信息。这种多层结构有助于处理复杂的地形特征。
3. 过滤可行走表面
在完成光栅化后,Recast 使用 GenerateCompressedLayers() 函数来过滤可行走表面。这个过程的主要逻辑包括:
-
层的生成:该函数会根据光栅化的结果生成压缩的层数据。每一层代表一个高度层的可行走区域。
-
过滤过程:在生成层的过程中,函数会过滤掉不必要的体素,例如那些被标记为不可行走的体素。这样可以确保最终生成的导航网格只包含有效的可行走区域。
-
压缩存储:为了提高存储效率,函数会对生成的层数据进行压缩。这种压缩不仅减少了内存占用,还提高了后续寻路计算的效率。
总结
体素化流程通过标记可行走的面、将网格光栅化和过滤可行走表面,最终生成一个多层的体素模型。这一过程为后续的导航网格生成奠定了基础,使得 AI 能够在复杂的三维场景中进行有效的寻路。通过使用 Recast 提供的函数,开发者可以高效地处理场景数据,生成适合游戏和应用的导航解决方案。
标记可行走的面
在体素化流程中,标记可行走的面是一个重要的步骤,主要通过 rcMarkWalkableTrianglesCos() 函数实现。这个函数的逻辑相对简单,主要通过遍历所有三角形并计算其法线方向来判断哪些面是可行走的。以下是对该函数的详细解析:
函数解析
void rcMarkWalkableTrianglesCos(rcContext* /*ctx*/, const float walkableSlopeCos, const float* verts, int /*nv*/, const int* tris, int nt, unsigned char* areas)
{
float norm[3]; // 用于存储三角形的法线
for (int i = 0; i < nt; ++i) // 遍历所有三角形
{
const int* tri = &tris[i*3]; // 获取当前三角形的顶点索引
calcTriNormal(&verts[tri[0]*3], &verts[tri[1]*3], &verts[tri[2]*3], norm); // 计算三角形法线
// 检查该面是否可行走
if (norm[1] > walkableSlopeCos) // norm[1] 是法线的y分量
areas[i] = RC_WALKABLE_AREA; // 标记为可行走区域
}
}
关键步骤解析
-
参数说明:
rcContext* ctx:上下文参数,通常用于调试或记录信息,但在此函数中未使用。const float walkableSlopeCos:可行走斜率的余弦值,用于判断三角形的可行走性。const float* verts:顶点数组,包含场景中所有顶点的坐标。int nv:顶点数量,未在此函数中使用。const int* tris:三角形索引数组,每三个整数表示一个三角形的三个顶点索引。int nt:三角形数量。unsigned char* areas:用于存储每个三角形的区域标记,标记为可行走或不可行走。
-
法线计算:
- 使用
calcTriNormal()函数计算每个三角形的法线。法线是一个垂直于三角形平面的向量,通常用于判断三角形的朝向。
- 使用
-
可行走性判断:
- 通过检查法线的 y 分量(
norm[1])与walkableSlopeCos的关系来判断三角形是否可行走。walkableSlopeCos是一个预定义的阈值,通常是某个角度的余弦值(例如,45度的余弦值约为0.707)。 - 如果法线的 y 分量大于
walkableSlopeCos,则认为该三角形是可行走的,并将其在areas数组中标记为RC_WALKABLE_AREA。
- 通过检查法线的 y 分量(
总结
rcMarkWalkableTrianglesCos() 函数通过遍历所有三角形并计算其法线,判断哪些三角形是可行走的。这个过程是体素化流程中的关键步骤之一,为后续的光栅化和导航网格生成奠定了基础。通过合理设置 walkableSlopeCos 的值,可以灵活控制可行走面的斜率,从而适应不同的场景需求。
将网格光栅化
在计算机图形学中,光栅化是将几何图形(如三角形)转换为像素或体素的过程。具体是rcRasterizeTriangles() 和 rasterizeTri() 函数,这些函数的主要任务是将三角形投影到一个二维网格上,并标记出这些三角形所占据的空间。
光栅化的基本概念
光栅化的目标是识别出在给定的坐标系中(在这里是 xz 平面),三角形所覆盖的连续小格子(span)。这些格子可以是开放的(表示可行走的空间)或密闭的(表示不可行走的空间)。在这个过程中,三角形的每个顶点都会被映射到网格的相应格子中。
关键算法步骤
-
计算三角形的 AABB 包围盒:
- 首先,计算三角形在 xz 平面上的投影,并确定其包围盒(AABB)。这可以通过找到三角形三个顶点的最小和最大 x、z 值来实现。
int x0 = intMin(intverts[0][0], intMin(intverts[1][0], intverts[2][0])); int x1 = intMax(intverts[0][0], intMax(intverts[1][0], intverts[2][0])); int y0 = intMin(intverts[0][1], intMin(intverts[1][1], intverts[2][1])); int y1 = intMax(intverts[0][1], intMax(intverts[1][1], intverts[2][1])); -
遍历三角形的边:
- 对于三角形的每一条边,遍历其在 z 方向和 x 方向上的格子,并记录每个格子中 x 的最大值和最小值。这些信息将用于后续的 span 合并。
-
记录 span:
- 使用
addFlatSpanSample()函数记录每个 z 层中 x 的范围。这个过程会在每个 z 层中标记出连续的 span。
static inline void addFlatSpanSample(rcHeightfield& hf, const int x, const int y) { hf.RowExt[y + 1].MinCol = intMin(hf.RowExt[y + 1].MinCol, x); hf.RowExt[y + 1].MaxCol = intMax(hf.RowExt[y + 1].MaxCol, x); } - 使用
-
合并 span:
- 在遍历完所有的 z 层后,使用
addSpan()函数将相邻的 span 合并。合并的条件是检查新 span 是否与已有 span 相交,并更新它们的最小和最大高度。
static void addSpan(rcHeightfield& hf, const int x, const int y, const unsigned short smin, const unsigned short smax, const unsigned char area, const int flagMergeThr) { while (cur) { if (cur->data.smin < s->data.smin) s->data.smin = cur->data.smin; if (cur->data.smax > s->data.smax) s->data.smax = cur->data.smax; // 合并可行走标记 if (rcAbs((int)s->data.smax - (int)cur->data.smax) <= flagMergeThr) s->data.area = rcMax(s->data.area, cur->data.area); } } - 在遍历完所有的 z 层后,使用
总结
光栅化过程的核心在于通过遍历三角形的边,记录其在网格中的投影,并最终合并相邻的 span。这一过程不仅涉及到几何计算,还需要对数据结构的有效管理,以确保性能和可读性。虽然 UE4 对原始代码进行了优化,但可读性可能有所下降,尤其是在涉及复杂的指针操作和条件判断时。
过滤可行走表面
在处理可行走表面的过滤时,主要有三个步骤,分别是:
-
过滤低悬的可行走障碍物:这个步骤的目的是将那些在可行走的span上方且高度差小于
walkableClimb的span标记为可行走。这样可以处理像楼梯这样的场景,使得玩家可以顺利地在不同高度的表面之间移动。代码实现的核心逻辑是遍历每个span,检查其下方的span是否可行走,并且两者的高度差是否在可攀爬的范围内。如果满足条件,则将当前span标记为可行走。
void rcFilterLowHangingWalkableObstacles(rcContext* ctx, const int walkableClimb, rcHeightfield& solid) { // ... for (rcSpan* s = solid.spans[x + y*w]; s; ps = s, s = s->next) { const bool walkable = s->data.area != RC_NULL_AREA; if (!walkable && previousWalkable) { if (rcAbs((int)s->data.smax - (int)ps->data.smax) <= walkableClimb) s->data.area = previousArea; } previousWalkable = walkable; previousArea = s->data.area; } // ... } -
过滤悬崖span:在这个步骤中,检查span与其邻居之间的高度差。如果高度差超过
walkableClimb,则认为该span不可达。此外,还要检查邻居之间的高度差,以确保没有陡坡的存在。代码实现中,遍历每个span的邻居,计算高度差并进行判断。如果发现高度差过大,则将该span标记为不可行走。
void rcFilterLedgeSpans(rcContext* ctx, const int walkableHeight, const int walkableClimb, rcHeightfield& solid) { // ... for (int dir = 0; dir < 4; ++dir) { // 遍历四个邻居 // ... if (rcMin(top,ntop) - rcMax(bot,nbot) > walkableHeight) minh = rcMin(minh, nbot - bot); // ... } if (minh < -walkableClimb) s->data.area = RC_NULL_AREA; if ((asmax - asmin) > walkableClimb) { s->data.area = RC_NULL_AREA; } } -
过滤低高度的可行走span:最后一步是检查可行走的span上方是否有不可行走的障碍物。如果有,则将该span标记为不可行走。
代码实现中,遍历每个span,检查其上方的span的高度。如果上方的span与当前span的高度差小于等于
walkableHeight,则将当前span标记为不可行走。void rcFilterWalkableLowHeightSpans(rcContext* ctx, const int walkableHeight, rcHeightfield& solid) { for (int y = 0; y < h; ++y) { for (int x = 0; x < w; ++x) { for (rcSpan* s = solid.spans[x + y*w]; s; s = s->next) { const int bot = (int)(s->data.smax); const int top = s->next ? (int)(s->next->data.smin) : MAX_HEIGHT; if ((top - bot) <= walkableHeight) s->data.area = RC_NULL_AREA; } } } }
通过这三个步骤,可以有效地过滤出可行走的表面,确保角色在游戏环境中能够顺利移动。
继续深入探讨可行走表面的过滤过程,我们可以进一步分析每个步骤的细节和潜在的优化方法。
1. 过滤低悬的可行走障碍物
在这个步骤中,我们的目标是确保在可行走的span上方的span,如果其高度差小于walkableClimb,也被标记为可行走。这对于处理像楼梯、斜坡等场景非常重要。
代码分析
void rcFilterLowHangingWalkableObstacles(rcContext* ctx, const int walkableClimb, rcHeightfield& solid)
{
// ...
for (rcSpan* s = solid.spans[x + y*w]; s; ps = s, s = s->next)
{
const bool walkable = s->data.area != RC_NULL_AREA;
if (!walkable && previousWalkable)
{
if (rcAbs((int)s->data.smax - (int)ps->data.smax) <= walkableClimb)
s->data.area = previousArea;
}
previousWalkable = walkable;
previousArea = s->data.area;
}
// ...
}
优化建议
- 提前终止:如果在遍历过程中发现当前span的高度差已经超过
walkableClimb,可以提前终止后续的检查,减少不必要的计算。 - 并行处理:如果数据量较大,可以考虑使用多线程来并行处理不同的区域,提高效率。
2. 过滤悬崖span
在这个步骤中,我们需要确保每个span与其邻居之间的高度差在可接受的范围内。如果高度差过大,则将该span标记为不可行走。
代码分析
void rcFilterLedgeSpans(rcContext* ctx, const int walkableHeight, const int walkableClimb, rcHeightfield& solid)
{
// ...
for (int dir = 0; dir < 4; ++dir)
{
// 遍历四个邻居
// ...
if (rcMin(top,ntop) - rcMax(bot,nbot) > walkableHeight)
minh = rcMin(minh, nbot - bot);
// ...
}
if (minh < -walkableClimb)
s->data.area = RC_NULL_AREA;
if ((asmax - asmin) > walkableClimb)
{
s->data.area = RC_NULL_AREA;
}
}
优化建议
- 邻居缓存:可以缓存每个span的邻居信息,避免在每次检查时都重新计算邻居的索引。
- 动态调整:根据场景的复杂性动态调整
walkableClimb和walkableHeight的值,以适应不同的环境。
3. 过滤低高度的可行走span
最后一步是检查可行走的span上方是否有不可行走的障碍物。如果有,则将该span标记为不可行走。
代码分析
void rcFilterWalkableLowHeightSpans(rcContext* ctx, const int walkableHeight, rcHeightfield& solid)
{
for (int y = 0; y < h; ++y)
{
for (int x = 0; x < w; ++x)
{
for (rcSpan* s = solid.spans[x + y*w]; s; s = s->next)
{
const int bot = (int)(s->data.smax);
const int top = s->next ? (int)(s->next->data.smin) : MAX_HEIGHT;
if ((top - bot) <= walkableHeight)
s->data.area = RC_NULL_AREA;
}
}
}
}
优化建议
- 高度预处理:在处理之前,可以对所有span的高度进行预处理,计算出每个span的最大和最小高度,以加快后续的判断。
- 空间划分:将空间划分为多个区域,针对每个区域单独处理,减少每次遍历的span数量。
总结
通过这三个步骤的过滤,我们能够有效地识别出可行走的表面,确保角色在游戏环境中的移动是自然且流畅的。每个步骤都有其特定的逻辑和实现方式,同时也存在优化的空间。通过合理的算法设计和数据结构选择,可以显著提高性能,尤其是在处理复杂场。
3116

被折叠的 条评论
为什么被折叠?



