From 5cf361ceae70e56726018da148386c70ec87ac11 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 5 May 2026 15:29:50 +0200 Subject: [PATCH] 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 <223556219+Copilot@users.noreply.github.com> --- src/LargeGridPathfinding/GridFiller.cs | 55 ++++++-- .../LargeGridPathfindingGame.cs | 124 +++++++++++++----- src/LargeGridPathfinding/Pathfinder.cs | 19 ++- 3 files changed, 149 insertions(+), 49 deletions(-) diff --git a/src/LargeGridPathfinding/GridFiller.cs b/src/LargeGridPathfinding/GridFiller.cs index caf4576..e5c298b 100644 --- a/src/LargeGridPathfinding/GridFiller.cs +++ b/src/LargeGridPathfinding/GridFiller.cs @@ -230,8 +230,9 @@ public class GridFiller if (changed) { - // Capture zones before recalculation + // Capture zones and rectangles before recalculation HashSet zonesBefore = new(PlacedRectangles.Keys); + var rectBefore = new Dictionary(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(zonesBefore); - affected.UnionWith(zonesAfter); + // Return zones that changed (added, removed, or had their rectangle modified) + HashSet 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 zonesBefore = new(PlacedRectangles.Keys); + var rectBefore = new Dictionary(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(zonesBefore); - affected.UnionWith(zonesAfter); + // Return zones that changed (added, removed, or had their rectangle modified) + HashSet 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 zonesBefore = new(PlacedRectangles.Keys); + var rectBefore = new Dictionary(PlacedRectangles); RecalculateAroundArea(minX, minY, maxX, maxY, recalculate, () => { @@ -418,9 +440,18 @@ public class GridFiller // Capture zones after recalculation HashSet zonesAfter = new(PlacedRectangles.Keys); - // Return all zones that were added or removed - var affected = new HashSet(zonesBefore); - affected.UnionWith(zonesAfter); + // Return zones that changed (added, removed, or had their rectangle modified) + HashSet 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; } diff --git a/src/LargeGridPathfinding/LargeGridPathfindingGame.cs b/src/LargeGridPathfinding/LargeGridPathfindingGame.cs index 4274a0d..165928e 100644 --- a/src/LargeGridPathfinding/LargeGridPathfindingGame.cs +++ b/src/LargeGridPathfinding/LargeGridPathfindingGame.cs @@ -38,6 +38,8 @@ public class LargeGridPathfindingGame : Game private readonly object pendingOperationsLock = new(); private readonly ProgressTracker progressTracker = new ProgressTracker(); private readonly List agents = []; + private readonly HashSet 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 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 agentsRequirePathList = new List(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 progress); - - 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 => + if (agent.Path is null) { - Agent agent = agentsRequirePath[i]; + agentsRequirePathList.Add(agent); + } + } - 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(); + if (agentsRequirePathList.Count != 0) + { + int agentCount = agentsRequirePathList.Count; + ProgressTracker.ProgressData progressDataPaths = progressTracker.AddProgress($"Calculating {agentCount} paths", out IProgress progress); - int localPathsCalculated = Interlocked.Increment(ref pathsCalculated); + // Use ParallelOptions to control concurrency + ParallelOptions parallelOptions = new ParallelOptions + { + MaxDegreeOfParallelism = Environment.ProcessorCount + }; - if (localPathsCalculated % reportInterval == 0) + // Batch progress reporting every 100ms instead of modulo checks + long lastProgressReport = Environment.TickCount64; + int pathsCalculated = 0; + + _ = Parallel.ForEach(agentsRequirePathList, parallelOptions, agent => + { + // Calculate path once and reuse + List? path = CalculatePath(agent.GridPosition, agent.Destination) ?? CalculatePath(); + agent.Path = path; + + // 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))]; - foreach (IGrouping weightGroup in weightGroups) + // Track affected zones for incremental graph updates + lock (affectedZonesLock) { - gridFiller.SetTileWeights(weightGroup, weightGroup.Key); - } + foreach (IGrouping weightGroup in weightGroups) + { + affectedZones.UnionWith(gridFiller.SetTileWeightsWithAffected(weightGroup, weightGroup.Key)); + } - if (resetWeightPoints.Length > 0) - { - gridFiller.ResetTileWeights(resetWeightPoints); - } + if (resetWeightPoints.Length > 0) + { + affectedZones.UnionWith(gridFiller.SetTileWeightsWithAffected(resetWeightPoints, 1)); + } - if (placeObstacleRectangles.Length > 0) - { - gridFiller.PlaceObstacles(placeObstacleRectangles); - } + if (placeObstacleRectangles.Length > 0) + { + affectedZones.UnionWith(gridFiller.PlaceObstaclesWithAffected(placeObstacleRectangles)); + } - if (removeObstacleRectangles.Length > 0) - { - gridFiller.RemoveObstacles(removeObstacleRectangles); + if (removeObstacleRectangles.Length > 0) + { + affectedZones.UnionWith(gridFiller.RemoveObstaclesWithAffected(removeObstacleRectangles)); + } } foreach (KeyValuePair operation in operationsBatch) diff --git a/src/LargeGridPathfinding/Pathfinder.cs b/src/LargeGridPathfinding/Pathfinder.cs index 5263019..50d1ece 100644 --- a/src/LargeGridPathfinding/Pathfinder.cs +++ b/src/LargeGridPathfinding/Pathfinder.cs @@ -21,6 +21,13 @@ public class Pathfinder public Dictionary> 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 rectangles, int[,] grid, int[,] weightGrid, bool pathRandomization = false, bool penalizeStretchedRectangles = false) { rectanglesSource = rectangles; @@ -367,11 +374,21 @@ 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();