Unity中手搓A*寻路算法:从原理到C#实现与性能优化

📅 2026/7/8 16:24:19 👁️ 阅读次数 📝 编程学习
Unity中手搓A*寻路算法:从原理到C#实现与性能优化

1. 项目概述:为什么要在Unity里手搓A*寻路?

在Unity里做游戏,想让NPC自己从A点走到B点,这听起来是个基础需求。Unity引擎自带的NavMesh导航系统功能强大,开箱即用,为什么我们还要费劲去“手搓”一个A*寻路算法呢?这恰恰是很多新手开发者,甚至一些有经验的同行容易忽略的进阶思考点。NavMesh确实方便,但它是一个“黑盒”,你喂给它一个烘焙好的导航网格,它给你一个路径结果。当你的游戏需要一些特殊规则时——比如,NPC需要根据动态变化的“地形消耗”来选择路径(雪地走得更慢、沼泽消耗体力),或者你需要实现一个完全基于网格(Grid-Based)的战棋类、RTS类游戏的寻路逻辑时,NavMesh的灵活性就可能不够用了。

A*(A-Star)算法,就是那个让你从“使用者”变成“创造者”的钥匙。它不仅仅是一个寻路算法,更是一套解决“在加权图中寻找最短路径”的通用思想。在Unity里用C#实现它,意味着你完全掌控了寻路的每一个细节:如何定义“节点”(Node)、如何计算“代价”(Cost)、如何评估“启发式”(Heuristic)函数。这份掌控力,能让你实现诸如“优先走大路避开小巷”、“躲避动态危险区域”、“寻路时考虑视野和隐蔽性”等高度定制化的AI行为。

这次,我们就抛开NavMesh,从零开始,用最纯粹的C#代码,在Unity的网格世界里,构建一个属于我们自己的A*寻路引擎。我会带你走过从原理理解、数据结构设计、核心算法实现,到与Unity GameObject无缝集成、性能优化,并最终打包成可复用的组件全过程。文末会提供完整的项目代码,你可以直接拿去用在你的下一个创意里。

2. 核心原理与数据结构设计:A*算法是如何“思考”的?

在动手写代码之前,我们必须先弄清楚A*算法的大脑是如何工作的。你可以把它想象成一个有策略的探险家,而不是一个无头苍蝇。

2.1 A*算法的核心思想:代价、启发与优先级

A*算法的目标是在一个由节点(比如我们的网格格子)构成的图中,找到从起点到终点的最低成本路径。它通过维护两个列表来工作:

  1. 开放列表(Open List):存放所有已发现但尚未评估的节点。这就像探险家的“待考察地点清单”。
  2. 封闭列表(Closed List):存放所有已经评估完毕的节点。这是“已排除或已走过”的地点。

算法为每个节点计算三个关键值:

  • G值(实际代价):从起点移动到当前节点的实际累积成本。比如,平地移动成本是1,沼泽地成本可能是3。
  • H值(启发代价/预估代价):从当前节点到终点的预估成本。这是一个“猜测”,常用曼哈顿距离或欧几里得距离来计算。它引导算法向目标前进。
  • F值(总代价)F = G + H。这是A*选择下一个评估节点的核心依据。算法总是优先从开放列表中选取F值最小的节点进行扩展

这个“F=G+H”的公式是A的灵魂。G值保证了路径的最优性(实际成本最低),H值保证了搜索的方向性和效率(直奔目标)。如果H值恒为0,A就退化成效率低下的迪杰斯特拉算法;如果H值远大于G值,它可能退化成贪心的最佳优先搜索,快但不一定最优。

2.2 在Unity中的数据结构映射:Node类设计

在Unity的2D或3D网格世界中,我们需要一个Node类来代表网格中的一个格子,并封装上述的所有寻路信息。

using UnityEngine; using System.Collections.Generic; public class Node { // 节点在世界中的位置(网格坐标) public Vector2Int GridPosition { get; private set; } // 节点在世界中的实际中心点(用于物体移动) public Vector3 WorldPosition { get; private set; } // 寻路核心三要素 public int GCost; // 从起点到本节点的实际代价 public int HCost; // 从本节点到终点的预估代价 public int FCost { get { return GCost + HCost; } } // 总代价,由G和H计算得出 // 节点是否可通过(障碍物) public bool IsWalkable { get; set; } = true; // 节点的额外移动成本(例如:沼泽=3,道路=0.5) public int MovementPenalty { get; set; } = 0; // 用于回溯路径:当前节点是由哪个节点探索过来的 public Node Parent { get; set; } // 构造函数 public Node(Vector2Int gridPos, Vector3 worldPos, bool walkable = true) { GridPosition = gridPos; WorldPosition = worldPos; IsWalkable = walkable; } // 比较两个节点的FCost,用于排序 public int CompareTo(Node other) { int compare = FCost.CompareTo(other.FCost); if (compare == 0) { // 如果FCost相同,比较HCost(更接近目标的优先) compare = HCost.CompareTo(other.HCost); } return compare; } }

注意:这里我选择将GridPosition设为Vector2Int,适用于2D平面网格或3D世界中在XZ平面上的网格。如果你的游戏是3D立体网格(如体素世界),则需要使用Vector3IntWorldPositionVector3,方便直接赋值给GameObject的Transform.position

2.3 网格管理器:Grid类的职责

单个节点没有意义,我们需要一个Grid类来管理整个地图的节点网络。它的核心职责是:

  1. 根据地图尺寸和格子大小创建节点网格。
  2. 将世界坐标转换为网格坐标,反之亦然。
  3. 获取一个节点的所有邻居节点(通常是上下左右,或加上对角线共8个方向)。
  4. 提供接口来动态更新节点的可通过状态(例如,动态放置障碍物)。
public class Grid : MonoBehaviour { public LayerMask unwalkableMask; // 用于检测障碍物的Layer public Vector2 gridWorldSize; // 网格覆盖的世界区域(XZ平面) public float nodeRadius; // 每个节点的物理半径 public bool displayGridGizmos; // 是否在Scene视图中绘制网格Gizmo private float nodeDiameter; private int gridSizeX, gridSizeZ; private Node[,] grid; void Awake() { nodeDiameter = nodeRadius * 2; // 计算网格在X和Z方向上有多少个节点 gridSizeX = Mathf.RoundToInt(gridWorldSize.x / nodeDiameter); gridSizeZ = Mathf.RoundToInt(gridWorldSize.y / nodeDiameter); // 注意:gridWorldSize.y对应世界Z轴 CreateGrid(); } void CreateGrid() { grid = new Node[gridSizeX, gridSizeZ]; // 计算网格左下角的世界坐标 Vector3 worldBottomLeft = transform.position - Vector3.right * gridWorldSize.x / 2 - Vector3.forward * gridWorldSize.y / 2; for (int x = 0; x < gridSizeX; x++) { for (int z = 0; z < gridSizeZ; z++) { // 计算当前节点中心的世界坐标 Vector3 worldPoint = worldBottomLeft + Vector3.right * (x * nodeDiameter + nodeRadius) + Vector3.forward * (z * nodeDiameter + nodeRadius); // 使用物理检测判断该点是否可通过 bool walkable = !(Physics.CheckSphere(worldPoint, nodeRadius, unwalkableMask)); grid[x, z] = new Node(new Vector2Int(x, z), worldPoint, walkable); } } } // 关键方法:根据世界坐标获取对应的节点 public Node NodeFromWorldPoint(Vector3 worldPosition) { // 将世界坐标转换为相对于网格左下角的百分比 float percentX = (worldPosition.x + gridWorldSize.x / 2) / gridWorldSize.x; float percentZ = (worldPosition.z + gridWorldSize.y / 2) / gridWorldSize.y; // 钳制在0-1之间,防止坐标超出网格范围 percentX = Mathf.Clamp01(percentX); percentZ = Mathf.Clamp01(percentZ); // 将百分比转换为网格数组索引 int x = Mathf.RoundToInt((gridSizeX - 1) * percentX); int z = Mathf.RoundToInt((gridSizeZ - 1) * percentZ); return grid[x, z]; } // 获取一个节点的所有邻居(4方向或8方向) public List<Node> GetNeighbours(Node node, bool allowDiagonal = true, bool cutCorner = false) { List<Node> neighbours = new List<Node>(); int checkX, checkZ; // 检查上下左右四个方向 for (int x = -1; x <= 1; x++) { for (int z = -1; z <= 1; z++) { // 跳过自身 if (x == 0 && z == 0) continue; // 如果不允许对角线移动,则跳过对角方向 if (!allowDiagonal && Mathf.Abs(x) == Mathf.Abs(z)) continue; checkX = node.GridPosition.x + x; checkZ = node.GridPosition.y + z; // 注意:GridPosition.y 对应的是网格Z坐标 // 检查邻居是否在网格范围内 if (checkX >= 0 && checkX < gridSizeX && checkZ >= 0 && checkZ < gridSizeZ) { Node neighbour = grid[checkX, checkZ]; // 如果允许对角线,且设置了不切角(cutCorner),需要额外判断 if (allowDiagonal && !cutCorner && Mathf.Abs(x) == Mathf.Abs(z)) { // 例如,从当前节点到东北方向的对角节点,需要确保北边和东边的节点都是可通行的,否则不能“切着墙角走” Node sideNode1 = grid[node.GridPosition.x + x, node.GridPosition.y]; Node sideNode2 = grid[node.GridPosition.x, node.GridPosition.y + z]; if (!sideNode1.IsWalkable || !sideNode2.IsWalkable) { continue; // 跳过这个对角线邻居 } } neighbours.Add(neighbour); } } } return neighbours; } // 在Scene视图绘制网格,便于调试 void OnDrawGizmos() { if (!displayGridGizmos || grid == null) return; Gizmos.DrawWireCube(transform.position, new Vector3(gridWorldSize.x, 1, gridWorldSize.y)); if (grid != null) { foreach (Node n in grid) { Gizmos.color = (n.IsWalkable) ? Color.white : Color.red; Gizmos.DrawCube(n.WorldPosition, Vector3.one * (nodeDiameter - 0.1f)); } } } }

实操心得GetNeighbours方法中的cutCorner参数非常实用。在大多数RTS或战棋游戏中,单位不应该从一个障碍物的角落“挤”过去,这看起来不真实。将cutCorner设为false可以避免这种情况,但会稍微增加计算量,因为需要检查两个侧向节点。你需要根据游戏类型权衡。对于俯视角的2D游戏,通常需要设为false;对于自由移动的3D角色,可能可以设为true

3. A*寻路算法的C#核心实现

有了NodeGrid,我们现在可以编写A*算法的主逻辑了。我们将创建一个静态的Pathfinding类,它不依赖于MonoBehaviour,是一个纯粹的工具类。

3.1 算法主循环:FindPath方法

这是A*算法的心脏。它接收起点和终点的世界坐标,返回一个由世界坐标Vector3组成的路径列表。

using System.Collections.Generic; using System.Linq; using UnityEngine; public static class Pathfinding { public static List<Vector3> FindPath(Vector3 startPos, Vector3 targetPos, Grid grid) { Node startNode = grid.NodeFromWorldPoint(startPos); Node targetNode = grid.NodeFromWorldPoint(targetPos); // 如果起点或终点不可通过,直接返回空路径 if (!startNode.IsWalkable || !targetNode.IsWalkable) { Debug.LogWarning("起点或终点不可通行!"); return new List<Vector3>(); } // 初始化开放列表和封闭列表 // 使用SortedSet或PriorityQueue以获得更好的性能,这里为清晰使用List并手动排序 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]; } } // 2. 将当前节点移至封闭列表 openSet.Remove(currentNode); closedSet.Add(currentNode); // 3. 如果当前节点就是目标节点,路径已找到! if (currentNode == targetNode) { return RetracePath(startNode, targetNode); } // 4. 遍历当前节点的所有邻居 foreach (Node neighbour in grid.GetNeighbours(currentNode)) { // 跳过不可通行或已在封闭列表中的邻居 if (!neighbour.IsWalkable || closedSet.Contains(neighbour)) { continue; } // 计算从当前节点移动到邻居的新G值 // 基础移动成本为10(水平/垂直),对角线为14(约等于10*√2) int newMovementCostToNeighbour = currentNode.GCost + GetDistance(currentNode, neighbour) + neighbour.MovementPenalty; // 如果新路径更优,或者该邻居尚未在开放列表中 if (newMovementCostToNeighbour < neighbour.GCost || !openSet.Contains(neighbour)) { // 更新邻居的G、H值,并设置父节点 neighbour.GCost = newMovementCostToNeighbour; neighbour.HCost = GetDistance(neighbour, targetNode); neighbour.Parent = currentNode; // 如果邻居不在开放列表,则加入 if (!openSet.Contains(neighbour)) { openSet.Add(neighbour); } // 注意:如果已在开放列表中,由于G值已更新,下次循环排序时会自动调整位置 } } } // 开放列表为空仍未找到路径,说明目标不可达 Debug.LogWarning("无法找到路径!"); return new List<Vector3>(); } // 回溯路径:从终点节点,通过Parent链,一路回溯到起点 private static List<Vector3> RetracePath(Node startNode, Node endNode) { List<Node> path = new List<Node>(); Node currentNode = endNode; while (currentNode != startNode) { path.Add(currentNode); currentNode = currentNode.Parent; } // 将节点列表反转,并从起点开始转换为世界坐标列表 List<Vector3> waypoints = path.Select(node => node.WorldPosition).Reverse().ToList(); // 通常也会把起点加进去 // waypoints.Insert(0, startNode.WorldPosition); return waypoints; } // 计算两个节点之间的移动距离(用于G和H值计算) private static int GetDistance(Node nodeA, Node nodeB) { int dstX = Mathf.Abs(nodeA.GridPosition.x - nodeB.GridPosition.x); int dstY = Mathf.Abs(nodeA.GridPosition.y - nodeB.GridPosition.y); // 同样是Z轴 // 使用对角移动的优化距离公式 // 假设水平/垂直移动成本为10,对角线移动成本为14 if (dstX > dstY) return 14 * dstY + 10 * (dstX - dstY); return 14 * dstX + 10 * (dstY - dstX); } }

3.2 关键细节与优化点解析

  1. 开放列表的数据结构选择:上面的代码使用List<Node>并每次循环查找最小F值节点,其时间复杂度是O(n)。对于大型地图,这会成为性能瓶颈。生产环境强烈建议使用优先队列(Priority Queue),例如C#的SortedSet(需自定义IComparer)或System.Collections.Generic.PriorityQueue(.NET 6+)。这可以将查找最小F值节点的操作降到O(log n)。

  2. 距离计算与移动成本GetDistance函数使用了常见的“切比雪夫距离”的变种,它假设对角线移动成本是水平移动的√2倍(近似为14/10)。这比简单的曼哈顿距离(只允许4方向移动)更符合在允许对角线移动的网格中的真实移动成本。MovementPenalty的加入使得算法能处理不同地形。

  3. 路径回溯与简化RetracePath函数得到的路径点是每个网格的中心点。对于移动单位来说,这条路径可能拐点太多(“锯齿状”)。一个常见的后处理步骤是路径点简化(Path Smoothing),使用射线检测等方法,尝试合并共线的点,或直接检测能否从当前点“看到”更远的点,从而跳过中间点,使路径更平滑。

4. 在Unity中集成与驱动:PathRequester组件

算法核心准备好了,现在我们需要一个MonoBehaviour组件来请求路径并让NPC沿着路径移动。这个组件将挂在需要寻路的NPC(如敌人、玩家单位)上。

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; // 我们不用NavMeshAgent,但可能用到其他工具 [RequireComponent(typeof(Grid))] // 假设Grid脚本挂载在场景中的某个管理器对象上 public class PathRequester : MonoBehaviour { public Transform target; // 寻路目标 public float speed = 5f; public float turnSpeed = 5f; public float stoppingDistance = 0.1f; public bool drawPathGizmo = true; private Grid grid; private List<Vector3> path; private int targetIndex; private Coroutine followPathCoroutine; void Start() { // 假设场景中只有一个Grid组件,挂在一个名为“PathfindingGrid”的空物体上 grid = FindObjectOfType<Grid>(); if (grid == null) { Debug.LogError("场景中未找到Grid组件!"); this.enabled = false; } } void Update() { // 示例:按下空格键寻路到目标 if (Input.GetKeyDown(KeyCode.Space) && target != null) { RequestPath(target.position); } } public void RequestPath(Vector3 targetPosition) { // 如果正在跟随旧路径,先停止 if (followPathCoroutine != null) { StopCoroutine(followPathCoroutine); } // 调用静态的Pathfinding.FindPath方法 path = Pathfinding.FindPath(transform.position, targetPosition, grid); if (path != null && path.Count > 0) { // 开始新的协程来跟随路径 followPathCoroutine = StartCoroutine(FollowPath()); } else { Debug.Log("寻路失败或路径为空。"); } } IEnumerator FollowPath() { if (path == null || path.Count == 0) yield break; targetIndex = 0; // 将第一个路径点设为当前目标 Vector3 currentWaypoint = path[0]; while (true) { // 计算朝向当前路径点的方向 Vector3 direction = (currentWaypoint - transform.position).normalized; // 平滑旋转朝向目标方向 if (direction != Vector3.zero) { Quaternion lookRotation = Quaternion.LookRotation(direction); transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, Time.deltaTime * turnSpeed); } // 向前移动 transform.Translate(direction * speed * Time.deltaTime, Space.World); // 检查是否到达当前路径点 if (Vector3.Distance(transform.position, currentWaypoint) <= stoppingDistance) { targetIndex++; // 如果已到达最后一个路径点,结束移动 if (targetIndex >= path.Count) { path = null; yield break; } // 更新到下一个路径点 currentWaypoint = path[targetIndex]; } yield return null; // 等待下一帧 } } // 在Scene视图中绘制路径,便于调试 void OnDrawGizmos() { if (drawPathGizmo && path != null) { Gizmos.color = Color.blue; for (int i = targetIndex; i < path.Count; i++) { Gizmos.DrawSphere(path[i], 0.2f); if (i == targetIndex) { Gizmos.DrawLine(transform.position, path[i]); } else { Gizmos.DrawLine(path[i - 1], path[i]); } } } } }

注意事项:这个FollowPath协程是一个简单的直线移动实现。在实际项目中,你可能需要更复杂的移动控制,比如:

  • 使用CharacterController或Rigidbody:以获得真实的物理碰撞和移动。
  • 路径点到达判断:使用2D平面距离(忽略Y轴)可能更合适。
  • 移动动画同步:根据速度参数speed来驱动Animator中的速度参数。
  • 动态避障:A*给出的是静态路径,如果路径上突然出现动态障碍物,需要结合局部避障算法(如RVO、势场法)或重新寻路。

5. 性能优化与高级技巧:让寻路更快更智能

一个基础的A*实现在小地图上运行良好,但当地图变大、单位变多时,性能问题就会凸显。以下是几个关键的优化方向。

5.1 数据结构优化:使用优先队列

如前所述,将开放列表从List换成优先队列是提升性能最直接有效的方法。这里以.NET的PriorityQueue为例(需要.NET 6或更高版本,或安装相应的NuGet包)。

// 首先,我们需要一个包装类来存储节点和其优先级(F值) public class NodePriorityWrapper : IComparable<NodePriorityWrapper> { public Node Node { get; } public int Priority { get; } // F值 public NodePriorityWrapper(Node node, int priority) { Node = node; Priority = priority; } public int CompareTo(NodePriorityWrapper other) { return Priority.CompareTo(other.Priority); } } // 然后在FindPath方法中,替换开放列表 // using System.Collections.Generic; // 确保引入 PriorityQueue<NodePriorityWrapper, int> openSet = new PriorityQueue<NodePriorityWrapper, int>(); openSet.Enqueue(new NodePriorityWrapper(startNode, startNode.FCost), startNode.FCost); while (openSet.Count > 0) { // 出队F值最小的节点 Node currentNode = openSet.Dequeue().Node; // ... 后续逻辑与之前类似 ... // 当需要将邻居加入开放列表时 if (!openSet.UnorderedItems.Any(item => item.Element.Node == neighbour)) // 简化判断,实际需优化 { openSet.Enqueue(new NodePriorityWrapper(neighbour, neighbour.FCost), neighbour.FCost); } else { // 如果已在队列中,需要更新优先级(PriorityQueue不支持直接更新,通常做法是重新入队或使用更复杂的结构) // 一种常见策略是允许重复节点入队,在出队时检查是否已在closedSet中 } }

实操心得PriorityQueue在.NET中默认是最小堆,非常适合A*算法。但请注意,标准的PriorityQueue不支持直接更新队列中已有元素的优先级。常见的处理模式是:允许重复节点入队,但在从队列中取出节点时,首先检查它是否已经在closedSet中,如果是则直接跳过。虽然这会导致队列中有一些“过时”的节点,但算法正确性不受影响,且实现简单。另一种方案是使用支持优先级更新的自定义堆或第三方库。

5.2 启发函数(Heuristic)的选择与优化

启发函数H(n)极大地影响A*的搜索效率和路径最优性的平衡。

  • 曼哈顿距离H = |dx| + |dy|。适用于只允许上下左右四方向移动的网格(如经典2D Roguelike)。它高估了对角线移动的成本,导致A*搜索更多节点,但保证找到最短路径。
  • 对角线距离(切比雪夫距离)H = max(|dx|, |dy|)。适用于允许八方向移动的网格。它更贴合允许对角线移动的实际成本。
  • 欧几里得距离H = sqrt(dx^2 + dy^2)。最符合“直线距离”的直觉,但在网格寻路中计算平方根开销较大,且可能轻微低估成本(因为网格移动不是严格直线),导致搜索节点略多。
  • 欧几里得距离的平方H = dx^2 + dy^2。为了避免开方运算,可以直接使用平方值。但请注意,这会使H值远大于G值,算法会变得非常“贪婪”,倾向于直奔目标而忽略障碍物,可能无法找到最短路径,甚至找不到路径。一般不推荐。

在我们的实现中,GetDistance函数同时用于G和H的计算,它是对角线距离的一种整数近似(10和14),保证了启发函数的可采纳性,即H值永远不会高估实际成本,从而确保A*能找到最优解。

5.3 路径平滑与后处理

A*在网格上找到的路径通常是“网格对齐”的,拐角很多。我们可以通过路径点简化来优化:

public static List<Vector3> SimplifyPath(List<Node> nodePath) { List<Vector3> waypoints = new List<Vector3>(); if (nodePath.Count < 2) { waypoints.Add(nodePath[0].WorldPosition); return waypoints; } Vector2 oldDirection = Vector2.zero; for (int i = 1; i < nodePath.Count; i++) { // 计算当前线段的方向(在XZ平面上) Vector2 newDirection = new Vector2( nodePath[i].GridPosition.x - nodePath[i-1].GridPosition.x, nodePath[i].GridPosition.y - nodePath[i-1].GridPosition.y ).normalized; // 如果方向改变,则上一个节点是一个拐点,需要保留 if (newDirection != oldDirection) { waypoints.Add(nodePath[i-1].WorldPosition); } oldDirection = newDirection; } // 添加终点 waypoints.Add(nodePath[nodePath.Count - 1].WorldPosition); return waypoints; }

更高级的平滑方法是射线投射法:从起点开始,向路径中后面的点发射射线(忽略障碍物Layer),如果能直接“看到”更远的点,就跳过中间的所有点。这能得到更接近直线的最短路径,但计算量稍大。

5.4 分层寻路与方向搜索

对于超大型地图(如开放世界),一次性寻路计算量巨大。可以采用分层寻路(Hierarchical Pathfinding):

  1. 高层:将地图划分为大的区块(Chunk),先在这些区块间寻路。
  2. 底层:在目标区块内进行精细的网格寻路。 这需要预先处理区块之间的连通性。

另一种优化是搜索方向。标准的A是从起点向终点双向搜索。双向A(Bidirectional A*)可以同时从起点和终点开始搜索,直到两个搜索区域相遇,这通常能减少总搜索节点数。

6. 常见问题、调试技巧与实战避坑指南

即使算法正确,集成到Unity项目时还是会遇到各种问题。这里记录一些我踩过的坑和解决方法。

6.1 NPC卡住、抖动或走“之”字形

  • 原因1:路径点过于密集。如果网格节点很小,路径点会非常接近,NPC可能在一个点附近来回振荡。解决:增加stoppingDistance,或进行路径点简化,移除过于接近的点。
  • 原因2:移动和旋转在同一帧竞争FollowPath协程中,移动和旋转都使用Transform直接修改,可能导致不自然的运动。解决:使用Vector3.MoveTowardsQuaternion.RotateTowards进行插值移动,或使用Rigidbody.velocityRigidbody.MoveRotation进行物理移动。
  • 原因3:帧率波动导致移动距离不一致。使用transform.Translate(..., Time.deltaTime)基本可以解决,但更稳健的做法是在FixedUpdate中进行物理移动。

6.2 寻路结果不合理(穿墙、绕远路)

  • 原因1:网格节点大小与障碍物碰撞体不匹配nodeRadius设置得太小,可能从两个障碍物的缝隙中“挤”过去;设置得太大,可能把可通行区域误判为障碍。解决:在Scene视图中打开Grid的Gizmo显示,仔细调整nodeRadius,确保网格能准确覆盖地形。可以使用一个与玩家碰撞体大小相似的球体进行Physics.CheckSphere检测。
  • 原因2:unwalkableMask设置错误。确保障碍物所在的Layer被正确添加到unwalkableMask中。
  • 原因3:对角移动切割墙角。这就是前面提到的cutCorner参数。如果你不希望单位贴着障碍物边缘走,在GetNeighbours调用时将其设为false
  • 原因4:启发函数权重过高。如果你给H值乘以一个大于1的权重(w * H),可以加快搜索速度(称为Weighted A*),但会牺牲路径最优性,可能产生绕远路的结果。权重越大,路径越不最优。

6.3 性能问题:帧率在寻路时骤降

  • 原因:每帧都在进行昂贵的寻路计算。A*是CPU密集型操作。解决
    1. 限制寻路频率:为每个PathRequester设置一个寻路冷却时间,比如每秒最多请求一次。
    2. 使用协程分帧计算:将FindPath函数改造成一个IEnumerator,将循环中的部分计算分摊到多帧完成。这对于单次长距离寻路避免卡顿非常有效。
    3. 对象池:对于需要频繁寻路的单位(如大量小兵),可以考虑预计算常用路径或使用更简单的AI(如朝着目标直线移动,遇到障碍再触发寻路)。
    4. 优化网格大小:不是格子越小越好。根据游戏角色的大小和移动精度,选择合适的nodeRadius。通常,节点直径略大于角色的碰撞体半径即可。

6.4 动态障碍物处理

我们的基础实现假设网格是静态的。处理动态障碍物(如可破坏的墙、移动的车辆)有两种主流策略:

  1. 局部避障 + 全局重规划:A*负责计算全局静态路径。当NPC在移动中遇到动态障碍物时,用一个简单的局部避障算法(如向量场、RVO)绕开它,或者当障碍物挡住路径一段时间后,触发一次从当前位置到目标的重规划。
  2. 实时更新网格:当动态障碍物出现或消失时,调用Grid的某个方法(如UpdateNodeWalkable(Vector3 position, bool walkable)),更新对应节点的IsWalkable状态。注意:这需要维护一个从世界坐标到Node对象的快速查找字典,并且所有正在使用旧路径的单位可能需要重新寻路。

6.5 多线程寻路

对于需要同时为大量单位寻路的游戏(如RTS),在主线程进行寻路是不可接受的。可以将Pathfinding类改造成支持多线程。核心是将FindPath方法封装到一个单独的线程或任务中,计算完成后通过回调(如Action<List<Vector3>>)或Unity的MainThreadDispatcher将结果传回主线程,再交给PathRequester执行。

重要警告:Unity的API(如Physics.CheckSphere,Transform)不是线程安全的!在多线程寻路中,网格的创建和节点可通过性判断必须在主线程完成。通常的做法是,在主线程预先创建好Grid并计算好所有节点的静态IsWalkable状态。寻路线程只读取这些数据并进行纯逻辑计算,不调用任何Unity API。动态障碍物的更新也需要通过线程安全的方式同步到网格数据中。

7. 完整项目集成与扩展思路

将以上所有代码模块整合到一个Unity项目中,你需要:

  1. 创建一个空GameObject,命名为“PathfindingGrid”,挂载Grid脚本。配置好gridWorldSizenodeRadiusunwalkableMask
  2. 在场景中放置一些带有Collider的物体(如Cube),并将其Layer设置为unwalkableMask中包含的层。
  3. 创建一个NPC(如一个Capsule),挂载PathRequester脚本。将“PathfindingGrid”对象拖拽到其Grid引用上(或通过FindObjectOfType自动查找)。
  4. 创建一个目标物体(如Sphere),将其Transform赋值给NPC的target变量。
  5. 运行游戏,按下空格键,观察NPC是否绕过障碍物走向目标。

扩展思路

  • 不同地形代价:在Node类中扩展MovementPenalty,并在Grid创建时根据地面Tag或纹理进行赋值。在GetDistance计算G值时加入此代价。
  • 单元碰撞与动态避让:A*处理静态环境,对于其他移动单位,可以使用局部避障算法,如RVO(Reciprocal Velocity Obstacles),它能让多个单位自然流畅地相互避让。
  • 与行为树/状态机集成:将PathRequester.RequestPath作为AI行为树的一个Action节点,或状态机中的一个状态。例如,“巡逻”状态会在到达一个点后,寻路到下一个巡逻点。
  • 制作编辑器工具:为Grid脚本编写一个自定义Editor脚本,在Inspector中提供按钮来可视化烘焙网格,甚至手动绘制不可通行区域。

手搓A*寻路的过程,是一次对游戏AI底层逻辑的深刻旅行。它让你不再满足于使用现成的工具,而是开始思考如何创造工具。当你看到自己编写的代码驱动着NPC在复杂的地形中穿梭时,那种成就感是无可替代的。这份完整的项目代码,希望能成为你探索更广阔AI世界的一块坚实跳板。记住,所有复杂的系统都始于一个清晰、可工作的核心,而你现在已经拥有了它。