栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 游戏开发 > Unity3D

A*寻路实例项目实践笔记

Unity3D 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

A*寻路实例项目实践笔记

文章目录
  • 前言
    • A*的算法原理
    • 实现网格系统
    • A*寻路算法实现
    • 使用堆优化节点查找
    • 路径运动单元
    • 运用权重
    • 使权重更平滑(Smooth Weights)
    • 路径平滑

前言

好久不见!今天使用Unity照着Sebastian Lague大佬的视频做一个A寻路算法的实例项目,包括A寻路的介绍,网格系统的创建,以及一些实际使用案例。

A*的算法原理

简单来说,A是一种寻路算法,通过A可以找到一系列网格中从A到B的最短路线。假如说,我们想得到下图所示A到B的最短路线,第一步我们需要得到关于A节点的所有相邻节点和这些相邻节点的的一些参数。第一个参数是相邻节点到A的距离,叫做G cost。第二个参数是该相邻节点到B节点的距离,叫做H cost,基本上可以说H cost是G cost的反面。最后一个参数为G cost和H cost的和,叫做F cost。

将这些相邻节点存入一个集合,然后算法看一圈,并拿到F cost最低的节点,对拿到的点重复上诉过程。

直到在相邻节点中找到了B点,算法就完成工作了。在没有障碍加入的情况下,路径向着终点就去了。

当然,如果你的游戏里面没有障碍物,还需要什么寻路算法,接下来我们看看加入障碍物以后的情况。如下图所示,在执行了一步操作以后,出现了三个F cost相同的相邻节点,这时候我们选择H cost,也就是离B点最近的节点

然后,依旧是选择F cost最小的两个中的一个,这一次两个节点从参数上看完全一样,所以随便选一个,反正如果选错的话得到的相邻节点的F cost最后都不可能大于另一个选择。

现在我们可以看到,鼠标指向的54是下一个选择,但是这里有个小细节,他左边相邻节点的G cost为38,而不是最短距离30,这是因为我们第一次将该节点考虑进来是通过鼠标指针上面的48和其相邻。所以说G cost得到的是根据最近一次路径运算得到的最小值,而如果我们马上考虑鼠标指向的54,我们就会发现到达这个左边相邻节点的G cost出现更小值,所以我们进行更新。这个节点现在G cost为更小值30,F cost为60。总结一下:G cost当前的值不一定是最小值,而是当前所以算过的路径下的最小值。其实说到这里,我们就知道节点还需要存储一个父节点,表明目前这个最小的G cost是从与哪个节点相邻得来的。

说句题外话,既然G cost不一定是最短, H cost呢?H cost一定是最短,H cost通过某节点先直线到B节点的同一横向或纵向,再直线到B得来的,所以一定是最短。

我们继续,现在更新后的60是最短,我们选60。

就这样一直选下去,最后我们就能得到这条路径,对了,别忘了前面说的每个节点需要记录自己是通过哪个父节点走到的,这样才能得到路径。

保存父节点,就像这样

来看一下A* 算法的伪代码。

可以将OPEN理解为上图中的绿色节点,意味着候选的路径节点,在算法起始时起点为OPEN中的唯一选择,CLOSED理解为红色的点过的节点,意味着算法已经算出了到这个点的最短距离了,以后也不需要再看他了。每次循环开始时从OPEN里面找到F cost最小的作为当前节点,对于当前节点的相邻节点,如果这个节点不可移动,或者已经找到最短路径了,就跳过,否则查看这个相邻节点是否不在OPEN里面或者能得到一个更短的G cost,一个更短的G cost意味着找到了新的到这个相邻节点的更短路径,需要更新这个节点的G cost F cost以及设置当前节点为该相邻节点新的父节点,而不在OPEN意味着该相邻节点从未被考虑,现在要被考虑进来。如此循环往复,当某次循环发现当前节点就是目标节点时,寻路结束。
接下来要做的就是找到目标节点的父节点,再找到这个父节点的父节点,直到找回起始点,得到路径。

实现网格系统

要在项目中使用A*我们首先需要网格,这里大佬直接教我们自制一套网格系统。我们的网格包含一个节点类,一个网格类。节点负责定义网格中的一个位置以及该位置的信息(目前只有unwalkable)。节点将由网格负责创建。可以自定网格整体大小,单个节点大小,可以显示在Scene面板(OnDrawGizmos),可以通过给定(网格中的)某个位置获得网格中的单个节点,由此可以获得玩家所在的网格节点。

节点类

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Node
{
    // node能走吗?会根据是否与障碍物重合判断
    public bool walkable;
    // node中心的世界坐标位置
    public Vector3 worldPosition;
    // 在Grid二维坐标中的位置
    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;
    }

    // fCost为gCost + hCost 所以写个属性就行
    public int fCost
    {
        get { return gCost + hCost; }
    }
}

网格类

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// Grid的GameObject X、Y轴要在场景正中
public class Grid : MonoBehaviour
{
    // 用来存储unwalkable的layermask
    public LayerMask unwalkableMask;
    // grid的大小
    public Vector2 gridWorldSize; // Vector2的y对应世界坐标中的z轴
    // grid中的node的半径(node立方体边长的一半)
    public float nodeRadius;
    // 玩家的位置
    public Transform player;

    // grid是二位的node数组
    Node[,] grid;
    // grid中的node的直径
    float nodeDiameter;
    // Grid中的node数量
    int gridSizeX, gridSizeY;

    // 路径
    public List path;

    private void Start()
    {
        // 根据grid的尺寸和node的尺寸计算node的数量并填入二维数组
        nodeDiameter = nodeRadius * 2;
        gridSizeX = Mathf.RoundToInt(gridWorldSize.x / nodeDiameter);
        gridSizeY = Mathf.RoundToInt(gridWorldSize.y / nodeDiameter);
        CreateGrid();
    }

    // 创建grid实例
    private void CreateGrid()
    {
        grid = new Node[gridSizeX, gridSizeY];
        // 计算得到grid(从上往下看)左下角的世界坐标位置
        Vector3 worldButtomLeft = transform.position - Vector3.right * gridWorldSize.x / 2 - Vector3.forward * gridWorldSize.y / 2; //forward没错,y对应node的z坐标

        for (int x = 0; x < gridSizeX; x++)
        {
            for (int y = 0; y < gridSizeY; y++)
            {
                // 计算每一个node的世界坐标位置
                Vector3 worldPoint = worldButtomLeft +
                    Vector3.right * (x * nodeDiameter + nodeRadius) +
                    Vector3.forward * (y * nodeDiameter + nodeRadius);

                // 判断是否有obstacles,如果有就将node设置为unwalkable
                bool walkable = !(Physics.CheckSphere(worldPoint, nodeRadius, unwalkableMask));
                // 创建每个node实例并给成员赋值
                grid[x, y] = new Node(walkable, worldPoint, x, y);
            }
        }
    }

    // 获取节点的相邻节点
    public List GetNeighbours(Node node)
    {
        List neighbours = new List();

        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;

                // 结果不能超出Grid的范围
                if(checkX >= 0 && checkY < gridSizeY && checkX < gridSizeX && checkY >= 0)
                {
                    neighbours.Add(grid[checkX, checkY]);
                }
            }
        }

        return neighbours;
    }

    // 通过世界坐标获得Node
    public Node GetNodeFromWorldPoint(Vector3 worldPosition)
    {
        // 通过将坐标换算为Grid中的百分比位置来获取Node
        float percentX = (worldPosition.x + gridWorldSize.x / 2) / gridWorldSize.x;
        float percentY = (worldPosition.z + gridWorldSize.y / 2) / gridWorldSize.y; //grid的y长对应世界坐标系的z

        percentX = Mathf.Clamp01(percentX);
        percentY = Mathf.Clamp01(percentY);

        int x = Mathf.RoundToInt((gridSizeX - 1) * percentX); //减一是因为gridSize是1开始,我们需要index
        int y = Mathf.RoundToInt((gridSizeY - 1) * percentY);
        return grid[x, y];
    }

    // 在Scene面板中显示grid
    private void OnDrawGizmos()
    {
        Gizmos.DrawWireCube(transform.position, new Vector3(gridWorldSize.x, 1, gridWorldSize.y));

        if (grid != null)
        {
            Node playerNode = GetNodeFromWorldPoint(player.position);
            foreach(Node node in grid)
            {
                Gizmos.color = node.walkable ? Color.white : Color.red;
                if(playerNode == node)
                {
                    Gizmos.color = Color.cyan;
                }
                if(path != null)
                {
                    if (path.Contains(node))
                        Gizmos.color = Color.green;
                }
                Gizmos.DrawCube(node.worldPosition, Vector3.one * (nodeDiameter - 0.1f));
            }
        }
    }
}

效果

A*寻路算法实现

再开始之前说一句,VS2022的人工智能代码提示真好用,快进到人工智能写代码淘汰我这种废物程序员。

上图除了函数签名、变量名和第七行,第17行以外都拿给它提示对了

继续咱们的A*算法,按照先前的算法思路,代码实现如下

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Pathfinding : MonoBehaviour
{
    public Transform seeker, target;

    public Grid grid;

    private void Awake()
    {
        grid = this.GetComponent();
    }

    private void Update()
    {
        FindPath(seeker.position, target.position);
    }

    void FindPath(Vector3 startPos, Vector3 targetPos)
    {
        // 获取起点终点
        Node startNode = grid.GetNodeFromWorldPoint(startPos);
        Node targetNode = grid.GetNodeFromWorldPoint(targetPos);
        
        // Open列表 存放所有预选的节点
        List openSet = new List();
        HashSet closeSet = new HashSet();
        openSet.Add(startNode);

        while (openSet.Count > 0)
        {
            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].gCost < currentNode.gCost)
                {
                    currentNode = openSet[i];
                }
            }

            openSet.Remove(currentNode);
            closeSet.Add(currentNode);
            
            // 碰到终点了
            if (currentNode == targetNode)
            {
                // 回溯节点以获取路径
                RetracePath(startNode, targetNode);
                return;
            }

            // 查看每个相邻节点
            foreach (Node neighbourNode in grid.GetNeighbours(currentNode))
            {
                // 如果相邻节点unwalkable或者已经在closeSet里面了 啥也不干
                if(!neighbourNode.walkable || closeSet.Contains(neighbourNode))
                {
                    continue;
                }
                // 计算从当前节点来看的neighbourNode的gCost
                int newMovementCostToNeighbour = currentNode.gCost + GetDistance(currentNode, neighbourNode);
                // 如果新的gCost更小 或者这是第一次考虑此neighbourNode
                if(newMovementCostToNeighbour < neighbourNode.gCost || !openSet.Contains(neighbourNode))
                {
                    // 更新此neighbourNode的Cost
                    neighbourNode.gCost = newMovementCostToNeighbour;
                    neighbourNode.hCost = GetDistance(neighbourNode, targetNode);
                    neighbourNode.parent = currentNode;

                    if(!openSet.Contains(neighbourNode))
                    {
                        openSet.Add(neighbourNode);
                    }
                }
            }
        }
    }

    void RetracePath(Node startNode, Node endNode)
    {
        List path = new List();
        Node currentNode = endNode;

        while(currentNode != startNode)
        {
            path.Add(currentNode);
            currentNode = currentNode.parent;
        }
        path.Reverse();

        grid.path = path;
    }

    int GetDistance(Node nodeA, Node nodeB)
    {
        int dstX = Mathf.Abs(nodeA.gridX - nodeB.gridX);
        int dstY = Mathf.Abs(nodeA.gridY - nodeB.gridY);

        if(dstX > dstY)
        {
            return 14 * dstY + 10 * (dstX - dstY);
        }
        else
        {
            return 14 * dstX + 10 * (dstY - dstX);
        }
    }
}

效果:

Victory is ours!
使用堆优化节点查找

在先前的代码中,我们采用遍历大法来寻找fcost更低的节点,现在我们实现一个效率更好的办法。

        while (openSet.Count > 0)
        {
            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].gCost < currentNode.gCost)
                {
                    currentNode = openSet[i];
                }
            }

            openSet.Remove(currentNode);
// ... ...

我好像一直都没了解过堆(heap)这种数据结构,在Sebastian大佬的描述中,堆就是 二叉树()。上网查了一下,堆是一种特殊的完全二叉树:二叉树和堆(理论)


现在,我们有一个节点fcost组成的二叉树,每个父节点都一定要比它的子节点小。

假如我们向其中插入一个新节点,比如图中右下角的5,显然现在5的位置不符合要求,如果出现这种情况,我们就将5和它的父节点10交换。

如果交换以后发现新的父节点依然大于它,则再次交换,直到到达所有父节点小于子节点的要求。这样一来,fcost最小的节点一定是堆顶部的节点。而在图示情况下我们只进行了三次比较而不是与全部其它14个节点做比较。

接下来我们把最小的那个节点拿走,取堆末尾的10放到最前。还是继续做交换,如果现在的父节点比两个子节点都小,就直接与最小的子节点交换。

继续换下去。直到再次符合要求。

观察上图的堆,可以发现,给定任意一个子节点索引为n,他的父节点索引为:(n-1)/2的计算机整数除法结果。比如节点12,减一再通过计算机的整数除法除以2会得到5,恰巧就是父节点。反过来,父节点n的两个子节点分别是n2+1和n2+2。
通过上述获得节点的办法,接下来我们写一个堆。

using System;
using System.Collections;
using UnityEngine;

public class Heap where T : IHeapItem
{
    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;
    }

    // 更新元素在堆中的位置
    public void UpdateItem(T item)
    {
        // 这两个最多有一个有用 不存在冲突
        SortUp(item);
        SortDown(item);
    }

    public int Count
    {
        get { return currentItemCount; }
    }

    // 堆中是否包含item
    public bool Contains(T item)
    {
        return Equals(items[item.HeapIndex], item);
    }

    // 将父节点与其子节点做比较并交换
    void SortDown(T item)
    {
        while(true)
        {
            int childIndexLeft = item.HeapIndex * 2 + 1;
            int childIndexRight = item.HeapIndex * 2 + 2;
            int swapIndex = 0;

            if(childIndexLeft < currentItemCount) //确保我们没有超出范围
            {
                // 检查两个子节点的优先级最高的是谁
                swapIndex = childIndexLeft;
                if(childIndexRight < currentItemCount)
                {
                    if (items[childIndexLeft].CompareTo(items[childIndexRight]) < 0)
                    {
                        swapIndex = childIndexRight;
                    }    
                }
                // 检查父节点与最高优先级子节点的优先级
                if (items[swapIndex].CompareTo(item) > 0)
                {
                    Swap(items[swapIndex], item); //子节点需要更靠前
                }
                else
                {
                    return; //item在当前位置符合堆的要求
                }
            }
            else
            {
                return; // 没有子节点
            }

        }
    }

    // 将子节点与其父节点做比较并交换
    void SortUp(T item)
    {
        int parentIndex = (item.HeapIndex - 1) / 2;
        while(true)
        {
            T parentItem = items[parentIndex];
            if(item.CompareTo(parentItem) > 0)
            {
                Swap(item, parentItem); //子节点需要更靠前
            }
            else
            {
                break; //item在当前位置符合堆的要求
            }
            // 计算新的parent
            parentIndex = (item.HeapIndex - 1) / 2;
        }
    }

    void Swap(T itemA, T itemB)
    {
        items[itemA.HeapIndex] = itemB;
        items[itemB.HeapIndex] = itemA;

        int itemAIndex = itemA.HeapIndex;
        itemA.HeapIndex = itemB.HeapIndex; 
        itemB.HeapIndex = itemAIndex;
    }
}

public interface IHeapItem : IComparable
{
    int HeapIndex
    {
        get;
        set;
    }
}

更新Node代码以实现IHeapItem接口功能

public class Node : IHeapItem
{
	// ... ...
	
    // IHeapItem接口实现
    int heapIndex;

    public int HeapIndex
    {
        get { return heapIndex; }
        set { heapIndex = value; }
    }

    // HeapItem CompareTo实现
    public int CompareTo(Node nodeToCompare)
    {
        int compare = fCost.CompareTo(nodeToCompare.fCost);
        if (compare == 0) // fCost相等 则比较hCost
        {
            compare = hCost.CompareTo(nodeToCompare.hCost);
        }
        return -compare; //fCost/hCost更小的优先级更大
    }
}

将算法中的openSet改为Heap。这样以后,之前的for循环获取最小fCost的Node就变成了调用Heap的RemoveFirst。

        Heap openSet = new Heap(grid.MaxSize);
       // ... ...
       
   		while (openSet.Count > 0)
        {
            Node currentNode = openSet.RemoveFirst();
            closeSet.Add(currentNode);
           
			// ... ...

值得一提的是,我们使用了接口来规范能够放在堆里的类型。Unity官方提供了接口教学:接口 - Unity Learn

路径运动单元

能找到路径,我们就可以实现能沿着路径运动的单元。但如果我们同时有大量单元需要寻路,会导致程序出现明显卡顿,为此我们需要将大量单元的寻路从一帧全算完变成一系列的请求,将原来一帧的计算分散到多帧。我们将使用任务队列和协程来完成上述功能。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class PathRequestManager : MonoBehaviour
{
    Queue pathRequestQueue = new Queue();
    PathRequest currentPathRequest;

    static PathRequestManager instance;
    Pathfinding pathfinding;

    bool isProcessingPath;

    private void Awake()
    {
        instance = this;
        pathfinding = GetComponent();
    }

    public static void RequestPath(Vector3 pathStart, Vector3 pathEnd, Action callback)
    {
        PathRequest newRequest = new PathRequest(pathStart, pathEnd, callback);
        instance.pathRequestQueue.Enqueue(newRequest);
        instance.TryProcessNext();
    }

    void TryProcessNext()
    {
        if(!isProcessingPath && pathRequestQueue.Count > 0)
        {
            currentPathRequest = pathRequestQueue.Dequeue();
            isProcessingPath = true;
            pathfinding.StartFindPath(currentPathRequest.pathStart, currentPathRequest.pathEnd);
        }
    }

    public void FinishedProcessingPath(Vector3[] path, bool success)
    {
        currentPathRequest.callback(path, success);
        isProcessingPath = false;
        TryProcessNext();
    }

    struct PathRequest
    {
        public Vector3 pathStart;
        public Vector3 pathEnd;
        public Action callback;

        public PathRequest(Vector3 _start, Vector3 _end, Action _callback)
        {
            pathStart = _start;
            pathEnd = _end;
            callback = _callback;
        }
    }
}

上述是路径请求管理器,主要任务是管理pathRequestQueue,每个pathRequestQueue 调用TryProcessNext来给一个个PathRequest调用A*算法。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Unit : MonoBehaviour
{
    public Transform target;
    public float speed = 1;
    Vector3[] path;
    int targetIndex;

    void Start()
    {
        PathRequestManager.RequestPath(transform.position, target.position, OnPathFound);
    }

    public void OnPathFound(Vector3[] newPath, bool pathSuccessful)
    {
        if(pathSuccessful)
        {
            path = newPath;
            StopCoroutine("FollowPath");
            StartCoroutine("FollowPath");
        }
    }

    IEnumerator FollowPath()
    {
        Vector3 currentWaypoint = path[0];
        while (true)
        {
            if(transform.position == currentWaypoint)
            {
                targetIndex++;
                if(targetIndex >= path.Length)
                {
                    yield break;
                }

                currentWaypoint = path[targetIndex];
            }
            
            transform.position = Vector3.MoveTowards(transform.position, currentWaypoint, speed * Time.deltaTime);
            yield return null;
        }
    }

    private void OnDrawGizmos()
    {
        if(path != null)
        {
            for (int i = targetIndex; i < path.Length; i++)
            {
                Gizmos.color = Color.black;
                Gizmos.DrawCube(path[i], Vector3.one);

                if(i == targetIndex)
                {
                    Gizmos.DrawLine(transform.position, path[i]);
                }
                else
                {
                    Gizmos.DrawLine(path[i-1], path[i]);
                }
            }
        }
    }
}

RequestPath由运动单元调用,现在每个运动单元会在开始时发出寻路请求,通过Manager的FinishedProcessingPath通知其路径已处理,开始沿路径运动。
为此,Pathfinding也需要做出一些修改。

// Pathfinding.cs ...

    public void StartFindPath(Vector3 pathStart, Vector3 targetPos)
    {
        StartCoroutine(FindPath(pathStart, targetPos));
    }

    IEnumerator FindPath(Vector3 startPos, Vector3 targetPos)
    {
        // 接收寻路的
        Vector3[] waypoints = new Vector3[0];
        // 寻路是否成功
        bool pathSuccess = false;

        // 获取起点终点
        Node startNode = grid.GetNodeFromWorldPoint(startPos);
        Node targetNode = grid.GetNodeFromWorldPoint(targetPos);
        
        // 起点和目标点都能走时我们才进行计算
        if(startNode.walkable && targetNode.walkable)
        {
            // Open列表 存放所有预选的节点
            Heap openSet = new Heap(grid.MaxSize);
            HashSet closeSet = new HashSet();
            openSet.Add(startNode);

            while (openSet.Count > 0)
            {
                Node currentNode = openSet.RemoveFirst();
                closeSet.Add(currentNode);

                // 碰到终点了
                if (currentNode == targetNode)
                {
                    pathSuccess = true;
                    break;
                }

                // 查看每个相邻节点
                foreach (Node neighbourNode in grid.GetNeighbours(currentNode))
                {
                    // 如果相邻节点unwalkable或者已经在closeSet里面了 啥也不干
                    if (!neighbourNode.walkable || closeSet.Contains(neighbourNode))
                    {
                        continue;
                    }
                    // 计算从当前节点来看的neighbourNode的gCost
                    int newMovementCostToNeighbour = currentNode.gCost + GetDistance(currentNode, neighbourNode);
                    // 如果新的gCost更小 或者这是第一次考虑此neighbourNode
                    if (newMovementCostToNeighbour < neighbourNode.gCost || !openSet.Contains(neighbourNode))
                    {
                        // 更新此neighbourNode的Cost
                        neighbourNode.gCost = newMovementCostToNeighbour;
                        neighbourNode.hCost = GetDistance(neighbourNode, targetNode);
                        neighbourNode.parent = currentNode;

                        if (!openSet.Contains(neighbourNode))
                        {
                            openSet.Add(neighbourNode);
                        }
                    }
                }
            }
        }
       
        yield return null;
        if(pathSuccess)
        {
            // 回溯节点以获取路径
            waypoints = RetracePath(startNode, targetNode);
        }
        requestManager.FinishedProcessingPath(waypoints, pathSuccess);
    }

    Vector3[] RetracePath(Node startNode, Node endNode)
    {
        List path = new List();
        Node currentNode = endNode;

        while(currentNode != startNode)
        {
            path.Add(currentNode);
            currentNode = currentNode.parent;
        }
        Vector3[] waypoints = SimplifyPath(path);
        Array.Reverse(waypoints);
        return waypoints;
    }

    Vector3[] SimplifyPath(List path)
    {
        List waypoints = new List();
        Vector2 directionOld = Vector2.zero;

        for (int i = 1; i < path.Count; i++)
        {
            // 如果一系列路径节点在一个方向上,则取最终的那个节点
            Vector2 directionNew = new Vector2(path[i - 1].gridX - path[i].gridX, path[i - 1].gridY - path[i].gridY);
            if (directionNew != directionOld)
            {
                waypoints.Add(path[i-1].worldPosition);
            }
            directionOld = directionNew;
        }

        return waypoints.ToArray();
    }

比较好玩的是,我们没有直接将A*算法得到的路径交给运动单元,而是用SimplifyPath简化了一下,将一个方向上的节点优化掉了。
展示一下效果。

运用权重

假设要表现这样一种情况,AI在大马路上运动最快最轻松,在草地上一般,在泥泞中很难移动,但是也不是不能移动。为了在程序中体现这种情况,我们将运用权重来定义网格的通过难易程度。
塞巴大佬使用的方式为,定义节点的移动惩罚(Penalty),在寻路时加入cost计算中。

public class Node : IHeapItem
{
    // ... ...
    
    // 节点移动权重
    public int movementPenalty;
    
    // ... ...

还要定义一个类来表达不同的地形,定义权重值。

[System.Serializable]
public class TerrainType
{
    public LayerMask terrainMask;
    public int terrainPenalty;
}

public class Grid : MonoBehaviour
{
    // ... ...
    // 地形类别 寻路的移动花销相关
    public TerrainType[] walkableRegionType;

最后,完成一些构建工作。

public class Grid : MonoBehaviour
{
    // ... ...

    // 所有能够行走的layer
    LayerMask walkableMask;
    // 方便节点构造时判断自身类型使用的字典
    Dictionary walkableRegionDictionary = new Dictionary();


    private void Awake()
    {
        // ... ...
        foreach (TerrainType region in walkableRegionType)
        {
            walkableMask.value = walkableMask | region.terrainMask.value;
            walkableRegionDictionary.Add((int)Mathf.Log(region.terrainMask.value, 2), region.terrainPenalty);
        }

        CreateGrid();
    }

    // 创建grid实例
    private void CreateGrid()
    {
        // ... ...

        for (int x = 0; x < gridSizeX; x++)
        {
            for (int y = 0; y < gridSizeY; y++)
            {
                // 计算每一个node的世界坐标位置
                Vector3 worldPoint = worldButtomLeft +
                    Vector3.right * (x * nodeDiameter + nodeRadius) +
                    Vector3.forward * (y * nodeDiameter + nodeRadius);

                // 判断是否有obstacles,如果有就将node设置为unwalkable
                bool walkable = !(Physics.CheckSphere(worldPoint, nodeRadius, unwalkableMask));
                // 设置节点移动权重
                int movementPenalty = 0;
                if (walkable)
                {
                    Ray ray = new Ray(worldPoint + Vector3.up * 50, Vector3.down);
                    RaycastHit hit;
                    if(Physics.Raycast(ray, out hit))
                    {
                        walkableRegionDictionary.TryGetValue(hit.collider.gameObject.layer, out movementPenalty);
                    }
                }

                // 创建每个node实例并给成员赋值
                grid[x, y] = new Node(walkable, worldPoint, x, y, movementPenalty);
            }
        }
    }


效果展示。

使权重更平滑(Smooth Weights)

上期制作的权重,需要的功能倒是实现了,就是会导致得到的路径总是沿着不同区域的边缘,十分难看。我们将使权重更加平滑来解决这个问题。

要使得权重更平滑,我们将使用一种模糊(blur)算法。假如我们有如下网格,该模糊算法将每个节点的值重新计算为周围值加上自身后的平均值。

要这样做,我们可以将每个节点周围的值加起来,放到新网格中。
对于边缘情况,可以考虑与边缘的值一致(或者说与“旁边”的值一致)。这样在我们的问题中每个节点加上周围的值都只用除以9。省的考虑每个节点周围具体有多少节点。


当然,大佬提到这样直接相加的效率不佳,于是提到了一种新的计算方法。首先水平方向上遍历每个节点,将其和左右两相邻节点相加。

更骚的是,下一个遍历到的节点的和,是上一个的和,减去上一个的左边,加上当前的右边,这样我们甚至省下了看当前节点的值这一操作。。。

计算完水平的,将得到的值保存下来,然后再竖直方向再遍历一次,将节点和上下两个相邻相加。

采用这种水平竖直的计算方式,得到的是和直接将周围节点相加一样的结果。我们可以很简单的看出:
直接相加周围的计算方式我们遍历了:5 * 5 * 9 + 25 = 250次
采用水平竖直方法我们遍历了:5 * 5 * 2 * 2 + 10 = 110次,牛哇!

接下来要在代码中实现上述模糊算法。

// 创建grid实例
    private void CreateGrid()
    {
        // ... ...
		
		// 网格生成完成以后 我们再进行节点权重模糊
        BlurPenaltyMap(2);
    }

    // 使网格的权重图更平滑
    void BlurPenaltyMap(int blurSize) // blurSize定义我们想要一个节点和多大的面积计算模糊,1就意味着和周围一圈,2就是周围两圈
    {
        // kernelSize就是一个节点的模糊计算涉及多大范围 比如3 X 3范围
        int kernelSize = blurSize * 2 + 1;
        // kernelExtents是只一个kernal的中心到边缘之间有几个节点 比如3 X 3的kernelSize有一个
        int kernelExtents = (kernelSize - 1) / 2;

        // 用于保存水平和竖直的计算结果
        int[,] penaltiesHorizontalPass = new int[gridSizeX, gridSizeY];
        int[,] penaltiesVerticalPass = new int[gridSizeX, gridSizeY];

        // 水平方向的遍历
        for (int y = 0; y < gridSizeY; y++)
        {
            for (int x = -kernelExtents; x <= kernelExtents; x++)
            {
                int sampleX = Mathf.Clamp(x, 0, kernelExtents);
                penaltiesHorizontalPass[0 ,y] += grid[sampleX, y].movementPenalty;
            }

            for (int x = 1; x < gridSizeX; x++)
            {
                int removeIndex = Mathf.Clamp(x - kernelExtents - 1, 0, gridSizeX);
                int addIndex = Mathf.Clamp(x + kernelExtents, 0, gridSizeX - 1);

                penaltiesHorizontalPass[x,y] = penaltiesHorizontalPass[x - 1, y] - grid[removeIndex, y].movementPenalty + grid[addIndex, y].movementPenalty;
            }
        }

        // 竖直方向的遍历
        for (int x = 0; x < gridSizeX; x++)
        {
            for (int y = -kernelExtents; y <= kernelExtents; y++)
            {
                int sampleY = Mathf.Clamp(y, 0, kernelExtents);
                penaltiesVerticalPass[x, 0] += penaltiesHorizontalPass[x, sampleY];
            }

            // 将y = 0时得到的blur penalty赋值给grid
            int blurredPenalty = Mathf.RoundToInt((float)penaltiesVerticalPass[x, 0] / (kernelSize * kernelSize));
            grid[x, 0].movementPenalty = blurredPenalty;

            for (int y = 1; y < gridSizeY; y++)
            {
                int removeIndex = Mathf.Clamp(y - kernelExtents - 1, 0, gridSizeY);
                int addIndex = Mathf.Clamp(y + kernelExtents, 0, gridSizeY - 1);

                penaltiesVerticalPass[x, y] = penaltiesVerticalPass[x, y - 1] - penaltiesHorizontalPass[x, removeIndex] + penaltiesHorizontalPass[x, addIndex];
                // 得到的结果 取个整数近似值
                blurredPenalty = Mathf.RoundToInt((float)penaltiesVerticalPass[x, y] / (kernelSize * kernelSize));
                grid[x,y].movementPenalty = blurredPenalty;

                if(blurredPenalty > penaltyMax)
                    penaltyMax = blurredPenalty;
                if(blurredPenalty < penaltyMin)
                    penaltyMin = blurredPenalty;
            }
        }
    }

效果展示。至少不贴边了,看着不错。

权重可视化的效果。

路径平滑
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/900959.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号