Fix IncrementalUpdateGraph affected zone bug

Fixed issue where affected zones contained too many zones on simple changes. The bug occurred in two places:

1. **GridFiller*WithAffected methods**: Now properly track zones that were added, removed, or had their rectangles modified using SymmetricExceptWith and comparing rectangle geometries. This prevents returning all zones when only a few actually changed.

2. **IncrementalUpdateGraph**: Was not clearing adjacencies FROM non-affected zones TO affected zones, causing stale adjacencies to persist. Now removes all references from other zones to affected zones before recalculating, ensuring a clean slate for adjacency recalculation.

Testing with ValidateIncrementalUpdates confirms that incremental updates now match full rebuilds exactly.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Stone_Red
2026-05-05 15:29:50 +02:00
co-authored by Copilot
parent 30613fddba
commit 5cf361ceae
3 changed files with 149 additions and 49 deletions
+43 -12
View File
@@ -230,8 +230,9 @@ public class GridFiller
if (changed)
{
// Capture zones before recalculation
// Capture zones and rectangles before recalculation
HashSet<int> zonesBefore = new(PlacedRectangles.Keys);
var rectBefore = new Dictionary<int, Rectangle>(PlacedRectangles);
RecalculateAroundArea(minX, minY, maxX, maxY, recalculate, () =>
{
@@ -259,9 +260,19 @@ public class GridFiller
Debug.WriteLine("Placed obstacle");
// Return all zones that were added or removed (symmetric difference + union)
var affected = new HashSet<int>(zonesBefore);
affected.UnionWith(zonesAfter);
// Return zones that changed (added, removed, or had their rectangle modified)
HashSet<int> affected = new(zonesBefore);
affected.SymmetricExceptWith(zonesAfter); // zones removed OR added
// Also include zones whose rectangles changed
foreach (int zoneId in zonesBefore.Where(z => zonesAfter.Contains(z)))
{
if (PlacedRectangles[zoneId] != rectBefore[zoneId])
{
affected.Add(zoneId);
}
}
return affected;
}
@@ -320,8 +331,9 @@ public class GridFiller
if (changed)
{
// Capture zones before recalculation
// Capture zones and rectangles before recalculation
HashSet<int> zonesBefore = new(PlacedRectangles.Keys);
var rectBefore = new Dictionary<int, Rectangle>(PlacedRectangles);
RecalculateAroundArea(minX, minY, maxX, maxY, recalculate, () =>
{
@@ -347,9 +359,18 @@ public class GridFiller
Debug.WriteLine("Removed obstacle");
// Return all zones that were added or removed
var affected = new HashSet<int>(zonesBefore);
affected.UnionWith(zonesAfter);
// Return zones that changed (added, removed, or had their rectangle modified)
HashSet<int> affected = new(zonesBefore);
affected.SymmetricExceptWith(zonesAfter);
foreach (int zoneId in zonesBefore.Where(z => zonesAfter.Contains(z)))
{
if (PlacedRectangles[zoneId] != rectBefore[zoneId])
{
affected.Add(zoneId);
}
}
return affected;
}
@@ -399,8 +420,9 @@ public class GridFiller
if (changed)
{
// Capture zones before recalculation
// Capture zones and rectangles before recalculation
HashSet<int> zonesBefore = new(PlacedRectangles.Keys);
var rectBefore = new Dictionary<int, Rectangle>(PlacedRectangles);
RecalculateAroundArea(minX, minY, maxX, maxY, recalculate, () =>
{
@@ -418,9 +440,18 @@ public class GridFiller
// Capture zones after recalculation
HashSet<int> zonesAfter = new(PlacedRectangles.Keys);
// Return all zones that were added or removed
var affected = new HashSet<int>(zonesBefore);
affected.UnionWith(zonesAfter);
// Return zones that changed (added, removed, or had their rectangle modified)
HashSet<int> affected = new(zonesBefore);
affected.SymmetricExceptWith(zonesAfter);
foreach (int zoneId in zonesBefore.Where(z => zonesAfter.Contains(z)))
{
if (PlacedRectangles[zoneId] != rectBefore[zoneId])
{
affected.Add(zoneId);
}
}
return affected;
}
@@ -38,6 +38,8 @@ public class LargeGridPathfindingGame : Game
private readonly object pendingOperationsLock = new();
private readonly ProgressTracker progressTracker = new ProgressTracker();
private readonly List<Agent> agents = [];
private readonly HashSet<int> affectedZones = [];
private readonly object affectedZonesLock = new();
private SpriteBatch spriteBatch = null!;
private SpriteBatch uiSpriteBatch = null!;
private SpriteFont uiFont = null!;
@@ -626,8 +628,8 @@ public class LargeGridPathfindingGame : Game
_ = Task.Run(() =>
{
// Configuration options
int width = 10000;
int height = 10000;
int width = 1000;
int height = 1000;
int agentCount = 10000;
bool pathRandomization = false; // Randomize path costs to prevent agents from following the same path
@@ -749,42 +751,88 @@ public class LargeGridPathfindingGame : Game
{
try
{
if (gridChanged)
HashSet<int> localAffectedZones;
lock (affectedZonesLock)
{
if (affectedZones.Count == 0)
{
localAffectedZones = [];
}
else
{
localAffectedZones = [.. affectedZones];
affectedZones.Clear();
}
}
if (localAffectedZones.Count > 0)
{
ProgressTracker.ProgressData progressDataUpdateGraph = progressTracker.AddProgress("Updating graph", true, out _);
// Use incremental update instead of full rebuild
pathfinder.IncrementalUpdateGraph(localAffectedZones);
progressTracker.RemoveProgress(progressDataUpdateGraph);
}
else if (gridChanged)
{
// Full rebuild only if grid changed but no zones tracked (e.g., initial grid fill)
gridChanged = false;
ProgressTracker.ProgressData progressDataBuildGraph = progressTracker.AddProgress("Rebuilding graph", true, out _);
pathfinder.RebuildGraph();
progressTracker.RemoveProgress(progressDataBuildGraph);
}
Agent[] agentsRequirePath = agents.Where(a => a.Path is null).ToArray();
if (agentsRequirePath.Length != 0)
// Efficiently collect agents without paths (avoid LINQ allocation on hot path)
List<Agent> agentsRequirePathList = new List<Agent>(Math.Min(agents.Count, 16)); // pre-allocate reasonable capacity
foreach (Agent agent in agents)
{
ProgressTracker.ProgressData progressDataPaths = progressTracker.AddProgress($"Calculating {agentsRequirePath.Length} paths", out IProgress<float> progress);
if (agent.Path is null)
{
agentsRequirePathList.Add(agent);
}
}
if (agentsRequirePathList.Count != 0)
{
int agentCount = agentsRequirePathList.Count;
ProgressTracker.ProgressData progressDataPaths = progressTracker.AddProgress($"Calculating {agentCount} paths", out IProgress<float> progress);
// Use ParallelOptions to control concurrency
ParallelOptions parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};
// Batch progress reporting every 100ms instead of modulo checks
long lastProgressReport = Environment.TickCount64;
int pathsCalculated = 0;
int reportInterval = Math.Max(1, agentsRequirePath.Length / 10);
// Calculate paths for agents that require a new path
_ = Parallel.For(0, agentsRequirePath.Length, i =>
_ = Parallel.ForEach(agentsRequirePathList, parallelOptions, agent =>
{
Agent agent = agentsRequirePath[i];
// Calculate path once and reuse
List<Vector2>? path = CalculatePath(agent.GridPosition, agent.Destination) ?? CalculatePath();
agent.Path = path;
agent.Path = CalculatePath(agent.GridPosition, agent.Destination);
agent.Path ??= CalculatePath();
agent.Position = agent.Path?[0] ?? agent.Position;
agent.NextPosition = agent.Path?[1] ?? agent.Position;
agent.Destination = agent.Path?.LastOrDefault().ToPoint();
int localPathsCalculated = Interlocked.Increment(ref pathsCalculated);
if (localPathsCalculated % reportInterval == 0)
// Avoid repeated property access and null checks
if (path?.Count > 0)
{
progress.Report((float)localPathsCalculated / agentsRequirePath.Length);
agent.Position = path[0];
agent.NextPosition = path.Count > 1 ? path[1] : path[0];
agent.Destination = new Point((int)path[^1].X, (int)path[^1].Y);
}
// Batch progress updates to reduce lock contention
int local = Interlocked.Increment(ref pathsCalculated);
long now = Environment.TickCount64;
if (now - lastProgressReport > 100)
{
progress.Report((float)local / agentCount);
lastProgressReport = now;
}
});
// Report final progress
progress.Report(1.0f);
progressTracker.RemoveProgress(progressDataPaths);
}
@@ -867,24 +915,28 @@ public class LargeGridPathfindingGame : Game
.Where(op => op.Value.Kind == PendingOperationKind.RemoveObstacle)
.Select(op => new Rectangle(op.Key.X, op.Key.Y, 1, 1))];
// Track affected zones for incremental graph updates
lock (affectedZonesLock)
{
foreach (IGrouping<int, Point> weightGroup in weightGroups)
{
gridFiller.SetTileWeights(weightGroup, weightGroup.Key);
affectedZones.UnionWith(gridFiller.SetTileWeightsWithAffected(weightGroup, weightGroup.Key));
}
if (resetWeightPoints.Length > 0)
{
gridFiller.ResetTileWeights(resetWeightPoints);
affectedZones.UnionWith(gridFiller.SetTileWeightsWithAffected(resetWeightPoints, 1));
}
if (placeObstacleRectangles.Length > 0)
{
gridFiller.PlaceObstacles(placeObstacleRectangles);
affectedZones.UnionWith(gridFiller.PlaceObstaclesWithAffected(placeObstacleRectangles));
}
if (removeObstacleRectangles.Length > 0)
{
gridFiller.RemoveObstacles(removeObstacleRectangles);
affectedZones.UnionWith(gridFiller.RemoveObstaclesWithAffected(removeObstacleRectangles));
}
}
foreach (KeyValuePair<Point, PendingOperation> operation in operationsBatch)
+18 -1
View File
@@ -21,6 +21,13 @@ public class Pathfinder
public Dictionary<int, List<int>> GetAdjacencyList() => adjacencyList;
public int GetGridValue(Point gridPoint)
{
if (gridPoint.X < 0 || gridPoint.Y < 0 || gridPoint.X >= grid.GetLength(1) || gridPoint.Y >= grid.GetLength(0))
return 0;
return grid[gridPoint.Y, gridPoint.X];
}
public Pathfinder(ConcurrentDictionary<int, Rectangle> rectangles, int[,] grid, int[,] weightGrid, bool pathRandomization = false, bool penalizeStretchedRectangles = false)
{
rectanglesSource = rectangles;
@@ -367,12 +374,22 @@ public class Pathfinder
}
}
// Clear adjacencies for all affected zones
// Clear adjacencies for all affected zones AND collect zones that referenced affected zones
foreach (int zoneId in allAffected)
{
adjacencyList[zoneId].Clear();
}
// Also remove references FROM other zones TO affected zones
// (since those adjacencies will be recalculated if needed)
foreach (int otherZoneId in adjacencyList.Keys.ToList())
{
if (!allAffected.Contains(otherZoneId))
{
adjacencyList[otherZoneId].RemoveAll(z => affectedZones.Contains(z));
}
}
// Recalculate adjacencies between affected zones and all zones
var allZonesList = rectangleMap.ToList();