1. 项目概述:为什么Unity路径寻找是游戏开发的基石
在游戏开发中,无论是让一个NPC从A点走到B点,还是让一群单位在复杂的地形中行军,甚至是让玩家角色在开放世界中自动寻路,其背后都离不开一个核心算法:路径寻找。对于Unity开发者而言,路径寻找不仅仅是调用一个API那么简单,它涉及到游戏世界的空间理解、性能开销的权衡、以及最终玩家体验的流畅度。一个高效的路径寻找系统,能让你的游戏世界感觉更“聪明”、更真实;而一个糟糕的实现,则可能导致角色卡墙、CPU占用飙升,甚至直接破坏游戏的核心玩法。
我见过太多项目,初期为了快速实现功能,直接用了Unity自带的NavMesh系统,但到了中后期,当需要实现更复杂的动态障碍物、多单位协同或者特定移动规则(如只能沿网格移动)时,才发现原有的方案捉襟见肘,不得不进行伤筋动骨的重构。这个教程的目的,就是带你从零开始,深入理解Unity中路径寻找的几种主流实现方式,不仅仅是“怎么做”,更重要的是“为什么这么做”,以及在不同场景下如何选择和优化。无论你是想做一个RTS游戏、一个Roguelike地牢探险,还是一个需要智能NPC的开放世界,掌握路径寻找的核心,都能让你在开发中游刃有余。
2. 核心思路与方案选型:从A*到行为树
在动手写代码之前,我们必须先理清思路:我们的游戏世界是什么样的?我们的寻路单位有什么特性?这将直接决定我们选择哪种技术方案。
2.1 网格寻路 vs. 导航网格寻路
这是两种最基础的寻路空间表示方法,选择哪一种,决定了你整个寻路系统的底层逻辑。
网格寻路,通常指的是基于一个规则的二维或三维网格(Grid)。你可以把它想象成一个棋盘,每个格子要么是可通行的,要么是障碍物。单位移动时,只能从一个格子中心移动到相邻的格子中心。这种方法的优势在于实现简单、概念直观,特别适合策略游戏(如《文明》)、回合制游戏或基于瓦片(Tile-based)的地图。它的路径是离散的,计算出来的路径是一系列格子的序列。
注意:网格寻路的一个常见误区是网格粒度的选择。格子太小,寻路计算量会呈指数级增长;格子太大,则路径会显得很“蠢”,角色可能会贴着障碍物边缘走直角,或者无法通过狭窄但实际可通行的缝隙。通常需要根据角色碰撞体的大小来动态调整网格的有效通行区域。
导航网格寻路,则是Unity内置的NavMesh系统所采用的方式。它不再使用规则的格子,而是将可行走区域划分成一系列凸多边形(通常是三角形)构成的网络。单位可以在这些多边形的内部和边缘上自由移动。这种方式更贴近3D游戏世界的连续空间,路径更加平滑自然,非常适合第三人称动作游戏、MMO等场景。Unity的NavMesh系统会自动烘焙(Bake)场景中的静态几何体,生成这些导航网格。
如何选择?如果你的游戏地图是高度动态的(比如可破坏的地形、频繁移动的障碍物),或者你需要非常精细的移动控制(比如沿网格对角线移动有不同代价),那么基于网格的寻路可能更灵活,因为你可以动态修改某个格子的通行状态。如果你的游戏世界主要是静态的,且追求角色的移动平滑度和性能(NavMesh有高度优化的C++后端),那么NavMesh是更省心的选择。
2.2 算法核心:理解A*寻路算法
无论底层是网格还是导航网格,寻路算法的心脏通常是A*(A-Star)。它之所以如此流行,是因为它在“找到路径”和“快速找到”之间取得了完美的平衡。
你可以把A*想象成一个有智慧的探险家。它手里有两张地图:一张是已经探索过的区域(已遍历的节点),另一张是它猜测可能正确的方向(待探索的节点)。这个“猜测”就是启发式函数(Heuristic),通常是当前点到目标点的直线距离(欧几里得距离)或曼哈顿距离。算法总是优先探索“当前代价 + 预估代价”最小的节点,这保证了它不会像无头苍蝇一样乱撞,又能最终找到最优或接近最优的路径。
它的核心公式是:F = G + H
- G:从起点移动到当前节点的实际代价。
- H:从当前节点到终点的预估代价(启发值)。
- F:节点的综合优先级,F值越小,优先级越高。
在Unity中实现一个基础的A*,你需要以下几个部分:
- 一个节点(Node)类:记录每个格子的位置、G值、H值、F值以及它的父节点(用于回溯路径)。
- 两个列表:开放列表(Open List)存放待检查的节点,关闭列表(Closed List)存放已检查过的节点。
- 循环逻辑:从开放列表中取出F值最小的节点,检查它是否是终点,如果是则回溯生成路径;否则,将其加入关闭列表,并检查其所有相邻节点,更新它们的G、H、F值和父节点。
2.3 高级方案:行为树与状态机整合
对于复杂的AI,寻路往往不是终点。一个NPC找到路径后,可能需要在途中执行一系列动作:躲避敌人、拾取物品、与场景交互等。这时,单纯的寻路组件就不够用了。
我们可以将寻路作为一个“服务”或“任务”,整合到更高级的AI框架中,比如行为树。在行为树中,你可以有一个“移动到位置”的叶子节点,这个节点内部调用我们的寻路系统来计算路径,并每帧驱动角色向路径上的下一个点移动。同时,行为树可以并行执行“检测危险”的节点,如果发现危险,可以中断移动任务,转而执行“躲避”或“攻击”任务。
另一种常见的整合方式是状态机。角色可以处于“巡逻”、“追击”、“逃跑”等状态。在“追击”状态下,状态机会每帧更新目标位置(玩家当前位置),并调用寻路系统重新计算或跟随路径。这种架构清晰地将移动逻辑与AI决策逻辑分离,便于维护和扩展。
3. 核心实现:手把手构建一个网格A*寻路系统
理论讲完了,我们进入实战环节。我将带你用C#在Unity中实现一个完整的、基于网格的A*寻路系统。这个系统将包含动态障碍物支持和简单的可视化调试。
3.1 创建网格与节点系统
首先,我们需要定义游戏世界中的网格。创建一个名为GridManager的单例类来管理整个网格。
using UnityEngine; using System.Collections.Generic; public class GridManager : MonoBehaviour { public static GridManager Instance; public LayerMask unwalkableMask; // 用于检测障碍物的图层 public Vector2 gridWorldSize; // 网格覆盖的世界大小 public float nodeRadius; // 每个节点的半径(决定格子大小) Node[,] grid; // 二维数组存储所有节点 float nodeDiameter; int gridSizeX, gridSizeY; void Awake() { Instance = this; nodeDiameter = nodeRadius * 2; // 计算网格在X和Y方向上有多少个节点 gridSizeX = Mathf.RoundToInt(gridWorldSize.x / nodeDiameter); gridSizeY = Mathf.RoundToInt(gridWorldSize.y / nodeDiameter); CreateGrid(); } void CreateGrid() { grid = new Node[gridSizeX, gridSizeY]; // 获取网格左下角的世界坐标 Vector3 worldBottomLeft = transform.position - Vector3.right * gridWorldSize.x / 2 - Vector3.forward * gridWorldSize.y / 2; for (int x = 0; x < gridSizeX; x++) { for (int y = 0; y < gridSizeY; y++) { // 计算当前节点中心的世界坐标 Vector3 worldPoint = worldBottomLeft + Vector3.right * (x * nodeDiameter + nodeRadius) + Vector3.forward * (y * nodeDiameter + nodeRadius); // 检查该点是否可通行 bool walkable = !(Physics.CheckSphere(worldPoint, nodeRadius, unwalkableMask)); grid[x, y] = new Node(walkable, worldPoint, x, y); } } } // 根据世界坐标获取对应的节点 public Node NodeFromWorldPoint(Vector3 worldPosition) { float percentX = (worldPosition.x + gridWorldSize.x / 2) / gridWorldSize.x; float percentY = (worldPosition.z + gridWorldSize.y / 2) / gridWorldSize.y; // 注意Z轴对应我们的网格Y percentX = Mathf.Clamp01(percentX); percentY = Mathf.Clamp01(percentY); int x = Mathf.RoundToInt((gridSizeX - 1) * percentX); int y = Mathf.RoundToInt((gridSizeY - 1) * percentY); return grid[x, y]; } // 获取一个节点的所有邻居(8方向或4方向) public List<Node> GetNeighbours(Node node) { List<Node> neighbours = new List<Node>(); // 这里以8方向为例 for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { if (x == 0 && y == 0) continue; // 跳过自己 int checkX = node.gridX + x; int checkY = node.gridY + y; // 检查是否在网格范围内 if (checkX >= 0 && checkX < gridSizeX && checkY >= 0 && checkY < gridSizeY) { neighbours.Add(grid[checkX, checkY]); } } } return neighbours; } // 用于调试绘制网格 void OnDrawGizmos() { Gizmos.DrawWireCube(transform.position, new Vector3(gridWorldSize.x, 1, gridWorldSize.y)); if (grid != null) { foreach (Node n in grid) { Gizmos.color = (n.walkable) ? Color.white : Color.red; Gizmos.DrawCube(n.worldPosition, Vector3.one * (nodeDiameter - .1f)); } } } } public class Node { public bool walkable; public Vector3 worldPosition; public int gridX; public int gridY; public int gCost; // 从起点到本节点的代价 public int hCost; // 从本节点到终点的预估代价 public Node parent; // 路径回溯用 public Node(bool _walkable, Vector3 _worldPos, int _gridX, int _gridY) { walkable = _walkable; worldPosition = _worldPos; gridX = _gridX; gridY = _gridY; } public int fCost { get { return gCost + hCost; } } }将GridManager脚本挂载到一个空物体上,在Inspector中设置Grid World Size和Node Radius,并指定Unwalkable Mask为你的障碍物所在图层。运行游戏,你就能在Scene视图中看到一个由白(可通行)红(障碍)方块组成的网格。
3.2 实现A*寻路算法
接下来,我们创建Pathfinding核心类。为了性能,我们使用协程来异步计算路径,避免主线程卡顿。
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Pathfinding : MonoBehaviour { GridManager grid; void Awake() { grid = GridManager.Instance; } // 寻路主函数 public void FindPath(Vector3 startPos, Vector3 targetPos) { StartCoroutine(FindPathCoroutine(startPos, targetPos)); } IEnumerator FindPathCoroutine(Vector3 startPos, Vector3 targetPos) { Node startNode = grid.NodeFromWorldPoint(startPos); Node targetNode = grid.NodeFromWorldPoint(targetPos); // 快速失败检查 if (!startNode.walkable || !targetNode.walkable) { Debug.LogWarning("起点或终点不可达!"); yield break; } List<Node> openSet = new List<Node>(); HashSet<Node> closedSet = new HashSet<Node>(); openSet.Add(startNode); while (openSet.Count > 0) { // 1. 从开放集中找到F值最小的节点 Node currentNode = openSet[0]; for (int i = 1; i < openSet.Count; i++) { if (openSet[i].fCost < currentNode.fCost || (openSet[i].fCost == currentNode.fCost && openSet[i].hCost < currentNode.hCost)) { currentNode = openSet[i]; } } openSet.Remove(currentNode); closedSet.Add(currentNode); // 2. 如果当前节点就是目标节点,路径找到! if (currentNode == targetNode) { RetracePath(startNode, targetNode); yield break; } // 3. 遍历当前节点的所有邻居 foreach (Node neighbour in grid.GetNeighbours(currentNode)) { if (!neighbour.walkable || closedSet.Contains(neighbour)) { continue; // 跳过不可通行或已检查的节点 } // 计算从当前节点移动到邻居的新G值 // 这里假设直线移动代价为10,对角线移动代价为14(约等于10*√2) int newMovementCostToNeighbour = currentNode.gCost + GetDistance(currentNode, neighbour); // 如果新路径更优,或者邻居还未在开放集中 if (newMovementCostToNeighbour < neighbour.gCost || !openSet.Contains(neighbour)) { neighbour.gCost = newMovementCostToNeighbour; neighbour.hCost = GetDistance(neighbour, targetNode); neighbour.parent = currentNode; if (!openSet.Contains(neighbour)) { openSet.Add(neighbour); } } } // 每帧处理一定数量的节点,避免卡顿,对于小网格也可以不加 // yield return null; } // 开放集为空,未找到路径 Debug.Log("未找到路径!"); } // 计算两个节点间的距离(启发式函数,这里用曼哈顿距离) int GetDistance(Node nodeA, Node nodeB) { int dstX = Mathf.Abs(nodeA.gridX - nodeB.gridX); int dstY = Mathf.Abs(nodeA.gridY - nodeB.gridY); // 对角线移动代价为14,直线为10 if (dstX > dstY) return 14 * dstY + 10 * (dstX - dstY); return 14 * dstX + 10 * (dstY - dstX); } // 从终点回溯,生成路径 void RetracePath(Node startNode, Node endNode) { List<Node> path = new List<Node>(); Node currentNode = endNode; while (currentNode != startNode) { path.Add(currentNode); currentNode = currentNode.parent; } path.Reverse(); // 反转列表,得到从起点到终点的路径 // 这里可以将路径传递给需要移动的单位 // 例如:GetComponent<Unit>().SetPath(path); Debug.Log("路径已找到,共" + path.Count + "个节点"); // 可视化路径(可选) VisualizePath(path); } void VisualizePath(List<Node> path) { // 简单的Gizmos绘制,实际项目中可以用LineRenderer for (int i = 0; i < path.Count; i++) { if (i + 1 < path.Count) { Debug.DrawLine(path[i].worldPosition, path[i+1].worldPosition, Color.green, 2f); } } } }将这个脚本也挂载到场景中。现在,你可以在任何地方调用Pathfinding.Instance.FindPath(startPos, targetPos)来开始一次寻路计算,并在Scene视图中看到绿色的路径线。
3.3 实现动态障碍物与路径更新
静态寻路是基础,但游戏世界是动态的。一个箱子被推开,一扇门被打开,都需要寻路系统能实时响应。
实现动态障碍物的关键在于局部网格更新和路径重新规划。我们不需要每次障碍物变化都重建整个网格,那样太耗性能。我们只需更新受影响的节点。
首先,在GridManager中添加一个方法,用于更新单个节点的可通行状态:
public void UpdateNodeWalkable(Vector3 worldPosition) { Node node = NodeFromWorldPoint(worldPosition); if (node != null) { // 重新检测该点的通行状态 bool newWalkable = !(Physics.CheckSphere(worldPosition, nodeRadius, unwalkableMask)); if (node.walkable != newWalkable) { node.walkable = newWalkable; // 可以在这里触发事件,通知所有正在寻路的单位重新检查路径 // OnGridChanged?.Invoke(node); } } }然后,我们需要一个Unit(移动单位)脚本来处理移动和动态路径更新。这个脚本会存储当前要跟随的路径,并每帧向路径上的下一个节点移动。同时,它会定期(或当收到网格更新事件时)检查路径的有效性。
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Unit : MonoBehaviour { public float speed = 5f; public float turnSpeed = 5f; public float pathUpdateMoveThreshold = 0.5f; // 移动多少距离后重新寻路 public float minPathUpdateTime = 0.2f; // 最短寻路间隔 Pathfinding pathfinding; List<Node> currentPath; int targetIndex; Vector3 lastPathRequestPos; void Start() { pathfinding = FindObjectOfType<Pathfinding>(); lastPathRequestPos = transform.position; } public void SetPath(List<Node> newPath) { if (newPath != null && newPath.Count > 0) { currentPath = newPath; targetIndex = 0; StopCoroutine("FollowPath"); StartCoroutine("FollowPath"); } } IEnumerator FollowPath() { if (currentPath == null || currentPath.Count == 0) yield break; Node currentNode = currentPath[0]; while (true) { if (transform.position == currentNode.worldPosition) { targetIndex++; if (targetIndex >= currentPath.Count) { // 到达终点 yield break; } currentNode = currentPath[targetIndex]; } // 平滑转向并移动 Vector3 dir = (currentNode.worldPosition - transform.position).normalized; Quaternion lookRotation = Quaternion.LookRotation(dir); transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, Time.deltaTime * turnSpeed); transform.position = Vector3.MoveTowards(transform.position, currentNode.worldPosition, speed * Time.deltaTime); yield return null; } } void Update() { // 简单示例:点击鼠标右键,让单位移动到该位置 if (Input.GetMouseButtonDown(1)) { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { RequestPath(hit.point); } } // 动态路径更新:如果单位当前位置与上次请求路径时的位置相差较大,则重新请求路径 // 这是一种简单的处理方式,更复杂的可以监听网格变化事件 if (Vector3.Distance(transform.position, lastPathRequestPos) > pathUpdateMoveThreshold) { RequestPath(currentPath != null && currentPath.Count > 0 ? currentPath[currentPath.Count - 1].worldPosition : transform.position); } } void RequestPath(Vector3 targetPosition) { // 防止过于频繁的寻路请求 StopCoroutine("UpdatePath"); StartCoroutine("UpdatePath", targetPosition); } IEnumerator UpdatePath(Vector3 targetPos) { // 等待最短间隔,防止一帧内多次寻路 yield return new WaitForSeconds(minPathUpdateTime); lastPathRequestPos = transform.position; pathfinding.FindPath(transform.position, targetPos); // 注意:这里FindPath是异步的,路径结果需要通过事件或回调传递给SetPath // 为了简化,我们假设Pathfinding计算完后会直接调用这个Unit的SetPath方法。 // 实际项目中,你需要建立一个请求-回调机制。 } }实操心得:动态寻路的性能优化是关键。频繁的全局寻路(尤其是长距离)是性能杀手。一个实用的技巧是分层寻路(Hierarchical Pathfinding)。先在大尺度、低精度的网格上规划一条粗略路径,然后在单位移动过程中,只在小范围、高精度的网格上做局部寻路,绕过动态障碍物。这能极大减少计算量。
4. 性能优化与高级技巧:让你的寻路系统飞起来
一个基础的A*实现可能在小型网格上运行良好,但一旦网格变大、单位变多,性能问题就会立刻凸显。下面分享几个我项目中验证过的优化技巧。
4.1 数据结构优化:用堆(Heap)替代列表
在基础的A*实现中,我们需要频繁地从openSet(开放集)中查找F值最小的节点。使用List并线性查找,其时间复杂度是O(n)。当节点数量成千上万时,这会成为瓶颈。
解决方案是使用二叉堆(Binary Heap)来作为优先队列。二叉堆能在O(log n)的时间内完成插入和提取最小值的操作,对于大规模寻路有质的提升。
我们可以创建一个通用的Heap类:
public class Heap<T> where T : IHeapItem<T> { T[] items; int currentItemCount; public Heap(int maxHeapSize) { items = new T[maxHeapSize]; } public void Add(T item) { item.HeapIndex = currentItemCount; items[currentItemCount] = item; SortUp(item); currentItemCount++; } public T RemoveFirst() { T firstItem = items[0]; currentItemCount--; items[0] = items[currentItemCount]; items[0].HeapIndex = 0; SortDown(items[0]); return firstItem; } // ... 省略 SortUp, SortDown, Swap, Contains, UpdateItem 等方法 ... public int Count { get { return currentItemCount; } } } public interface IHeapItem<T> : IComparable<T> { int HeapIndex { get; set; } }然后修改我们的Node类实现IHeapItem<Node>接口,并修改Pathfinding脚本,用Heap<Node>替换List<Node>作为openSet。这个改动能轻易将大规模寻路的性能提升数倍。
4.2 路径平滑与移动优化
A*在网格上寻出的路径是由一系列网格中心点组成的折线,这会导致角色移动时产生不自然的“锯齿状”拐角。我们可以通过路径平滑来优化。
一种简单有效的方法是漏斗算法(Funnel Algorithm)的简化版:射线投射。在得到原始路径点列表后,我们从起点开始,尝试用射线检测是否能“看到”更后面的点,如果能看到,就跳过中间的点。
List<Vector3> SmoothPath(List<Node> path) { if (path == null || path.Count < 2) return ConvertNodePathToVector(path); List<Vector3> smoothPath = new List<Vector3>(); smoothPath.Add(path[0].worldPosition); int currentIndex = 0; while (currentIndex < path.Count - 1) { for (int i = path.Count - 1; i > currentIndex; i--) { if (IsWalkable(path[currentIndex].worldPosition, path[i].worldPosition)) { // 如果从currentIndex可以直接走到i,则把i加入平滑路径,并跳过中间点 smoothPath.Add(path[i].worldPosition); currentIndex = i; break; } // 如果循环结束都没找到,则只能走到下一个点 if (i == currentIndex + 1) { smoothPath.Add(path[i].worldPosition); currentIndex = i; } } } return smoothPath; } bool IsWalkable(Vector3 start, Vector3 end) { // 使用Physics.Linecast或Raycast检查两点之间是否有障碍物 return !Physics.Linecast(start, end, GridManager.Instance.unwalkableMask); }此外,在移动逻辑上,不要简单使用MoveTowards直接走向下一个路径点。这会在拐角处产生生硬的停顿。更好的方法是使用转向力(Steering Force)或AI移动插件(如Unity的NavMeshAgent内部所做的那样),让移动带有加速度和减速度,看起来更自然。
4.3 多单位管理与避障
当屏幕上同时有几十上百个单位需要寻路时,如果每个单位每帧都进行完整的A*计算,帧率会瞬间崩溃。这时需要单位管理和局部避障。
- 群体寻路:对于目标相同的一群单位(如RTS中框选的一队士兵),可以只计算一次路径,然后让所有单位共享这条主路径。每个单位再根据自身位置,在这条主路径上进行微调。
- 空间分区:使用四叉树、网格空间分区或Unity的
Physics.OverlapSphere配合图层掩码,让每个单位只感知和处理其周围一定范围内的其他单位,而不是全图的所有单位。 - 局部避障算法:
- RVO(Reciprocal Velocity Obstacles):这是目前最流行的局部避障算法之一。它通过计算每个单位的速度障碍区域,并调整自身速度来避免碰撞,能产生非常自然流畅的群体移动效果。有开源的RVO库可供集成。
- 势场法:将目标和障碍物想象成具有“势能”的物体,目标点产生吸引力,障碍物产生排斥力,单位的移动方向由合力决定。实现简单,但容易陷入局部最小值(卡在角落里)。
- 简单的力导向:对于小规模单位,可以给每个单位一个很小的“排斥半径”,当两个单位进入此半径时,给它们施加一个相互远离的力。虽然粗糙,但计算量小,在单位不多时效果尚可。
5. 常见问题与排查技巧实录
在实际开发中,你一定会遇到各种稀奇古怪的寻路问题。下面是我踩过的一些坑和解决方法。
5.1 角色卡住或抖动
现象:角色移动到某个位置后停止不动,或者在小范围内来回抖动。
- 检查网格精度:
Node Radius是否设置得比角色碰撞体还大?这可能导致角色认为自身所在的节点是不可通行的。确保节点半径略小于角色半径。 - 检查路径点高度:你的路径点
worldPosition的Y轴是否与角色实际行走的高度一致?如果路径点在地面以下,角色可能会尝试向下移动而被卡住。在NodeFromWorldPoint方法中,可以考虑使用Raycast来获取准确的地面高度。 - 检查移动逻辑:在
FollowPath协程中,判断到达一个节点的条件是transform.position == currentNode.worldPosition。由于浮点数精度问题,可能永远无法完全相等。应改为判断距离是否小于一个极小阈值(如Vector3.Distance < 0.1f)。
5.2 寻路性能突然下降
现象:游戏运行一段时间后,帧率下降,Profiler显示Pathfinding脚本占用大量CPU。
- 内存泄漏:检查你的
openSet和closedSet在每次寻路结束后是否被正确清空?旧的引用是否还被持有?确保每次寻路都是全新的开始。 - 网格过大:是否在运行时动态创建了过大的网格?对于大型开放世界,不要一次性加载整个世界的寻路网格。应该使用动态加载或分层寻路网格。
- 单位过多:是否在同一帧有数十个单位同时发起寻路请求?你需要一个寻路请求队列,每帧只处理固定数量的寻路请求,其余的排队等待。
5.3 路径看起来“很傻”
现象:角色明明可以走直线,却绕了一个大圈;或者贴着障碍物走得很不自然。
- 启发式函数权重:尝试调整启发式函数
H的权重。标准的A使用F = G + H。如果你给H乘以一个大于1的系数(如F = G + 1.2 * H),算法会变得更“贪婪”,倾向于更快地朝目标移动,寻路更快但可能不是最短路径。如果乘以小于1的系数,则会更注重已走路径的代价,寻路更精确但更慢。这被称为加权A。 - 网格方向:在
GetNeighbours函数中,你允许了8方向移动。对角线移动的代价你设置正确了吗?如果对角代价和直线一样,路径会偏好走对角线。通常对角线代价设为14(10*√2的近似值)。 - 路径后处理:一定要加上前面提到的路径平滑步骤。原始的网格路径本身就是锯齿状的。
5.4 NavMeshAgent常见问题
如果你使用的是Unity内置的NavMesh系统,也会遇到特定问题:
- Agent不移动:检查
NavMeshAgent组件是否被禁用?destination是否设置成功?Agent是否在NavMesh上?使用NavMeshAgent.isOnNavMesh来检查。 - Agent卡在角落:调整
NavMeshAgent的Radius、Height和Obstacle Avoidance属性。Radius太大可能导致无法通过狭窄区域。也可以尝试调高Quality设置。 - 动态障碍物更新延迟:NavMeshObstacle组件在移动后,需要一点时间来更新NavMesh数据。对于快速移动的障碍物,可以考虑使用
Carve功能,但它性能开销较大。
最后,一个非常重要的调试习惯是可视化。不仅要可视化最终路径,最好还能可视化开放集、关闭集、当前检查的节点。这能让你直观地看到A*算法是如何“思考”的,对于定位寻路逻辑错误有奇效。可以在OnDrawGizmos中用不同的颜色绘制这些集合,开发完成后关闭即可。
路径寻找是游戏AI的腿脚,它直接决定了虚拟角色能否在玩家创造的世界中自由、智能地行走。从理解网格与导航网格的区别,到亲手实现A*算法,再到处理动态环境和优化性能,每一步都需要耐心和实践。我建议你先在一个小场景中实现基础功能,然后逐步增加复杂度,比如加入不同类型的移动代价(沼泽、公路)、实现简单的群体移动,最后再挑战动态加载的大世界寻路。记住,没有一劳永逸的解决方案,最好的寻路系统永远是那个最贴合你项目特定需求的系统。