Yet another implementation of A* pathfinding. It is focused on:
- Performance (both speed and memory allocations).
- Readability and simplicity.
- Well defined objects and methods.
- Accordance with general conventions (naming, signatures, class structure, design principles etc).
Path is calculated on 2D grid using integer vectors:
public interface IPath
{
IReadOnlyCollection<Vector2Int> Calculate(Vector2Int start, Vector2Int target, IReadOnlyCollection<Vector2Int> obstacles);
}
First, I'll define Vector2Int. It's pretty straightforward:
namespace AI.A_Star
{
public readonly struct Vector2Int : IEquatable<Vector2Int>
{
private static readonly float Sqr = (float) Math.Sqrt(2);
public Vector2Int(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
/// <summary>
/// Estimated path distance without obstacles.
/// </summary>
public float DistanceEstimate()
{
int linearSteps = Math.Abs(Y - X);
int diagonalSteps = Math.Max(Math.Abs(Y), Math.Abs(X)) - linearSteps;
return linearSteps + Sqr * diagonalSteps;
}
public static Vector2Int operator +(Vector2Int a, Vector2Int b) => new Vector2Int(a.X + b.X, a.Y + b.Y);
public static Vector2Int operator -(Vector2Int a, Vector2Int b) => new Vector2Int(a.X - b.X, a.Y - b.Y);
public static bool operator ==(Vector2Int a, Vector2Int b) => a.X == b.X && a.Y == b.Y;
public static bool operator !=(Vector2Int a, Vector2Int b) => !(a == b);
public bool Equals(Vector2Int other)
=> X == other.X && Y == other.Y;
public override bool Equals(object obj)
{
if (!(obj is Vector2Int))
return false;
var other = (Vector2Int) obj;
return X == other.X && Y == other.Y;
}
public override int GetHashCode()
=> HashCode.Combine(X, Y);
public override string ToString()
=> $"({X}, {Y})";
}
}
IEquatable interface is implemented for future optimizations. Sqr value is cached because there is no need to calculate it more than once.
DistanceEstimate() used for heuristic cost calculation. It is more accurate than Math.Abs(X) + Math.Abs(Y) version, which overestimates diagonal cost.
Next: PathNode which represents single location on grid:
namespace AI.A_Star
{
internal interface IPathNode
{
Vector2Int Position { get; }
[CanBeNull] IPathNode Parent { get; }
float TraverseDistance { get; }
float HeuristicDistance { get; }
float EstimatedTotalCost { get; }
}
internal readonly struct PathNode : IPathNode
{
public PathNode(Vector2Int position, float traverseDistance, float heuristicDistance, [CanBeNull] IPathNode parent)
{
Position = position;
TraverseDistance = traverseDistance;
HeuristicDistance = heuristicDistance;
Parent = parent;
}
public Vector2Int Position { get; }
public IPathNode Parent { get; }
public float TraverseDistance { get; }
public float HeuristicDistance { get; }
public float EstimatedTotalCost => TraverseDistance + HeuristicDistance;
}
}
PathNode is defined as struct: there will be a lot of node creation. However, it has to include a reference to it's parent, so I'm using IPathNode interface to avoid cycle inside the struct.
Next: creator of Node neighbours:
namespace AI.A_Star
{
internal class PathNodeNeighbours
{
private static readonly (Vector2Int position, float cost)[] NeighboursTemplate = {
(new Vector2Int(1, 0), 1),
(new Vector2Int(0, 1), 1),
(new Vector2Int(-1, 0), 1),
(new Vector2Int(0, -1), 1),
(new Vector2Int(1, 1), (float) Math.Sqrt(2)),
(new Vector2Int(1, -1), (float) Math.Sqrt(2)),
(new Vector2Int(-1, 1), (float) Math.Sqrt(2)),
(new Vector2Int(-1, -1), (float) Math.Sqrt(2))
};
private readonly PathNode[] buffer = new PathNode[NeighboursTemplate.Length];
public PathNode[] FillAdjacentNodesNonAlloc(IPathNode parent, Vector2Int target)
{
var i = 0;
foreach ((Vector2Int position, float cost) in NeighboursTemplate)
{
Vector2Int nodePosition = position + parent.Position;
float traverseDistance = parent.TraverseDistance + cost;
float heuristicDistance = (nodePosition - target).DistanceEstimate();
buffer[i++] = new PathNode(nodePosition, traverseDistance, heuristicDistance, parent);
}
return buffer;
}
}
}
Another straightforward class, which simply creates neighboring Nodes around the parent on the grid (including diagonal ones). It uses array buffer, avoiding creation of unnecessary collections.
Code didn't seem quite right inside PathNode struct or inside Path class. It felt like minor SRP violation - so I moved it to separate class.
Now, the interesting one:
namespace AI.A_Star
{
public class Path : IPath
{
private readonly PathNodeNeighbours neighbours = new PathNodeNeighbours();
private readonly int maxSteps;
private readonly SortedSet<PathNode> frontier = new SortedSet<PathNode>(Comparer<PathNode>.Create((a, b) => a.EstimatedTotalCost.CompareTo(b.EstimatedTotalCost)));
private readonly HashSet<Vector2Int> ignoredPositions = new HashSet<Vector2Int>();
private readonly List<Vector2Int> output = new List<Vector2Int>();
public Path(int maxSteps)
{
this.maxSteps = maxSteps;
}
public IReadOnlyCollection<Vector2Int> Calculate(Vector2Int start, Vector2Int target, IReadOnlyCollection<Vector2Int> obstacles)
{
if (!TryGetPathNodes(start, target, obstacles, out IPathNode node))
return Array.Empty<Vector2Int>();
output.Clear();
while (node != null)
{
output.Add(node.Position);
node = node.Parent;
}
return output.AsReadOnly();
}
private bool TryGetPathNodes(Vector2Int start, Vector2Int target, IReadOnlyCollection<Vector2Int> obstacles, out IPathNode node)
{
frontier.Clear();
ignoredPositions.Clear();
frontier.Add(new PathNode(start, 0, 0, null));
ignoredPositions.UnionWith(obstacles);
var step = 0;
while (frontier.Count > 0 && ++step <= maxSteps)
{
PathNode current = frontier.Min;
if (current.Position.Equals(target))
{
node = current;
return true;
}
ignoredPositions.Add(current.Position);
frontier.Remove(current);
GenerateFrontierNodes(current, target);
}
// All nodes analyzed - no path detected.
node = default;
return false;
}
private void GenerateFrontierNodes(PathNode parent, Vector2Int target)
{
// Get adjacent positions and remove already checked.
var nodes = neighbours.FillAdjacentNodesNonAlloc(parent, target);
foreach(PathNode newNode in nodes)
{
// Position is already checked or occupied by an obstacle.
if (ignoredPositions.Contains(newNode.Position))
continue;
// Node is not present in queue.
if (!frontier.TryGetValue(newNode, out PathNode existingNode))
frontier.Add(newNode);
// Node is present in queue and new optimal path is detected.
else if (newNode.TraverseDistance < existingNode.TraverseDistance)
{
frontier.Remove(existingNode);
frontier.Add(newNode);
}
}
}
}
}
Collections are defined inside class body, not inside methods: this way in subsequent calculations there will be no need in collection creation and resizing (assuming calculated paths are always have somewhat same length).
SortedSet and HashSet allows calculation to complete 150-200 times faster; List usage is miserably slow.
TryGetPathNodes() returns child node as out parameter; Calculate() iterates through all node's parents and returns collection of their positions.
I'm really uncertain about following things:
PathNodestruct containsIPathNodereference. It doesn't seem normal at all.The rule of thumb, never return reference to mutable collection. However,
PathNodeNeighboursclass returns original array buffer itself instead of it's copy. Is that tolerable behavior forinternalclasses (which are expected to be used in one single place)? Or it is always preferable to provide external buffer and fill it viaCopyTo()? I'd prefer to keep classes as clean as possible, without multiple 'temporary' arrays.85% of memory allocations are happening inside
GenerateFrontierNodes()method. Half of that caused bySortedSet.Add()method. Nothing I can do there?Boxing from value
PathNodeto referenceIPathNodecauses another half of allocations. But makingPathNodea class instead of struct makes things worse! There are thousands ofPathNode's! And I have to provide a reference to a parent to each node: otherwise there will be no way to track final path through nodes.
Are there any poor solutions used in my pathfinding algorithm? Are there potential improvements in performance to achieve? How can I further improve readability?