# 一、简介

  • 开源地址:RecastNavigation
  • RecastNav 寻路 = 导航网格 + 寻路算法。
    • Recast 负责根据输入的环境网格 (Mesh) 生成导航网格。-> 路径数据
    • Detour 使用导航网格寻路。-> 路径算法
    • RecastDemo 使用 Recast+Detour 的一个示例工程。
  • 知乎大佬详解 Recast Navigation
  • 知乎大佬详解 Recast Navigation

# 二、RecastDemo UML

Recast Demo

  1. 程序入口 Main 函数 - 初始化。
  2. While 循环实现 Tick 功能。
  3. 构建 UI(文本、按钮...)。监听和处理输入。
  4. 输入环境网格,设置参数,生成导航网格(NavMesh)。
  5. 绘制显示。
  6. 调试和 Debug。

# 三、约定俗成

  • 环境网格 (EnvMesh):输入的源网格体数据,用于生成导航网格体数据。
  • 导航网格 (NavMesh):Recast 根据环境网格输出导航网格体数据,该网格数据用于寻路。

# 四、输入源 (环境网格)

  • RecastDemo 输入的环境网格是从 Obj 文件读取的。文件格式非常简单且两种数据,如下所示:
    • 顶点数据:v 32.471557617 31.175949097 3.788104773
    • 三角形索引数据:f 1 2 3
  • 解析 Obj 文件,将顶点和索引数据存入下列数据结构中:
    • float* rcMeshLoaderObj::m_verts; // 每三个组成一个顶点
    • int* rcMeshLoaderObj::m_tris; // 每三个组成一个三角形索引
  • UE5 的环境网格是由引擎提供。

# 五、Recast 参数

  • 单位 (Units):Units are usually in voxels (vx) or world units (wu). The units for voxels, grid size, and cell size are all based on the values of #cs and #ch.
    • vx:体素单位。这些单位全是 int 值,表示有多少个体素。体素的大小 width (XZ - 竖直面)/ 体素 height (Y - 高度)。
    • wu:世界单位。
  • 限制 (Limit):数据值的限制范围。
  • 坐标系:SDL 库 (Opengl) 是右手坐标系。
    Coordinate System
  • C++ 参数
// Specifies a configuration to use when performing Recast builds.
struct rcConfig
{
	/// The minimum/maximum bounds of the field's AABB. [(x, y, z)] [Units: wu]
	/// 环境Mesh中顶点XYZ的最大最小值构成的AABB包围盒,将环境都包含在内。
	/// 这个值也可以自定义,毕竟真实的游戏很复杂,有些区域不需要寻路,比如:水里、天空、不可到达的区域...
	float bmin[3]; 
	float bmax[3];

	/// The xz-plane/y-axis cell size/height to use for fields. [Limit: > 0] [Units: wu] 
	/// 体素细分单位。体素长宽高单位大小。
	float cs; // cell size -> 立方体XZ的大小 -> XZ平面正方形
	float ch; // cell height -> 立方体高度大小

	/// The width/height of the field along the x-axis/z-axis. [Limit: >= 0] [Units: vx]
	/// 将上面的AABB包围盒细分。将X轴细分为width块,将Z轴细分为height块。
	int width;  // X轴细分。浮点数向上取整。如:width = (fAABBMax_X - fAABBMin_X)/(cs + 0.5f)
	int height; // Z轴细分。
		    // 注意:Y轴没有被细分。
	
	/// The size of the non-navigable border around the heightfield. [Limit: >=0] [Units: vx]
	/// 边界区域大小,这些边界区域不会生成导航网格。
	int borderSize;

	/// The width/height size of tile's on the xz-plane. [Limit: >= 0] [Units: vx]
	int tileSize;
	
	/// The maximum slope that is considered walkable. [Limits: 0 <= value < 90] [Units: Degrees]
	/// 爬坡下坎的角度,斜面超过这个角度不能导航。 
	float walkableSlopeAngle;

	/// Minimum floor to 'ceiling(天花板)' height 
	/// that will still allow the floor area to be considered walkable. [Limit: >= 3] [Units: vx] 
	/// 可行走的最小高度 - 体素坐标系 - 单位为体素的高
	int walkableHeight;
	
	/// Maximum ledge height that is considered to still be traversable. [Limit: >=0] [Units: vx] 
	/// 可以攀爬的最大高度 - 体素坐标系 - 单位为体素的高
	int walkableClimb;
	
	/// The distance to erode/shrink the walkable area of the heightfield away from obstructions.  [Limit: >=0] [Units: vx] 
	/// 行走半径。区域圆的半径大于该值时才可以行走。每个物体都有半径,行走物应该都有半径![Limit: >=0]
	int walkableRadius;
	
	/// The maximum allowed length for contour edges along the border of the mesh. [Limit: >=0] [Units: vx]
	int maxEdgeLen;
	
	/// The maximum distance a simplified contour's border edges should deviate the original raw contour. [Limit: >=0] [Units: vx]
	float maxSimplificationError;
	
	/// The minimum number of cells allowed to form isolated island areas. [Limit: >=0] [Units: vx] 
	/// 平面XZ最小可行走区域。
	int minRegionArea;
	
	/// Any regions with a span count smaller than this value will, if possible, be merged with larger regions. [Limit: >=0] [Units: vx] 
	int mergeRegionArea;
	
	/// The maximum number of vertices allowed for polygons generated during the contour to polygon conversion process. [Limit: >= 3] 
	int maxVertsPerPoly;
	
	/// Sets the sampling distance to use when generating the detail mesh.(For height detail only.) [Limits: 0 or >= 0.9] [Units: wu] 
	float detailSampleDist;
	
	/// The maximum distance the detail mesh surface should deviate from heightfield data. (For height detail only.) [Limit: >=0] [Units: wu] 
	float detailSampleMaxError;
};

# 六、体素化

# 1. 概念

  • 光栅化:将三角形转换为二维平面的像素格子
  • 体素化:将三角形转换为三维的立方体格子

# 2. 效果图片

Voxelization

  • 图 a 是三维模型,3D 美术师在 Maya 等软件中创建的三维网格体。
  • 图 b-d 就是对图 a 的体素化,用一个一个立方体切割出来的体素模型。
  • RecastDemo 体素化图
    Recast Demo Mode
    Recast Demo Voxelization

# 3. 专有名词解释

![Recast Voxel 1](Recast-Voxel_1.png)
  • 立方体由长宽高三个属性确定,体素也一样:CellSize (X) * CellHeight (Y) * CellSize (Z)
  • Cell:一个体素 (Voxel)。
  • 偷天换日:XYZ 轴的精度被偷换了!常用的 XYZ 轴的精度为 1,现在体素化中 XYZ 以体素大小为精度 (Width\Height\Depth)
    • World X = Width * cs; //cs->CellSize,表示 X 轴切割了 Width 份 cs 大小的单元 下面同理。-> 详情可见 Recast 参数和代码
    • World Y = Height * ch; //ch->CellHeight
    • World Z = Depth(Height_Z) * cs;
      // 注:不论是代码还是参考资料里面,Height 关键字都容易混肴
      // 因为 Height 在代码中被使用在 Z 方向上,而计算机图形、游戏引擎、相关资料以及部分代码的字里行间它又被使用在 Y 方向上!
      // 但计算机图形学和游戏引擎一直是 Y 表示高度,Z 表示深度!
      // 所以本篇文章 Z 轴的 Height_Z 使用 Depth,Y 轴使用 Height 来区分,相关代码也会特别标注是哪个轴向!
  • BoundsMax/BoundMin:AABB 包围盒,表示整个体素化的范围,该范围内的所有输入的环境网格体都会被体素化,生成导航网格。
    Recast Voxel 4
  • Span:由一个或多个体素在竖直方向上的连续集合体。
  • SpanMax/SpanMin:Span 在 Y 轴上面的体素个数 CellNum = SpanMax - SpanMin
  • 高度场 (rcHeightfield):由 Width*Depth 个 Span** 二维指针组成,竖直串联所有的 Span * 对象。-> 链表
    Height Field
    • 可以想象,XZ 平面每个格子都有一个头指针(一一对应),然后像路边烧烤串串一样,将 Span 都对象串联起来,只不过是竖直放置的。
    • 体素化过程中只有一个高度场对象,用来保存 “串串” 的二维头指针。
    • 为什么要用二维头指针?-> 这一串竖直方向上没有 Span 或者创建一个空 Span 作为头指针太浪费了!

# 4. 切割原理

  • 二维三角形切割 - 三维的投影
    Voxel Triangle Axis X Z

    • 首先 XZ 平面有一个三角形 OAB,然后先按 Z 轴横着切,再按 X 轴竖着切。 -> 切块的大小 = CellSize
    • Z 轴第一刀将三角形切为两个多边形,一个是四边形 OCDB,一个是三角形 CAD。
    • X 轴第一刀将四边形 OCDB 切为两个四边形,一个是 OCGE,一个是 EGDB;然后再将 CAD 切成了两个多边形,一个是三角形 CFG,一个是四边形 FADG。
    • 所以 OCGE 就在产生了一个 Span,此时该 Span 的 X 轴的 Width 和 Z 轴的 Depth 都已经确定 (被切出来的)。
  • 三维三角形切割 - 二维转到三维

    • 那么 Y 轴的 SpanMax/SpanMin 呢?只有确定了 SpanMax/SpanMin,才能表示一个完整的 Span 立方体,才能知道竖直方向上有多少个体素!
    • 其实代码里面并没有真正的切割 Y 轴 (没有调用切割函数 dividePoly),我刚开始也很困惑 (因为我脑阔还在二维空间中),直到我把那段代码理解之后,恍然大悟!
    • Y 轴的 Height 其实在上面 XZ 轴切割的时候就已经可以算出来了!请看下图:
      Recast Voxel 5
    • 上图就是按照 XZ 轴切割后,一块 Span 和三角形的切割示例图。
    • XZ 轴切割后是知道各个 Span 中多边形的顶点数据,比如上面示例红色立方体 Span 中的不规则五边形的五个顶点数据全部已知!
    • 那么 SpanMax = 多边形所有顶点 Y 的最大值向上取整,SpanMin = 多边形所有顶点 Y 的最小值向下取整(取整单位为 CellHeight)。
  • 切割代码

/// 体素化所有三角形
/// Rasterizes an indexed triangle mesh into the specified heightfield.
///
/// Spans will only be added for triangles that overlap the heightfield grid.
/// 
/// @see rcHeightfield
/// @ingroup recast
/// @param[in,out]	context				The build context to use during the operation.
/// @param[in]		verts				The vertices. [(x, y, z) * @p nv]
/// @param[in]		numVerts			The number of vertices. (unused) TODO (graham): Remove in next major release
/// @param[in]		tris				The triangle indices. [(vertA, vertB, vertC) * @p nt]
/// @param[in]		triAreaIDs			The area id's of the triangles. [Limit: <= #RC_WALKABLE_AREA] [Size: @p nt]
/// @param[in]		numTris				The number of triangles.
/// @param[in,out]	heightfield			An initialized heightfield.
/// @param[in]		flagMergeThreshold	The distance where the walkable flag is favored over the non-walkable flag. 
///										[Limit: >= 0] [Units: vx]
/// @returns True if the operation completed successfully.
bool rcRasterizeTriangles(rcContext* context, const float* verts, int numVerts, const int* tris,
				const unsigned char* triAreaIDs, int numTris,
				rcHeightfield& heightfield, int flagMergeThreshold = 1)
{
	...	
	// 体素大小的倒数
	const float inverseCellSize = 1.0f / heightfield.cs;
	const float inverseCellHeight = 1.0f / heightfield.ch;
	
	// 遍历三角形,依次体素化
	for (int triIndex = 0; triIndex < numTris; ++triIndex)
	{
		const float* v0 = &verts[tris[triIndex * 3 + 0] * 3];
		const float* v1 = &verts[tris[triIndex * 3 + 1] * 3];
		const float* v2 = &verts[tris[triIndex * 3 + 2] * 3];
		// 体素化该三角形
		if (!rasterizeTri(v0, v1, v2, triAreaIDs[triIndex], heightfield, heightfield.bmin, heightfield.bmax, heightfield.cs, inverseCellSize, inverseCellHeight, flagMergeThreshold))
		{
			context->log(RC_LOG_ERROR, "rcRasterizeTriangles: Out of memory.");
			return false;
		}
	}

	return true;
}




/// 体素化单个三角形
///	Rasterize a single triangle to the heightfield.
///
///	This code is extremely hot, so much care should be given to maintaining maximum perf here.
/// 
/// @param[in] 	v0					Triangle vertex 0
/// @param[in] 	v1					Triangle vertex 1
/// @param[in] 	v2					Triangle vertex 2
/// @param[in] 	areaID				The area ID to assign to the rasterized spans
/// @param[in] 	hf					Heightfield to rasterize into
/// @param[in] 	hfBBMin				The min extents of the heightfield bounding box
/// @param[in] 	hfBBMax				The max extents of the heightfield bounding box
/// @param[in] 	cellSize			The x and z axis size of a voxel in the heightfield
/// @param[in] 	inverseCellSize		1 / cellSize
/// @param[in] 	inverseCellHeight	1 / cellHeight
/// @param[in] 	flagMergeThreshold	The threshold in which area flags will be merged 
/// @returns true if the operation completes successfully.  false if there was an error adding spans to the heightfield.
static bool rasterizeTri(const float* v0, const float* v1, const float* v2,
                         const unsigned char areaID, rcHeightfield& hf,
                         const float* hfBBMin, const float* hfBBMax,
                         const float cellSize, const float inverseCellSize, const float inverseCellHeight,
                         const int flagMergeThreshold)
{
	//////////////////////// 代码较长,精简主要步骤 ////////////////////////
	
	/// 计算三角形的AABB包围盒 -> 世界坐标
	// Calculate the bounding box of the triangle.
	float triBBMin[3];
	rcVcopy(triBBMin, v0);
	rcVmin(triBBMin, v1);
	rcVmin(triBBMin, v2);

	float triBBMax[3];
	rcVcopy(triBBMax, v0);
	rcVmax(triBBMax, v1);
	rcVmax(triBBMax, v2);
	
	/// 计算该三角形z方向上坐标 -> 世界坐标转体素坐标 -> z0和z1表示三角形在体素坐标内AABB包围盒在Z轴方向上的最大最小值!
	// Calculate the footprint of the triangle on the grid's z-axis
	int z0 = (int)((triBBMin[2] - hfBBMin[2]) * inverseCellSize);
	int z1 = (int)((triBBMax[2] - hfBBMin[2]) * inverseCellSize);
	/// 在Z轴上一刀一刀的横切
	for (int z = z0; z <= z1; ++z)
	{
		// 切割函数
		dividePoly(..., RC_AXIS_Z); // 枚举值RC_AXIS_Z表示按照Z轴切割
		
		/// 同z0和z1
		int x0 = (int)((minX - hfBBMin[0]) * inverseCellSize);
		int x1 = (int)((maxX - hfBBMin[0]) * inverseCellSize);
		/// 在X轴上一刀一刀的竖切
		for (int x = x0; x <= x1; ++x)
		{
			dividePoly(..., RC_AXIS_X);// 枚举值RC_AXIS_X表示按照X轴切割
			
			/// 遍历切割后的多边形顶点,求出顶点Y的最大最小值(spanMin/spanMax)
			// Calculate min and max of the span.
			float spanMin = p1[1];
			float spanMax = p1[1];
			for (int vert = 1; vert < nv; ++vert)
			{
				spanMin = rcMin(spanMin, p1[vert * 3 + 1]);
				spanMax = rcMax(spanMax, p1[vert * 3 + 1]);
			}
			/// 减去体素坐标的世界坐标原点,转为体素坐标 -> 但是精度还是世界坐标精度1
			spanMin -= hfBBMin[1];
			spanMax -= hfBBMin[1];
			
			/// 将spanMin/spanMax转为体素坐标精度
			// Snap the span to the heightfield height grid.
			unsigned short spanMinCellIndex = (unsigned short)rcClamp((int)floorf(spanMin * inverseCellHeight), 0, RC_SPAN_MAX_HEIGHT);
			unsigned short spanMaxCellIndex = (unsigned short)rcClamp((int)ceilf(spanMax * inverseCellHeight), (int)spanMinCellIndex + 1, RC_SPAN_MAX_HEIGHT);
			
			/// 添加Span
			if (!addSpan(hf, x, z, spanMinCellIndex, spanMaxCellIndex, areaID, flagMergeThreshold))
			{
				return false;
			}
		}
	}
}




/// 按轴切割多边形
/// Divides a convex polygon of max 12 vertices into two convex polygons
/// across a separating axis.
/// 
/// @param[in]	inVerts			The input polygon vertices
/// @param[in]	inVertsCount	The number of input polygon vertices
/// @param[out]	outVerts1		Resulting polygon 1's vertices
/// @param[out]	outVerts1Count	The number of resulting polygon 1 vertices
/// @param[out]	outVerts2		Resulting polygon 2's vertices
/// @param[out]	outVerts2Count	The number of resulting polygon 2 vertices
/// @param[in]	axisOffset		THe offset along the specified axis
/// @param[in]	axis			The separating axis
static void dividePoly(const float* inVerts, int inVertsCount,
                       float* outVerts1, int* outVerts1Count,
                       float* outVerts2, int* outVerts2Count,
                       float axisOffset, rcAxis axis)
{
	/// 切割算法 -> 没有细看这部分源码 -> 而且这部分源码因为优化非常"好理解"
	/// 每条边都可以确定一个直线线段(两点确定一条直线)-> 可以求出直线方程
	/// 先判断相交情况,用线段的当前轴(axis)最大最小值和当前切割线(axisOffset)比大小,只有线段当前轴最小值<axisOffset<线段当前轴最大值才相交
	/// 相交后,将axisOffset带入直线方程求解就能得到交点

	/// 当前三角形和当前切割线的所有交点(最多只有两个[0-2])
	/// 判断左右来区分切割后的两个多边形,同一边的就是同一个多边形! -> 判断左右和判断相交一样,比较大小就可以了
	/// 返回切割后的两个多边形顶点outVerts1/outVerts2和数量outVerts1Count/outVerts2Count
}




/// 向高度场对象插入一个Span -> 链表操作
/// Adds a span to the heightfield.  If the new span overlaps existing spans,
/// it will merge the new span with the existing ones.
///
/// @param[in]	hf					Heightfield to add spans to
/// @param[in]	x					The new span's column cell x index
/// @param[in]	z					The new span's column cell z index
/// @param[in]	min					The new span's minimum cell index
/// @param[in]	max					The new span's maximum cell index
/// @param[in]	areaID				The new span's area type ID
/// @param[in]	flagMergeThreshold	How close two spans maximum extents need to be to merge area type IDs
static bool addSpan(rcHeightfield& hf,
                    const int x, const int z,
                    const unsigned short min, const unsigned short max,
                    const unsigned char areaID, const int flagMergeThreshold)
{
	/// 创建一个Span
	/// 在高度场中找到XZ平面对应的Cell链表头指针
	/// 按照高度从低到高依次遍历已有的Span
	/// 找到链表合适的位置,对于上下重叠的或者小于阈值的临近Span,就合并到新的Span,释放旧Span
	/// 插入新Span
}

# 七、筛选可行走 Span

  • 根据 Recast 参数筛选出对应的 Span,并打上对应的 area id 标签。参数:可行走的高度、可攀爬高度...
  • 参考知乎博主

# 八、CompactHeightfield

# 1. 概念

  • SolidSpan: 不可行走的空间 (Span)。 -> Heightfield
    • Heightfield:高度场中每一个 cell 对象内是一个 span 的链表,span 表示的是玩家不可通过的实心体素。
  • CompactSpan(Open Span ): 与上面互补,可行走的空间区域。-> CompactHeightfield
    • CompactHeightfield:紧缩高度场由一个一个 rcCompactSpan 组成。
    • rcCompactSpan 的数据结构如下
      /// Represents a span of unobstructed space within a compact heightfield.
      struct rcCompactSpan
      {
      	/// 底部首个开始的SolidSpan -> Agent寻路的表面
      	unsigned short y;				///< The lower extent of the span. (Measured from the heightfield's base.)
      	unsigned short reg;			///< The id of the region the span belongs to. (Or zero if not in a region.)
      	unsigned int con : 24;		///< Packed neighbor connection data.
      	/// CompactSpan的高度,也就是以y开始**连续向上**由多少个**SolidSpan**组成
      	unsigned int h : 8;			///< The height of the span.  (Measured from #y.)
      };
      
  • 注意:前文提到的都是 SolidSpan,后文提到的都是 CompactSpan!

# 2. 函数和原理

  • rcBuildCompactHeightfield 函数:乾坤颠倒。将 Heightfield 转为 CompactHeightfield,然后释放原来的 Heightfield (后面不会使用了)。
  • rcErodeWalkableArea 函数:根据可走行半径参数,标记靠近边界或障碍物的 CompactSpan 为不可走。
    • 每个 CompactSpan 的都有一个 Area 属性标记(可走,不可走,草地,雪地,水域...),寻路时每个 agent 有一个 flag 与 area 对应,例如玩家的 flag 包含草地、雪地和水域,那么寻路路径可穿越这些 area,例如怪的 flag 包含草地、雪地,那么寻路时认为水域是阻挡物而绕开。
    • Area 可由开发者扩展!

# 九、创建区域 (CompactRegion)

  • 区域(region)是一组连续的 span,表示可走表面的范围。
  • 函数 rcBuildRegions 为每个 CompactSpan 分配一个所属的 Region Id(即:rcCompactSpan::reg)。
  • Region 效果图:每块 Region 由不同的颜色区分,黑线代表相邻的 Region 连接可达
    Region Connection

# 十、导航网格(三维到二维)

# 1. 步骤

  • 取出 Region 轮廓线 Contours
    • 函数 rcBuildContours:将 CompactHeightfield 转换为 ContourSet。根据 Span 的 Region 信息提取出轮廓线多边形和凸包处理。
  • 根据 Contours 生成导航网格 PloyMesh
    • 函数 rcBuildPolyMesh:生成导航导航网格,用于寻路算法!

# 2. 效果图

Raw Contours

# 下一章:寻路

  • 导航网格已经生成 (Region),后面就是用寻路算法在 Region 上寻路。
編集日

*~( ̄▽ ̄)~[お茶]を一杯ください

XiaoLiang WeChat 支払う

WeChat 支払う

XiaoLiang Alipay

Alipay