☰
Unity自动寻路算法详解:从NavMesh烘焙到A*搜索与路径平滑
2026/10/27 19:44:35 网站建设 项目流程

1. 自动寻路到底在解决什么问题

做Unity项目的人,迟早会碰到这样一个需求:点一下地面,角色自己走过去;或者一群小怪绕过障碍物追着玩家跑。表面上看是“让角色动起来”,实际上背后要处理的是空间表示、路径搜索、路径平滑、动态避障这一整套问题。自动寻路算法详解这个题目,我在不同项目里反复啃过好几遍,每次重新翻出来看,理解都会更深一层。

先把话说清楚:这篇内容适合谁?如果你刚接触Unity,想知道NavMesh到底是怎么算出来的,能看懂;如果你已经用过NavMeshAgent,但遇到“角色卡墙角”“绕远路”“一群单位挤成一团”这类问题不知道怎么调,也能找到答案;如果你想自己手写一套轻量寻路而不想完全依赖内置组件,这里也有可落地的思路。核心关键词就三个:网格表示、启发式搜索、路径后处理。把这三点吃透,Unity里的自动寻路基本就没有黑箱了。

我见过太多项目,寻路出问题就疯狂调Agent参数,把半径改小、把高度改大,结果治标不治本。真正的原因往往在烘焙阶段——网格没烘干净,或者障碍物标记方式不对。所以这篇不打算只讲算法名词,而是从“为什么这么设计”讲到“实际怎么调”,把每个环节的取舍逻辑摊开来说。

2. 寻路系统的整体设计与方案选型

2.1 为什么是“网格+搜索”而不是“连续空间求解”

理论上,角色在一个连续平面上移动,最优路径是几何意义上的直线或曲线。但计算机没法直接处理“无限精度”的连续空间,必须先把世界离散化。离散化的方式有很多种:栅格地图、路点图、导航网格(NavMesh)、可视点图。Unity内置方案选的是导航网格,这个选择不是随便定的。

栅格地图最好理解,把地面切成一个个小方块,能走的标记为可通行,不能走的标记为障碍。优点是实现简单、动态更新方便;缺点是格子太细内存爆炸,格子太粗路径锯齿严重,而且斜着走的时候会出现“贴着格子边缘走”的别扭感。路点图是手工摆点连线,适合固定路线,但灵活性差,场景一改就得重摆。

导航网格的思路更聪明:它把可行走区域抽象成凸多边形的集合。凸多边形有个好性质——多边形内任意两点之间的直线都不会跑出多边形外。这意味着在多边形内部移动不需要再做碰撞检测,只有跨多边形的时候才需要判断边的连通性。相比栅格,多边形数量少得多,内存和搜索开销都低;相比路点图,它又是自动生成的,场景改动后重新烘焙即可。

提示:凸多边形这个性质是NavMesh高效的根本原因。理解这一点,后面看A*为什么在多边形图上跑得快就顺了。

2.2 三层结构:烘焙层、搜索层、执行层

一套完整的自动寻路系统,我习惯拆成三层来看。烘焙层负责把场景几何转成可行走数据,Unity里就是NavMesh烘焙,输出的是多边形网格和连通关系。搜索层负责在网格上找出一条多边形序列,核心是A*及其变体。执行层负责把多边形序列转成角色能走的平滑路径,并驱动角色移动,对应NavMeshAgent的转向、加速、避障逻辑。

这三层是解耦的。烘焙出问题,搜索再准也没用;搜索策略不对,执行层再怎么调参数也是绕远路;执行层不做路径平滑,角色就会在多边形边界处出现生硬的折线转向。很多教程只讲中间那层A*,结果读者照着做出来角色走起来一顿一顿的,就是因为忽略了另外两层。

2.3 内置方案与自研方案的取舍

什么时候用Unity自带的NavMesh,什么时候自己写?我的经验是:静态场景、单位数量中等、需求标准,直接用内置的,省时省力,烘焙工具、避障、Off-Mesh Link都是现成的。但遇到这几种情况就得考虑自研或混合:场景是程序化生成的、每帧都在变;单位数量上千,Agent开销扛不住;需要特殊的移动规则,比如只能走特定地形、有高度差跳跃逻辑。

自研不代表从零造轮子,可以只替换搜索层。比如用内置烘焙拿到NavMesh数据,但自己实现一套更轻量的寻路查询,避开NavMeshAgent的完整状态机开销。这种混合方案在中大型项目里其实很常见,尤其是做RTS那种大量单位同屏的场景。

3. 核心算法拆解:从A*到NavMesh查询

3.1 A*的启发函数为什么决定了一切

A*的本质是带启发的最优优先搜索。它维护一个开放列表,每次取出估价函数f值最小的节点扩展,f = g + h,g是起点到当前节点的实际代价,h是当前节点到终点的估计代价。关键在于h怎么取。

如果h恒为0,A退化成Dijkstra算法,保证最优但会向四周均匀扩散,效率低。如果h高估了实际代价,搜索会偏向终点方向,速度快但可能找不到最优路径。如果h恰好等于实际代价,那A会直奔最优路径,一个多余节点都不扩展——这是理想情况,实际做不到,但可以逼近。

在网格地图上,常用的启发函数有几种:曼哈顿距离适合四方向移动,因为只能上下左右走,斜线距离被高估;对角距离适合八方向移动;欧几里得距离适合任意方向移动。选错了启发函数,轻则路径不是最优,重则搜索效率暴跌。

// 八方向网格的对角距离启发函数 float Heuristic(Vector2 a, Vector2 b) { float dx = Mathf.Abs(a.x - b.x); float dy = Mathf.Abs(a.y - b.y); // 走对角代价为1.414,直走代价为1 return (dx + dy) + (1.414f - 2f) * Mathf.Min(dx, dy); }

这段代码的逻辑是:先假设全部走对角,再对多算的部分修正。为什么是这个形式?因为对角移动同时消耗x和y两个方向的距离,直走只消耗一个。取min(dx,dy)就是能走对角的最大步数,剩下的差值只能直走。这个公式在八方向网格上是精确的,不会高估也不会低估,搜索效率最高。

注意:NavMesh上的A不是跑在方格上的,节点是多边形,边是相邻多边形的公共边。启发函数用的是多边形中心点或入口点的欧氏距离,代价函数用的是穿越多边形的实际距离。所以NavMesh的A比网格A*节点少得多,但每次扩展的计算量略大。

3.2 开放列表的数据结构选择

A*的性能瓶颈往往不在启发函数,而在开放列表的取最小操作。用普通数组每次遍历找最小,节点一多就是O(n)的扩展开销,整体退化成O(n²)。正确做法是用二叉堆,取最小O(log n),插入也是O(log n)。

但二叉堆有个问题:A*需要频繁更新已存在节点的f值(找到更优路径时),而标准二叉堆不支持高效地“降低某个元素的键值”。解决办法有两种:一是允许堆里存在重复节点,取出时判断是否已关闭,是就跳过,这叫惰性删除,实现简单且实测够用;二是用索引堆,维护节点到堆位置的映射,支持直接更新,内存换效率。

我在中小规模场景里一般用惰性删除,代码短、不容易出bug。单位数量上千、每帧大量寻路请求的时候才考虑索引堆,因为重复节点会让堆膨胀,内存和比较次数都上去了。

3.3 路径后处理:为什么搜出来的路不能直接用

A*输出的是一条多边形序列,直接连中心点会得到一条折线,角色走起来就是一段段直线拼接,转角处突然转向,非常机械。所以必须做路径平滑。

最常用的是漏斗算法(Funnel Algorithm)。它的思路是:把路径看成一条穿过一系列多边形公共边的“走廊”,在走廊里拉一根绳子,绳子的形状就是最短路径。具体做法是维护一个漏斗,左右边界分别是当前能到达的最左和最右点,每经过一个入口点就收紧漏斗,当漏斗交叉时说明必须在该点转向,输出一个拐点。

这个算法输出的路径是严格最短的(在多边形图意义下),而且拐点数量最少。相比简单的“视线检测+拉直”,漏斗算法不需要反复做射线检测,效率高得多。Unity的NavMesh内部用的就是类似思路。

// 漏斗算法核心逻辑示意(简化版) void AddPoint(Vector3 point) { // 判断新点是否在漏斗内 if (IsInsideFunnel(point)) { // 收紧对应边界 UpdateFunnel(point); } else { // 漏斗交叉,输出拐点,重置漏斗 OutputVertex(GetApex()); ResetFunnel(point); } }

实际实现要处理左右边界的叉积判断、共线情况、起点终点特殊处理,代码量不小但逻辑清晰。我建议第一次实现时拿几个简单场景(直线走廊、L形转角、带凹口的多边形)逐个验证,确认拐点位置正确再上复杂场景。

3.4 动态障碍与局部避障的分工

全局寻路解决“从A到B走哪条路”,局部避障解决“路上有东西挡着怎么绕”。这两件事必须分开,混在一起做会非常难维护。

Unity的方案是:NavMeshAgent负责全局路径跟随,同时内置了局部避障(基于RVO,即互惠速度障碍)。每个Agent根据周围邻居的位置和速度,计算一个不碰撞的期望速度,再朝这个速度转向。RVO的好处是单位之间会“互相礼让”,不会出现一个单位硬推另一个单位的情况。

但RVO不是万能的。单位密度极高时,局部避障会陷入“抖动”——两个单位互相让,让来让去原地打转。这时候需要配合优先级或者分层避障,让一部分单位先走。另外,动态障碍物(比如临时放下的箱子)如果没重新烘焙NavMesh,全局路径不会绕开它,只能靠局部避障硬躲,效果往往不好。正确做法是用NavMeshObstacle组件,它支持“ carving”模式,运行时在NavMesh上挖洞,全局路径会实时更新。

提示:carving有开销,障碍物多的时候别全开。静态障碍烘焙进网格,只有真正会动的才用carving。

4. 实操过程:从烘焙到角色跑起来

4.1 场景标记与烘焙参数怎么定

第一步是把场景里的几何体标记成Navigation Static。这一步看着简单,坑却最多。地面要标记为Walkable,墙壁、石头标记为Not Walkable,有些装饰物(比如矮草丛)如果不想挡路,干脆不标记。标记错了,烘焙出来的网格要么有洞,要么多出莫名其妙的可走区域。

烘焙参数里最影响结果的是三个:Agent Radius、Agent Height、Max Slope。Radius决定角色能挤过多窄的缝,设大了窄路走不了,设小了角色会贴墙穿模。经验值是取角色碰撞体半径的0.9倍左右,留一点余量。Height影响头顶净空判断,矮洞能不能钻过去看它。Max Slope是最大可行走坡度,超过这个角度的斜面会被当成障碍。

参数作用常见取值调大后果调小后果
Agent Radius角色半径0.3~0.5窄路不可走贴墙穿模
Agent Height角色高度1.8~2.0矮空间不可走钻头穿顶
Max Slope最大坡度45度陡坡可走缓坡不可走
Step Height台阶高度0.3~0.4可跨高台小台阶卡住

烘焙完一定要打开Navigation窗口的显示,用彩色区域检查。蓝色是可走区域,如果有该蓝的地方是灰的,说明标记或参数有问题。我习惯在场景里放一个胶囊体当“探针”,手动拖到各个角落看能不能到达,比盯着网格看直观。

4.2 代码里怎么发起寻路请求

最直接的方式是给角色挂NavMeshAgent,设置destination,Agent自己会算路径并移动。但这种方式是“黑箱”的,你拿不到中间路径点。如果需要自己做路径跟随(比如要加特殊移动逻辑),就得用NavMesh.CalculatePath手动查询。

using UnityEngine; using UnityEngine.AI; public class PathQuery : MonoBehaviour { public Transform target; private NavMeshPath path; void Start() { path = new NavMeshPath(); } void Update() { if (Input.GetMouseButtonDown(0)) { // 从屏幕点发射射线找地面点 Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 100f)) { // 查询路径 bool found = NavMesh.CalculatePath( transform.position, hit.point, NavMesh.AllAreas, path); if (found && path.status == NavMeshPathStatus.PathComplete) { // path.corners 就是平滑后的拐点数组 Debug.Log($"找到路径,拐点数:{path.corners.Length}"); } } } } }

这里有个细节:CalculatePath返回true不代表路径完整,还要检查path.status。如果终点在NavMesh外,状态会是PathPartial或PathInvalid,corners数组可能只有起点。很多新手直接拿corners用,结果角色原地不动,就是没检查状态。

另外NavMesh.AllAreas是区域掩码,如果场景里划分了不同区域(比如草地、泥地代价不同),这里要传对应的掩码,否则查询会忽略区域代价设置。

4.3 让角色沿路径平滑移动

拿到corners之后,自己驱动角色移动的核心是朝下一个拐点走,到了就切换。但直接朝拐点走会在拐点处急转,需要加转向平滑。

void MoveAlongPath() { if (currentIndex >= path.corners.Length) return; Vector3 targetPoint = path.corners[currentIndex]; Vector3 dir = (targetPoint - transform.position); dir.y = 0; // 忽略高度差,避免角色上下抖动 // 到达当前拐点则切换下一个 if (dir.magnitude < 0.2f) { currentIndex++; return; } // 平滑转向 Quaternion targetRot = Quaternion.LookRotation(dir); transform.rotation = Quaternion.Slerp( transform.rotation, targetRot, Time.deltaTime * 8f); // 沿朝向前进 transform.position += transform.forward * speed * Time.deltaTime; }

这段代码的关键在dir.y = 0和转向用Slerp。忽略y是因为NavMesh路径点可能带高度,直接朝三维点走会让角色在斜坡上抬头低头,视觉上很怪。Slerp的系数8是经验值,太小转向拖沓,太大又变回急转,根据角色体型调。

注意:自己驱动移动时,角色不会自动避障,也不会自动贴地。如果场景有起伏,需要额外做射线检测贴地,否则角色会悬空或陷地。这也是为什么大多数项目还是直接用NavMeshAgent——它把这些琐事都处理了。

4.4 多单位寻路的性能控制

同屏几十个单位同时寻路,如果每个都每帧调用CalculatePath,CPU直接爆。优化思路有几条:请求合并,同一帧内多个单位去同一目标,只算一次路径然后共享;分帧查询,把寻路请求排队,每帧只处理固定数量;路径缓存,起点终点相近的请求复用结果。

我做过一个测试,100个单位同时寻路,不做任何优化时单帧耗时能到十几毫秒,分帧处理(每帧最多算5个)后降到1毫秒以内,视觉上完全看不出延迟。因为寻路是“发起一次、跟随一段时间”的操作,没必要每帧都算。

另外,单位静止时不要调用SetDestination,Agent在到达后会自己停止,重复设置destination会触发重复寻路。这个坑我踩过,明明单位没动,CPU却一直高,查了半天发现是每帧都在设目标点。

5. 常见问题与排查实录

5.1 角色卡在墙角或原地打转

这是最高频的问题。原因通常有三类:路径点太贴墙、Agent半径与碰撞体不匹配、转向速度过快导致过冲。

路径点贴墙是因为烘焙时Agent Radius设小了,NavMesh边缘离墙太近,角色沿边走的时候碰撞体蹭到墙。解决办法是把Radius调大一点重新烘焙,或者在移动代码里对路径点做一点内缩偏移。

原地打转多半是转向逻辑问题。如果角色朝目标点走,但目标点在身后,转向和前进同时进行,就会出现绕圈。正确做法是先转向再前进,或者用“到达角度阈值才前进”的判断。

排查时我习惯打开Gizmos把path.corners画出来,看路径本身对不对。路径对但走不对,是执行层问题;路径本身歪歪扭扭,是烘焙或搜索层问题。这个二分法能快速定位。

5.2 寻路结果绕远路

明明直线能到,角色却绕一大圈,通常是这几个原因:NavMesh上有“洞”导致直线区域不连通;区域代价设置让系统故意绕开某片区域;启发函数权重设得太低,搜索偏向广度扩散。

先检查NavMesh显示,看直线区域是不是完整连通的。如果中间有个小缺口(比如一个没标记好的装饰物),路径就会绕。区域代价的话,检查Navigation窗口的Areas标签页,看有没有区域被设了高代价。启发权重在自研A*里调,内置NavMesh改不了,但可以通过调整区域代价间接影响。

5.3 单位之间互相推挤、抖动

RVO避障在单位密集时会抖动,这是算法特性决定的。缓解手段:给Agent设置不同的避障优先级(priority),让一部分单位主动让路;降低避障质量(obstacle avoidance quality)减少计算抖动;或者干脆对静止单位关闭避障,只对移动单位开启。

还有一个容易被忽略的点:Agent的radius如果比实际碰撞体大很多,单位之间会保持过大间距,看起来像在互相排斥。把Agent radius调到和碰撞体一致,拥挤感会明显改善。

5.4 常见问题速查表

现象可能原因排查方向解决手段
角色不动路径状态非Complete打印path.status检查终点是否在NavMesh上
卡墙角路径点贴墙画Gizmos看路径调大Radius重烘
绕远路网格有洞/区域代价看NavMesh显示补标记/调代价
原地打转转向前进同时进行看转向逻辑先转后走
单位抖动RVO密集冲突看单位间距调优先级/降质量
穿模碰撞体与Agent不匹配对比半径统一尺寸
悬空/陷地未做贴地处理看高度加射线贴地

5.5 几个踩过的坑

第一个坑:Off-Mesh Link的方向性。跳跃点、传送点这类连接是单向还是双向要明确设置,设错了角色能跳过去回不来。而且Off-Mesh Link的起终点要精确对齐NavMesh边缘,差一点就触发不了。

第二个坑:NavMesh Surface组件与旧版烘焙混用。新版Unity推荐用NavMeshSurface组件做运行时烘焙,但如果场景里同时有旧版Navigation窗口烘焙的数据,两者会打架,出现路径诡异的情况。统一用一种方式,别混。

第三个坑:动态障碍carving的性能。我见过一个项目给几百个可移动物体都开了carving,帧率直接腰斩。carving是运行时修改NavMesh数据,开销不小,只给真正影响寻路的动态物体开。

6. 进阶方向:什么时候该考虑自研

内置NavMesh覆盖了大多数需求,但有几类场景它确实吃力。程序化生成的无限地图,NavMesh烘焙跟不上生成速度,得用分块烘焙或者栅格方案。超大规模单位(几百上千),每个Agent的状态机开销累积起来很可观,这时候往往只保留寻路查询,移动和避障自己写轻量版。特殊移动规则,比如单位只能沿特定轨道走、有复杂的攀爬逻辑,内置的平面寻路表达不了。

自研的话,我的建议是先替换搜索层,保留烘焙层。用NavMesh.CalculatePath拿到多边形路径,但自己实现A*和漏斗算法,这样可以针对项目特点优化(比如加自定义代价、做路径缓存、支持异步查询)。等这套跑顺了,再考虑要不要连烘焙也自己管。

异步查询这块值得单独说。CalculatePath是同步的,单位多的时候会卡主线程。可以把它丢到Job System或者单独线程里跑,主线程只负责发起请求和接收结果。注意NavMesh数据在多线程读取时是安全的,但不能在查询的同时修改(比如carving),需要加锁或者用双缓冲。

最后分享一个我常用的调试技巧:在Scene视图里用OnDrawGizmos把当前路径、Agent的期望速度、避障检测范围都画出来。寻路问题十有八九是“看不见的数据”导致的,把中间状态可视化,排查效率能翻好几倍。这个习惯我从做第一个寻路项目保持到现在,每次都能省下大量瞎猜的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询