基于Unity的A星寻路算法(绝对简单完整版本)
前言
在上一篇文章,介绍了网格地图的实现方式,基于该文章,我们来实现一个A星寻路的算法,最终实现的效果为:
项目源码已上传Github:AStarNavigate
在阅读本篇文章,如果你对于里面提到的一些关于网格地图的创建方式的一些地图不了解的话,可以先阅读了解一下下面的这篇文章:
文章链接:
1、简单做一些背景介绍
在介绍A星寻路算法前,先介绍另外一种算法:Dijkstra
寻路算法,简单的来说是一种A星寻路的基础版。Dijkstra
作为一种无启发的寻路算法,通过围绕起始点向四周扩展遍历,一直到找到目标点结束,简单来说就是暴力破解,由近到远遍历所有可能,从而找到目标点
很明显,这种寻路方式是很的消耗性能的,非常的不高效,有没有更好的解决方式呢
从实际生活中出发,如果你要到达某地,却不知道具体的路该怎么办呢,是不是先大概确定方向,边靠近目标点边问路呢
A星寻路算法也是基于这样的思路,通过一定的逻辑找到可以靠近物体的方向,然后一步步的走进目标点,直到到达目的地。
二、A星寻路算法的基本原理
整个理解过程是一个线性结构,只需要一步步完整的走下去,基本就可以对于A星有一个大概的了解。
确定直角斜角权重:
本质上来讲,A星寻路是基于一种网格的地图来实现的寻路的方式,在网格中,一个点可以到达的位置为周围的八个方向。而由于水平与垂直和倾斜的方向距离不一样,所以我们在寻路时需要设置不同的长度:
通过图片可以看出,直线距离与斜线距离是分别等腰直角三角形直角边与斜边。根据勾股定理我们可以得知两者的比例关系约为1.41:1
,为了方便计算,我们就将斜边权重为14
,而直角边权重为10
,这样的话,要得到最短的路径,可以按照下面的思路去考虑:
遍历移动格子可能性:
接下来需要考虑第二个问题,在对起始点周围的可移动格子遍历完成后,如何找到最短路径上的那个格子呢,即下一步该走哪一个格子,这里就是整个A星寻路算法的核心:
如图,当我们第一步对起始点A周围所有的格子遍历后,从A出发有八个可以移动的方向可以到达下一个格子。如果你作为一个人类,当然一眼可以看出下一步向绿色箭头方向移动产生的路径是最短的。
我们人类可以根据经验很快的判断出方向,但是机器不能,计算机需要严谨的程序逻辑来实现这样的效果,需要我们赋予他基本的执行程序。通过重复的执行这样的逻辑,得到最终的效果。因此,接下来,需要思考如何让计算机在一系列点位中找到方向最正确的那个点位
计算某一格子期望长度:
到目前,我们的目的就是使计算机可以找到找到所有可以走的格子中产生路径最短的格子。接下来以你的经验来思考,比较长短往往是依据什么。嘿嘿,别想歪,确实是数字的大小。所以我们需要给每一个格子一个数值来作为路径通过该格子的代价。
当程序进行到现在,要解决的问题是如何求得一个数字来代表该格子。实现方式是通过计算一个通过格子路径长度的对比来找到最短的路径。而任一格子记录路径长度标记为All,并可以将其分为两部分:已走路径与预估路径(不理解没关系,接着往下看):
如图(灵魂画手,顺便加个防伪标志嘿嘿)求从A到B点的路径,当前已经寻路到C点,如何求得经过该点的一个期望路径的长度呢:
- 到达该格子已经走过的路径长度
G
:G
值的计算是基于递推的思想,根据上一个格子的G再加上上一个格子到这个格子的距离即可 - 当前格子到达终点预估路径长度H:该距离是一个估计的距离,至于如何估计的,接下来会进行介绍
然后就可以求出该点的整个期望路径长度All,对G和H进行一个简单的加法:
这样我们就可以通过下一步所有可能的移动的格子中找到最短的格子
关于预估路径长度H的计算:
- 实现对于H的计算的估计有很多,由于本来就是预估,换句话就是不是一定准确的结果,所以我们可以通过计算当前节点到目标点的直线距离或者水平加垂直距离来获得
在本文章的后面演示案例中,是基于水平加垂直距离来计算预估路径长度H,即在上面的图中,从C到B的预估路径计算方式为:
Hcb = 水平格子差 * 10 + 垂直格子差 * 10
上述步骤总结升级:
假设我们走到了C点,并且接下来只能从C点向下一步移动,可以在下面的图中看出接下来格子的所有可能性:
下面我们来手动计算一下4号
和5号
的预估路径长度来帮助你理解这个过程,开始前我们要知道一条斜边长14
,直边长度为10
:
则AC的长度为:
Lac=4*14=56
4号:
H = Lac + 1 * 14 = 70
G = 2 * 10 + 2 * 10 = 40
All = H + G = 110
5号:
H = Lac + 1 * 10 = 66
G = 2 * 10 + 3 * 10 = 50
All = H + G = 116
经过对比,5号
格子的期望路径长度长于4号
,在计算机运行程序时,会对1到7号都进行这样的计算,然后求得其中的一个最小值并作为下一步的移动目标
注意:
- 如过有两个或者多个相同的最小值,会根据程序的写法选择任意一个,这不影响整个程序的运行思路
进一步升级
我们发现,上述步骤是有一些问题,因为场景中没有障碍物,所以物体会一直走直线。但是在实际情况中,假若寻路走进了死胡同,最后的C点周围没有可以移动的点位怎么办呢。
事实上在前面为了便于理解,我们在A星寻路上将问题简化了,一直以最终点作为下一次寻路的起始点,这种方式是没有办法保证最短的路径的,而在实际的A星寻路中,在每一步中,都会记录新的可以移动的路径加入到列表中,我们命名这个列表为开放列表,找到最短的一个节点后,将该点移除,并加入另外一个节点,命名为关闭列表,具体的可以这么说
- 开放列表:用来在其中选择预估路径长度最短的点
- 封闭列表:用来表示已经计算过该点,以后不再进行索引
图中信息注解:
- 红色格子:障碍物
- 白色格子:可以移动区域
- 黄色格子:起始点与终点
- 蓝色格子:代表开放列表中的格子,用来标识下一步所有可以移动的区域
- 绿色格子:所有走过的格子,同时代表闭合列表中的格子
- 黑色格子:最终的路径
通过反复的观看这张动图,相信你应该对于A星寻路有一个完整的理解,接下来,就需要通过编程来实现该寻路算法
三、编程实现
1、制作格子预制体模板
如果你之前看过Unity 制作一个网格地图生成组件这篇文章,你应该很清楚接下来要做什么,如果你不了解也没有关系,我这里再演示一遍:
创建一个Cube
,并调整其缩放,挂载一个脚本Grid
,然后编辑该脚本:
由于是作为寻路的基本格子,因此需要其记录一些信息,我们定义一些变量:
//格子的坐标位置
public int posX;
public int posY;
//格子是否为障碍物
public bool isHinder;
public Action OnClick;
//计算预估路径长度三个值
public int G = 0;
public int H = 0;
public int All = 0;
//记录在寻路过程中该格子的父格子
public Grid parentGrid;
同时在本项目中格子模板需要一个可以改变其颜色的方法用来标识当前模板所处于的状态(障碍、起始点、终点、路径等等),以及一个注册点击事件的委托方法,所以最后完整的代码为:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using UnityEngine.UI;
public class Grid : MonoBehaviour
{
public int posX;
public int posY;
public bool isHinder;
public Action OnClick;
//计算预估路径长度三个值
public int G = 0;
public int H = 0;
public int All = 0;
//记录在寻路过程中该格子的父格子
public Grid parentGrid;
public void ChangeColor(Color color)
{
gameObject.GetComponent<MeshRenderer>().material.color = color;
}
//委托绑定模板点击事件
private void OnMouseDown()
{
OnClick?.Invoke();
}
}
完成代码的编写后,就可以将其拖入我们的资源管理窗口Project面板做成一个预制体,或者直接隐藏也可以
注意:
- 如果你不理解我在干什么或者不懂代码的内容,一定要去查看这篇文章:Unity 制作一个网格地图生成组件
2、地图创建
为了提升代码的通用性,在这篇文章中,对于网格地图创建的脚本做出了一些修改,主要在于替换掉脚本中的Grid
变量的定义,转换为GameObject
,由于之前对该脚本有了详细的介绍,所以只贴出了代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
public class GridMeshCreate : MonoBehaviour
{
[Serializable]
public class MeshRange
{
public int horizontal;
public int vertical;
}
[Header("网格地图范围")]
public MeshRange meshRange;
[Header("网格地图起始点")]
private Vector3 startPos;
[Header("创建地图网格父节点")]
public Transform parentTran;
[Header("网格地图模板预制体")]
public GameObject gridPre;
[Header("网格地图模板大小")]
public Vector2 scale;
private GameObject[,] m_grids;
public GameObject[,] grids
{
get
{
return m_grids;
}
}
//注册模板事件
public Action<GameObject, int, int> gridEvent;
/// <summary>
/// 基于挂载组件的初始数据创建网格
/// </summary>
public void CreateMesh()
{
if (meshRange.horizontal == 0 || meshRange.vertical == 0)
{
return;
}
ClearMesh();
m_grids = new GameObject[meshRange.horizontal, meshRange.vertical];
for (int i = 0; i < meshRange.horizontal; i++)
{
for (int j = 0; j < meshRange.vertical; j++)
{
CreateGrid(i, j);
}
}
}
/// <summary>
/// 重载,基于传入宽高数据来创建网格
/// </summary>
/// <param name="height"></param>
/// <param name="widght"></param>
public void CreateMesh(int height, int widght)
{
if (widght == 0 || height == 0)
{
return;
}
ClearMesh();
m_grids = new GameObject[widght, height];
for (int i = 0; i < widght; i++)
{
for (int j = 0; j < height; j++)
{
CreateGrid(i, j);
}
}
}
/// <summary>
/// 根据位置创建一个基本的Grid物体
/// </summary>
/// <param name="row">x轴坐标</param>
/// <param name="column">y轴坐标</param>
public void CreateGrid(int row, int column)
{
GameObject go = GameObject.Instantiate(gridPre, parentTran);
//T grid = go.GetComponent<T>();
float posX = startPos.x + scale.x * row;
float posZ = startPos.z + scale.y * column;
go.transform.position = new Vector3(posX, startPos.y, posZ);
go.SetActive(true);
m_grids[row, column] = go;
gridEvent?.Invoke(go, row, column);
}
/// <summary>
/// 删除网格地图,并清除缓存数据
/// </summary>
public void ClearMesh()
{
if (m_grids == null || m_grids.Length == 0)
{
return;
}
foreach (GameObject go in m_grids)
{
if (go != null)
{
Destroy(go);
}
}
Array.Clear(m_grids, 0, m_grids.Length);
}
}
3、实现寻路的过程:
创建一个脚本命名为AStarLookRode
作为寻路的脚本
变量定义:
在正式的逻辑代码开始前,需要定义一些基本的变量:
- 开放列表:存储所有下一步可移动的格子
- 封闭列表:存储所有移动过的格子
- 路径栈:存储最终寻路的路径格子
- 起始点
- 终点
- 场景中的网格地图
完成变量的定义后,需要在寻路程序开始,对一些变量进行赋值,同时初始化列表,所以我们定义一个初始化的方法:
public GridMeshCreate meshMap;
public Grid startGrid;
public Grid endGrid;
public List<Grid> openGrids;
public List<Grid> closeGrids;
public Stack<Grid> rodes;
public void Init(GridMeshCreate meshMap, Grid startGrid, Grid endGrid)
{
this.meshMap = meshMap;
this.startGrid = startGrid;
this.endGrid = endGrid;
openGrids = new List<Grid>();
closeGrids = new List<Grid>();
rodes = new Stack<Grid>();
}
添加路径点周围格子至开放列表:
接下来进行一个功能的代码逻辑设计,如何将一个点周围的格子加入到开放列表。可以观察场景中的格子,有下面的两种情况:
- 位于地图中心:周围有八个可以移动的格子:
- 位于地图的边缘:左边或者右边,上边或者下边没有格子
这就需要我们从中找到可以取值的范围,由于格子的位置信息是一个二维坐标,X
和Y
,单纯的从X
轴来考虑,X-1
会是格子左边的格子的坐标,但是如果X-1<0
则说明其左边没有格子,基于这样的计算方式,来求得当前格子item
周围格子的坐标范围,并剔除一些不需要添加的格子,具体的选择步骤为:
- 遍历周围的格子
grid
,如果存在于封闭列表closeGrids
内,不处理 - 如果格子在开放列表
openGrids
中,计算该点位到目前寻路位置点的期望路径长度,如果长度更短的话,将当前格子item
的父物体替换为该格子的grid
- 接下来如果
grid
既不在开放列表openGrids
,也不再闭合列表closeGrids
内,若判断不为障碍物,则将其加入开放列表openGrids
,并设置其父物体为当前寻路位置item
简单的从图中理解:
假定我们现在走到了A
点(A
代表当前路径点Item
),那么添加其周围的格子(用grid
代表)范围限定在红色框,为了便于区分不同的情况,我做了一些简单的标识:
- 红色格子:障碍物,不处理
- 绿色格子:已经走过的路径,在闭合列表
closeList
内,不处理 - 标有圆形的格子,未执行过任何操作,添加到
openList
里面 - 橙色小框
C
:最需要理解的一个格子,首先要明白,该格子已经被其上面的绿色格子遍历过,简单的来说是已经在开放列表内,这个时候我们就要判断A
点如果经过C
点过来,路径会不会更短,如果会,则修改该A
点的父元素为C
点,否则不处理
public void TraverseItem(int i, int j)
{
int xMin = Mathf.Max(i - 1, 0);
int xMax = Mathf.Min(i + 1, meshMap.meshRange.horizontal - 1);
int yMin = Mathf.Max(j - 1, 0);
int yMax = Mathf.Min(j + 1, meshMap.meshRange.vertical - 1);
Grid item = meshMap.grids[i, j].GetComponent<Grid>();
for (int x = xMin; x <= xMax; x++)
{
for (int y = yMin; y <= yMax; y++)
{
Grid grid = meshMap.grids[x, y].GetComponent<Grid>();
if ((y == j && i == x) || closeGrids.Contains(grid))
{
continue;
}
if (openGrids.Contains(grid))
{
if(item.All > GetLength(grid, item))
{
item.parentGrid = grid;
SetNoteData(item);
}
continue;
}
if (!grid.isHinder)
{
openGrids.Add(grid);
grid.parentGrid= item;
}
}
}
}
求任一格子的期望路径长度:
接下来就需要计算出一个格子的期望路径的长度,要基于的父元素的G
来计算出该格子的G
,同时预估出来该格子到达目标的距离H,计算方式在原理里面已经介绍过,这里直接贴出代码的执行方式:
public int SetNoteData(Grid grid)
{
Grid itemParent = rodes.Count == 0 ? startGrid : grid.parentGrid;
int numG = Mathf.Abs(itemParent.posX - grid.posX) + Mathf.Abs(itemParent.posY - grid.posY);
int n = numG == 1 ? 10 : 14;
grid.G = itemParent.G + n;
int numH = Mathf.Abs(endGrid.posX - grid.posX) + Mathf.Abs(endGrid.posY - grid.posY);
grid.H = numH * 10;
grid.All = grid.H + grid.G;
return grid.All;
}
在前面的代码中,有一个开放列表中已经存在,对比期望长度的更改父格子的功能功能。用到了求根据一个格子求下一个格子期望路径长度的功能。虽然与上面的代码功能类似,但是不能直接使用,提升通用性修改起来又麻烦,所以直接再写一个:
public int GetLength(Grid bejinGrid,Grid grid)
{
int numG = Mathf.Abs(bejinGrid.posX - grid.posX) + Mathf.Abs(bejinGrid.posY - grid.posY);
int n = numG == 1 ? 10 : 14;
int G = bejinGrid.G + n;
int numH = Mathf.Abs(endGrid.posX - grid.posX) + Mathf.Abs(endGrid.posY - grid.posY);
int H = numH * 10;
int All = grid.H + grid.G;
return All;
}
开放列表中寻找期望路径最短的格子:
在完成对于一个格子的期望路径长度的计算,我们就需要从开放列表中找出期望路径长度最短的路径加入到路径栈中
但是在这一步有这样的一个问题,在原理介绍中也有说到,寻路过程中遇到障碍会进行回溯到之前的某一个路径点,如果在在栈中执行这样的操作呢
这里就要用到格子模板Grid
中存储的父格子的信息,通过对比栈中的数据,查找到父格子的位置,清除后面的数据,并将该格子插入,具体代码为:
/// <summary>
/// 在开放列表选中路径最短的点加入的路径栈,同时将路径点加入到闭合列表中
/// </summary>
public void Traverse()
{
if (openGrids.Count == 0)
{
return;
}
Grid minLenthGrid = openGrids[0];
int minLength = SetNoteData(minLenthGrid);
for (int i = 0; i < openGrids.Count; i++)
{
if (minLength > SetNoteData(openGrids[i]))
{
minLenthGrid = openGrids[i];
minLength = SetNoteData(openGrids[i]);
}
}
minLenthGrid.ChangeColor(Color.green);
Debug.Log("我在寻找人生的方向" + minLenthGrid.posX + "::::" + minLenthGrid.posY);
closeGrids.Add(minLenthGrid);
openGrids.Remove(minLenthGrid);
rodes.Push(minLenthGrid);
}
获取最终路径:
在完成寻路的步骤后,需要根据路径栈和格子的父物体来找到最短的路径,这里比较功能逻辑比较清晰,直接贴代码:
void GetRode()
{
List<Grid> grids = new List<Grid>();
rodes.Peek().ChangeColor(Color.black);
grids.Insert(0, rodes.Pop());
while (rodes.Count != 0)
{
if (grids[0].parentGrid != rodes.Peek())
{
rodes.Pop();
}
else
{
rodes.Peek().ChangeColor(Color.black);
grids.Insert(0, rodes.Pop());
}
}
}
封装方法,对外暴露:
在解决三个关键功能的代码后,就需要通过一个方法来完成整个寻路的过程,在方法的最后需要通过对终点坐标与栈顶物体的坐标进行对比,如果相同,则跳出循环,执行路径查找完成后的操作
同时为了在本案例中为了使得整个寻路过程的步骤可视化,使用一个协程来完成寻路过程的方法调用,这样,在每一次完成一格的寻路后,可以通过协程来延时执行下一次循环:
public IEnumerator OnStart()
{
//Item itemRoot = Map.bolls[0].item;
rodes.Push(startGrid);
closeGrids.Add(startGrid);
TraverseItem(startGrid.posX, startGrid.posY);
yield return new WaitForSeconds(1);
Traverse();
//为了避免无法完成寻路而跳不出循环的情况,使用For来规定寻路的最大步数
for (int i = 0; i < 6000; i++)
{
if (rodes.Peek().posX == endGrid.posX && rodes.Peek().posY == endGrid.posY)
{
GetRode();
break;
}
TraverseItem(rodes.Peek().posX, rodes.Peek().posY);
yield return new WaitForSeconds(0.2f);
Traverse();
}
}
四、 执行代码
接下来需要创建一个脚本明命名为MainRun 来执行整个项目,主要部分为创建场景的网格地图,在前面反复提到的文章里面已经有这一部分的介绍。接下来就需要对A星的调用:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MainRun : MonoBehaviour
{
//获取网格创建脚本
public GridMeshCreate gridMeshCreate;
//控制网格元素grid是障碍的概率
[Range(0,1)]
public float probability;
bool isCreateMap=false;
int clickNum=0;
Grid startGrid;
Grid endGrid;
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Run();
isCreateMap = false;
clickNum = 0;
}
if (Input.GetKeyDown(KeyCode.Q))
{
AStarLookRode aStarLookRode = new AStarLookRode();
aStarLookRode.Init(gridMeshCreate,startGrid,endGrid);
StartCoroutine(aStarLookRode.OnStart());
}
}
private void Run()
{
gridMeshCreate.gridEvent = GridEvent;
gridMeshCreate.CreateMesh();
}
/// <summary>
/// 创建grid时执行的方法,通过委托传入
/// </summary>
/// <param name="grid"></param>
private void GridEvent(GameObject go,int row,int column)
{
//概率随机决定该元素是否为障碍
Grid grid = go.GetComponent<Grid>();
float f = Random.Range(0, 1.0f);
Color color = f <= probability ? Color.red : Color.white;
grid.ChangeColor(color);
grid.isHinder = f <= probability;
grid.posX = row;
grid.posY = column;
//模板元素点击事件
grid.OnClick = () => {
if (grid.isHinder)
return;
clickNum++;
switch (clickNum)
{
case 1:
startGrid = grid;
grid.ChangeColor(Color.yellow);
break;
case 2:
endGrid = grid;
grid.ChangeColor(Color.yellow);
isCreateMap = true;
break;
default:
break;
}
};
}
}
在该脚本中,主要是用来执行网格地图创建的方法的,同时写入A星脚本的执行接口。
场景执行:
创建一个空物体,并挂载网格地图创建脚本GridMeshCreate
与运行脚本MainRun
,然后对这两个脚本进行赋值:
在两个脚本中,我们可以控制一些变量来改变网创建网格地图大小与障碍物的占比:
-
MainRun
中Probability
:用来控制地图中障碍物的数量占比 -
GridMeshCreate
中Mesh Range
:用来控制网格地图的大小范围
在完成上面的脚本挂载与设置后,就可以运行游戏,进入游戏场景后,点击空格即可创建地图,
在创建地图后,可以使用鼠标点击地图中的白色格子,第一次点击表示选择了路径的起始点,而第二次点击表示选择了目标点格子
注意:
- 这一块Bug挺多的,我也没有修改,所以尽量按着提示来,不要非要点击障碍物,或者非要在场景中点击三次
在完成对于两个关键节点的选择后,就可以点击Q键
开始执行寻路过程,然后就可以直接观察整个场景中的运行流程:
4、关于A星寻路的能效问题
算法复杂度问题:
第一张图片:障碍物的比例比较低时,寻找的路径接近于一条直线,同时没有多余的寻路节点产生:
当地图复杂度上升后,A星寻路产生巨大的代价才能获取最后的路径,而这些代价产生的原因是由于为了获取最短的路径而进行大量的回溯,而回溯又进一步造成了遍历列表长度的增加,进一步的消耗了计算资源。
所以当地图复杂度到达一定阈值并再次上升后,寻路的代价会急速的上升,也可以简单的理解为指数的形式,而当这一数值超过了0.5
,地图基本就处于不可用的状态,会有大量的死胡同,很大概率造成无路可循。
特殊情况的寻路效果:
话不多说,先看图:
通过上图可以看出,虽然场景中的网格地图很简单,但是当两个寻路点之间存在比较大的横截面时,也同样会付出巨大的寻路代价
扩展:
- 看到这张图,你知道Unity官方的NavMesh是如何实现寻路的吗?
当我们使用NavMesh
来执行寻路操作时,会事先对场景进行烘培,如果你曾经观察过这张烘培地图,就会发现其是由一张张三角面来构成的,而当我们进入游戏,执行寻路操作时,NavMesh
就会根据这些三角面的顶点来执行可移动的路径计算。
如图,其实NavMesh
的优势在与烘培阶段对于地图障碍的处理,通过一些顶点来大大简化了寻路时的计算。
如果你先学习NavMesh 的使用方式:
- 可以通过该文章:unity中Navigation实现自动寻路功能
总结
总的来说,A星是目前应用最广的寻路方式,其特点简单明了,整个过程以最短路径为设计准则,逐渐的接近目标点。
但是要注意,A星虽然一直以最短为驱动,但是最终得到的路径不一定最短(至少本篇文章的案例是这样)。至于原因,你如果理解了代码的实现过程应该也能明白,如果你不理解,知道原因也没意义,嘿嘿!