1. 初识寻路与NavMesh:为什么路点导航不够用了
做游戏开发,尤其是涉及角色AI、怪物移动、RTS单位控制的时候,“寻路”是绕不开的一个坎。早些年大家做寻路,最土的办法是路点(Waypoint)导航——美术或者策划在地图上摆一串坐标点,角色朝下一个点走,到了再朝下一个点走。这东西在小场景、路径固定的游戏里勉强够用,但一旦地图稍微复杂一点,问题就全冒出来了:角色会直愣愣地穿过墙角、队伍会被一个窄门卡死、动态障碍物一变化路径就断掉。
后来有了网格(Grid)导航,把地图切成均匀的方格,用A*搜索格子。这比路点强不少,但问题也很明显——地图大时格子数量爆炸,而且角色走出来的路径是折线,非常僵硬,还得额外做平滑处理。
再后来就是标题里提到的NavMesh,导航网格。它把可行走区域剖分成一组凸多边形,角色在网格顶点之间移动,路径看起来自然得多,查询效率也远高于格子搜索。NavMesh现在几乎是商业游戏寻路的标配,Unity的NavMesh、Unreal的NavMesh、甚至很多自研引擎都在用。而Recast,就是目前业界最成熟的开源NavMesh生成库之一,它的配套Demo——RecastDemo,正是用来展示和调试这套流程的最好工具。
这篇博客,我就从RecastDemo入手,聊聊NavMesh的核心原理、Recast生成导航网格的几个关键阶段、实际操作中怎么调参,以及我在自己项目里踩过的那些坑。
这篇内容适合谁看?正在入门游戏AI寻路、想搞懂NavMesh工作方式的人,或者已经用Unity/UE的NavMesh、但不知道它底层怎么来的开发者。读完你应该能看懂RecastDemo里每个按钮是干嘛的,了解导航网格从无到有经历了什么,以及在自己的项目中怎么把这套东西接进来。
2. RecastDemo:一个“打开就能玩”的导航网格实验室
2.1 Recast和它的Demo到底是什么
Recast是开源社区里最出名的自动生成NavMesh工具库,作者是Mikko Mononen,最初是为了给游戏《War Thunder》的寻路系统做技术储备而开发的,后来开源出来。它由两个核心库组成:Recast负责“生成”导航网格,Detour负责“使用”导航网格——包括路径查询、路径跟随、动态障碍物的处理等。
RecastDemo是官方配套的示例程序,直接拉下来编译就能跑。它内置了几个测试场景,从简单的房间到一个城堡废墟,可以让你一边旋转摄像机,一边看到NavMesh是怎么一层一层被生成出来的。打开Demo的第一感觉可能是“东西真多”,左侧一堆参数面板、右侧是3D场景、顶部还有几组构建按钮。但别慌,这些UI都对应着NavMesh生成流程的各个阶段,一步步理解下来,比读十篇文档都有用。
之所以推荐从RecastDemo入手去研究寻路,是因为它把“从几何数据到可用于路径查询的导航网格”这条完整链路,用可视化的方式摊开摆在了你面前。你可以看到体素化之后的样子、可以单独显示每个阶段的结果、可以拖拽参数实时观察效果变化——这种直观性是看任何源码、读任何文档都替代不了的。
2.2 导航网格跟A*、路点到底差在哪
先理清一个概念:NavMesh不是一种寻路算法,它是一种“数据表示”。底层的路径搜索算法仍然是A*之类的启发式搜索,但搜索的图从格子变成了“由多边形组成的连通图”。
路点导航是点对点连线,网格导航是格子搜索,NavMesh则是把地图划分成多个凸多边形。凸多边形的好处是:多边形内任意两点连线都在多边形内部,这意味着角色可以直线走,不会撞墙。搜索时把多边形作为节点,找到一条从起点多边形到终点多边形的路径,然后沿着多边形之间的边一步步走。
对比起来优势很明显:
- 路径点数量少,搜索速度快不少
- 路径更平直自然,不需要大量的后处理平滑
- 可以天然表达“斜坡”“跳跃”“下落”等不同移动类型
- 数据量比均匀网格小得多,适合大地图
代价就是生成过程比格子导航复杂。格子导航只需要遍历一遍高度图就行,NavMesh要经历体素化、区域划分、轮廓提取、凸多边形化等一系列步骤。这也是为什么Recast源码读起来略费劲——每一步都有很多细节。
3. 核心机制拆解:Recast把地图变成导航网格的四个阶段
3.1 第一阶段:体素化,把三角形网格变成“乐高块”
Recast的输入是场景的三角形网格(Triangle Mesh),包括静态几何体和地形。第一步体素化(Voxelization),把这堆三角形转换成一堆小立方块(体素,Voxel)。
这一步的理解可以类比成:给你一堆三角形组成的雕塑,你要用乐高积木把它重新堆出来。三角形的面会被离散化成一个个小方块,方块落在哪些格子里,这个格子就是“有东西”的。这些体素并不需要很精细,它们的主要目的是后续判断“哪些空间是可以站人的”。
RecastDemo的Sample Solo Mesh这个例子里,点击Build后首先生成的就是这层体素。在侧边栏勾选“Voxels”可以看到结果:地面和墙体变成了密集的小方块,虽然看起来“糙”,但已经足够表达碰撞空间了。
体素化阶段的几个关键参数:
Cell Size:体素格子的水平边长,默认0.3(单位是场景单位)。这个值越小,导航网格越精细,但计算量和内存也会成倍增加。0.3是性能和精度的不错平衡点。Cell Height:体素格子的垂直高度,默认0.2。它决定竖直方向的分辨率,台阶这种小高度差能不能被识别就看这个值。
这里有个容易踩的坑:如果你把Cell Size调得很大(比如1.0),角色会“变胖”,很多细窄通道会被直接忽略;调到很小(0.1)则构建时间会瞬间暴涨。所以调整这两个参数时,要同时考虑你的角色半径和地图尺寸,而不是纯靠感觉。
3.2 第二阶段:生成区域,识别哪些地方是“可走的地面”
体素化完成之后,Recast需要判断哪些体素是“可行走”的。它会对每个体素做检测:这个体素上面到下面有没有足够的高度供角色站立?这个体素的倾斜角度是否超过角色能爬的坡度?
满足条件的体素会被标记为“可行走体素”,然后Recast会把它们连接成连通的区域(Region)。这一步相当关键——整个场景被切割成多个互不连通的大块,比如“一楼大厅”“二楼走廊”“外面的院子”,各自成为一个独立的区域。
在Demo里勾选“Regions”可以看到不同颜色的区域,同一颜色代表同一块连通的可行走区域。如果两个区域之间没有连接(比如中间有一堵墙完全隔开),那它们就会显示为不同颜色。
这一步涉及两个重要参数:
Agent Radius:角色半径,决定角色能挤过的最小通道宽Agent Max Climb:角色能攀爬的最大台阶高度Agent Max Slope:角色能走的最大坡度,单位是度
这三个参数本质上定义了“谁会在这张地图上走”。你得根据你的实际单位尺寸来设,比如你的角色半径是0.5,那通道最小宽度至少得是2倍的0.5加上一点余量,否则角色会被卡住。
3.3 第三阶段:生成轮廓与凸多边形,把“乐高堆”变成“几何图”
区域还是体素表示的,不是多边形,Recast还需要把它们转换成多边形数据。
过程是先提取区域边界上的体素轮廓(Contour),得到一圈粗糙的折线,然后把轮廓上的顶点简化(Simplify),减少顶点数,再把轮廓内部的区域细分(Triangulate)成一个个凸多边形。
这一步大约是Recast里最数学的部分。轮廓简化用了Douglas-Peucker算法,目的是在保持整体形状的前提下大幅减少顶点数量。后面细分凸多边形时,核心要求是每个多边形都是凸的,因为只有凸多边形的内部路径才保证不越界。如果生成了凹多边形,角色走“直线”就可能穿模。
在Demo中,勾选“Contours”观察轮廓,轮廓边线是粗锯齿状的;再勾选“Poly Mesh”观察细分后的多边形,这时你会看到地面变成了整齐的蓝绿色块。如果某个区域多边形数量特别多、甚至出现重叠或破碎,多半就是参数设置不对。
3.4 第四阶段:生成Detail Mesh,把高度变化补回来
到这里,导航网格已经是一组平面的凸多边形了,但它还缺少“高度信息”。之前的体素化已经丢掉了部分细节,地面咖咖不平怎么办?角色站在斜坡上怎么表示?
Recast的解决方案是生成Detail Mesh(细节网格):在每个凸多边形内部,根据原始几何体的表面高度,生成一些额外的顶点和三角面,用来记录精细的地表起伏。导航网格本身用凸多边形做路径搜索,路径寻优只需要平面坐标;但角色在行进过程中,脚下的高度需要引用Detail Mesh来插值计算。
所以NavMesh的数据通常是两层结构:低分辨率的凸多边形用于寻路,高分辨率的细节网格用于精确的高度采样。明白了这一点,你就知道为什么Recast要费两道工序来做这件事了——路径搜索和高精度定位的需求是分开的。
通过Demo的“Detail Mesh”选项可以直观看到:之前方方正正的凸多边形表面,出现了细碎的小三角形,用来贴合地面形状。这个阶段几乎不需要手动调参,但理解了它,你在上层写路径跟随逻辑时就知道该怎么取高度了。
4. 实操演练:从编译RecastDemo到跑通第一个Sample的完整流程
4.1 编译RecastDemo:没有IDE也没关系
先交代一下环境。RecastDemo官方仓库在GitHub上,用C++写的,依赖很少,基本就是标准库加一个OpenGL渲染器。编译方式有两种:用CMake生成工程文件后再build,或者直接在IDE里打开。
我的个人建议是走CMake。在项目根目录创建一个build文件夹,然后执行:
git clone https://github.com/recastnavigation/recastnavigation.git cd recastnavigation mkdir build && cd build cmake .. -G "Visual Studio 17 2022" # Windows下,macOS/Linux用默认生成器 cmake --build . --config Release如果你的机器是Mac或者Linux,把-G那条去掉或换成对应的生成器参数就行。编译完成之后,在build/bin目录下会生成RecastDemo的可执行文件,双击运行即可。
提示:老版本RecastDemo对高DPI屏幕支持不太好,如果你打开后界面显示模糊,可以右键属性里改一下“高DPI设置”,或者直接用4K缩放为200%的机器,观感会好很多。
4.2 第一次Build:理解左侧参数面板的每一个关键项
启动RecastDemo后,左侧面板最上方有一个“Sample”下拉框,默认是“Solo Mesh”。这个模式下可以设置参数、构建NavMesh,还能手动添加角色和路径查询点。先选Solo Mesh,然后点一下工具栏的Build按钮,几秒钟后你就能看到地面和墙体上出现了蓝色半透明的导航网格。
此时左侧的参数几乎都能实时调整,每次改动后重新Build就能看到变化。最关键的几个我之前已经提到了,这里再罗列一下,方便对照着调:
| 参数 | 默认值 | 作用 | 调大的影响 | 调小的影响 |
|---|---|---|---|---|
| Cell Size | 0.3 | 体素水平分辨率 | 生成更快,细节丢失 | 更精细,构建更慢 |
| Cell Height | 0.2 | 体素垂直分辨率 | 台阶识别粗糙 | 能识别更小的台阶 |
| Agent Radius | 0.6 | 角色半径 | 通道变宽,小缝隙忽略 | 能过窄道,容易卡墙角 |
| Agent Max Climb | 0.9 | 最大攀爬高度 | 能翻高台阶 | 矮台阶也过不去 |
| Agent Max Slope | 45 | 最大可走坡度 | 能爬陡坡 | 缓坡也判定不可走 |
| Tile Size | 0 | 分块大小 | 0表示不分块 | 分块后能局部更新 |
调完这些参数别忘了按Build。如果你发现某块区域始终生成不出NavMesh,大概率是Agent Radius设太大、把通道判定成“人过不去”了。这是新手最容易困惑的地方:明明地面是平的,为什么生成不了网格?答案往往就是角色半径把路堵死了。
4.3 试试“人”怎么走:用工具栏做路径查询
构建完NavMesh,工具栏上有一个带圆点的小图标,用来添加角色(Agent)和设置目标点。操作方式很直观:先点击放置角色的初始位置,再点击放置目标位置,RecastDemo会自动调用Detour的路径查询接口,显示一条从起点到终点的路径。
这一步实际走的就是游戏里寻路的完整流程:
dtNavMeshQuery::findNearestPoly():找到距离起终点最近的多边形dtNavMeshQuery::findPath():在导航网格上做A*搜索,得到一系列多边形dtNavMeshQuery::moveAlongCorridor():角色沿路径走廊移动,并做碰撞规避
路径在场景中往往是一根白线,仔细观察能发现它在多边形边界之间穿行,而不是贴在所有格子的中心线上——这就是NavMesh相对格子导航的一大优势:路径又直又近。
Demo还支持多角色同时寻路、动态障碍物测试,你可以试着把一个Agent放在几个连通区域里,依次设置目标点,看看它们能不能绕开障碍找到路径。
4.4 从Demo回到自己的引擎:Recast集成的最小代码骨架
Demo看完,总得落到自己代码里。Recast的生成接口简单来说就是“喂顶点和索引,调几个参数,拿结果”。下面是一段最简C++伪码,展示了Recast的SoloMesh生成主流程:
// 1. 输入场景三角形网格 // vertexList: 顶点数组, triList: 三角形索引数组 rcConfig cfg; memset(&cfg, 0, sizeof(cfg)); cfg.cs = 0.3f; // 体素水平尺寸 cfg.ch = 0.2f; // 体素垂直尺寸 cfg.walkableRadius = 0.6f; // 角色半径 cfg.walkableClimb = 0.9f; // 可攀爬高度 cfg.walkableSlopeAngle = 45.0f; // 最大坡度 cfg.minRegionArea = 8; // 剔除小于该面积(体素数)的区域 cfg.mergeRegionArea = 20; // 合并小区域阈值 // 2. 构建高度场 rcHeightfield* solid = rcAllocHeightfield(); rcCreateHeightfield(ctx, *solid, cfg.width, cfg.height, cfg.bmin, cfg.bmax, cfg.cs, cfg.ch); rcRasterizeTriangles(ctx, vertexList, triCount, triList, triCount, *solid, cfg.walkableClimb); // 3. 过滤可行走区域 rcFilterLowHangingWalkableObstacles(ctx, cfg.walkableClimb, *solid); rcFilterLedgeSpans(ctx, cfg.walkableHeight, cfg.walkableClimb, *solid); rcFilterWalkableLowHeightSpans(ctx, cfg.walkableHeight, *solid); // 4. 生成区域、轮廓、凸多边形(流程较长,此处省略部分函数) // rcBuildCompactHeightfield // rcBuildDistanceField // rcBuildRegions // rcBuildContours // rcBuildPolyMesh // 5. 生成细节网格 rcPolyMeshDetail* detailMesh = rcAllocPolyMeshDetail(); rcBuildPolyMeshDetail(ctx, *polyMesh, *solid, cfg, *detailMesh);大致流程就是这样。产出的polyMesh+detailMesh就是导航网格数据,你可以把它们烘焙成二进制文件,运行时用Detour加载。这在游戏开发中很常见:编辑器里离线构建导航网格,游戏运行时只负责查询。
5. 常见问题与排查技巧实录:我踩过的那些寻路坑
5.1 NavMesh生成不出来,或者大块缺失
场景Build完之后某些区域没有导航网格,这是最常遇到的问题。我总结下来,九成情况出在下面几个原因:
- Agent Radius太大,通道被判定“不够宽”
- WalkableClimb太小,台阶或门槛变成“不可逾越”的障碍
- WalkableSlopeAngle太小,斜坡被判定“不可走”
- 场景几何体本身有破面、悬浮三角形,导致可走空间计算异常
排查思路很简单:把Agent Radius调到0.1、WalkableClimb调到1.5、Slope调到80,如果这个区域能生成出大块网格,说明是参数太严格了,一点一点往回调;如果降低参数后依然缺失,再去查几何体本身有没有问题。
5.2 角色路径会“绕远路”或者“穿墙”
路径查询出来路径比较远、明显偏离直觉,先别急着怀疑算法。检查一下是不是没加dtQueryFilter的可走标记,或者NavMesh里有“空洞”——有些不该阻挡的区域(比如一张水下地表被误判成不可走)把路径给切断了。
穿墙问题通常是Detail Mesh和Poly Mesh的顶点没对齐导致的。Recast的Poly Mesh顶点是体素化的结果,Detail Mesh是原始几何的投影,两边一比对,如果偏差超过Agent Radius的一半,就会出现“路径看起来贴在墙里”的情况。解决方法通常是调整Cell Size让体素更精细,或者检查几何体自身有没有重叠面。
5.3 寻路结果跳变:同一位置不同时间走的路径不一样
这种情况常见于动态障碍物的场景。Detour提供了动态Tile的机制(Tile Cache),它允许你在运行时局部更新某一块Tile的导航网格——比如一堵墙被炸毁了,你重新生成那一块的Tile数据即可。
跳变往往是因为局部更新和全局数据的Tile边界没有对齐。每张Tile必须有少量重叠边缘(通常是一个体素以上的宽度),更新时只提交改动的区域,不要整张Tile全部重建。另外注意:使用动态Tile时,推荐把Tile Size设成非零值,让NavMesh统一分块,这是动态更新的前提条件。
5.4 性能问题:Build慢、查询慢
Build慢一般是体素分辨率太高。大地图如果用Cell Size=0.1,那个计算量是成指数增长的。能用0.3就尽量别用0.2。对于超大地图,强烈建议开启分块构建(Tile Size设为32或64),这样构建时可以多线程并行,而且以后做动态更新也方便。
查询慢多半是NavMesh数据没有做空间分区。Detour本身用BVH树加速多边形查找,但如果你的场景是多个不连通区域合在一张Mesh里,查询时会把不相关的多边形也扫一遍。多区域场景尽量分开构建NavMesh,查询时用dtNavMeshQuery::findNearestPoly先定位,再做路径搜索,性能会好很多。
6. 避坑清单与调参心得:从“能用”到“好用”
- Agent Radius不是“角色物理碰撞半径”,而是“寻路用的保守半径”。如果你把物理半径直接填进去,角色在窄通道边界贴着走时很容易蹭墙。一般是物理半径的0.8~0.9倍,给寻路留一点余量。
- WalkableHeight这个参数经常被忽略,它决定头顶空间,默认是Agent Height。如果地图里有低矮的天花板或桥洞,这个值没设对,角色会显示“能走”但实际上“站不起来”。
- 不要一次性把所有参数调到“看起来完美”。先保证生成结果正确,再看路径是否自然,最后才去优化构建时间和内存。这个顺序反了会浪费大量时间。
- 在Tiled模式下,所有Tile的参数必须一致。你不能这块Tile用0.3的分辨率,那块用0.2,不然Tile接缝处会出现裂缝。
- 真的想深入了解Recast的话,建议把源码里的
Recast.cpp、DetourNavMeshQuery.cpp读一遍,重点是dtNavMeshQuery::findPath函数的实现。你会发现核心就是A*加上一些堆优化,并没有太多玄学。 - 官方Demo里的几个Sample——Solo Mesh、Tile Mesh、Temp Obstacles——建议都跑一遍。Temp Obstacles是动态障碍物的演示,它展示了运行时如何往NavMesh上临时“压”一个障碍并且自动重新计算路径,这个在很多动作游戏里都能用到。
7. 从RecastDemo到Unity、Godot:这套技术在哪都能落地
如果你用过Unity的NavMesh,你会发现它的参数面板上也有Agent Radius、Agent Height、Max Slope、Step Height这几个值,跟Recast的几乎一模一样。没错,Unity的内置导航网格底层从2018版之后改成了Honeycomb,但核心思路和参数体系与Recast是一脉相承的。你在RecastDemo里调参的经验,直接可以迁移到Unity里用。
Godot引擎同样内置了基于Recast思想实现的NavigationServer,它把导航网格的烘焙和查询分成两个层面,运行时可以通过代码动态更新障碍物。Godot 4.x的NavigationMesh的cell_size、agent_radius这些属性,熟悉吧?换了层皮,底层逻辑还是那套。
至于UE5,它的NavMesh是基于自研的Navigation System,但如果你用过UE的RecastNavMesh,会发现它的参数(Cell Size、Agent Radius、Agent Max Slope)跟Recast的如出一辙——官方文档直接承认就是受Recast启发的实现。
所以说,学会RecastDemo,你几乎是同时掌握了三大引擎的导航网格调参思路。以后再遇到“角色爬不了斜坡”“NavMesh生成不出来”“动态阻挡没反应”这类问题,你脑子里会有清晰的排查路径,而不是一头雾水地各个论坛翻帖子。
我个人的体会是,寻路系统这种东西,直接上手跑一遍Demo,比单纯看文档有效十倍。RecastDemo的代码量并不大,界面也不算精致,但它是把一套复杂的工程系统“可视化”出来的典范。花一个周末的时间,把每个按钮都点一遍、每个参数都调一遍、每个Sample都构建一遍,你对NavMesh的理解会超过刷十篇博客。顺手把Detour的路径查询接口也跑通,那么在Unity、Godot、UE里写寻路逻辑,你都心里有底。