A* pathfinding finds different paths based on directions

Hello,

I’ve tried to find a reason why my pathfinding finds so completely different paths based on going left or right:


The cost are the same: 10 tiles, so technically it’s not wrong. Map size doesn’t matter either

As I understands it, the code looks for the neighbour with the lowest “fCost” which in both cases are 2 different “Tiles” aka. “Nodes”. It chooses the lates neighbour Node that it checks the fCost on.

I got no clue how to approach this problem and all suggestions are welcome!

Code in spoiler

Code

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

public class AStarPathfinding
{
    public List<Tile> theTilePath = new List<Tile>();

    /// <summary>
    /// Find path between the the Start Tile and Target Tile
    /// </summary>
    /// <param name="_startTile"></param>
    /// <param name="_targetTile"></param>
    public void FindPath(Tile _startTile, Tile _targetTile)
    {
        Tile startTile = _startTile;
        Tile targetTile = _targetTile;

        List<Tile> openSet = new List<Tile>();
        HashSet<Tile> closedSet = new HashSet<Tile>();

        openSet.Add(startTile);
        int triesToFindTile = 0;
       
        while (openSet.Count > 0)
        {
            triesToFindTile++;
            if (triesToFindTile > 1000)
            {
                Debug.LogError("Cannot generate a path or target tile is unaccessible");
                break;
            }

            Tile currentTile = openSet[0];
            for (int i = 1; i < openSet.Count; i++)// Skips this on the first run, we already know our current tile
            {
                if (openSet[i].fCost < currentTile.fCost || openSet[i].fCost == currentTile.fCost)
                {
                    if (openSet[i].hCost < currentTile.hCost)
                    {
                        currentTile = openSet[i];
                    }

                }
            }
            openSet.Remove(currentTile);
            

            closedSet.Add(currentTile);

            if (currentTile.CubeCoord == targetTile.CubeCoord)
            {
                RetracePath(startTile, currentTile);
                return;
            }
            foreach (Tile neighbour in currentTile.neighbours)
            {
                TileType kindofTile = neighbour.TileType;
                

                if (!kindofTile.isMovable || closedSet.Contains(neighbour) || neighbour.isOccupied)
                {
                    continue;
                }

                float newMovementCostToNeighbour = currentTile.gCost + GetDistance(currentTile, neighbour);

                if (newMovementCostToNeighbour < neighbour.gCost || !openSet.Contains(neighbour))
                {
                    neighbour.gCost = newMovementCostToNeighbour + kindofTile.movementCost;
                    neighbour.hCost = GetDistance(neighbour, targetTile);
                    neighbour.parent = currentTile;

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

            triesToFindTile++;
        }
    }

    bool CheckForIdenticalGcost(Tile neighbour, List<Tile> openset)
    {
        bool check = false;

        foreach (Tile neigh in openset)
        {
            if (neigh.gCost == neighbour.gCost && neigh.hCost == neighbour.hCost)
            {
                check = true;
            }
        }
        return check;
    }

    void RetracePath(Tile startTile, Tile endTile)
    {
        List<Tile> path = new List<Tile>();

        Tile newCurrentTile = endTile;

        while (newCurrentTile != startTile)
        {
            path.Add(newCurrentTile);
            newCurrentTile = newCurrentTile.parent;
        }

        path.Reverse();

        theTilePath = path;
    }

    public int GetDistance(Tile A, Tile B)
    {
        int distance = (int)(Mathf.Abs(A.CubeCoord.x - B.CubeCoord.x) + Mathf.Abs(A.CubeCoord.y - B.CubeCoord.y) + Mathf.Abs(A.CubeCoord.z - B.CubeCoord.z)) / 2;
        return distance;
    }

}

I mean… what else do you want? Computers are the absolute masters of being technically correct.

If you’re looking for it to take a more straight-looking route, then that parameter needs to be programmed into the algorithm. All it does is attempt to find the shortest distance, and it’s done that correctly.

The reason they can look different is simply that the algorithm is going to stop looking once it finds a route, and the order in which it checks tiles to find a route can be pretty arbitrary.

1 Like

I expect it’s just a natural quirk of the way your doing things. If the next 2 tiles have the same F cost & h score then which one gets selected will be whichever one is earlier in that array and thus found earlier by your loop, and which one is earlier in the array will be whichever one was earlier in the list produced by tile.neighbours. This makes which option of 2 equally costly moves is taken fairly arbitrary, and would likely produce reverse results when traveling in opposite directions.

I think I wasn’t making myself clear. I am full aware and happy that it’s all logical but I cannot be the only one that’s facing this specific task of making it look better. That’s why I’m open to all suggestions.

So first off. The problem as previously pointed out is because the path finds the ‘first’ of the shortest paths. There can be multiple paths with the same cost, we just need to find the ‘first’ one.

You don’t like what’s the first path, since it’s arbitrary.

So to resolve your problem, you need figure out what parameters cause the first, and adjust.

And that has to do with 2 things, your sort algorithm, and the order in which neighbours are found.

Noting that your ‘neighbours’ enumeration is going to grab them in the same order every time… so if say it started at 12 o’clock and returned all neighbours counter-clockwise. When heading left the first shortest path found is the upper left corner, but when heading right it’s the lower right corner.

So lets get into your algorithm… first off this sort algorithm:

            for (int i = 1; i < openSet.Count; i++)// Skips this on the first run, we already know our current tile
            {
                if (openSet[i].fCost < currentTile.fCost || openSet[i].fCost == currentTile.fCost)
                {
                    if (openSet[i].hCost < currentTile.hCost)
                    {
                        currentTile = openSet[i];
                    }

                }
            }

this isn’t necessarily a bug, this here is just… weird:

openSet[i].fCost < currentTile.fCost || openSet[i].fCost == currentTile.fCost

There is a less than or equal operator that does both steps in one:

openSet[i].fCost <= currentTile.fCost

Next up is this:

if (openSet[i].hCost < currentTile.hCost)

Now I don’t know how you implement your ‘fCost’ property… it doesn’t appear to be a field since you don’t ever set it. So is it a property getter that sums g and h together?

However it is, f should be g + h. Which means the previous f cost comparison should already take care of this. No need to compare the h cost again.

Next I want to get on your heuristic:

    public int GetDistance(Tile A, Tile B)
    {
        int distance = (int)(Mathf.Abs(A.CubeCoord.x - B.CubeCoord.x) + Mathf.Abs(A.CubeCoord.y - B.CubeCoord.y) + Mathf.Abs(A.CubeCoord.z - B.CubeCoord.z)) / 2;
        return distance;
    }

So this appears to be a halved manhattan distance? Not sure what a CubeCoord is, so not sure what implications the z element makes. If that relates to height, or somethign completely different.

The thing I’m wondering though is why is it an int?

Especially since you halve it…

This loss of fractional granularity can make 2 near nodes appear identical distances rather than one being closer than the other. Not sure the scale of your nodes, but since they’re hexagonal, I’m fairly certain you get a lot of fractional bits which you’re just tossing away.

Might want to change that to a float.

Also… is the g and f cost int as well? Probably shouldn’t be either.

Up to this point this should resolve a fair amount of your issues. It should definitely help in removing so much arc from the right picture you posted.

If it’s not quite what you want though, you have more options.

You can change up your heuristic. Currently the halved manhattan heuristic you use already will get a huge bump if changed to floats. But it’s still not perfect. You could change to a squared distance heuristic (essentially the distance formula without performing the costly squareroot), or the distance formula direct. Both of which will result in far more accurate heuristics.

You can apply post smoothing to your path. Basically you can average out your nodes using various algorithms to create a less jaggy path… downside being that it reduces accuracy from a node to node basis.

now for some optimization tips

Your sort algorithm is slow.

Personally I use a BinaryHeap:

The comparer I add to it (that sorts the heap) compares the f cost (you could make it compare even more).

As you can see here in my Astar implementation (the stepped portion of the code removed, but exists on the github):

using System;
using System.Collections.Generic;
using System.Linq;

using com.spacepuppy.Collections;

namespace com.spacepuppy.Graphs
{

    public class AStarPathResolver<T> : ISteppingPathResolver<T> where T : class
    {

        #region Fields

        private IGraph<T> _graph;
        private IHeuristic<T> _heuristic;

        private BinaryHeap<VertexInfo> _open;
        private HashSet<T> _closed = new HashSet<T>();
        private HashSet<VertexInfo> _tracked = new HashSet<VertexInfo>();
        private List<T> _neighbours = new List<T>();

        private T _start;
        private T _goal;

        private bool _calculating;

        #endregion

        #region CONSTRUCTOR

        public AStarPathResolver(IGraph<T> graph, IHeuristic<T> heuristic)
        {
            _graph = graph;
            _heuristic = heuristic;
            _open = new BinaryHeap<VertexInfo>(graph.Count, VertexComparer.Default);
        }
      
        #endregion

        #region Properties

        public bool IsWorking
        {
            get { return _calculating; }
        }

        #endregion

        #region IPathResolver Interface

        public T Start
        {
            get { return _goal; }
            set
            {
                if (_calculating) throw new InvalidOperationException("Cannot update start node when calculating.");
                _goal = value;
            }
        }

        public T Goal
        {
            get { return _start; }
            set
            {
                if (_calculating) throw new InvalidOperationException("Cannot update goal node when calculating.");
                _start = value;
            }
        }

        public IList<T> Reduce()
        {
            if (_calculating) throw new InvalidOperationException("PathResolver is already running.");
            if (_graph == null || _heuristic == null || _start == null || _goal == null) throw new InvalidOperationException("PathResolver is not initialized.");

            var lst = new List<T>();
            this.Reduce(lst);
            return lst;
        }

        public int Reduce(IList<T> path)
        {
            if (_calculating) throw new InvalidOperationException("PathResolver is already running.");
            if (_graph == null || _heuristic == null || _start == null || _goal == null) throw new InvalidOperationException("PathResolver is not initialized.");

            this.Reset();
            _calculating = true;
          
            try
            {
                _open.Add(this.CreateInfo(_start, _heuristic.Weight(_start), _goal));

                while (_open.Count > 0)
                {
                    var u = _open.Pop();

                    if (u.Node == _goal)
                    {
                        int cnt = 0;
                        while (u.Next != null)
                        {
                            path.Add(u.Node);
                            u = u.Next;
                            cnt++;
                        }
                        path.Add(u.Node);
                        return cnt + 1;
                    }

                    _closed.Add(u.Node);

                    _graph.GetNeighbours(u.Node, _neighbours);
                    var e = _neighbours.GetEnumerator();
                    while(e.MoveNext())
                    {
                        var n = e.Current;
                        if (_closed.Contains(n)) continue;

                        float g = u.g + _heuristic.Distance(u.Node, n) + _heuristic.Weight(n);

                        int i = GetInfo(_open, n);
                        if (i < 0)
                        {
                            var v = this.CreateInfo(n, g, _goal);
                            v.Next = u;
                            _open.Add(v);
                        }
                        else if (g < _open[i].g)
                        {
                            var v = _open[i];
                            v.Next = u;
                            v.g = g;
                            v.f = g + v.h;
                            _open.Update(i);
                        }
                    }
                    _neighbours.Clear();
                }

            }
            finally
            {
                this.Reset();
            }

            return 0;
        }
      
        private VertexInfo CreateInfo(T node, float g, T goal)
        {
            var v = _pool.GetInstance();
            v.Node = node;
            v.Next = null;
            v.g = g;
            v.h = _heuristic.Distance(node, goal);
            v.f = g + v.h;
            _tracked.Add(v);
            return v;
        }

        private static int GetInfo(BinaryHeap<VertexInfo> heap, T node)
        {
            for (int i = 0; i < heap.Count; i++)
            {
                if (heap[i].Node == node) return i;
            }
            return -1;
        }

        public void Reset()
        {
            _steppedCompletedParentNode = null;

            if(_tracked.Count > 0)
            {
                var e = _tracked.GetEnumerator();
                while (e.MoveNext())
                {
                    _pool.Release(e.Current);
                }
            }
            _open.Clear();
            _closed.Clear();
            _tracked.Clear();
            _calculating = false;
        }

        #endregion

        #region Special Types

        private static ObjectCachePool<VertexInfo> _pool = new ObjectCachePool<VertexInfo>(-1, () => new VertexInfo(), (v) =>
        {
            v.Node = null;
            v.Next = null;
            v.g = 0f;
            v.h = 0f;
            v.f = 0f;
        });

        private class VertexInfo
        {
            public T Node;
            public VertexInfo Next;
            public float g;
            public float h;
            public float f;
        }

        private class VertexComparer : IComparer<VertexInfo>
        {
            public readonly static VertexComparer Default = new VertexComparer();

            public int Compare(VertexInfo x, VertexInfo y)
            {
                return y.f.CompareTo(x.f);
            }
        }

        #endregion

    }
}

Note the comparer used:

        private class VertexComparer : IComparer<VertexInfo>
        {
            public readonly static VertexComparer Default = new VertexComparer();

            public int Compare(VertexInfo x, VertexInfo y)
            {
                return y.f.CompareTo(x.f);
            }
        }

The HashSet you used for the closedSet is spot on. Nice fast ‘contains’ checks there.

I also shoot for allowing the calling code to pass in its own collection to add to… it allows for easy recycling.

Also this portion of your code here:

                if (!kindofTile.isMovable || closedSet.Contains(neighbour) || neighbour.isOccupied)
                {
                    continue;
                }

So you may notice that in my code I do something like this when calculating the g cost:

float g = u.g + _heuristic.Distance(u.Node, n) + _heuristic.Weight(n);

Weight is a nice addition you can add to a node.

You can put a weight on a node to say it’s less desirable to walk on. Lets say you have a road, then thick grassy area to the side, and a swamp beyond that. The road might have a Weight of 0, the grass a 10, and the swamp a 50. This would allow the pathfinder to prefer the road even if the way through the swamp is technically shorter. But because the swamp is a pain in the butt to walk through (maybe you slow their speed, or there is attack rates higher in there), they would rather walk around the swamp… unless it’s the only path allowed.

Well you might be asking what about ‘movable/occupied’… such as tiles that can’t be walked whatsoever.

Use very large numbers for weight, upto and including PositiveInfinity.

Your sort algorithm is always going to favor a path that doesn’t take very large weight.

BUT, what if you want to know for 100% that a path exists… it just happens that someone is in your way. Allowing for weights, it’ll last ditch use the path through the high weighted node, but when it returns as completed, you know that your path could get there, but is just blocked by something, because the cost to get their is so high.

2 Likes

This is an awesome reply and I am truly grateful for your time spent on this!
I will be going over all the great feedback and suggestions during the day.

Thank you so much @lordofduct ! I don’t know how else I can thank you for your time!

Okay, I think I’ve come to the point where I’m satisfied.

What I did first was taking @lordofduct suggestions and changing the poorly written code:
On line 35: Changed the double fCost statement.
on line 39. Removed hCost comparison so I got this. fCost is calculated by adding the Vector or Nodes g & h cost
This is the result:

            Tile currentTile = openSet[0];
            for (int i = 1; i < openSet.Count; i++)
            {
                if (openSet[i].fCost <= currentTile.fCost)
                {
                    currentTile = openSet[i];
                }
            }

Further I looked at the Getdistance() method on line 119. Of course, as lordofduct pointed out, it makes so much more sense to use float instead of integers so I changed that.

The method is using Cube Coordinates, read more about it here: Hexagonal Grids

The manhattan distance worked fine when calculating to the closest neighbour but when calculating the hCost (Distance to target/endgoal) for the neighbour-tiles on line 71:

neighbour.hCost = GetDistance(neighbour, targetTile);

It makes more sense to use a straight path comparison. “Is this tile actually closer to the target than the other tile?”

I simply calculate the distance between the two points directly by using this:

    public float GetDistanceSqrt(Tile A, Tile B)
    {
        float distance = (A.CubeCoord - B.CubeCoord).sqrMagnitude;
        return distance;
    }

This is not the fastest way but it will work perfectly for my small game where the tile-count never will be over 300 tiles.

To continue, I’m still looking into / learning the optimization part and how to implement BinaryHeap which sounds interesting!

1 Like