Replace DomainIndex internals with a lookup from HierarchicalPathFinder instead

Teach HierarchicalPathFinder to keep a cache of domain indices, refreshing them only on demand and when invalidated by terrain changes. This provides an accurate and quick determination for checking if paths exist between given locations.

By exposing PathExistsForLocomotor on the IPathFinder interface, we can remove the DomainIndex trait entirely.
This commit is contained in:
RoosterDragon
2022-04-18 20:31:51 +01:00
committed by Matthias Mailänder
parent 5a8f91aa21
commit aef65d353d
15 changed files with 130 additions and 283 deletions

View File

@@ -112,6 +112,14 @@ namespace OpenRA.Mods.Common.Pathfinder
/// </summary>
Dictionary<CPos, List<GraphConnection>> abstractGraph;
/// <summary>
/// The abstract domains are represented here.
/// An abstract node is the key, and a domain index is given.
/// If the domain index of two nodes is equal, a path exists between them (ignoring all blocking actors).
/// If unequal, no path is possible.
/// </summary>
readonly Dictionary<CPos, uint> abstractDomains;
/// <summary>
/// Knows about the abstract nodes within a grid. Can map a local cell to its abstract node.
/// </summary>
@@ -227,20 +235,27 @@ namespace OpenRA.Mods.Common.Pathfinder
BuildGrids();
BuildCostTable();
abstractDomains = new Dictionary<CPos, uint>(gridXs * gridYs);
RebuildDomains();
// When we build the cost table, it depends on the movement costs of the cells at that time.
// When this changes, we must update the cost table.
locomotor.CellCostChanged += RequireCostRefreshInCell;
}
public IReadOnlyDictionary<CPos, List<GraphConnection>> GetOverlayData()
public (
IReadOnlyDictionary<CPos, List<GraphConnection>> AbstractGraph,
IReadOnlyDictionary<CPos, uint> AbstractDomains) GetOverlayData()
{
if (costEstimator == null)
return default;
// Ensure the abstract graph is up to date when using the overlay.
// Ensure the abstract graph and domains are up to date when using the overlay.
RebuildDirtyGrids();
return new ReadOnlyDictionary<CPos, List<GraphConnection>>(abstractGraph);
RebuildDomains();
return (
new ReadOnlyDictionary<CPos, List<GraphConnection>>(abstractGraph),
new ReadOnlyDictionary<CPos, uint>(abstractDomains));
}
/// <summary>
@@ -704,6 +719,31 @@ namespace OpenRA.Mods.Common.Pathfinder
}
}
/// <summary>
/// Determines if a path exists between source and target.
/// Only terrain is taken into account, i.e. as if <see cref="BlockedByActor.None"/> was given.
/// This would apply for any actor using the same <see cref="Locomotor"/> as this <see cref="HierarchicalPathFinder"/>.
/// </summary>
public bool PathExists(CPos source, CPos target)
{
if (costEstimator == null)
return false;
RebuildDomains();
var sourceGridInfo = gridInfos[GridIndex(source)];
var targetGridInfo = gridInfos[GridIndex(target)];
var abstractSource = sourceGridInfo.AbstractCellForLocalCell(source);
if (abstractSource == null)
return false;
var abstractTarget = targetGridInfo.AbstractCellForLocalCell(target);
if (abstractTarget == null)
return false;
var sourceDomain = abstractDomains[abstractSource.Value];
var targetDomain = abstractDomains[abstractTarget.Value];
return sourceDomain == targetDomain;
}
/// <summary>
/// The abstract graph can become out of date when reachability costs for terrain change.
/// When this occurs, we must rebuild any affected parts of the abstract graph so it remains correct.
@@ -713,6 +753,9 @@ namespace OpenRA.Mods.Common.Pathfinder
if (dirtyGridIndexes.Count == 0)
return;
// An empty domain indicates it is out of date and will require rebuilding when next accessed.
abstractDomains.Clear();
var customMovementLayers = world.GetCustomMovementLayers();
foreach (var gridIndex in dirtyGridIndexes)
{
@@ -767,6 +810,48 @@ namespace OpenRA.Mods.Common.Pathfinder
}
}
/// <summary>
/// The abstract domains can become out of date when the abstract graph changes.
/// When this occurs, we must rebuild the domain cache.
/// </summary>
void RebuildDomains()
{
// First, rebuild the abstract graph if it is out of date.
RebuildDirtyGrids();
// Check if our domain cache is empty, if so this indicates it is out-of-date and needs rebuilding.
if (abstractDomains.Count != 0)
return;
List<GraphConnection> AbstractEdge(CPos abstractCell)
{
if (abstractGraph.TryGetValue(abstractCell, out var abstractEdge))
return abstractEdge;
return null;
}
// As in BuildGrid, flood fill the search graph until all disjoint domains are discovered.
var domain = 0u;
var abstractCells = new HashSet<CPos>(abstractGraph.Count);
foreach (var grid in gridInfos)
grid.CopyAbstractCellsInto(abstractCells);
while (abstractCells.Count > 0)
{
var searchCell = abstractCells.First();
var search = PathSearch.ToTargetCellOverGraph(
AbstractEdge,
locomotor,
searchCell,
searchCell,
abstractGraph.Count / 8);
var searched = search.ExpandAll();
foreach (var abstractCell in searched)
abstractDomains.Add(abstractCell, domain);
abstractCells.ExceptWith(searched);
domain++;
}
}
/// <summary>
/// Maps a local cell to a abstract node in the graph.
/// Returns null when the local cell is unreachable.

View File

@@ -28,12 +28,11 @@ namespace OpenRA.Mods.Common.Traits.BotModules.Squads
// Navy squad AI can exploit enemy naval production to find path, if any.
// (Way better than finding a nearest target which is likely to be on Ground)
// You might be tempted to move these lookups into Activate() but that causes null reference exception.
var domainIndex = first.World.WorldActor.Trait<DomainIndex>();
var locomotor = first.Trait<Mobile>().Locomotor;
var mobile = first.Trait<Mobile>();
var navalProductions = owner.World.ActorsHavingTrait<Building>().Where(a
=> owner.SquadManager.Info.NavalProductionTypes.Contains(a.Info.Name)
&& domainIndex.IsPassable(first.Location, a.Location, locomotor)
&& mobile.PathFinder.PathExistsForLocomotor(mobile.Locomotor, first.Location, a.Location)
&& a.AppearsHostileTo(first));
if (navalProductions.Any())

View File

@@ -323,11 +323,6 @@ namespace OpenRA.Mods.Common.Traits
radarSignature[i++] = (c, tileInfo.GetColor(self.World.LocalRandom));
}
// If this bridge repair operation connects two pathfinding domains,
// update the domain index.
var domainIndex = self.World.WorldActor.Trait<DomainIndex>();
domainIndex.UpdateCells(self.World, footprint.Keys);
if (LongBridgeSegmentIsDead() && !killedUnits)
{
killedUnits = true;

View File

@@ -70,8 +70,6 @@ namespace OpenRA.Mods.Common.Traits
{
foreach (var cell in cells)
self.World.Map.CustomTerrain[cell] = terrainIndex;
self.World.WorldActor.Trait<DomainIndex>().UpdateCells(self.World, cells);
}
void INotifyAddedToWorld.AddedToWorld(Actor self)

View File

@@ -25,13 +25,13 @@ namespace OpenRA.Mods.Common.Traits
class ProductionFromMapEdge : Production
{
readonly CPos? spawnLocation;
readonly DomainIndex domainIndex;
readonly IPathFinder pathFinder;
RallyPoint rp;
public ProductionFromMapEdge(ActorInitializer init, ProductionInfo info)
: base(init, info)
{
domainIndex = init.Self.World.WorldActor.Trait<DomainIndex>();
pathFinder = init.Self.World.WorldActor.Trait<IPathFinder>();
var spawnLocationInit = init.GetOrDefault<ProductionSpawnLocationInit>(info);
if (spawnLocationInit != null)
@@ -65,7 +65,7 @@ namespace OpenRA.Mods.Common.Traits
{
var locomotor = self.World.WorldActor.TraitsImplementing<Locomotor>().First(l => l.Info.Name == mobileInfo.Locomotor);
location = self.World.Map.ChooseClosestMatchingEdgeCell(self.Location,
c => mobileInfo.CanEnterCell(self.World, null, c) && domainIndex.IsPassable(c, destinations[0], locomotor));
c => mobileInfo.CanEnterCell(self.World, null, c) && pathFinder.PathExistsForLocomotor(locomotor, c, destinations[0]));
}
}

View File

@@ -1,254 +0,0 @@
#region Copyright & License Information
/*
* Copyright 2007-2022 The OpenRA Developers (see AUTHORS)
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System.Collections.Generic;
using System.Linq;
using OpenRA.Graphics;
using OpenRA.Support;
using OpenRA.Traits;
namespace OpenRA.Mods.Common.Traits
{
[TraitLocation(SystemActors.World)]
[Desc("Identify untraversable regions of the map for faster pathfinding, especially with AI.",
"This trait is required. Every mod needs it attached to the world actor.")]
class DomainIndexInfo : TraitInfo<DomainIndex> { }
public class DomainIndex : IWorldLoaded
{
Dictionary<uint, MovementClassDomainIndex> domainIndexes;
public void WorldLoaded(World world, WorldRenderer wr)
{
domainIndexes = new Dictionary<uint, MovementClassDomainIndex>();
var locomotors = world.WorldActor.TraitsImplementing<Locomotor>().Where(l => !string.IsNullOrEmpty(l.Info.Name));
var movementClasses = locomotors.Select(t => t.MovementClass).Distinct();
foreach (var mc in movementClasses)
domainIndexes[mc] = new MovementClassDomainIndex(world, mc);
}
public bool IsPassable(CPos p1, CPos p2, Locomotor locomotor)
{
// HACK: Work around units in other movement layers from being blocked
// when the point in the main layer is not pathable
if (p1.Layer != 0 || p2.Layer != 0)
return true;
if (locomotor.Info.DisableDomainPassabilityCheck)
return true;
return domainIndexes[locomotor.MovementClass].IsPassable(p1, p2);
}
/// <summary>Regenerate the domain index for a group of cells.</summary>
public void UpdateCells(World world, IEnumerable<CPos> cells)
{
var dirty = cells.ToHashSet();
foreach (var index in domainIndexes)
index.Value.UpdateCells(world, dirty);
}
public void AddFixedConnection(IEnumerable<CPos> cells)
{
foreach (var index in domainIndexes)
index.Value.AddFixedConnection(cells);
}
}
class MovementClassDomainIndex
{
readonly Map map;
readonly uint movementClass;
readonly CellLayer<ushort> domains;
readonly Dictionary<ushort, HashSet<ushort>> transientConnections;
public MovementClassDomainIndex(World world, uint movementClass)
{
map = world.Map;
this.movementClass = movementClass;
domains = new CellLayer<ushort>(world.Map);
transientConnections = new Dictionary<ushort, HashSet<ushort>>();
using (new PerfTimer($"BuildDomains: {world.Map.Title} for movement class {movementClass}"))
BuildDomains(world);
}
public bool IsPassable(CPos p1, CPos p2)
{
if (!domains.Contains(p1) || !domains.Contains(p2))
return false;
if (domains[p1] == domains[p2])
return true;
// Even though p1 and p2 are in different domains, it's possible
// that some dynamic terrain (i.e. bridges) may connect them.
return HasConnection(domains[p1], domains[p2]);
}
public void UpdateCells(World world, HashSet<CPos> dirtyCells)
{
var neighborDomains = new List<ushort>();
foreach (var cell in dirtyCells)
{
// Select all neighbors inside the map boundaries
var thisCell = cell; // benign closure hazard
var neighbors = CVec.Directions.Select(d => d + thisCell)
.Where(c => map.Contains(c));
var found = false;
foreach (var n in neighbors)
{
if (!dirtyCells.Contains(n))
{
var neighborDomain = domains[n];
if (CanTraverseTile(world, n))
{
neighborDomains.Add(neighborDomain);
// Set ourselves to the first non-dirty neighbor we find.
if (!found)
{
domains[cell] = neighborDomain;
found = true;
}
}
}
}
}
foreach (var c1 in neighborDomains)
foreach (var c2 in neighborDomains)
CreateConnection(c1, c2);
}
public void AddFixedConnection(IEnumerable<CPos> cells)
{
// HACK: this is a temporary workaround to add a permanent connection between the domains of the listed cells.
// This is sufficient for fixed point-to-point tunnels, but not for dynamically updating custom layers
// such as destroyable elevated bridges.
// To support those the domain index will need to learn about custom movement layers, but that then requires
// a complete refactor of the domain code to deal with MobileInfo or better a shared pathfinder class type.
var cellDomains = cells.Select(c => domains[c]).ToHashSet();
foreach (var c1 in cellDomains)
foreach (var c2 in cellDomains.Where(c => c != c1))
CreateConnection(c1, c2);
}
bool HasConnection(ushort d1, ushort d2)
{
// Search our connections graph for a possible route
var visited = new HashSet<ushort>();
var toProcess = new Stack<ushort>();
toProcess.Push(d1);
while (toProcess.Count > 0)
{
var current = toProcess.Pop();
if (!transientConnections.ContainsKey(current))
continue;
foreach (var neighbor in transientConnections[current])
{
if (neighbor == d2)
return true;
if (!visited.Contains(neighbor))
toProcess.Push(neighbor);
}
visited.Add(current);
}
return false;
}
void CreateConnection(ushort d1, ushort d2)
{
if (!transientConnections.ContainsKey(d1))
transientConnections[d1] = new HashSet<ushort>();
if (!transientConnections.ContainsKey(d2))
transientConnections[d2] = new HashSet<ushort>();
transientConnections[d1].Add(d2);
transientConnections[d2].Add(d1);
}
bool CanTraverseTile(World world, CPos p)
{
if (!map.Contains(p))
return false;
var terrainOffset = world.Map.GetTerrainIndex(p);
return (movementClass & (1 << terrainOffset)) > 0;
}
void BuildDomains(World world)
{
ushort domain = 1;
var visited = new CellLayer<bool>(map);
var toProcess = new Queue<CPos>();
toProcess.Enqueue(MPos.Zero.ToCPos(map));
// Flood-fill over each domain.
while (toProcess.Count != 0)
{
var start = toProcess.Dequeue();
// Technically redundant with the check in the inner loop, but prevents
// ballooning the domain counter.
if (visited[start])
continue;
var domainQueue = new Queue<CPos>();
domainQueue.Enqueue(start);
var currentPassable = CanTraverseTile(world, start);
// Add all contiguous cells to our domain, and make a note of
// any non-contiguous cells for future domains.
while (domainQueue.Count != 0)
{
var n = domainQueue.Dequeue();
if (visited[n])
continue;
var candidatePassable = CanTraverseTile(world, n);
if (candidatePassable != currentPassable)
{
toProcess.Enqueue(n);
continue;
}
visited[n] = true;
domains[n] = domain;
// PERF: Avoid LINQ.
foreach (var direction in CVec.Directions)
{
// Don't crawl off the map, or add already-visited cells.
var neighbor = direction + n;
if (visited.Contains(neighbor) && !visited[neighbor])
domainQueue.Enqueue(neighbor);
}
}
domain += 1;
}
Log.Write("debug", "Found {0} domains for movement class {1} on map {2}.", domain - 1, movementClass, map.Title);
}
}
}

View File

@@ -17,7 +17,7 @@ using OpenRA.Traits;
namespace OpenRA.Mods.Common.Traits
{
[TraitLocation(SystemActors.World)]
public class ElevatedBridgeLayerInfo : TraitInfo, Requires<DomainIndexInfo>, ILobbyCustomRulesIgnore, ICustomMovementLayerInfo
public class ElevatedBridgeLayerInfo : TraitInfo, ILobbyCustomRulesIgnore, ICustomMovementLayerInfo
{
[Desc("Terrain type used by cells outside any elevated bridge footprint.")]
public readonly string ImpassableTerrainType = "Impassable";
@@ -44,7 +44,6 @@ namespace OpenRA.Mods.Common.Traits
public void WorldLoaded(World world, WorldRenderer wr)
{
var domainIndex = world.WorldActor.Trait<DomainIndex>();
var cellHeight = world.Map.CellHeightStep.Length;
foreach (var tti in world.WorldActor.Info.TraitInfos<ElevatedBridgePlaceholderInfo>())
{
@@ -61,7 +60,6 @@ namespace OpenRA.Mods.Common.Traits
}
var end = tti.EndCells();
domainIndex.AddFixedConnection(end);
foreach (var c in end)
{
// Need to explicitly set both default and tunnel layers, otherwise the .Contains check will fail

View File

@@ -88,10 +88,10 @@ namespace OpenRA.Mods.Common.Traits
: new[] { Locomotor };
foreach (var locomotor in locomotors)
{
var abstractGraph = pathFinder.GetOverlayDataForLocomotor(locomotor);
var (abstractGraph, abstractDomains) = pathFinder.GetOverlayDataForLocomotor(locomotor);
// Locomotor doesn't allow movement, nothing to display.
if (abstractGraph == null)
if (abstractGraph == null || abstractDomains == null)
continue;
foreach (var connectionsFromOneNode in abstractGraph)
@@ -129,6 +129,19 @@ namespace OpenRA.Mods.Common.Traits
yield return new TextAnnotationRenderable(font, centerPos, 0, lineColor, cost.Cost.ToString());
}
}
foreach (var domainForCell in abstractDomains)
{
var nodeCell = domainForCell.Key;
var srcUv = (PPos)nodeCell.ToMPos(self.World.Map);
if (!visibleRegion.Contains(srcUv))
continue;
// Show the abstract cell and its domain index.
var nodePos = self.World.Map.CenterOfSubCell(nodeCell, SubCell.FullCell);
yield return new TextAnnotationRenderable(
font, nodePos, 0, info.AbstractNodeColor, $"{domainForCell.Value}: {nodeCell}");
}
}
}

View File

@@ -46,7 +46,9 @@ namespace OpenRA.Mods.Common.Traits
world = self.World;
}
public IReadOnlyDictionary<CPos, List<GraphConnection>> GetOverlayDataForLocomotor(Locomotor locomotor)
public (
IReadOnlyDictionary<CPos, List<GraphConnection>> AbstractGraph,
IReadOnlyDictionary<CPos, uint> AbstractDomains) GetOverlayDataForLocomotor(Locomotor locomotor)
{
return hierarchicalPathFindersByLocomotor[locomotor].GetOverlayData();
}
@@ -133,6 +135,16 @@ namespace OpenRA.Mods.Common.Traits
return search.FindPath();
}
/// <summary>
/// Determines if a path exists between source and target.
/// Only terrain is taken into account, i.e. as if <see cref="BlockedByActor.None"/> was given.
/// This would apply for any actor using the given <see cref="Locomotor"/>.
/// </summary>
public bool PathExistsForLocomotor(Locomotor locomotor, CPos source, CPos target)
{
return hierarchicalPathFindersByLocomotor[locomotor].PathExists(source, target);
}
static Locomotor GetActorLocomotor(Actor self)
{
// PERF: This PathFinder trait requires the use of Mobile, so we can be sure that is in use.

View File

@@ -17,7 +17,7 @@ using OpenRA.Traits;
namespace OpenRA.Mods.Common.Traits
{
[TraitLocation(SystemActors.World)]
public class TerrainTunnelLayerInfo : TraitInfo, Requires<DomainIndexInfo>, ILobbyCustomRulesIgnore, ICustomMovementLayerInfo
public class TerrainTunnelLayerInfo : TraitInfo, ILobbyCustomRulesIgnore, ICustomMovementLayerInfo
{
[Desc("Terrain type used by cells outside any tunnel footprint.")]
public readonly string ImpassableTerrainType = "Impassable";
@@ -43,7 +43,6 @@ namespace OpenRA.Mods.Common.Traits
public void WorldLoaded(World world, WorldRenderer wr)
{
var domainIndex = world.WorldActor.Trait<DomainIndex>();
var cellHeight = world.Map.CellHeightStep.Length;
foreach (var tti in world.WorldActor.Info.TraitInfos<TerrainTunnelInfo>())
{
@@ -60,7 +59,6 @@ namespace OpenRA.Mods.Common.Traits
}
var portal = tti.PortalCells();
domainIndex.AddFixedConnection(portal);
foreach (var c in portal)
{
// Need to explicitly set both default and tunnel layers, otherwise the .Contains check will fail

View File

@@ -815,5 +815,12 @@ namespace OpenRA.Mods.Common.Traits
Func<CPos, int> customCost = null,
Actor ignoreActor = null,
bool laneBias = true);
/// <summary>
/// Determines if a path exists between source and target.
/// Only terrain is taken into account, i.e. as if <see cref="BlockedByActor.None"/> was given.
/// This would apply for any actor using the given <see cref="Locomotor"/>.
/// </summary>
bool PathExistsForLocomotor(Locomotor locomotor, CPos source, CPos target);
}
}

View File

@@ -162,7 +162,6 @@ World:
Bridges: bridge1, bridge2, bridge3, bridge4
ProductionQueueFromSelection:
ProductionTabsWidget: PRODUCTION_TABS
DomainIndex:
SmudgeLayer@SCORCH:
Type: Scorch
Sequence: scorches

View File

@@ -137,7 +137,6 @@ World:
ValidGround: Sand, Rock, Transition, Spice, SpiceSand, Dune, Concrete
InitialSpawnDelay: 1500
CheckboxDisplayOrder: 1
DomainIndex:
WarheadDebugOverlay:
BuildableTerrainLayer:
ResourceLayer:

View File

@@ -184,7 +184,6 @@ World:
WaterChance: 20
InitialSpawnDelay: 1500
CheckboxDisplayOrder: 1
DomainIndex:
SmudgeLayer@SCORCH:
Type: Scorch
Sequence: scorches

View File

@@ -241,7 +241,6 @@ World:
BuildingInfluence:
ProductionQueueFromSelection:
ProductionPaletteWidget: PRODUCTION_PALETTE
DomainIndex:
SmudgeLayer@SMALLSCORCH:
Type: SmallScorch
Sequence: smallscorches