Unity-NavMesh详解-其一

今天我们来详细地探究一下Unity的NavMesh这一性能强大的组件:

NavMesh基本使用

NavMesh简单地说本质上是一个自动寻路的AI组件,我们首先来学习基本的使用。

画面中我已经添加好了地面,目标,障碍物以及玩家四个要素。

注意我们要进行NavMesh的一些前提工作:

在所有我们想要加入NavMesh网格导航的场景元素的static处选择Navigation Static。

然后在window->ai处可以看到Navigation。

打开后如图所示:

可以看到有四个部分,我们一部分一部分地来看:

Agent,我们可以理解为导航代理,简单地说,如果我们借助NavMesh实现自动寻路,那么所有有自动寻路功能的对象都需要挂载NavMeshAgent,这个Agent定义的东西包括挂载对象在NavMesh网格中的半径、高度、最大可跨越的台阶高度(Step Height)、最大坡度(对于这个对象而言,超过这个坡度的网格被视作不可到达),我们还可以为这个代理命名。

Areas,我们可以理解为导航的区域,因为NavMesh的底层是启发式算法,我们需要预先设定好各个不同区域的移动成本(cost),图中的不同索引对应的就是不同区域的移动成本(0为可行走,1为不可行走)。

Bake,也就是烘焙。烘焙决定了我们会如何去根据场景中想要被NavMesh使用的具体情况来生成网格。

在这里我们可能要插入一下关于NavMesh的使用原理:

我们首先将场景中的设置为Navigation Static的物体的三角网格转换为体素(立方体小格子),然后过滤掉不可行走的区域(根据Bake中设定的参数如最大坡度最大台阶高度等),生成一个高度场(此时在XZ平面是一个个大小相等的格子,而在Y轴则是一个个高度值);然后我们将相邻的体素合并成连续的可行走区域,之后我们再将体素的边界转化为一个个凹多边形(因为很多复杂的情况可行走区域只能转换成凹多边形),然后再将凹多边形转换为凸多边形(方便进行路径搜索),凸多边形最后会变成点和线的集合,也就是可行走区域最后会转化成一个图结构,在图上我们就可以去使用寻路算法了。

而Bake本质上就是我们去执行NavMesh体素化时的一些考虑参数,比如我们可以看见的Agent的半径、高度等,值得注意的是,我们这里的Agent 参数和之前Agent的Radius的参数意义并不相同:比如这里的Agent Radius代表的是Agent与其他物体(也包括其他Agent)之间的最小距离,换句话说就是防止两个物体离得太近。除此之外还有生成离网链接等功能(离网链接即一些特殊情况下的路线)

这个模块就比较简单了,基本都是前面我们所说的内容。

现在我们只需要按下Bake键,就可以看到烘焙后的场景了:

可以看到有一层绿色笼罩在地面上,现在我们要做的是去实现一个基本的导航功能。

我们在Player身上挂载这个Nav Mesh Agent:是的,就是我们之前的Agent,可以看到我们的Agent Type就是我们之前定义的Humanoid,这里还有一些其他的参数如速度、角速度等。

有了Agent我们就可以导航了,不过我们还需要一个脚本来具体控制这个Player:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class PlayerControl : MonoBehaviour
{
    public NavMeshAgent nav; //获取导航网格代理组件,通过此组件来告知AI目标
    public Transform target; //目标的位置

    private void Update()
    {
        nav.SetDestination(target.position); //每帧更新目标位置
    }

}

填好参数之后,运行就可以看到:运行前:

运行后:

那么我们的基本的NavMesh就使用成功了。

一些其他的基本的Unity的NavMesh的内容大家可以在这里找到,这个博主写的更多是一些具体应用的方面,大家可以在这里查缺补漏。详解Unity中的Nav Mesh|导航寻路系统 (一)_navmesh-CSDN博客

Recast Navigation

Unity的NavMesh的底层其实是基于我们的Recast Navigation实现的,这里有大神的帖子:

Recast Navigation源码分析:导航网格Navmesh的生成原理 - 知乎 (zhihu.com)

RecastNavigation 是一个的导航寻路工具集,它包括了几个子集:

所以我们如果想要理解Recast Navigation,就只需要搞明白三件事情:怎么根据输入生成导航网格、怎么在导航网格上自动寻路、怎么针对一群人自动寻路。

如何生成导航网格?

接下来让我们来一个一个过程地展开介绍:

体素化:

我们可以把像素看作2D平面的最小单位,那么体素就是3D空间内的最小单位。在生成导航网格的最开始,我们把3D空间中的物体变成一个个体素,将基于边界的表现形式(例如:多边形模型,曲面等)转换成为体积的表现形式(例如:体素块)并构建一个高度场。

高度场:

体素化之后的3D空间就像一个个用积木块拼凑出来的空间一样,而高度场则是将整个体素空间压缩成一个二维平面与一个高度值;体素化之后的体素空间内每一个体素都会包含高度信息,我们的高度场本质上只是把这个高度单独提出来作为信息之后再去除掉高度维度,这样就保留了高度信息的同时压缩了维度。

高度场是容纳所有体素格子的AABB包围盒,在可视化之后可以看到,我们会按照CellSize对这个包围盒进行XZ方向均匀的一个切割(关于CellSize,基本的原则是CellSize越大则计算开销越小,CellSize越小则越精细),按照CellHeight接着对包围盒的高度进行均匀切割。切割后的一个个像素块就是一个个体素块,正常情况下我们用俯视的角度看到的应该是在XZ平面均匀分布的一个个方形,这些方形中存放着这个体素的高度区间的集合(Span),这个就是我们的高度场。

Span:

对于高度场中的一列体素,我们一般只会去关注他是否是“实心”的,有的话就代表这个体素的空间是有障碍的。我们会合并一列体素中的连续的实心体素,称为一个Span。

搜索邻居:

在NavMesh生成中,邻居搜索的核心目标是识别可行走区域的连通性,即判断哪些体素(或高度区间)可以相互连接形成连续的可行走表面。总的来说,搜索邻居的过程需要我们在高度场中的高度和平面综合考虑才可,我们会首先找到每一个体素块高度差值不超过预设值的体素块(预设值如Unity的最大台阶高度),然后再在XZ平面检查是否是邻接关系,最后判断是否是连续的可行走区域。

实体高度场:

体素化的最终目的就是为了创建一个实体的高度场,这个实体的高度场会表面具体哪里有障碍,同时也表明了连通的可行走区域。

需要注意的是,在之前我们的措辞中并没有说明关于从贴图转换到体积表达的具体过程其实就是把构成贴图的三角形转换成立方体,而这个过程中我们采取的策略是“保守体素化”:保证生成的体素将原来的几何图形全部包围。

生成实体高度场之后,我们就需要去考虑一些预设值的影响了,如最大坡度,最大可达台阶高度等,将实体高度场中不符合要求的部分过滤掉。

裁剪多边形表面是否可行走:

对于每一个Span,我们去判断该Span顶部体素的几何体的斜率是否低于可行走表面的最大坡度即可判断该表面是否可以通过:

相关源码如下:

/// The maximum slope that is considered walkable. [Limits: 0 <= value < 90] [Units: Degrees] 
   float walkableSlopeAngle;

//标记可行走三角面
void rcMarkWalkableTriangles(rcContext* ctx, const float walkableSlopeAngle,
							 const float* verts, int nv,
							 const int* tris, int nt,
							 unsigned char* areas)
{
	rcIgnoreUnused(ctx);
	rcIgnoreUnused(nv);
	
	const float walkableThr = cosf(walkableSlopeAngle/180.0f*RC_PI);
       // 三角形面的法向量
	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);
		// Check if the face is walkable.
		if (norm[1] > walkableThr)
			areas[i] = RC_WALKABLE_AREA;
	}
}

static void calcTriNormal(const float* v0, const float* v1, const float* v2, float* norm)
{
	float e0[3], e1[3];
	rcVsub(e0, v1, v0);
	rcVsub(e1, v2, v0);
	rcVcross(norm, e0, e1);
	rcVnormalize(norm);
}
float walkableSlopeAngle;

显然这是我们定义的可行走的最大坡度,这里是度数制。

	rcIgnoreUnused(ctx);
	rcIgnoreUnused(nv);

这两句是一个显式告诉编译器忽略未使用的参数ctx和nv,因为一般来说如果出现未使用的参数时编译器可能会抛出警告,这个相当于告诉编译器自己故意定义了参数而不使用。

	const float walkableThr = cosf(walkableSlopeAngle/180.0f*RC_PI);

这句代码用于将设定的最大可行走坡度转换为弧度制之后的余弦值。

原理大致如图:

 可以看到我们三个点组成的三角形的法向量的y轴分量其实就是这个三角形的坡度。

static void calcTriNormal(const float* v0, const float* v1, const float* v2, float* norm)
{
	float e0[3], e1[3];
	rcVsub(e0, v1, v0);
	rcVsub(e1, v2, v0);
	rcVcross(norm, e0, e1);
	rcVnormalize(norm);
}

这个函数中使用的rcVsub,rcVcross,rcVnormalize分别代表向量的减法,叉乘和归一化,这里的诸多操作其实就是计算三角形的法向量:v0,v1,v2三个float变量代表三个顶点,我们通过相减得到两个边向量,然后两个边向量叉乘得到法向量,法向量归一化之后就是单位法向量。

		// Check if the face is walkable.
		if (norm[1] > walkableThr)
			areas[i] = RC_WALKABLE_AREA;

如果计算三角形单位法向量的函数得到的单位法向量的y分量大于我们的余弦值,则证明这个坡度是可行走的(角度越大余弦值越小)。

光珊化三角形,添加span到实体高度场:

获取到具体可行走的多边形后,我们就要将这些多边形添加到实体高度场了,但是现在的问题是我们的多边形——其实是由一个个三维的三角形组成的——是一个非常难以处理的内容,我们不知道如何去把这个多边形放置在合适位置的高度场上。

如果你直接去处理多边形本身,那当然非常难以处理,误差可能很大。我们要做的是将这个多边形分割成一个个三角形,然后再将这一个个三角形转换成实体高度场需要的X-Z轴与高度信息,这里就牵扯到一个切割凸多边形的问题:我们的一个个三角形在X-Z轴的平面对应的体素格子里可能是一个个凸多边形。

从遍历凸多边形的每条边开始,通过检测每条边与分割线的空间关系来切割多边形:​

  • 若边的两个端点均位于分割线同侧,则保留该边,将其起点加入对应侧的新多边形顶点集;
  • 若边跨越分割线​(端点分居两侧),则计算交点并分割该边——交点同时加入两侧新多边形,起点按位置归属分配至具体子多边形;
  • 遇端点落在分割线上时,将其同步标记为上下两个新多边形的共享顶点。
    最终按序连接每侧的保留边、分割点和交点,生成两个边界连续的子凸多边形。​

此流程通过沿X/Z轴方向每隔cellsize单位设置平行分割线(如图中五边形被网格线切割所示),将三角形表面递归分解为与网格列对齐的片段,继而通过取各片段顶点的最小Y值(体素下沿)和最大Y值(体素上沿),实现三维表面到体素化格子(x,z坐标+Y轴区间)的无损转换,为导航网格生成奠定数据基础。

上图为例,可以看到一个三维空间中的三角形,我们现在要做的事是把这个三角形转换成X-Z空间与高度信息的形式。

 可以看到对于X-Z空间的一个体素格子来说,这个三角形的高度信息对应的就是这两个高度的体素格子与三角形的实际交点。

现在我们向实体高度场中添加span:

  • 与多边形相交的网格列。
  • 裁剪多边形的最小-最大高度范围(网格列被阻挡的部分)。

我们根据获得的最小最大高度范围来判断该span是否为可行走:

  • 如果新span不与网格列中的任何已经存在的span相交,则会创建一个新span。如果新span与已经存在的span相交或被已经存在的span所包含,则合并这两个span。
  • 当新span与已经存在的span合并时,必须评估生成的聚合span是否是可行走的。这个“可行走标志”只适用于span的顶部表面。如果设置了,就意味着span顶部表示多边形,该多边形有足够低的斜率是可行走的。
  • 如果新span的顶部高于它正在合并到的span,则新span的可行走标志用于聚合span;如果新span的顶部低于它正在合并到的span,那么我们不关心新span的标志,新span的标志被丢弃。
  • 如果新span的顶部与其合并到的span处于同一高度,则如果其中任意一个被认为是可行走的,聚合span就被标记为可行走。

一句话:看哪个span高,高的span能行走就走,否则就不能走。

这样我们的体素化过程就大体完成了:

体素化过程始于建立高度场这一基础数据结构,它将空间按配置的精度(cellSizecellHeight)划分为网格,并在每个网格位置(x,z)存储称为 ​Span​ 的高度区间链表(每个Span记录下界 smin 和上界 smax)。通过分析输入三角面片,系统裁剪多边形表面并基于坡度参数(walkableSlopeAngle)初步判断其是否可行走。核心操作是 ​光栅化三角形​:将每个三角形分割为与网格列对齐的凸多边形片段,计算其顶点Y值范围(即Span的smin/smax),并将这些携带可行走标记的Span添加到实体高度场中(处理Span重叠与合并)。最终生成的实体高度场完整记录了三维场景中所有实体(障碍)占据的空间及其表面的可行走属性。为准备后续区域生成,算法会对实体高度场进行搜索邻居操作:在垂直方向过滤不满足Agent高度(walkableHeight)的Span,并在水平方向检测满足可攀爬高度差(walkableClimb)的相邻Span连接关系,该步骤实质上是在转换数据为开放高度场(CompactHeightfield)​​ 并预计算连通性,为导航网格的洪水填充区域划分奠定基础。

筛选可走表面

span有一个标志,指示其顶面是否被认为是可行走的。 但是此标志仅根据与span相交的多边形的斜率来设置。现在是进行更多过滤的好时机。此过滤从某些span中删除可行走标志。

Recast Navigation中给出了三种过滤方法:

筛选低垂的可行走障碍

为了在低洼区域形成可走区域。比如楼梯,将不可走标记为可走。

算法比较简单:迭代每一列,从下往上遍历span,对于同列任意两个相邻的span1(下)和span2(上),当span1可走,并且span2不可走的时候,计算这两个span的上表面高度差Diff,如果Diff小于配置参数“walkableClimb”,则将span2设置为“可走”。

显然比较常见的场景就是从低向高的攀爬场景,只要两个span的高度差不大于参数就允许行走。

void rcFilterLowHangingWalkableObstacles(rcContext* ctx, const int walkableClimb, rcHeightfield& solid)
{
   rcAssert(ctx);

   rcScopedTimer timer(ctx, RC_TIMER_FILTER_LOW_OBSTACLES);

   const int w = solid.width;
   const int h = solid.height;

   for (int y = 0; y < h; ++y)
   {
      for (int x = 0; x < w; ++x)
      {
         rcSpan* ps = 0;
         bool previousWalkable = false;
         unsigned char previousArea = RC_NULL_AREA; 

         for (rcSpan* s = solid.spans[x + y*w]; s; ps = s, s = s->next)
         {
            const bool walkable = s->area != RC_NULL_AREA;  
            // 如果当前跨度不可行走,但其下方有可行走跨度,则将其上方的跨度也标记为可行走。
            if (!walkable && previousWalkable)
            {
               if (rcAbs((int)s->smax - (int)ps->smax) <= walkableClimb)
                  s->area = previousArea;
            }
            // Copy walkable flag so that it cannot propagate
            // past multiple non-walkable objects.
            previousWalkable = walkable;
            previousArea = s->area;
         }
      }
   }
}
过滤可行走的低高度区间

这个是同列相邻两区间之间距离的校验,保证最小可通过距离,可走变不可走。如果在span上方有太近的障碍物,那么span的顶面是不能穿越的。也就是说span的顶部与其上方span的底部至少有一个最小距离(最高的agent可以站在span上而不会与上方的障碍物发生碰撞)。想象一张桌子放在地板上,桌子下面的地板表面是平的,但由于桌子比较矮,不能在桌子底下行走,所以不能被认为是可穿越的(traversable)。

  • 迭代每一列,从下往上遍历span,如果当前span不可走,直接跳过。
  • 否则,计算当前span_B的上表面和其上相邻的span_A的下表面之间的高度差Diff。span_A不存在的话,高度差Diff设为无穷大。
  • 如果“高度差Diff”小于“walkableHeight”,则将当前span的标识位置为“不可走”。
void rcFilterWalkableLowHeightSpans(rcContext* ctx, int walkableHeight, rcHeightfield& solid)
{
   rcAssert(ctx);

   rcScopedTimer timer(ctx, RC_TIMER_FILTER_WALKABLE);

   const int w = solid.width;
   const int h = solid.height;
   const int MAX_HEIGHT = 0xffff;

   //从上面没有足够空间让 agent 站在那里的 span 中移除可行走标志。
   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->smax);
            const int top = s->next ? (int)(s->next->smin) : MAX_HEIGHT;
            if ((top - bot) <= walkableHeight)
               s->area = RC_NULL_AREA;
         }
      }
   }
}
过滤有效区间和陡峭区间

同列相邻两区间之间距离的校验,保证最小可通过距离。可走变不可走,这种过滤器会首先查找当前span的所有“有效邻居区间”。需要注意的是:如果当前span已经不可走,则直接跳过了。

  • 当前迭代区间为span1,遍历四个方向的轴邻居列,从下往上迭代轴邻居列的高度区间span2。
  • 假设span1的上面还与其有同列相邻的span4,span2上面有与其同列相邻的span3。如果span3或span4不存在,则认为其高度为无穷大。
  • 计算max(span1, span2)和min(span3, span4)的差值H,如果diff大于“配置可走高度walkableHeight”,则认为span2是一个“有效邻居区间”。可以证明,每一个轴邻居列上最多只会存在一个“有效邻居区间”。

可以很明显的看出,“有效邻居区间”限定了两种情况:

找到所有“有效邻居区间”后,过滤器会继续过滤“峭壁区间”。

  • 如果有任意轴邻居列上没有任何“高度区间”,则认为当前span是”峭壁区间“。
  • 如果有任意轴邻居列上没有”有效邻居区间“,则认为当前span是“峭壁区间”。
  • 如果有任意轴邻居列上存在“有效邻居区间”span2,span2的上表面低于span的上表面,且span和span2的上表面高度差Diff大于配置参数“可爬坡高度”,则认为当前span是”峭壁区间”。
  • 如果当前span本来可走,且判断为“峭壁区间”,则设置为“不可走”。

找不到峭壁区间后,还会进行一层间接峭壁区间的判断。

  • 所有上表面高于span上表面的“有效邻居区间”中,上表面最高的“有效邻居区间”的上表面高度设为a。
  • 所有上表面低于span上表面的“有效邻居区间”中,上表面最低的“有效邻居区间”的上表面高度设为b。
  • 如果a减b大于配置参数“爬坡高度walkableClimb”,则认为当前span处于峭壁上--间接峭壁区间。
  • 如果当前span本来可走,且判断为“间接峭壁区间”,则置为“不可走”。
void rcFilterLedgeSpans(rcContext* ctx, const int walkableHeight, const int walkableClimb,
                  rcHeightfield& solid)
{
   rcAssert(ctx);

   rcScopedTimer timer(ctx, RC_TIMER_FILTER_BORDER);

   const int w = solid.width;
   const int h = solid.height;
   const int MAX_HEIGHT = 0xffff;

   // 标记边界Span
   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)
         {
            // 跳过不可走span
            if (s->area == RC_NULL_AREA)
               continue;

            const int bot = (int)(s->smax);
            const int top = s->next ? (int)(s->next->smin) : MAX_HEIGHT;

            // 查找邻居的最小高度
            int minh = MAX_HEIGHT;

            // Min and max height of accessible neighbours.
            int asmin = s->smax;
            int asmax = s->smax;

            for (int dir = 0; dir < 4; ++dir)
            {
               int dx = x + rcGetDirOffsetX(dir);
               int dy = y + rcGetDirOffsetY(dir);
               //跳过越界的邻居
               if (dx < 0 || dy < 0 || dx >= w || dy >= h)
               {
                  minh = rcMin(minh, -walkableClimb - bot);
                  continue;
               }

               // From minus infinity to the first span.
               rcSpan* ns = solid.spans[dx + dy*w];
               int nbot = -walkableClimb;
               int ntop = ns ? (int)ns->smin : MAX_HEIGHT;
               //如果 spans 之间的间隙太小,则跳过 neightbour
               if (rcMin(top,ntop) - rcMax(bot,nbot) > walkableHeight)
                  minh = rcMin(minh, nbot - bot);

               //其余的跨度
               for (ns = solid.spans[dx + dy*w]; ns; ns = ns->next)
               {
                  nbot = (int)ns->smax;
                  ntop = ns->next ? (int)ns->next->smin : MAX_HEIGHT;
                  // 如果Span之间的间隙太小,则跳过 neightbour
                  if (rcMin(top,ntop) - rcMax(bot,nbot) > walkableHeight)
                  {
                     minh = rcMin(minh, nbot - bot);

                     // 查找最小/最大可访问邻居高度
                     if (rcAbs(nbot - bot) <= walkableClimb)
                     {
                        if (nbot < asmin) asmin = nbot;
                        if (nbot > asmax) asmax = nbot;
                     }

                  }
               }
            }

            //如果下降到任何邻居Span小于 walkableClimb,将Span标记为RC_NULL_AREA
            if (minh < -walkableClimb)
            {
               s->area = RC_NULL_AREA;
            }
            //如果所有邻居之间的差异太大,我们在陡坡上,将Span标记为RC_NULL_AREA
            else if ((asmax - asmin) > walkableClimb)
            {
               s->area = RC_NULL_AREA;
            }
         }
      }
   }
}

通过这三层过滤后就可以进行下一个步骤了。

划分可走表面为简单区域

我们在一个步骤中获取了具体可行走的表面,现在对于可行走表面我们需要一些步骤将可通过的区域分割成可以最终形成简单多边形的相邻的span(表面)区域。

创建开放高度场CompactHeightField

什么是开放高度场?和实体高度场有什么联系和区别?

无论是用实体高度场还是开放高度场,只是数据结构的不同,在逻辑上没有任何差别,Recast采用了开放高度场的数据结构进行体素化之后的所有算法。换句话说,再进行体素化构建实体高度场后,进行了一步实体高度场到开放高度场的转换。注意,开放高度场是在整个体素化过程结束之后才转换的,此时已经经过了高度区间的合并和过滤,换句话说,其实此时实体高度区间的下表面已经没有任何意义了。至于为什么选择开放高度场,更多的考虑可能是Recast关心的是场景中的“无实体障碍可通过空间”,而不关心“实体空间”。但是需要理解,本质上,使用哪一个高度场并没有什么区别。

因此,open span使用“地板(floor)”和“天花板(ceiling)”的术语。open span的地板是其关联的solid span的顶部。 open span的天花板是它所属的列中下一个更高的solid span的底部。如果没有更高的solid span,则open span的天花板是任意的最大值,例如整数的最大值。

CompactHeightField也叫紧缩高度场。我们不只关心实体空间,许多算法都是在solid span上方的空间上操作的。对于导航网格的生成来说,solid span的上表面是其最重要的部分,需要注意的是,开放高度场不是实体空间的简单反转。如果一个高度场列不包含任何solid span,则它也没有任何open span。 最低的solid span以下的区域也被忽略,只有solid span上方的空间由open span来表示。

看起来有点眼花撩乱了可能,但其实说白了实体高度场就是可以拿来给角色踩的那一层,就是你现实生活中脚下的部分;开放高度场则是从你脚下的那一层到你头顶的那一层,也就是你人物本身可以移动的高度部分,如果你头顶没有东西那就是无限大——代表云,天空或者太阳。

开放高度场的创建相对简单,循环遍历所有实体span,如果span被标记为可通过,则确定它的最高值与其所在列中下一个更高span的最低值之间的开放空间。 这些值分别形成了新的开放span的地板和天花板。如果一个实体span是它所在列中最高的span,则其关联的开放span将其天花板设置为任意高值(例如 Integer.MAX_VALUE)。新生成的开放span形成所谓的开放高度场:

bool rcBuildCompactHeightfield(rcContext* ctx, const int walkableHeight, const int walkableClimb,
                        rcHeightfield& hf, rcCompactHeightfield& chf)
{
   const int w = hf.width;
   const int h = hf.height;
   const int spanCount = rcGetHeightFieldSpanCount(ctx, hf);
    
    //这里省略一些非核心代码......

   const int MAX_HEIGHT = 0xffff;

   // 填充rcCompactCell和rcCompactSpan
   int idx = 0;
   for (int y = 0; y < h; ++y)
   {
      for (int x = 0; x < w; ++x)
      {
         const rcSpan* s = hf.spans[x + y*w];
         // If there are no spans at this cell, just leave the data to index=0, count=0.
         if (!s) continue;
         rcCompactCell& c = chf.cells[x+y*w];
         c.index = idx;
         c.count = 0;
         while (s)
         {
            if (s->area != RC_NULL_AREA)
            {
               const int bot = (int)s->smax;
               const int top = s->next ? (int)s->next->smin : MAX_HEIGHT;
               chf.spans[idx].y = (unsigned short)rcClamp(bot, 0, 0xffff);
               chf.spans[idx].h = (unsigned char)rcClamp(top - bot, 0, 0xff);
               chf.areas[idx] = s->area;
               idx++;
               c.count++;
            }
            s = s->next;
         }
      }
   }

   return true;
}
创建邻居链接 

我们现在有一个开放高度场,里面充满了不相关的开放span。下一步是找出哪些span形成了连续span的潜在表面。这是通过创建轴邻居链接(axis-neighbor links)来实现的。 对于每个span,搜索其轴相邻列以查找候选对象。如果满足以下两个条件,则相邻列中的span被视为邻居span:

1. 两个span的顶部上升或下降的步长小于 walkableClimb的值。 这允许将楼梯台阶和路缘这样的表面检测为有效的邻居。

这里又涉及到这个参数 walkableClimb了,这个参数的意义就是帮助我们正确的设置可行走高度差异的,一般来说,walkableClimb 应该大于 cellHeight 的两倍(walkableClimb > cellHeight * 2)。 否则体素场的分辨率可能不够高,无法准确检测可通过的窗台(ledge)。窗台可以合并,有效地将它们的台阶高度加倍。对于楼梯来说,这尤其是个问题。(为什么楼梯这么难处理)

2. 当前span的地板和潜在邻居span的天花板之间的开放空间间隙足够大(大于walkableHeight)。

例如,如果agent要从一个span跨到另一个span,它会用头撞到邻居的天花板上吗?这是与潜在邻居之间的间隙检查。我们已经知道地板到天花板的高度对于每个单独的span来说都是足够的,该检查是在构建实体高度场时进行的。 但是不能保证在潜在邻居之间移动时的间隙满足相同的高度要求。

bool rcBuildCompactHeightfield(rcContext* ctx, const int walkableHeight, const int walkableClimb,
                        rcHeightfield& hf, rcCompactHeightfield& chf)
{
    //创建邻居链接
   const int MAX_LAYERS = RC_NOT_CONNECTED-1; //static const int RC_NOT_CONNECTED = 0x3f , 最多63层
   int tooHighNeighbour = 0;
   for (int y = 0; y < h; ++y)
   {
      for (int x = 0; x < w; ++x)
      {
         const rcCompactCell& c = chf.cells[x+y*w];
         for (int i = (int)c.index, ni = (int)(c.index+c.count); i < ni; ++i)
         {
            rcCompactSpan& s = chf.spans[i];

            for (int dir = 0; dir < 4; ++dir)
            {
               rcSetCon(s, dir, RC_NOT_CONNECTED); //先设置默认值RC_NOT_CONNECTED二进制是 111111
               const int nx = x + rcGetDirOffsetX(dir);
               const int ny = y + rcGetDirOffsetY(dir);
               // First check that the neighbour cell is in bounds.
               if (nx < 0 || ny < 0 || nx >= w || ny >= h)
                  continue;

               // 检查当前span的所有邻居span,看这个span是否和当前span有邻居关系
               const rcCompactCell& nc = chf.cells[nx+ny*w];
               for (int k = (int)nc.index, nk = (int)(nc.index+nc.count); k < nk; ++k)
               {
                  const rcCompactSpan& ns = chf.spans[k];
                  const int bot = rcMax(s.y, ns.y);
                  const int top = rcMin(s.y+s.h, ns.y+ns.h);

                  //检查2个span间的gap是否满足walkableHeight和walkableClimb的限制
                  if ((top - bot) >= walkableHeight && rcAbs((int)ns.y - (int)s.y) <= walkableClimb)
                  {
                     // Mark direction as walkable.
                     const int lidx = k - (int)nc.index;
                     if (lidx < 0 || lidx > MAX_LAYERS)
                     {
                        tooHighNeighbour = rcMax(tooHighNeighbour, lidx);
                        continue;
                     }
                     rcSetCon(s, dir, lidx);
                     break;
                  }
               }

            }
         }
      }
   }

   return true;
}
根据walkableRadius剔除边缘

我们现在获取了一系列连贯的开放高度场,但是还需要小小的优化一下:我们的人物模型本身也是有一定体积的,如果允许人物的模型中心移动到最边缘处可能会出现穿模的情况,所以具体的做法就是在所有的边缘处加上一层小小的剔除效果,即将障碍物周围可行走区域按radius值适当扩散不可行走区域。

此时的做法是对每一个“可走高度区间”加上一个“逻辑距离dist”的概念。该距离在逻辑上标识当前高度区间距离某个最近边界的距离。做两遍扫描,计算出每个span离边缘的距离,放到char*类型的dist 中。每次操作都是用小值替换大值,第一遍扫描从左上角往右下角,第二遍从右下角往左上角。这样就能保证每个位置的span计算出来的离边缘距离的准确性。然后再将离边距离小于两倍半径的span从可行走的span中剔除。

 从左下到右上遍历
for (int y = 0; y < h; ++y)
{
   for (int x = 0; x < w; ++x)
   {
      const rcCompactCell& c = chf.cells[x+y*w];
      for (int i = (int)c.index, ni = (int)(c.index+c.count); i < ni; ++i)
      {
         const rcCompactSpan& s = chf.spans[i];

         if (rcGetCon(s, 0) != RC_NOT_CONNECTED)
         {
            // (-1,0)	左邻居
            const int ax = x + rcGetDirOffsetX(0);
            const int ay = y + rcGetDirOffsetY(0);
            const int ai = (int)chf.cells[ax+ay*w].index + rcGetCon(s, 0);
            const rcCompactSpan& as = chf.spans[ai];
           // 轴邻居距离+2
            nd = (unsigned char)rcMin((int)dist[ai]+2, 255);
            if (nd < dist[i])
               dist[i] = nd;

            // (-1,-1) 左下邻居
            if (rcGetCon(as, 3) != RC_NOT_CONNECTED)
            {
               const int aax = ax + rcGetDirOffsetX(3);
               const int aay = ay + rcGetDirOffsetY(3);
               const int aai = (int)chf.cells[aax+aay*w].index + rcGetCon(as, 3);
               // 斜方向的邻居距离+3
               nd = (unsigned char)rcMin((int)dist[aai]+3, 255);
               if (nd < dist[i])
                  dist[i] = nd;
            }
         }
         if (rcGetCon(s, 3) != RC_NOT_CONNECTED)
         {
            // (0,-1) 下邻居
            const int ax = x + rcGetDirOffsetX(3);
            const int ay = y + rcGetDirOffsetY(3);
            const int ai = (int)chf.cells[ax+ay*w].index + rcGetCon(s, 3);
            const rcCompactSpan& as = chf.spans[ai];
            nd = (unsigned char)rcMin((int)dist[ai]+2, 255);
            if (nd < dist[i])
               dist[i] = nd;

            // (1,-1) 右下邻居
            if (rcGetCon(as, 2) != RC_NOT_CONNECTED)
            {
               const int aax = ax + rcGetDirOffsetX(2);
               const int aay = ay + rcGetDirOffsetY(2);
               const int aai = (int)chf.cells[aax+aay*w].index + rcGetCon(as, 2);
               nd = (unsigned char)rcMin((int)dist[aai]+3, 255);
               if (nd < dist[i])
                  dist[i] = nd;
            }
         }
      }
   }
}
根据ConvexVolume标记体素Area掩码

我们完成了开放高度场之后,还要考虑场景内的物体是否影响可行走问题,这涉及到了动态避障问题,我们在这里先介绍ConvexVolume——在Recast Navigation中,ConvexVolume(凸体体积)是一个用于动态标记导航网格特定区域的功能模块,主要用于定义自定义可行走区域、障碍物或特殊地形属性。

static const int MAX_CONVEXVOL_PTS = 12;
struct ConvexVolume
{
   float verts[MAX_CONVEXVOL_PTS*3];//Volume顶点数据
   float hmin;//Volume高度最低值
   float hmax;//Volume高度最高值
   int nverts;//Volume顶点数
   int area;  //区域类型,可自定义类型,比如Ground,Water,Grass等等
};

这是凸体体积的数据结构。

void rcMarkConvexPolyArea(rcContext* ctx, const float* verts, const int nverts,
                    const float hmin, const float hmax, unsigned char areaId,
                    rcCompactHeightfield& chf)
{
   // 遍历多边形范围内的体素
   for (int z = minz; z <= maxz; ++z)
   {
      for (int x = minx; x <= maxx; ++x)
      {
         const rcCompactCell& c = chf.cells[x+z*chf.width];
         for (int i = (int)c.index, ni = (int)(c.index+c.count); i < ni; ++i)
         {
            rcCompactSpan& s = chf.spans[i];
            if (chf.areas[i] == RC_NULL_AREA)
               continue;
            if ((int)s.y >= miny && (int)s.y <= maxy)
            {
               float p[3];
               p[0] = chf.bmin[0] + (x+0.5f)*chf.cs; 
               p[1] = 0;
               p[2] = chf.bmin[2] + (z+0.5f)*chf.cs; 

               if (pointInPoly(nverts, verts, p))  //判断点是否在poly范围内
               {
                  chf.areas[i] = areaId; //设置体素area类型
               }
            }
         }
      }
   }
}

该函数是 Recast Navigation 中用于标记凸多边形区域内体素区域类型的关键函数,其作用是为导航网格生成预定义特殊区域(如水域、草地等)。其中:

Shape Height = Volume高度最高值hmax - Volume高度最低值hmin,Shape Descent = 坐标y最低值 - Volume高度最低值hmin,Shape Descent代表坐标面下沉值。

创建区域

​到目前为止,所有的一切都是在为区域创建做准备。区域(region)是一组连续的span,表示可走表面的范围。它应该满足尽量大的、连续的、不重叠的、中间没有“洞”的“区域,区域的一个重要方面是,当投影到xz平面上时,它们会形成简单的多边形。Recast里提供了三种方式的区域切分方法:

分水岭算法(Watershed)

分水岭算法(watershed algorithm)用于初始区域的创建。使用分水岭类比,距离边界最远的span代表分水岭中的最低点,边界span代表可能的最高水位(和盆地的概念类似)。主循环从分水岭的最低点开始迭代,然后随着每个循环递增,直到达到允许的最高水位。这从最低点开始缓慢地“淹没”span。在循环的每次迭代期间,都会定位出低于当前水位的span,并尝试将它们添加到现有区域或创建新的区域。在区域扩展(region expansion)阶段,如果新淹没的span与一个已经存在的区域接壤,则通常会将其添加到该区域中。任何在区域扩展阶段,残留下来的新淹没的span都被用作创建新区域的种子。

分水岭算法通常用于图形处理领域,基于图像的灰度值来分割图像。这里唯一的不同点是用距离域来取代灰度值。距离域是指每个区间与可行走区域边缘的最近距离。距离域越大,等同于地势越低。

  • 经典的Recast分区
  • 创建最好的细分
  • 通常最慢,一般用于离线处理,适合大地图
  • 将Heightfield划分为没有孔或重叠的良好区域。
  • 在某些极端情况下,此方法创建会产生孔洞和重叠
    • 当小的障碍物靠近较大的开放区域时,可能会出现孔(三角剖分可以解决此问题)
    • 如果您有狭窄的螺旋形走廊(即楼梯),则可能会发生重叠,这会使三角剖分失败
  • 如果是预处理网格,通常是最佳选择,如果您有较大的开放区域,这种方法也适用。

这个算法的详细过程比较复杂,涉及到距离场的建立等内容,我这里感觉详细介绍太占篇幅,建议大家自己去查阅。

单调分区
  • 单调算法,注重效率,在性能上是最快的
  • 能将高度场划分为无洞和重叠的区域
  • 创建长而细的多边形,有时会导致路径走弯
  • 如果要快速生成导航网格,请使用此选项
按层分区
  • 分层算法,折中思想,效果与性能都处于上述两种算法之间
  • 将heighfield划分为非重叠区域
  • 依靠三角剖分来处理孔(因此比单调分区要慢)
  • 产生比单调分区更好的三角形
  • 没有分水岭分区的特殊情况
  • 速度可能很慢,并且会产生一些难看的镶嵌效果(仍然比单调效果更好),如果您的开放区域较大且障碍物较小(如果使用瓷砖则没有问题)
  • 用于中小型瓷砖的导航网格的好选择
阶段总结

注意:Region 虽然是不重叠且没有洞的区域,但仍然有可能是凹多边形,无法保证 Region 内任意两点在二维平面一定可以直线到达。后续需要进行轮廓生成和凸多边形生成,为寻路做准备。

轮廓生成(Contour Generation)

在经过区域生成之后,region的描述是以span为颗粒度的,复杂度是否可以更简化一下?此时区域与区域之间的分界就是非常重要的信息了。其实我们只需要region的轮廓,而轮廓(Contour)就是描述区域边界的概念。

这个阶段生成表示源几何体的可行走表面的简单多边形(凸多边形和凹多边形)。轮廓仍然以体素空间为单位表示,但这是从体素空间(voxel space)回到向量空间(vector space)的过程中的第一步。

搜索区域边缘

从开放高度场结构转向轮廓结构时,最大的概念变化是从关注span的表面(surface)转变为关注span的边(edges)。

对于轮廓,我们关心span的边,有两种类型的边:

  1. 区域边(region ):区域边是其邻居位于另一个区域中的span的边
  2. 内部边(internal): 内部边是其邻居在同一区域中的span的边

在此步骤中,我们希望将边分类为区域边或内部边。这些信息很容易找到。我们遍历所有span,对于每个span,我们检查所有轴邻居,如果轴邻居与当前span不在同一区域中,则该边将被标记为区域边。

// Mark boundaries.
for (int y = 0; y < h; ++y)
{
   for (int x = 0; x < w; ++x)
   {
      const rcCompactCell& c = chf.cells[x+y*w];
      for (int i = (int)c.index, ni = (int)(c.index+c.count); i < ni; ++i)
      {
         unsigned char res = 0;
         const rcCompactSpan& s = chf.spans[i];
        //如果span不存在region的ID,或者是边界,就不考虑这种span
         if (!chf.spans[i].reg || (chf.spans[i].reg & RC_BORDER_REG))
         {
            flags[i] = 0;
            continue;
         }
         for (int dir = 0; dir < 4; ++dir)
         {
            unsigned short r = 0;
            if (rcGetCon(s, dir) != RC_NOT_CONNECTED)
            {
               const int ax = x + rcGetDirOffsetX(dir);
               const int ay = y + rcGetDirOffsetY(dir);
               const int ai = (int)chf.cells[ax+ay*w].index + rcGetCon(s, dir);
               r = chf.spans[ai].reg;
            }
            //周围邻居所属的region和当前span的region相同,说明是连通的,标记为1
            if (r == chf.spans[i].reg)
               res |= (1 << dir);
         }
        //flags保存每个span四个方向是否为边界, 值是按位保存:1是边界(区域边),0不是边界(内部边)
         flags[i] = res ^ 0xf; //不连通的方向标记为1
      }
   }
}
查找区域轮廓

我们根据上一个步骤获取的区域边来绘制出区域的轮廓:

  • 找到区域任意一个区域边A,以当前区域边开始算法。
  • 如果当前是区域边,将当前区域边添加到轮廓中,然后顺时针旋转90度,继续判断旋转后的边。
  • 如果当前是内部边,则进入到共当前边的邻居内,然后逆时针旋转90度,继续判断旋转后的边。
  • 直到回到区域边A为止,结束,此时依次添加进轮廓中的所有边界边全部查找完毕。

看起来有点不知所云,用几张图展示一下这个过程:

找到了一条区域的边。

旋转了九十度之后找到了又一条区域边,加入轮廓。直到找到一条内部边后,我们根据此时箭头的方向步进到相邻的span中。

进入新的span中先逆时针旋转判断边,又是一个内部边,那就进去之后再逆时针旋转九十度,发现是区域边了,又顺时针旋转...不断循环往复直到我们回到起始span且面对起始方向。

遍历的过程我们还要不断地提取轮廓点,提取的规则如下:

  1. 体素左方是边界,轮廓点取其上方体素。
  2. 体素上方是边界,轮廓点取其右上方体素。
  3. 体素右方是边界,轮廓点取其右方体素。
  4. 体素下方是边界,轮廓点取其自身。

这样做的目的是,使得各个区域的轮廓线多边形的边互相重合,因为最终生成的navimesh数据多边形之间是共用一个边的,最终效果如下图所示:

相关代码:

static void walkContour(int x, int y, int i,
                  rcCompactHeightfield& chf,
                  unsigned char* flags, rcIntArray& points)
{
   // 找到第一个区域边的方向dir
   unsigned char dir = 0;
   while ((flags[i] & (1 << dir)) == 0)
      dir++;

   unsigned char startDir = dir;
   int starti = i;

   const unsigned char area = chf.areas[i];

   int iter = 0;
   while (++iter < 40000)  //迭代次数限制
   {
       // dir方向指向区域边界,则保存轮廓点后,顺时针旋转后再循环尝试
      if (flags[i] & (1 << dir))  //当前边是区域边
      {
         // Choose the edge corner
         bool isBorderVertex = false;
         bool isAreaBorder = false;
        //默认轮廓点取其自身。
         int px = x;
         int py = getCornerHeight(x, y, i, dir, chf, isBorderVertex);
         int pz = y;
        // 为了使相邻region walk出来的轮廓一样,所以并不一定是以自身为轮廓,而是按照一下规则
         switch(dir)
         {
            case 0: pz++; break;       //1. 体素左方是边界,轮廓点取其上方体素。
            case 1: px++; pz++; break; //2. 体素上方是边界,轮廓点取其右上方体素。
            case 2: px++; break;       //3. 体素右方是边界,轮廓点取其右方体素。
         }
         int r = 0;
         const rcCompactSpan& s = chf.spans[i];
         if (rcGetCon(s, dir) != RC_NOT_CONNECTED)
         {
            const int ax = x + rcGetDirOffsetX(dir);
            const int ay = y + rcGetDirOffsetY(dir);
            const int ai = (int)chf.cells[ax+ay*chf.width].index + rcGetCon(s, dir);
            r = (int)chf.spans[ai].reg;
            if (area != chf.areas[ai]) // area的边界
               isAreaBorder = true;   
         }
         if (isBorderVertex)
            r |= RC_BORDER_VERTEX;
         if (isAreaBorder)
            r |= RC_AREA_BORDER;
        //添加到轮廓中
         points.push(px);
         points.push(py);
         points.push(pz);
         points.push(r);
         //去掉该dir上的边界标记
         flags[i] &= ~(1 << dir);
         //然后顺时针旋转90度,继续判断旋转后的边
         dir = (dir+1) & 0x3; 
      }
      else   // // 如果不是区域边界,当前边是内部边,则移动到邻居内,并将dir逆时针旋转
      {
         int ni = -1;
         const int nx = x + rcGetDirOffsetX(dir);
         const int ny = y + rcGetDirOffsetY(dir);
         const rcCompactSpan& s = chf.spans[i];
         if (rcGetCon(s, dir) != RC_NOT_CONNECTED)
         {
            const rcCompactCell& nc = chf.cells[nx+ny*chf.width];
            ni = (int)nc.index + rcGetCon(s, dir);
         }
         if (ni == -1)
         {
            // Should not happen.
            return;
         }
        //进入到共当前边的邻居内
         x = nx;
         y = ny;
         i = ni;
         dir = (dir+3) & 0x3; //然后逆时针旋转90度,等待继续判断旋转后的边
      }

      if (starti == i && startDir == dir) //我们回到起始span,面对起始方向,结束查找
      {
         break;
      }
   }
}
从边到顶点

我们真正需要的内容不是边而是顶点,对于X-Z轴空间的点来说选取非常简单,取组成边的两个顶点即可:

确定顶点的y值就比较棘手了。这就是我们回归3D可视化的地方。在下面的例子中,我们选择哪个顶点?

选择最高的y值有两个原因:它确保最终顶点(x, y, z)位于源网格表面的上方。它还提供了一个通用的选择机制,以便所有使用该顶点的轮廓将使用相同的高度。

简化轮廓

我们已经为所有区域生成了轮廓。到这一步时,轮廓点由一系列连续的点组成的,在这些点里,有一些点是共线的,有一些点忽略后与最终轮廓形状差距不大。下面是一个宏观的视角。请注意,有两种类型的轮廓部分(contour sections):

  1. 两个相邻区域之间门户(portal)的部分,即连接两个有效区域之间的边界边。
  2. 与“无效”区域接壤的部分。无效区域被称为“空区域”(null region),我在这里使用同样的术语。

秉持着没活硬整的态度,我们当然要继续优化。

即使在直线轮廓上,构成边的每个span都有一个顶点。显然,答案是否定的。唯一真正必需的顶点是那些在区域连接中发生变化的顶点。

去除一些对轮廓形状影响不大的点,得到更加丝滑的轮廓,可以有效减少锯齿轮廓。

这里约定一种顶点的概念:强制性顶点(Mandatory Vertices),它的含义是区域连接发生变化的顶点,可以看出有两种顶点:

  • 连接有效区域的边界边上的顶点
  • 连接有效区域和无效区域边界上的顶点

考虑一种特殊情况,可以很容易证明出,并不是所有的区域都有“强制性顶点”,此时如何进行上述算法呢?很简单,随便找两个相对较远的顶点作为强制性顶点即可。Recast的做法是,使用连接有效区域和无效区域边界上的顶点,从轮廓点中选择最左下和最右上的两个点作为初始简化点。

区域-区域门户(region-region portals)的简化很容易。我们丢弃除强制性顶点(mandatory vertices)之外的所有顶点:

// Add initial points.
bool hasConnections = false;
for (int i = 0; i < points.size(); i += 4)
{
   // point索引:0=x 1=y 2=z 3=r
   // 在之前的walkContour中产生,r的低16位如果是0,说明边界是不可行走的,否则该point有邻居region
   if ((points[i+3] & RC_CONTOUR_REG_MASK) != 0)
   {
        hasConnections = true;
	 break;
   }
}
 // 如果轮廓有邻居region
if (hasConnections)
{
   for (int i = 0, ni = points.size()/4; i < ni; ++i)
   {
      //下一个point
      int ii = (i+1) % ni;
      //邻近的两个轮廓点接壤不同的region
      const bool differentRegs = (points[i*4+3] & RC_CONTOUR_REG_MASK) != (points[ii*4+3] & RC_CONTOUR_REG_MASK);
      //邻近的两个轮廓点里,一个接壤其他region,另一个接壤不可行走
      const bool areaBorders = (points[i*4+3] & RC_AREA_BORDER) != (points[ii*4+3] & RC_AREA_BORDER);
      // 总之邻近的两个轮廓点接壤不是同一个region,则记录这个点
      if (differentRegs || areaBorders)
      {
         simplified.push(points[i*4+0]);
         simplified.push(points[i*4+1]);
         simplified.push(points[i*4+2]);
         simplified.push(i);
      }
   }
}

帮助我们找到初始的简化点。

// 如果不连接任何region则没有simplified点,那么选择左下和右上的两个点作为simplified点
if (simplified.size() == 0)
{
   int llx = points[0];
   int lly = points[1];
   int llz = points[2];
   int lli = 0;
   int urx = points[0];
   int ury = points[1];
   int urz = points[2];
   int uri = 0;
   for (int i = 0; i < points.size(); i += 4)
   {
      int x = points[i+0];
      int y = points[i+1];
      int z = points[i+2];
      if (x < llx || (x == llx && z < llz))
      {
         llx = x;
         lly = y;
         llz = z;
         lli = i/4;
      }
      if (x > urx || (x == urx && z > urz))
      {
         urx = x;
         ury = y;
         urz = z;
         uri = i/4;
      }
   }
   simplified.push(llx);
   simplified.push(lly);
   simplified.push(llz);
   simplified.push(lli);

   simplified.push(urx);
   simplified.push(ury);
   simplified.push(urz);
   simplified.push(uri);
}

根据maxSimplificationError参数来决定丢弃哪些顶点以得到简化的线段——代表网格的边可以偏离源几何体的最大距离,较低的值将导致网格边缘更准确地遵循 xz 平面的几何轮廓,但会增加三角形数量。
不建议将值设为0,因为它会导致最终网格中的多边形数量大幅增加,处理成本很高。

现在从强制顶点(mandatory vertices)开始,将最远顶点添加回来,这样原始顶点与简化边之间的距离都不会超过maxSimplificationError

下面的图中展示这一过程:

首先找到左下和右上两个点

如果简化后的边长度超过了maxSimplificationError我们把离简化边最远的点加回来。

重复这个过程,直到不再有顶点到简化边的距离超过允许值:

相关代码:

// Add points until all raw points are within
// error tolerance to the simplified shape.
const int pn = points.size()/4;
for (int i = 0; i < simplified.size()/4; )
{
   int ii = (i+1) % (simplified.size()/4);
   // simplified索引:0=x 1=y 2=z 3=在points中的索引
   int ax = simplified[i*4+0];
   int az = simplified[i*4+2];
   int ai = simplified[i*4+3];

   int bx = simplified[ii*4+0];
   int bz = simplified[ii*4+2];
   int bi = simplified[ii*4+3];

   // Find maximum deviation from the segment.
   float maxd = 0;
   int maxi = -1;
   // ci、endi为points中的索引,cinc为索引每次遍历的偏移方向
   // ci=从此索引开始遍历,endi=从此索引遍历结束
   int ci, cinc, endi;
  // 选择偏左下的点为遍历的起点,偏右上的点为遍历的终点
   if (bx > ax || (bx == ax && bz > az))
   {
     // 沿着正方向
      cinc = 1;
      ci = (ai+cinc) % pn;
      endi = bi;
   }
   else
   {
      // 沿着负方向
      cinc = pn-1;
      ci = (bi+cinc) % pn;
      endi = ai;
      rcSwap(ax, bx);
      rcSwap(az, bz);
   }

   // 考虑有效区域和有效区域连接,或者有效区域和空区域的边界情况. 
   if ((points[ci*4+3] & RC_CONTOUR_REG_MASK) == 0 ||
      (points[ci*4+3] & RC_AREA_BORDER))
   {
      while (ci != endi)
      {
        //计算点到直线的距离
         float d = distancePtSeg(points[ci*4+0], points[ci*4+2], ax, az, bx, bz);
         if (d > maxd)
         {
            maxd = d;
            maxi = ci;
         }
         ci = (ci+cinc) % pn;
      }
   }


   // 找到离简化边最远的点,如果它到简化轮廓的距离超过了maxError,则将顶点添加回轮廓
   if (maxi != -1 && maxd > (maxError*maxError))
   {
      // Add space for the new point.
      simplified.resize(simplified.size()+4);
      const int n = simplified.size()/4;
      for (int j = n-1; j > i; --j)
      {
         simplified[j*4+0] = simplified[(j-1)*4+0];
         simplified[j*4+1] = simplified[(j-1)*4+1];
         simplified[j*4+2] = simplified[(j-1)*4+2];
         simplified[j*4+3] = simplified[(j-1)*4+3];
      }
      // Add the point.
      simplified[(i+1)*4+0] = points[maxi*4+0];
      simplified[(i+1)*4+1] = points[maxi*4+1];
      simplified[(i+1)*4+2] = points[maxi*4+2];
      simplified[(i+1)*4+3] = maxi;
   }
   else
   {
      ++i;
   }
}

效果如图:

长边轮廓二分为短边

区域-区域之间的轮廓已经简化完成了,而针对区域-空区域之间的轮廓简化还有别的做法:使用maxEdgeLen参数重新插入顶点,以确保没有线段超过最大长度,它是通过检测长边,然后将它们分成两半来实现这一点的。它会继续这个过程,直到检测不到过长的边为止。

// Split too long edges.
if (maxEdgeLen > 0 && (buildFlags & (RC_CONTOUR_TESS_WALL_EDGES|RC_CONTOUR_TESS_AREA_EDGES)) != 0)
{
   for (int i = 0; i < simplified.size()/4; )
   {
      const int ii = (i+1) % (simplified.size()/4);

      const int ax = simplified[i*4+0];
      const int az = simplified[i*4+2];
      const int ai = simplified[i*4+3];

      const int bx = simplified[ii*4+0];
      const int bz = simplified[ii*4+2];
      const int bi = simplified[ii*4+3];

      // Find maximum deviation from the segment.
      int maxi = -1;
      int ci = (ai+1) % pn;

      // Tessellate only outer edges or edges between areas.
      bool tess = false;
      // Wall edges. 不可行走边界
      if ((buildFlags & RC_CONTOUR_TESS_WALL_EDGES) && (points[ci*4+3] & RC_CONTOUR_REG_MASK) == 0)
         tess = true;
      // Edges between areas. region边界
      if ((buildFlags & RC_CONTOUR_TESS_AREA_EDGES) && (points[ci*4+3] & RC_AREA_BORDER))
         tess = true;

      if (tess)
      {
         int dx = bx - ax;
         int dz = bz - az;
         if (dx*dx + dz*dz > maxEdgeLen*maxEdgeLen)  //线段超过最大长度maxEdgeLen,就分为两个线段
         {
            // Round based on the segments in lexilogical order so that the
            // max tesselation is consistent regardles in which direction
            // segments are traversed.
            const int n = bi < ai ? (bi+pn - ai) : (bi - ai);// ai与bi相差n个索引
            if (n > 1) // n > 1,说明ai bi之间有轮廓点,可切分
            {
               if (bx > ax || (bx == ax && bz > az))
                  maxi = (ai + n/2) % pn;
               else
                  maxi = (ai + (n+1)/2) % pn;
            }
         }
      }

      // If the max deviation is larger than accepted error,
      // add new point, else continue to next segment.
      if (maxi != -1)  // maxi位置的点插入到simplified中
      {
         // Add space for the new point.
         simplified.resize(simplified.size()+4);
         const int n = simplified.size()/4;
         for (int j = n-1; j > i; --j)
         {
            simplified[j*4+0] = simplified[(j-1)*4+0];
            simplified[j*4+1] = simplified[(j-1)*4+1];
            simplified[j*4+2] = simplified[(j-1)*4+2];
            simplified[j*4+3] = simplified[(j-1)*4+3];
         }
         // Add the point.
         simplified[(i+1)*4+0] = points[maxi*4+0];
         simplified[(i+1)*4+1] = points[maxi*4+1];
         simplified[(i+1)*4+2] = points[maxi*4+2];
         simplified[(i+1)*4+3] = maxi;
      }
      else
      {
         ++i;
      }
   }
}
检查和合并空洞

首先我们要检查空洞(为什么还要考虑空洞问题呢?在Recast Navigation中,​轮廓生成后仍需检查空洞,根本原因在于区域划分阶段无法完全消除所有空洞风险,且轮廓生成过程可能引入新的空洞问题)。

首先我们要知道一个关于三角形叉乘和面积的关系:如果是顺时针方向,求取的面积值是负的,如果是逆时针方向,求取的面积值是正的。

而正常轮廓线的顶点是顺时针存储,空洞轮廓线的顶点是逆时针存储(在查找轮廓中完成):

所以根据叉乘算出每个轮廓多边形的有向面积,如果结果为小于0,则为轮廓点的顺序为逆时针,这个轮廓就是一个空洞。

int nholes = 0;
for (int i = 0; i < cset.nconts; ++i)
{
   rcContour& cont = cset.conts[i];
   // If the contour is wound backwards, it is a hole.
   winding[i] = calcAreaOfPolygon2D(cont.verts, cont.nverts) < 0 ? -1 : 1;
   if (winding[i] < 0)
      nholes++;
}

现在我们找到空洞之后,就要去合并空洞:

  1. 找到空洞的左下方顶点B4
  2. 将轮廓线所有顶点与B4相连,如果连线与轮廓线、空洞都不相交,则连线构成1条对角线。上图满足条件的有A5B4,A5B4,A4B4
  3. 选择其中长度最短的1条对角线A5B4,将空洞合并到轮廓线中

最终轮廓线的顶点序列为A5、A6、A1、A2、A3、A4、A5、B4、B1、B2、B3、B4。(如果包含多个空洞的话,将空洞按左下方顶点排序,依次迭代将外围轮廓与空洞进行合并。)

阶段总结

在这个阶段结束时,我们有形成简化多边形的轮廓。顶点仍然在体素空间中,但是我们正在回到向量空间的路上。

凸多边形生成(Convex Polygon Generation)

有了轮廓的数据之后,就有了一片区域的边,那么此时就需要对区域进行更加详细的定位,例如寻路是要具体寻到某一个点,并且区域内部任意两点并不是一定直线联通的,所以要将区域划分成更加细化的可以描述整个区域面的信息的数据。此时采用的是将区域划分成一些凸多边形的集合,这些凸多边形的并集就是整个区域。本阶段是从由轮廓表示的简单多边形生成凸多边形。这也是我们从体素空间回到向量空间的地方。

本阶段的主要任务如下:

  • 轮廓的顶点在体素空间中,这意味着它们的坐标采用整数格式并表示到高度场原点的距离。因此轮廓顶点数据被转换为原始源几何体的向量空间坐标。
  • 每个轮廓完全独立于所有其他轮廓。在此阶段,我们合并重复的数据并将所有内容合并到一个单一网格中。
  • 轮廓只能保证表示简单的多边形,包括凸多边形和凹多边形。凹多边形对导航网格来说是没有用的(凸多边形中,任意两点间的直线路径必然完全位于多边形内部,确保AI移动时不会穿越障碍物。而凹多边形存在“凹陷”区域(内角>180°),两点间的直线可能穿出多边形边界,进入未知区域(如墙体或悬崖),导致路径失效或角色卡死),所以我们根据需要细分轮廓以获得凸多边形。
  • 我们收集指示每个多边形的哪些边连接到另一个多边形的连接信息(多边形邻居信息)。

坐标转换和顶点数据合并是相对简单的过程,所以我不会在这里讨论它们。如果您对这些算法感兴趣,可以查看文档详尽的源代码。这里我们将专注于凸多边形的细分。对每个轮廓会执行以下步骤:

  1. 对每个轮廓进行三角面化(triangulate)。
  2. 合并三角形以形成最大可能的凸多边形。

我们通过生成邻居连接信息来结束这个阶段。

三角形剖分(Triangulation)

针对凹多边形的三角划分:耳裁法(为什么这里又是针对凹多边形呢?其实这个方法也适用于凸多边形而且更简单)

  • 每次迭代凹多边形,将其分成两部分,一个三角形和剩余的部分。然后迭代“剩余的部分”,继续划分,直到没有三角形可以划分位置。
  • 算法的核心点是如何对每次的“剩余多边形”划分出一个三角形。
  • 采用的方法很简单,基于“任意不共线三点构成三角形”的理论,每次寻找多边形相邻的两条边,如果其不共直线,那么连接这两条边的三个端点,就可以形成一个三角形。
  • 但是需要注意的是,这样形成的三角形可能会是多边形外部的,注意区分剔除即可。

从所有潜在的候选者中,选择具有最短的新边的那个。新边被称为“分割边”(partition edges),或简称为“分割”(partition)。该过程继续处理剩余的顶点,直到三角形剖分完成。

为了提高性能,三角形剖分在轮廓的 xz 平面投影上运行。

首先我们要构建可能的切割边:
三角形剖分是通过沿着轮廓的边往前走(walking the edges of the contour)来完成的,以任意端点开始,沿着一个方向依次去找两个边,如果两个边不共线,则连接两边不重合的点,形成一条连线,称为“分割边”。对每一个点进行该过程,会形成很多分割边,剔除那些在多边形外部的分割边,剩余的就是有效分割边。

在上述所有有效分割边中,找出一条最短的分割边,然后将该分割边与“形成该分割边的其他两条多边形边”形成一个三角形。此时就将凹多边形划分出了一个三角形。剩余多边形继续重新划分“分割边”(每进行一次分割就要重新分割),重复该过程即可。

使用最短分割边的原因是,在概率上试图每次分出去一个尽可能小的三角形,以此增加最终分割的三角形的数量,进而增强分割后的信息量。

相关代码:

/// 三角形剖分
/// n verts顶点个数
/// verts 顶点数据
/// indices 顶点索引
/// tris 三角形的索引
/// 返回值 三角形的个数
static int triangulate(int n, const int* verts, int* indices, int* tris)
{
   int ntris = 0;
   int* dst = tris;

   // The last bit of the index is used to indicate if the vertex can be removed.
   for (int i = 0; i < n; i++)
   {
      int i1 = next(i, n);
      int i2 = next(i1, n);
        //i1 是一个耳尖点,并且与所有的边都不相交
      if (diagonal(i, i2, n, verts, indices))
         indices[i1] |= 0x80000000;
   }

   while (n > 3)
   {
      int minLen = -1;
      int mini = -1;
      // 找最小的耳朵
      for (int i = 0; i < n; i++)
      {
         int i1 = next(i, n);
         if (indices[i1] & 0x80000000)
         {
            // i1是耳尖点,找到最小的p0到p2的距离
            const int* p0 = &verts[(indices[i] & 0x0fffffff) * 4];
            const int* p2 = &verts[(indices[next(i1, n)] & 0x0fffffff) * 4];

            int dx = p2[0] - p0[0];
            int dy = p2[2] - p0[2];
            int len = dx*dx + dy*dy;

            if (minLen < 0 || len < minLen)
            {
               minLen = len;
               mini = i;
            }
         }
      }

      if (mini == -1)
      {
         // We might get here because the contour has overlapping segments, like this:
         //
         //  A o-o=====o---o B
         //   /  |C   D|    \.
         //  o   o     o     o
         //  :   :     :     :
         // We'll try to recover by loosing up the inCone test a bit so that a diagonal
         // like A-B or C-D can be found and we can continue.
         minLen = -1;
         mini = -1;
         for (int i = 0; i < n; i++)
         {
            int i1 = next(i, n);
            int i2 = next(i1, n);
            if (diagonalLoose(i, i2, n, verts, indices))
            {
               const int* p0 = &verts[(indices[i] & 0x0fffffff) * 4];
               const int* p2 = &verts[(indices[next(i2, n)] & 0x0fffffff) * 4];
               int dx = p2[0] - p0[0];
               int dy = p2[2] - p0[2];
               int len = dx*dx + dy*dy;

               if (minLen < 0 || len < minLen)
               {
                  minLen = len;
                  mini = i;
               }
            }
         }
         if (mini == -1)
         {
            // The contour is messed up. This sometimes happens
            // if the contour simplification is too aggressive.
            return -ntris;
         }
      }

      int i = mini;
      int i1 = next(i, n);
      int i2 = next(i1, n);

      *dst++ = indices[i] & 0x0fffffff;
      *dst++ = indices[i1] & 0x0fffffff;
      *dst++ = indices[i2] & 0x0fffffff;
      ntris++;

      // Removes P[i1] by copying P[i+1]...P[n-1] left one index.
      n--;
      for (int k = i1; k < n; k++)
         indices[k] = indices[k+1];

      if (i1 >= n) i1 = 0;
      i = prev(i1,n);
      // Update diagonal flags.
        // 判断i点是否为耳尖点
      if (diagonal(prev(i, n), i1, n, verts, indices))
         indices[i] |= 0x80000000;
      else
         indices[i] &= 0x0fffffff;
        // 判断i1点是否为耳尖点
      if (diagonal(i, next(i1, n), n, verts, indices))
         indices[i1] |= 0x80000000;
      else
         indices[i1] &= 0x0fffffff;
   }

   // Append the remaining triangle.
    // 把最后的三个点加入到tris
   *dst++ = indices[0] & 0x0fffffff;
   *dst++ = indices[1] & 0x0fffffff;
   *dst++ = indices[2] & 0x0fffffff;
   ntris++;

   return ntris;
}

这里还得补充一下检测有效分割边的做法:

使用内角算法边相交算法,两种算法来确定一组三个顶点是否可以形成有效的内部三角形。第一种算法(内角算法)很快,可以快速剔除完全位于多边形之外的分割边。如果分割边在多边形内部,则使用更昂贵的算法来确保它不与任何现有多边形的边相交。

合并为凸多边形(凸多边形化)

合并只能发生在从单个轮廓创建的多边形之间。不会尝试合并来自相邻轮廓的多边形。

请注意,我已切换到一般形式的“多边形”(polygon)而不是三角形(triangle)。虽然初始合并将在三角形之间进行,但随着合并过程的进行,非三角形多边形可能会相互合并。

该过程如下:

  1. 找出所有可以合并的多边形。
  2. 从该列表中,选择共享边最长的两个多边形并将它们合并。
  3. 重复这个过程直到没有可以进行的合并。

如果满足以下所有条件,则可以合并两个多边形:

  • 多边形共享一条边。
  • 合并后的多边形仍然是凸多边形。
  • 合并后的多边形的边数不会超过maxVertsPerPoly

为什么是最长边:这是一种“伪贪心”思想,试图从概率上使每次合并的多边形更大,从而减少合并后多边形的数量,数量越少,后续的Detour算法越简单。

如何确定合并后的多边形是否为凸多边形:此检查的关键就是保证合并后的多边形所有内角不超过180度。

合并多边形ABC和多边形ADC,两者的公共边是AC,前提条件是,∠ABC和∠ADC都是小于180度的。问题是证明合并后的∠BAD和∠BCD在什么情况下会小于180度,在什么情况下会大于180度。

采用的方式是,连接BD作为参考线。BD产生的条件,从公共边两端点中选择一点A,点A在两个多边形中分别有两条边,选择不是公共边的那一条,即图中的AB和AD作为“校验边”,此时连接两条校验边的另外一个端点B和D,形成参考线。

形成参考线后,只需要保证公共边两端点分属在参考线的两侧,即证明以刚才选择点A所形成的内角∠BAD是一个小于180度的角。

然后再以公共边另外一个端点C重复上述过程,证明∠BCD是否满足条件。

其实很好理解,用公共边一个端点和参考线一定可以形成一个三角形,只需要判断这个三角形的当前内角是合并多边形的内角还是外角就可以了。而当公共边的两端点在参考线两侧的时候,恰好对应的是内角。至于以公共边的两端点来进行这个校验,是因为我们保证了,合并之前的多边形都是凸多边形,意味着所有不参与合并的角本来就是满足条件的,不需要检查了,而参与合并的角,就是公共边两端点的角。

一定要理解:上述算法的所有思路,都是建立在合并之前的两个多边形都是凸多边形的基础上。

// Merge polygons.
if (nvp > 3)
{
   for(;;)
   {
      // Find best polygons to merge.
      int bestMergeVal = 0; //共边的长度值
      int bestPa = 0, bestPb = 0, bestEa = 0, bestEb = 0;

      for (int j = 0; j < npolys-1; ++j)
      {         
         unsigned short* pj = &polys[j*nvp];
         for (int k = j+1; k < npolys; ++k)
         {
            unsigned short* pk = &polys[k*nvp];
            int ea, eb;
            // 返回可合并的边的长度
            int v = getPolyMergeValue(pj, pk, mesh.verts, ea, eb, nvp);
            // 找到最长的边进行合并
            if (v > bestMergeVal)
            {
               bestMergeVal = v;
               bestPa = j;
               bestPb = k;
               bestEa = ea;
               bestEb = eb;
            }
         }
      }

      if (bestMergeVal > 0)
      {
         // Found best, merge.
         unsigned short* pa = &polys[bestPa*nvp];
         unsigned short* pb = &polys[bestPb*nvp];
          //pa和pb合并成一个,最后放到pa里
         mergePolyVerts(pa, pb, bestEa, bestEb, tmpPoly, nvp);
         //最后的poly放到pb
         unsigned short* lastPoly = &polys[(npolys-1)*nvp];
         if (pb != lastPoly)
            memcpy(pb, lastPoly, sizeof(unsigned short)*nvp);
         npolys--;
      }
      else
      {
         // Could not merge any polygons, stop.
         break;
      }
   }
}
构建边的邻接关系

虽然有了凸多边形信息,但是每个凸多边形的邻接关系是不知道的,因此这一步的目的就是要遍历整个网格中的所有多边形并生成邻接信息(connection information),方便后续寻路使用。

struct rcEdge
{
    unsigned short vert[2];     // 边的两个点
    unsigned short polyEdge[2]; // 邻接的两个多边形的边的索引
    unsigned short poly[2];     // 邻接的两个多边形的索引
};

这是邻接边的数据结构。

  1. 遍历多边形,初始化边信息rcEdge,每条边两个顶点的索引v0、索引v1,保证v0<v1
  2. 再进行一次遍历,这次筛选出顶点索引v0 > 顶点索引v1形成的边,如果两个顶点与某个rcEdge相同,则补全rcEdge的邻接信息
  3. 最后把rcEdge信息保存到mesh.polys中,每个poly用长度为2*maxVertsPerPoly的short表示,[0, maxVertsPerPoly)表示多边形的顶点索引,[maxVertsPerPoly, 2*maxVertsPerPoly)表示顶点邻接的多边形索引
阶段总结

许多算法只能用于凸多边形。因此,这一步将构成轮廓的简单多边形细分为凸多边形网格。这是通过使用一个适用于简单多边形的三角形划分(triangulation),然后再将三角形合并为最大可能的凸多边形来实现的。

 详细网格生成(Detailed Mesh Generation)

构建导航网格的第五个也是最后一个阶段,即生成具有详细高度信息的三角形网格。

为什么要执行这一步?

在三维空间中,多边形网格可能无法充分遵循源网格的高度轮廓 ,无论拆分单元的粒度有多小,都不可能完全拟合原实物空间,总是存在误差。而经过多步针对“体素块”的操作后,这些误差可能被放大,导致了Mesh导航面其实只是“原场景”的一个“大概表面”。比如楼梯。此时就需要,在Mesh多边形的基础上,去对比“原场景表面”,然后对Mesh多边形进行再加工——添加高度细节,使其最大限度的贴合原场景表面,减少误差。

该阶段的主要步骤如下,对于每个多边形:

  1. 对多边形的外壳边缘(hull edges)进行采样。向任何偏离高度补丁数据超过detailSampleMaxError的边添加顶点。
  2. 对多边形执行Delaunay 三角形剖分。
  3. 对多边形的内部表面进行采样。 如果表面与高度补丁数据的偏差超过 detailSampleMaxError的值,则添加顶点。 更新新顶点的三角形剖分。

这个阶段增加了高度细节,这样细节网格(detail mesh)将在所有轴上与源网格的表面相匹配。为了实现这一点,我们遍历所有多边形,并在多边形与源网格过度偏离时沿着多边形的边和其表面插入顶点

从技术上来讲,从寻路的角度来看,这一步不是必需的。凸多边形网格是生成适合使用寻路算法的图所需要的全部,而在上一阶段创建的多边形网格提供了所有必要的数据。这尤其适用于使用物理或ray-casting将agent放置在源网格表面的情况。事实上,Recast Navigation的Detour库只使用多边形网格来寻路。这个阶段生成的高度细节被认为是可选的,当包含它时,它仅用于完善由各种Detour函数返回的点的位置。

同样重要的是要注意,这个过程仍然只会产生原始网格表面的一个更好的近似。体素化过程已经决定了精确的位置是不可能的。除此之外,由于搜索性能和内存的考虑,过多的高度细节通常比太少的细节更糟糕。

因为CSDN现在又非常卡了,加上这部分内容并不是必须的,容我偷个懒就不介绍这部分的内容了,大家有兴趣的可以去看知乎原文:(99+ 封私信 / 80 条消息) Recast Navigation源码分析:导航网格Navmesh的生成原理 - 知乎 (zhihu.com)

 这篇文章里还提到了关于NavMesh里的一些缺点:

(1)RecastNavigation所有操作都基于地表面,因此,对空中的对象的交互,用它是无法完成的。现在国产武侠类 MMORPG 里大行其道的轻功、甚至御剑飞行,是无法只单纯依赖 RecastNavigation 的数据去实现的。特别是对于某些具有层次错落结构的地形,就非常容易出现掉到两片导航网格的夹缝里的情况。这类机制的实现需要其他场景数据的支持,通常这时会结合其他引擎,如physx

(2)像《塞尔达传说:旷野之息》的爬山、《忍者龙剑传》的踩墙这种机制,则会在生成导航网格的阶段就会遇到麻烦。因为设计前提2的存在,RecastNavigation 是无法对与地面夹角小于或等于90°的墙面生成导航网格的。因此需要从另外的机制、设计上去规避或处理。不过,Unity 2017 已经可以支持了在各种角度的墙面生成导航网格了:Ceiling and Wall Navigation in Unity3D

当然,这篇文章成文时间是22年,很有可能他说的这些内容都已经实现了。我去翻了下原作者的其他文章,他显然食言了呀,没有做Recast Navigation后续的内容了,只有关于构建导航网络的解析,我后续会继续研究NavMesh的内容的,就放在后续来讲解吧。

一些问题和补充:

Q:

Detour的A寻路在大规模场景下有哪些优化点?你如何进一步提升性能?

A:

A算法是一种常用的启发式寻路算法,广泛应用于自动导航和游戏AI等场景。它的基本思想是在一个由节点和带权重的边组成的图结构上,从起点出发,寻找一条总代价最小的路径到达终点。算法会维护两个列表:开放列表(Open List,记录待访问的节点)和关闭列表(Closed List,记录已访问的节点)。每个节点都有一个总代价f(n) = g(n) + h(n),其中g(n)表示从起点到当前节点的实际代价,h(n)是当前节点到终点的估算代价(即启发式函数,常用欧几里得距离或曼哈顿距离)。算法每次从开放列表中选择f值最小的节点进行扩展,如果该节点是终点则回溯得到最优路径,否则将其加入关闭列表,并遍历所有相邻节点,更新它们的代价和父节点。如此循环,直到找到终点或开放列表为空。A算法的优势在于结合了实际代价和启发式估价,能够高效地找到最优路径,并且在实际应用中还可以对路径进行平滑处理,使移动轨迹更加自然。

针对大规模场景下A寻路算法的优化,主要可以从以下四个方面入手:首先,可以将一次完整的A搜索过程拆分为多个小步骤,分帧(分片)执行,每一帧只处理部分节点,避免主线程卡顿,提升实时性;其次,将整个导航网格(NavMesh)划分为多个Tile(瓦片),每次寻路时只在与起点和终点相关的Tile内进行搜索,极大地减少了需要遍历的节点数量,提高了寻路效率;第三,通过优化开放列表和关闭列表等核心数据结构,比如采用优先队列或堆结构,能够有效减少内存分配和访问延迟,加快节点的插入和查找速度;最后,可以自定义和调整代价函数,为不同区域设置不同的权重,使寻路算法在保证效率的同时,能够根据实际需求灵活选择最优路径。这些优化方法从算法流程、空间划分、数据结构和代价评估等多个层面出发,能够显著提升A寻路在大规模复杂场景下的性能和实用性。

Q:

NavMesh动态更新时,如何做到只重建受影响区域?TileCache的原理是什么?

A:

在动态场景中实现NavMesh(导航网格)的高效增量区域更新,核心思想是将整个导航网格划分为多个独立的Tile(瓦片),每个Tile都可以单独构建和管理。当场景中发生变化(如障碍物的增删或地形的修改)时,系统会首先检测和定位出受影响的Tile区域,然后只对这些Tile进行重建,而未受影响的Tile则无需重新计算,从而极大地提升了导航网格的更新效率。这种按需、局部的重建方式被称为“增量区域更新”。

TileCache正是为实现这一机制而设计的关键模块。它的原理在于对每个Tile的原始数据(如高度场、区域划分、障碍物信息等)进行缓存和管理。当有动态变化时,TileCache会自动计算出受影响的Tile,并只对这些Tile进行增量重建。TileCache还维护了高效的空间索引,能够快速定位障碍物影响范围与Tile的对应关系。此外,TileCache支持Tile数据的复用,未发生变化的Tile会直接复用之前缓存的网格数据,避免重复计算和内存浪费。通过这种机制,TileCache实现了大规模、动态场景下NavMesh的高效、实时、增量式更新,是现代游戏和仿真系统中动态导航网格管理的核心技术之一。

Q:

群体寻路中,如何避免Agent之间的碰撞?路径平滑和避让是怎么实现的?

A:

在群体寻路中,为了避免多个Agent(智能体)之间的碰撞,并实现自然流畅的移动,DetourCrowd模块采用了多种机制。首先,每个Agent在移动过程中会实时感知周围的其他Agent和障碍物,系统会为每个Agent分配局部避障(Obstacle Avoidance)参数,通过采样和惩罚机制动态调整Agent的速度和方向,使其主动避开周围的障碍和其他Agent。具体来说,DetourCrowd会为每个Agent构建一个局部的速度采样空间,评估不同速度下的碰撞风险和代价,最终选择一个既能朝目标前进又能有效避让的最优速度。此外,DetourCrowd还支持分离(Separation)策略,通过计算与邻居Agent的距离,自动调整自身位置,尽量保持合理的间距,减少拥挤和碰撞。

路径平滑的实现原理主要是为了让Agent在导航网格上移动时,能够沿着一条更加自然、流畅的路线前进,而不是机械地沿着A算法初步生成的折线路径移动。具体做法包括:首先,通过可见性优化,系统会检查路径上的连续拐点,判断当前点与后续某个点之间是否可以直线到达,如果可以,则跳过中间多余的拐点,将路径拉直,从而减少不必要的转折;其次,利用路径拓扑优化,系统会根据Agent的实际位置和目标点,动态调整和简化路径结构,避免因动态避障或误差导致的路径偏离和冗余;在更高级的实现中,还可以对路径点进行插值,甚至采用样条曲线等方法进一步平滑路径。通过这些优化,最终生成的路径不仅更短,而且移动轨迹更加平滑自然,大大提升了Agent的智能移动表现和真实感。

Q:

你如何设计路径缓存系统?NavMesh缓存和路径缓存的区别是什么?

A:

路径缓存系统的设计思路,是在业务层针对频繁出现的寻路请求,建立一个以“起点-终点”为Key的路径缓存表。每当有新的寻路请求时,系统会首先查询缓存,如果缓存中已经存在该起点和终点对应的路径,则直接复用缓存结果,避免重复计算;如果缓存中没有,则执行一次真实的寻路算法(如A),并将结果存入缓存,供后续复用。为了保证缓存的有效性和内存占用,通常还会设计缓存淘汰策略,比如LRU(最近最少使用)或定期清理过期路径。

NavMesh缓存和路径缓存是两个不同层面的优化手段。NavMesh缓存(如TileCache)主要是对导航网格本身的数据进行分块、存储和增量更新,属于底层数据结构的优化,目的是提升导航网格的动态重建和管理效率。而路径缓存则是对寻路结果的缓存,属于业务逻辑层的优化,目的是减少重复的路径计算,提升寻路请求的响应速度。二者可以结合使用:NavMesh缓存保证底层网格数据的高效和实时性,路径缓存则提升高频寻路请求的性能,从而实现整体寻路系统的高效和可扩展。

Q:

DetourCrowd的局限性有哪些?如何扩展支持更多Agent?

A:

DetourCrowd的主要局限性体现在以下几个方面:首先,它采用的是局部寻路和局部避障算法,适合中小规模的Agent群体(一般建议不超过25~30个Agent),当Agent数量过多时,性能和内存消耗会迅速上升,容易出现卡顿或响应延迟。其次,DetourCrowd对每个Agent的位置和速度有严格的内部管理,用户无法直接干预Agent的移动轨迹,只能通过参数调整行为,这在某些需要高度自定义行为的场景下会有一定限制。此外,DetourCrowd的避障和路径优化算法主要针对静态或缓慢变化的场景,对于极端高动态、密集或大规模的群体,局部避障效果和全局调度能力有限。

为了扩展支持更多Agent,可以采取以下措施:一是将大群体拆分为多个小群体(分组管理),每个小群体独立使用DetourCrowd进行局部寻路和避障,分摊计算压力;二是采用分区调度,将场景划分为多个区域,每个区域内的Agent独立管理,减少全局计算量;三是降低更新频率或采用分帧、分批次更新Agent状态,避免每一帧都处理所有Agent;四是结合多线程或并行计算,将Agent的寻路和避障任务分配到不同的CPU核心,提高整体吞吐量;五是针对大规模场景,可以引入更高层次的全局调度或路径引导系统,为Agent分配中间目标点,减少全局路径规划压力。通过这些方法,可以显著提升DetourCrowd在大规模、复杂场景下的扩展性和性能表现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值