ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity寻路实战:弃用NavMesh转投A* Pathfinding Project Pro

Unity寻路实战:弃用NavMesh转投A* Pathfinding Project Pro 先说我为什么放着Unity原生的NavMesh不用非要换成A* Pathfinding Project Pro这套寻路插件之前做多人在线对战项目的AI原型地图里同时要放上百个AI单位各自找路原生NavMesh在初始阶段倒是不难用但等状态一复杂起来——运行时开门关门、临时封锁区域、一堆单位挤同一道门——调起来就很头疼。后来把核心寻路换成A* Pathfinding Project Pro路径计算和移动逻辑变得清楚得多。这篇博文会把从选型、搭图、接移动、动态更新到性能调优的完整过程写下来多数内容在Grid Graph场景下通用希望能给正在做AI寻路却不知道从哪下手的Unity开发者一点参考。1. 为什么选A* Pathfinding Project而不是Unity自带NavMesh1.1 NavMesh真正让人难受的地方Unity原生NavMesh在初学者阶段确实够用烘焙一下关卡给角色挂个NavMeshAgent设置目标点就能跑。可一旦需求超过从A点走到B点它的短板就暴露得很明显。第一个痛点是运行时动态修改。比如MOBA里的防御塔被打掉之后小兵原本绕路的路径应该变成直线或者一扇铁门被玩家打开后AI应该从门口穿过而不是继续贴墙绕。用原生NavMesh做这种事最常见的方案是NavMeshObstacle的Carve模式但大量动态物体同时雕刻时网格的一致性和重建时机都很难控。更麻烦的是如果想给某个区域加临时的通行Cost比如路过沼泽减速原生组件做起来非常别扭。第二个痛点是路径过程的可控性。原生NavMeshAgent把路径计算和移动渲染都打包进了一个组件看起来方便可一旦需要自定义寻路逻辑——比如让某类AI只在固定路线上巡逻、让不同阵营的单位对同一片区域有不同的通行偏好、或者在某个点强行插入一段必须绕过去再回来的逻辑——你就不得不在外部做各种补丁。第三个痛点是性能视角不透明。NavMeshAgent的寻路计算到底花了多少毫秒、当前有多少个agent在同时请求路径、哪些计算是可以合并的这些信息在原生体系里很难直观看到。项目一旦进入优化阶段这种黑盒状态很致命。1.2 A* Pathfinding Project Pro哪些地方值得信任A* Pathfinding Project让我愿意换方案的核心原因不只是它实现了A*算法而是它把寻路这件事拆成了几层Graph负责描述地图、Path负责计算路径、移动组件负责执行三者的边界很清楚。我用的Pro版本里它的Recast Graph可以直接基于场景里已有的几何体实时生成导航网格这对于那种需要快速搭原型的关卡非常合适。Grid Graph则更适合规则化的地图尤其是RTS和MOBA这种网格感很强的场景节点分布整齐局部更新方便。它还提供了RVO局部避障组件专门解决多个单位挤在一起时的避让问题。实际项目里我最依赖的两个特性一个是GraphUpdateObject可以在运行时有针对性地更新一小块区域而不是把全图重新扫描一遍另一个是多线程寻路可以让大量AI单位同时发起路径请求时计算被分散到后台线程主线程不会因为寻路集中请求而掉帧。这套分层设计的好处是出问题的时候你能很快判断是哪一环出了问题路径没更新多半是Graph层面路径计算了但单位不走多半是移动层面路径走得怪通常要检查移动参数和避障配置。这种可排查性原生NavMesh给不了。2. 一小时内搭出第一个可运行的Grid Graph场景2.1 场景布置与关键Layer设置拿到插件后第一步不是在角色身上挂组件而是先在场景里创建一个空物体取名叫Pathfinder然后挂上AstarPath组件。这个AstarPath就是整个寻路系统的总管理器它负责管理所有Graph、处理路径请求、执行扫描。AstarPath组件挂上以后需要在Graphs列表里添加一个Graph。如果关卡是平面地图、建筑分布比较规整直接选Grid Graph如果关卡地形起伏很大带山坡、桥洞、多层结构用Recast Graph会更合理。这篇主要以Grid Graph为例因为它最容易理解也最好调。添加Grid Graph之后会看到一系列参数什么Width、Depth、Node Size、Center。这些先别急着改我第一次用的时候踩过一个很典型的坑直接扫场景结果AI整体判定地面全是不可通行——因为当时地面本身挂着Collider而Grid Graph默认碰撞测试会把所有三角面都考虑进去地面被当成了障碍物。正确的做法是在Project Settings的Physics层管理里单独建立一个Obstacle层把需要绕开的物体——围墙、柱子、树干、建筑——都放到这个层上然后在Grid Graph的碰撞检测里让Collision Mask只管Obstacle层。这样扫描时地面完全不会干扰判断路径自然就干净了。2.2 Grid Graph参数背后那笔计算账Grid Graph的工作方式可以理解成把地图切成了一个个均匀的小方格每个方格就是一个节点。节点之间通过相邻关系连接寻路算法在这些节点上跑。所以Node Size是重中之重它直接决定了路径的精细度和成本。举个直观的例子一张100米乘100米的地图如果Node Size设成1米那么横向100个节点、纵向100个节点总计10000个节点如果把Node Size改成0.5米节点数就变成200乘200也就是40000个节点。节点越多路径越精细能避开更小的障碍但扫描时间和寻路计算量也会跟着涨。我的建议是先看角色碰撞体的直径再决定Node Size。一个直径约1米的角色Node Size选0.5或0.6通常合适既能保证路径识别障碍物的灵敏度也不至于让节点数量爆炸。如果角色是坦克那种大体积单位甚至可以选1米。千万不要为了追求精细就把Node Size往0.1调一个大地图直接多出几十万节点后面怎么优化都救不回来。除了Node Size还有一个容易被忽略的参数是Aspect Ratio或者叫Node Stretch它控制横向节点和纵向节点的比例。如果你做的地图本身是不规则的矩形可以调整这个值来适应地图比例否则会出现一大块没用的空区域也被扫描成可通行节点白白浪费内存和计算。2.3 扫描后怎么验证路径不是自欺欺人配置完成以后点一下Scan按钮。如果设置正确Scene视图里会出现一片绿色的节点区域这就是AI可以行走的地方。有障碍物的地方节点会被直接标成不可通行的红色或者干脆缺失。验证路径是否正常最简单的办法是直接在场景里拖一个目标点然后观察绿色路径线。路径线会从起点一路连到目标点如果绕开了所有障碍物说明Graph扫描基本合格。如果发现路径线穿墙先不要急着怪AI回头检查两件事第一墙是不是真的在Obstacle层上第二Graph有没有在墙体移动或删除之后重新扫描。还有一个小习惯是我后来才养成的每次在编辑器里改完地图比如挪了墙、新增了路障记得手动重新扫描一次Graph。这个插件不会像某些工具一样在编辑器里自动同步场景改动如果你觉得地图明明改了AI却还在走老路线多半就是忘了重新Scan。3. 给AI装上大脑Seeker、Path和移动组件的配合3.1 Seeker是寻路入口AIPath是移动执行器Graph搭建好之后接下来要让一个具体角色能走路。需要给这个角色挂两个组件一个叫Seeker一个叫AIPath或者RichAI。Seeker的职责是跟Pathfinder总管理器打交道当角色需要从当前点走到目标点时Seeker会负责发起路径请求并在路径计算完成后接收结果。AIPath则负责把计算出来的路径变成实际移动。它们之间的界限很清楚Seeker管怎么走AIPath管走起来。AIPath和RichAI怎么选我的经验是如果角色在地面上移动转向不需要太曲率用AIPath就够了它的参数简单直接代码也更轻如果做的是比较复杂的载具类单位需要更平滑的转向和更贴近路径曲率的移动方式RichAI更合适。大部分角色的AIAIPath是足够用的。AIPath上有几个关键参数值得花时间调。Speed是移动速度Pick Next Waypoint Dist决定了角色离路径点多远就算到达了下一个点End Reached Distance则是整体到终点的最小判定距离。这几个参数如果设得太小角色会在路径点上反复横跳看起来像在抖动设得太大又会导致角色提前转弯或者目标未到就停下来。3.2 手写路径请求的参数与回调处理虽然AIPath会自动处理路径请求但实际项目里我们经常需要手动请求路径比如在战斗中把AI切换到一个新目标时想在最开始强制刷新一次路径不等到repathRate自然触发。这时候就要用Seeker.StartPath。下面是最基础的代码结构using Pathfinding; using UnityEngine; public class ManualPathRequest : MonoBehaviour { public Seeker seeker; public Transform target; void Start() { seeker.StartPath(transform.position, target.position, OnPathComplete); } void OnPathComplete(Path p) { if (p.error) { Debug.Log(路径计算失败目标点可能不可达); return; } // vectorPath就是从起点到目标点的一串世界坐标 Vector3[] waypoints p.vectorPath.ToArray(); // 把waypoints交给移动逻辑去逐点执行 GetComponentYourMover().SetWaypoints(waypoints); } }这里有几个细节需要特别注意。第一回调里返回的Path对象是一个会被插件复用的对象也就是说OnPathComplete跑完之后如果你把Path引用存下来下一次寻路时这个对象可能已经被覆盖了。正确的做法是只把需要的Vector3路径点拷贝出来不要长期持有Path对象本身。第二p.vectorPath的顺序是从起点到终点不要手滑从数组尾部开始取很多初学者会把目标点当成第一个路径点导致角色全程倒退走路。第三p.error是个布尔值它只在路径真的计算失败时才是true。如果你想让AI在目标点不可达时原地停下需要在回调里做明确的失败分支处理而不能默认路径一定成功。3.3 Repath频率的控制艺术AIPath组件内部有一个Repath Rate参数它控制着AI多长时间重新计算一次路径。这个参数如果设成每秒一次角色会每秒都向Pathfinder请求一次新路径如果设成0.2秒一次那请求就会非常频繁。很多新人一上来就把Repath Rate拉到很小觉得这样AI反应更快。但寻路请求是有成本的即便有路径缓存每次计算都要重新遍历节点图。在一个有几百个AI的战场上如果所有单位的Repath Rate都小于0.1秒那光寻路计算就能把主线程吃掉一大部分。我的建议是根据角色状态动态控制Repath Rate。普通移动状态不要小于0.3秒战斗中追击也不建议小于0.1秒。如果单位只是在一条直线上移动目标点没有变压根不需要频繁重新寻路甚至可以手动把Repath Rate调到1秒以上只在目标点变化时调用StartPath。4. 动态世界里的路径维护GraphUpdateObject与RVO避障4.1 区域级更新才是运行时修改地形的正确姿势真正让A* Pathfinding Project Pro值回票价的功能是运行时动态更新地图。比如一扇门被打开了、一座桥被炸断了、一块区域被结界封锁了这些都需要让AI的路径认知同步变化。如果这时候你去调用全图扫描Scan那相当于把整张地图重新算一遍慢且没必要。正确做法是用GraphUpdateObject只更新一个指定的包围盒区域。using Pathfinding; using UnityEngine; public class DynamicObstacle : MonoBehaviour { public Vector3 updateSize new Vector3(2f, 2f, 2f); public void BlockArea(Vector3 center) { Bounds bounds new Bounds(center, updateSize); GraphUpdateObject guo new GraphUpdateObject(bounds); guo.Walkable false; AstarPath.active.UpdateGraphs(guo); } public void UnblockArea(Vector3 center) { Bounds bounds new Bounds(center, updateSize); GraphUpdateObject guo new GraphUpdateObject(bounds); guo.Walkable true; AstarPath.active.UpdateGraphs(guo); } }这段代码的意思很直接以center为中心把一块2米乘2米乘2米的区域标记成不可通行对应障碍物生成再调用一次把同一块区域标记回可通行对应障碍物拆除。要提醒的是如果有多个障碍物同时变化最好把它们的bounds合并成一个更大的AABB一次性更新而不是在循环里一个一个调用UpdateGraphs。原因是多次小范围更新比一次大范围更新的总开销可能更高并且会让图状态在一个帧内频繁切换容易出现路径计算过程中图变了的情况。4.2 NavmeshCut适合做什么如果你的项目用的是Recast Graph还要知道一个组件叫NavmeshCut。它可以在运行时实时切割导航网格效果非常直观把组件挂在会动的遮挡物上——比如升降的闸门、移动的机关墙——它就会自动把自身位置占据的导航网格部分切掉。NavmeshCut的适用场景是障碍物本身会动比如一堵不断上下移动的墙如果用GraphUpdateObject你得每帧计算新位置再提交更新不仅代码麻烦性能也浪费。而NavmeshCut是挂上组件让它自动跟随物体位置去平滑地改变切割区域。不过要注意NavmeshCut只对NavMesh类的Graph有效Grid Graph是不认的。如果你在用Grid Graph做规则化地图动态障碍就老老实实用GraphUpdateObject别在这上面纠结。4.3 RVO局部避障解决的是挤在一起的问题战场里经常出现一幅画面几百个AI同时往一个方向走结果在狭窄路口扎堆互相卡住。这个问题的根源不是寻路而是局部避让策略。A* Pathfinding Project Pro提供的RVO组件就是用来做局部避让的。具体来说它在已有全局路径的基础上在移动层面对周围单位进行速度规划让每个单位尽量避开其他人但又不偏离全局路径太远。使用RVO需要两步场景里挂一个RVOSimulator作为避障管理器每个需要避障的单位身上挂RVOController。RVOController上几个参数要重点关注Radius代表单位的真实碰撞半径Max Neighbours代表每个单位同时考虑多少个邻近单位Layer Mask则决定哪些图层会被纳入避障计算。我调RVO参数走过弯路最明显的问题是单位会在窄通道里左右摇摆。原因是Radius设得太大或者Max Neighbours太高导致每个单位都要不停闪避周围十几个人。真正合理的做法是Radius贴近实际碰撞体尺寸Max Neighbours控制在8到12之间Layer Mask只包含单位之间互相避让的层不要包含场景障碍物。还有一个重要认知RVO只负责速度避让不能替代Collider。寻路层保证AI能避开墙避障层保证AI能避开其他AI但最终的物理碰撞约束仍然要靠角色身上的Collider收口。把Collider拿掉只靠RVO单位还是会互相穿透。5. 支撑几百个AI不卡顿的调优思路5.1 全图Scan是性能黑洞能躲就躲很多人习惯在每次地图变化时直接调Scan()重新扫描整个Graph这在Demo阶段没什么感觉但地图一大问题就来了。举个例子一张150米乘150米的地图Node Size设0.4米节点数大概是375乘375也就是将近140000个节点。全图扫描这种节点量级的Graph在编辑器里可能都要花几十毫秒在移动设备上更是灾难。如果这个扫描发生在战斗进行中玩家会明显感觉到画面卡顿。所以我的第一个优化原则是如果你不确定某次更新需要覆盖多大范围宁可用GraphUpdateObject把范围圈小一点也不要随手全图Scan。全图Scan只在两种情况下使用游戏开场前的预扫描或者是地图整体结构发生了翻天覆地的变化。其余时候局部更新都是更优解。5.2 多线程让同时算路径的压力分散掉大规模AI场景里真正的压力来源不是移动而是大量单位同时发起的路径请求。如果每个单位都在同一帧里要求Pathfinder计算新路径且只有一个线程在算那这个线程会瞬间成为瓶颈。Pro版本的多线程寻路就是把这些路径计算任务分散到后台线程去跑。主线程只需要提交我要从这到那的请求后台线程算完之后再通过回调把结果传回主线程。这样即使有几百个AI同时在请求路径主线程也不会被寻路计算塞满。判断你的项目是不是需要多线程有一个简单方法屏蔽所有AI的移动组件只保留寻路请求看看帧率下降多少。如果帧率还在掉说明寻路计算本身已经成了瓶颈这时候去调移动参数意义不大应该考虑怎么让寻路计算分担出去或者降低路径请求频率。5.3 路径请求频率和路径池的边界在哪里在A* Pathfinding Project里路径对象是会被池化复用的。也就是说系统会尽量复用之前用过的Path对象而不是每次重新new一个。这对性能是好事但给开发带来了一个隐蔽的问题如果你把Path对象存下来供后续使用之后它可能会被系统回收复用里面的数据早就变了。所以一个铁律是回调结束之后只保留从Path对象里拷贝出来的Vector3路径点不要存Path本身。这不仅是好习惯也是避免很多诡异Bug的必要手段。另一个优化点是少发无效请求。如果目标点没有变化路径计算就不该反复触发。很多卡顿其实是因为AI每帧都在请求同样的路径而Pathfinder不得不计算一个它已经计算过的结果。在代码里加一层判断检测一下目标点跟上次相比是否偏离了某个阈值没变就不重新寻路这个简单的操作往往能砍掉一多半的路径请求。5.4 路径失败之后的兜底逻辑无论Graph怎么配置总会遇到目标点不可达的情况比如目标点被包围在墙里或者因为地形动态变化导致原本可达的点变成了死角。这时候AI如果什么都不做会傻站着。我之前在RTS项目里处理这种问题用的是AstarPath.active.GetNearest。它的作用是给定一个任意世界坐标找到离它最近的可通行节点。这样当目标点不可达时就把这个最近的可通行点当作新的目标点重新发起一次路径请求。NNInfo nearest AstarPath.active.GetNearest(targetPosition, NNConstraint.Default); ai.destination nearest.position; seeker.StartPath(ai.position, ai.destination, OnPathComplete);这套兜底逻辑非常实用尤其是玩家右键点了一块岩石高地中间的空地时单位不会站在原地发呆而是自动走到距离那个点最近的可通行位置上。需要注意的是GetNearest返回的节点有可能依然失败所以兜底代码里还要保留对p.error的判断宁可让AI停在原地也不要让它朝错误方向乱走。6. 实测里最常见的5个坑和排查链路6.1 AI在空地上原地打转看着像没路径这个问题的典型特征是AI明明站在一大片空地中央目标点也在空地上但角色就是不朝目标走最多在原地转圈。按照寻路的层级去排查。第一步查Graph打开Scene视图的Graph显示确认绿色节点覆盖到了角色和目标点。如果角色脚下的节点是红色或缺失那问题大概率是碰撞Mask设置不对地面被当成了障碍物。第二步查Seeker的StartPath调用有没有真的往目标点发起请求或者AtStart时目标点是不是被设置成了一个无效坐标。第三步查移动层AIPath的Pick Next Waypoint Dist是不是太大了导致角色认为自己已经到达了某个中间路径点于是反复在寻路下一段。大部分情况下第一步就能解决问题因为配置错Layer比组件挂错更常见。6.2 动态障碍物出现了AI却仍然穿墙如果你在游戏运行中创建了一个障碍物并且已经在代码里给它挂上了Collider可是AI照样穿墙走这时候先别怀疑寻路算法Graph根本不知道这个墙存在。Grid Graph的节点是在扫描时生成的运行时新增的Collider不会自动影响已经扫描好的节点。你需要手动处理动态障碍要么用GraphUpdateObject标记它为不可通行要么使用支持动态切割的Recast Graph加NavmeshCut。只是简单地在场景里多一个带Collider的物体是不会改变导航图的。另外还要检查Gate的类型和代码如果你的障碍物是子物体确保你在更新时用了障碍物在世界空间的位置和实际Bounds而不是用了父物体的位置否则更新区域偏移了墙自然还是透明的。6.3 单位在窄路口挤成一团左右摇摆这基本是RVO参数没调好的症状而不是寻路问题。排查优先级从前到后是Radius是不是比实际碰撞体大了一截Max Neighbours是不是太高导致每个单位都在躲所有单位Layer Mask里是不是混入了场景静态障碍物把Radius压到真实碰撞尺寸、Max Neighbours收到10左右最后把Layer Mask中的静态障碍层去掉绝大多数摇摆问题能消失。如果还晃就检查Global内的预期目标速度是不是跟移动组件的Speed冲突了两者不一致会让RVO计算出来的速度方向反复横跳。6.4 上坡或上下楼梯时角色前后抖动Grid Graph处理高度变化的能力本身比较弱它是二维格子的概念不同高度的地块默认是连通的。这意味着如果地图上有斜坡或者台阶AI很容易在上下坡过程中把路径点生成在靠近坡坎的位置导致移动时不停上下振动。解决思路有两个方向。一是把Node Size调小一些让路径点对高度变化更敏感尽量绕开陡峭的边线。二是给移动逻辑加一层Smoothing让路径点的转向和竖直方向变化不要太突兀。如果你本身就对地形起伏很敏感建议直接换Recast Graph来处理这类关卡不要在Grid Graph上投入太多调参时间。6.5 编辑器里改了地图AI却还在走老路线这个坑我踩过不止一次在Scene视图里挪了一堵墙然后直接进Play模式测试结果AI依然按照旧墙的位置绕路看起来特别傻。原因前面已经提过——这个插件在编辑器里不会自动跟着场景改而实时更新Graph。你在编辑状态下调整了地形或者障碍物之后必须重新执行一次Scan让新的障碍信息烘焙进Graph节点里运行时的寻路才会反映地图的真实变化。养成这个习惯之后这类明明改了地图却不生效的疑问会少很多。另外如果是在Play模式里临时移动了障碍物跑完测试回到编辑器后Graph可能停留在运行状态改过的样子。这种情况下再点一次Scan把Graph重置回编辑器状态能避免很多奇怪的后遗症。如果你在集成时也遇到看着像穿墙实际是Graph和物理体没同步的问题优先从Graph更新这一环排查先不要急着去调移动参数和转向参数。寻路插件的价值在于把路径计算的复杂度摊开在面前出了问题逐层定位反而比原生方案更好下手。
返回列表