1. 项目概述为什么我们需要一个动态寻路系统如果你在Unity里做过稍微复杂一点的游戏尤其是RTS、MOBA、塔防或者开放世界RPG肯定遇到过这样的场景一群单位需要从A点移动到B点但地图上布满了障碍物比如树木、建筑、河流。你可能会先用Unity自带的NavMesh系统它确实能解决静态环境下的寻路问题。但一旦障碍物动起来——比如玩家建造了一堵墙或者一个可破坏的箱子被炸毁了——你就会发现之前计算好的路径瞬间失效单位要么卡住要么开始鬼畜地抖动试图穿过一个已经不存在的“空气墙”。这就是静态寻路的局限性。它基于一个预先烘焙好的导航网格NavMesh工作这个网格在游戏运行时是固定的。任何对场景导航可行性的动态改变都需要重新烘焙网格这个过程在运行时开销巨大几乎不可行。因此动态寻路系统的核心诉求就出现了我们需要一个能实时响应环境变化为每个单位快速、高效地重新规划路径的解决方案。A*A-Star算法作为寻路领域的经典与基石正是解决这个问题的利器。它不像NavMesh那样依赖一个全局的静态数据结构而是基于一个抽象的“图”Graph来工作。图中的节点可以是网格的格子、路点Waypoint或者任何你定义的可通行位置边则代表节点间的连接关系。A*算法通过评估从起点到终点的预估总代价智能地探索这张图从而找到一条最优或次优的路径。其最大的优势在于灵活性——你可以随时修改图中节点和边的通行代价Cost或可通行状态Walkable算法就能立即基于新的图数据规划出新路径。然而在Unity中从零手写一个高效、稳定且功能完整的A寻路系统绝非易事。你需要处理网格/图的构建、算法的优化如使用二叉堆实现优先队列、多线程支持、路径平滑、局部避障等一系列复杂问题。这正是APathfinding Project这类成熟插件的价值所在。它封装了所有这些底层复杂性提供了可视化编辑器、丰富的寻路代理AI组件以及强大的动态更新接口让我们能专注于游戏逻辑而非算法实现。本次实战我们就将基于这款强大的插件一步步搭建一个能够应对动态障碍物的寻路系统。2. 核心插件选型与项目初始化市面上Unity的寻路插件不止一个为什么选择A* Pathfinding Project我对比过几个主流方案Unity自带的NavMesh适合纯静态场景其他一些A插件可能更轻量但功能往往不够全面。APathfinding Project后文简称A*插件的生态最为成熟文档齐全社区活跃更重要的是它对动态更新的支持是原生且高效的。它内置了多种图形Grid Graph网格图、Point Graph点图、NavMesh Graph等并且可以实时修改图中任意节点的阻挡信息这正是我们实现动态寻路的基础。2.1 插件导入与基础场景搭建首先在Asset Store中获取并导入A* Pathfinding Project插件。导入后你的项目里会多出AstarPathfindingProject文件夹。核心组件是AstarPath脚本它是整个寻路系统的大脑。创建寻路系统在场景中创建一个空游戏对象命名为“A*”。为其添加AstarPath组件Add Component - Pathfinding - Pathfinder。这个组件在Awake时会自动初始化寻路系统。创建测试环境创建一个简单的平面Plane作为地面。然后用立方体Cube搭建一些静态障碍物比如一堵墙、几个散落的箱子。将这些障碍物放到一个名为“StaticObstacles”的空对象下以便管理。创建寻路代理创建一个胶囊体Capsule作为我们的移动单位命名为“Agent”。为其添加Seeker组件和AIPath或IAstarAI接口的脚本组件。Seeker负责向AstarPath系统请求路径计算。AIPath一个封装好的移动控制器它会从Seeker获取路径并驱动角色沿着路径移动。它包含了速度、加速度、旋转速度、终点距离阈值等参数开箱即用。2.2 配置网格图Grid GraphAstarPath组件上可以添加多种图形。对于大多数基于格子Tile-based或需要均匀搜索的场景Grid Graph网格图是最直观和常用的。添加Grid Graph在AstarPath组件的Inspector面板点击“Add Graph” - “Grid Graph”。调整图形参数WidthDepth定义网格的尺寸格子数。根据你的地面大小设置确保能覆盖所有可行走区域。Node Size每个格子节点的物理世界大小。例如如果你的角色宽1米设为0.5或1米比较合适。值越小寻路精度越高但节点数呈平方增长性能开销越大。这是一个需要权衡的关键参数。对于动态寻路我不建议设得太小否则动态更新大量节点会带来性能压力。Center网格的中心点。通常设在地面中心。Rotation调整网格的朝向。Collision Testing这是网格生成的灵魂。它决定了哪些地方是“不可行走”的。Collision Type选择“Ray”或“Sphere”。对于立方体障碍用“Ray”从每个节点中心向下发射射线检测即可。Mask设置一个Layer比如“Obstacle”并将你的静态障碍物立方体都归入这个Layer。在这里的Mask中选择“Obstacle”。这样插件在生成网格时会检测节点位置是否与“Obstacle”层的碰撞体相交如果相交则该节点被标记为不可行走Unwalkable。扫描Scan生成网格配置好参数后点击AstarPath组件上的“Scan”按钮。你会在Scene视图中看到一个蓝色的网格覆盖在地面上其中红色的节点代表不可行走区域被障碍物阻挡。实操心得初次配置Node Size时很容易设得过小想着“精度高点总没错”。但在一个100x100的区域内Node Size从1.0降到0.5节点数会从10,000暴增到40,000。这不仅仅是扫描时间变长每一次A*搜索的开销也会显著增加。我的经验是先用一个能让角色顺畅通过的大小比如角色半径的2倍进行测试如果发现角色经常卡在拐角再考虑局部细化网格或使用路径后处理如Funnel Modifier来优化而不是盲目提高全局精度。现在将Agent的AIPath组件的Destination设为一个地面上的点可以创建一个空物体作为目标点然后拖拽赋值。运行游戏你的胶囊体应该能自动绕开静态障碍物走向目标点。基础的静态寻路已经完成。3. 实现动态障碍物与实时寻路更新静态寻路只是热身动态障碍物才是重头戏。我们的目标是当一个新的障碍物比如一堵玩家瞬间建造的墙出现时所有正在移动或即将移动的单位都能立即感知到并重新规划路径避开这个新障碍。A*插件的动态寻路核心在于动态更新图形节点。我们不需要重新扫描Scan整个网格那样太慢。而是直接修改受影响节点的Walkable属性。3.1 创建动态障碍物创建一个新的立方体命名为“DynamicWall”。为其添加一个碰撞体如Box Collider。创建一个C#脚本命名为DynamicObstacle.cs并挂载到“DynamicWall”上。using UnityEngine; using Pathfinding; // 引入A*插件的命名空间 public class DynamicObstacle : MonoBehaviour { // 定义这个障碍物会影响到的图形通常是Grid Graph public GraphMask graphMask GraphMask.everything; // 障碍物的碰撞体 private Collider obstacleCollider; void Start() { obstacleCollider GetComponentCollider(); if (obstacleCollider null) { Debug.LogError(DynamicObstacle requires a Collider component!); return; } // 障碍物激活时立即更新寻路网格 UpdateGraph(); } void OnDestroy() { // 障碍物被销毁时如被炸毁也需要更新网格释放可通行区域 UpdateGraph(); } // 如果障碍物可能会移动非典型但可能可以在Update或FixedUpdate中调用 // void Update() { // if (hasMoved) UpdateGraph(); // } void UpdateGraph() { // 获取一个Bounds对象代表这个碰撞体的世界坐标轴对齐包围盒 Bounds bounds obstacleCollider.bounds; // 调用AstarPath.active.UpdateGraphs 来更新指定范围内的图形 // 这是最关键的一步通知寻路系统这个范围内的节点需要重新计算可通行性 AstarPath.active.UpdateGraphs(bounds, graphMask); } }代码解析GraphMask用于指定更新哪个图形。如果你的场景只有一个Grid Graph用GraphMask.everything即可。UpdateGraphs(Bounds bounds)这是插件的核心API。它接收一个包围盒Bounds参数只会重新计算与该包围盒相交的节点的可通行性性能极高。我们传入障碍物碰撞体的bounds效率远高于全局扫描。我们在Start和OnDestroy中调用它确保了障碍物出现和消失时寻路网格都能得到正确更新。3.2 验证动态寻路效果运行游戏让Agent开始向目标点移动。在Game运行模式下在Hierarchy中选中“DynamicWall”通过Inspector修改其Transform的Position将其移动到Agent的必经之路上。你不会立即看到效果因为我们的脚本只在Start和OnDestroy时调用。为了测试你可以临时在DynamicObstacle.cs的Update方法中添加一个测试逻辑比如按空格键触发UpdateGraph()。更好的测试方法是编写一个简单的脚本实例化Instantiate这个“DynamicWall”预制体到Agent路径上。当墙被创建Instantiate时其Start方法会被调用从而触发网格更新。你应该能看到当墙出现时正在移动的Agent会明显停顿一下正在重新计算路径然后沿着新规划出的、绕过这堵墙的路径继续前进。这就是动态寻路在起作用注意事项UpdateGraphs是异步操作吗默认情况下它是立即执行的可能会在主线程造成卡顿如果更新的区域非常大。插件提供了UpdateGraphsAsync方法可以分帧处理避免帧率骤降。对于大多数动态障碍物单个或几个直接使用同步更新即可。如果你要同时更新一大片区域比如整个战场地形改变务必考虑使用异步更新。4. 高级优化与实战技巧基础功能跑通后我们会遇到更多实际问题。下面分享几个提升系统健壮性和效率的关键技巧。4.1 处理移动中的动态障碍物上面的例子是障碍物“突然出现”。但如果障碍物是持续缓慢移动的呢比如一个平移的平台频繁调用UpdateGraphs每一帧都更新开销太大。解决方案使用GraphUpdateScene组件A*插件提供了一个更优雅的组件GraphUpdateScene。你可以将它挂载到任何游戏对象上比如你的移动平台。给移动平台游戏对象添加GraphUpdateScene组件。在组件上你可以定义一个区域通过子物体变换或手动设置点并设置这个区域内的节点属性比如Penalty通行惩罚值增加或者直接设置为Walkable。勾选Apply On Start和Apply On Scan。关键点这个组件定义的是一个“持续生效”的区域。当寻路系统进行扫描Scan或动态更新涉及该区域时会应用这些规则。对于缓慢移动的平台你可以不频繁地调用UpdateGraphs而是结合物理触发。平台每移动一定距离或者每隔几秒获取它当前的包围盒调用一次AstarPath.active.UpdateGraphs(oldBounds)更新旧区域和AstarPath.active.UpdateGraphs(newBounds)更新新区域。这样旧位置被“解锁”新位置被“锁定”。4.2 路径平滑与本地回避Local Avoidance直接用A*计算出的路径往往是网格中心点的连线导致移动轨迹生硬有“格子感”。而且多个单位沿着狭窄路径对向而行时会互相卡住。路径平滑Funnel Modifier在Agent的Seeker组件上你可以添加各种“Modifier”。强烈推荐添加Funnel Modifier。它会对原始路径进行漏斗算法处理生成一条紧贴障碍物边缘的平滑路径消除格子感让移动更自然。本地回避RVO/RVOController当你有大量单位时仅靠全局寻路是不够的。两个单位可能被规划到相互交叉的路径上。插件提供了基于“互惠速度障碍”Reciprocal Velocity Obstacles, RVO的本地回避系统。为每个Agent添加RVOController组件并设置其半径、速度等参数。在AstarPath组件的Inspector中找到“RVO”设置创建一个RVOSimulator。这个模拟器会管理场景中所有的RVOController计算它们之间的避让。同时在AIPath组件上勾选“Enable RVO Integration”。这样AIPath在移动时就会考虑RVOController计算出的速度实现流畅的群体避让。实测对比没有RVO时50个单位涌向一个狭窄门口会堆叠卡死。启用RVO后它们会自发地排队、穿插流畅地通过效果非常震撼。4.3 性能监控与调试动态寻路系统可能成为性能瓶颈尤其是在单位数量多、障碍物变化频繁的场景。使用内置性能分析在AstarPath组件的“Debug”区域勾选“Show Graphs”和“Show Search Tree”。在运行时Scene视图会可视化显示当前的寻路网格、被标记为不可行走的节点以及AI正在搜索的路径。红色节点是阻挡蓝色区域是搜索过的节点绿色线是最终路径。这是调试寻路问题最直观的工具。关注日志AstarPath组件在遇到严重错误如图形扫描失败时会在控制台输出日志。确保在开发阶段打开这些信息。性能开销主要来源路径请求频率控制每个Seeker请求路径的频率。AIPath有一个Repath Rate参数默认是0.5秒。不要设置得太低如0.1秒除非必要。图形复杂度Grid Graph的Node Size和尺寸是根本。Point Graph的节点数量也需控制。动态更新范围确保UpdateGraphs调用的Bounds尽可能精确只更新受影响区域。单位数量这是最直接的因素。如果同时有上百个单位在复杂地形中寻路考虑使用更简单的图形、增大Node Size或者对非玩家直接控制的单位使用更低的Repath Rate。5. 常见问题排查与解决方案实录在实际开发中我踩过不少坑。这里列几个典型问题及其解决方法。问题现象可能原因排查步骤与解决方案Agent原地抖动或打转1. 终点距离阈值AIPath的End Reached Distance设置过小Agent永远无法“到达”终点。2. 路径被频繁重新计算Repath且新路径与旧路径差异大。3. 本地回避RVO与其他单位或障碍物产生剧烈速度冲突。1. 适当增大End Reached Distance比如从0.1调到0.5。2. 检查Repath Rate是否过高或是否有其他逻辑如脚本在频繁设置新的Destination。3. 暂时禁用RVO看是否问题消失。如果是调整RVO的Agent Time Horizon或Neighbor Dist参数。Agent无视新出现的动态障碍物1.DynamicObstacle脚本未正确挂载或生效。2.UpdateGraphs的Bounds计算有误未覆盖障碍物。3. 障碍物碰撞体Collider的Is Trigger被勾选而图形扫描的碰撞检测未包含触发器。1. 确认脚本已挂载且障碍物Layer包含在Grid Graph的Collision Mask中。2. 在UpdateGraph方法中Debug.Log输出bounds的中心和大小在Scene视图中确认其范围。3. 在Grid Graph的Collision Testing设置中检查Use 2D Physics和Height Testing等选项确保碰撞检测方式与你的障碍物匹配。对于触发器可能需要使用GraphUpdateScene或自定义规则。寻路性能突然下降卡顿1. 单帧内触发了大量UpdateGraphs调用如多个障碍物同时生成。2. 某个UpdateGraphs更新的区域Bounds异常巨大。3. 同时有极大量的路径搜索请求。1. 使用UpdateGraphsAsync进行异步更新或将更新分散到多帧进行。2. 检查动态障碍物的碰撞体尺寸是否意外变大。3. 使用AstarPath.active.GetNodes等调试接口在Profiler中查看AstarPath的CPU耗时。优化图形设置减少单位数量或使用对象池管理寻路请求。Agent卡在角落或门边1.Node Size相对于Agent碰撞体过大导致路径在拐角处“擦边”实际物理碰撞过不去。2. 路径没有经过平滑处理拐角点正好在障碍物旁边。1. 尝试减小Node Size或在关键区域如门口使用更精细的局部网格通过多个Grid Graph或Node Link实现。2. 为Seeker添加Funnel Modifier并确保Agent的Radius参数设置正确应略小于Node Size的一半。扫描Scan时编辑器无响应或崩溃图形设置尤其是Grid Graph的尺寸或Node Size设置不合理导致生成的节点数量Width*Depth巨大内存溢出。1. 估算节点数节点数 ≈ (Width * NodeSize) * (Depth * NodeSize) / (NodeSize^2) Width * Depth。如果Width和Depth是1000节点数就是一百万非常危险。2.永远不要先设大尺寸再调小Node Size。应该先确定需要的物理覆盖范围再根据性能预算反推合适的Width/Depth和Node Size。最后再分享一个小技巧对于超大型开放世界单一Grid Graph可能不现实。A*插件支持多图形Multiple Graphs。你可以将世界划分为多个区域每个区域用一个Grid Graph并设置好连接点。或者对于道路明确的游戏使用Point Graph在关键位置手动或自动放置路点效率会高得多。动态更新Point Graph通常是通过UpdateGraphs更新特定节点所在的区域或者直接修改节点的Walkable属性。选择哪种图形完全取决于你的游戏类型和性能要求没有银弹。我的经验是原型阶段用Grid Graph快速验证玩法后期再根据性能分析和游戏设计优化或更换为更合适的图形结构。