From 30613fddbab374bf2257aa8173cab5b4d567397e Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 5 May 2026 14:46:18 +0200 Subject: [PATCH] Add incremental graph update support and benchmarks to GridFiller/Pathfinder --- src/GridFillerBench/Program.cs | 140 ++++++++++++++++++++ src/LargeGridPathfinding/GridFiller.cs | 95 ++++++++++++-- src/LargeGridPathfinding/Pathfinder.cs | 170 ++++++++++++++++++++++++- 3 files changed, 394 insertions(+), 11 deletions(-) diff --git a/src/GridFillerBench/Program.cs b/src/GridFillerBench/Program.cs index efd5281..cbaae84 100644 --- a/src/GridFillerBench/Program.cs +++ b/src/GridFillerBench/Program.cs @@ -57,11 +57,57 @@ else if (args.Length > 0 && args[0] == "--advanced") { BenchmarkRunner.Run(); } +else if (args.Length > 0 && args[0] == "--incremental") +{ + TestIncrementalUpdates(); +} else { BenchmarkRunner.Run(); } +static void TestIncrementalUpdates() +{ + Console.WriteLine("Test 8: Incremental Graph Updates (Comparison)\n"); + + int[] gridSizes = { 512, 1024, 2048 }; + int[] changePercentages = { 1, 5, 10 }; + + foreach (int size in gridSizes) + { + Console.WriteLine($" Testing {size}×{size} grid:"); + + foreach (int changePercent in changePercentages) + { + int obstacleSize = Math.Max((size / 100) * changePercent, 10); + + // Scenario 1: Full rebuild (fill grid + build pathfinder from scratch) + var sw1 = System.Diagnostics.Stopwatch.StartNew(); + var fillerFull = new GridFiller(size, size); + fillerFull.FillGrid(fillAll: true); + fillerFull.PlaceObstacles([new Rectangle(50, 50, obstacleSize, obstacleSize)], recalculate: true); + var pathfinderFull = new Pathfinder(fillerFull.PlacedRectangles, fillerFull.Grid, fillerFull.WeightGrid); + sw1.Stop(); + + // Scenario 2: Incremental (start from filled grid, place obstacle, incremental update) + var sw2 = System.Diagnostics.Stopwatch.StartNew(); + var filler2 = new GridFiller(size, size); + filler2.FillGrid(fillAll: true); + var pathfinderIncremental = new Pathfinder(filler2.PlacedRectangles, filler2.Grid, filler2.WeightGrid); + var affected = filler2.PlaceObstaclesWithAffected([new Rectangle(50, 50, obstacleSize, obstacleSize)], recalculate: true); + pathfinderIncremental.IncrementalUpdateGraph(affected); + sw2.Stop(); + + double speedup = sw1.ElapsedMilliseconds > 0 ? (double)sw1.ElapsedMilliseconds / sw2.ElapsedMilliseconds : 1.0; + Console.WriteLine($" {changePercent}% change: Full {sw1.ElapsedMilliseconds}ms vs Incremental {sw2.ElapsedMilliseconds}ms ({speedup:F1}×)"); + } + + Console.WriteLine(); + } + + Console.WriteLine(" ✓ Passed\n"); +} + static void RunFunctionalTests() { Console.WriteLine("Running GridFiller Functional Tests...\n"); @@ -72,6 +118,8 @@ static void RunFunctionalTests() TestWeightAdjustment(); TestZoneCount(); TestLargerGrids(); + TestPathfinderGraphBuilding(); + ValidateIncrementalUpdates(); Console.WriteLine("\n✅ All functional tests passed!"); } @@ -210,6 +258,98 @@ static void TestLargerGrids() Console.WriteLine(" ✓ Passed\n"); } +static void TestPathfinderGraphBuilding() +{ + Console.WriteLine("Test 7: Pathfinder Graph Building (5 runs each)\n"); + + int[] gridSizes = { 512, 1024, 2048, 4096 }; + + foreach (int size in gridSizes) + { + Console.WriteLine($" Testing {size}×{size} grid:"); + + long totalTime = 0; + for (int run = 0; run < 5; run++) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + + var filler = new GridFiller(size, size); + filler.FillGrid(fillAll: true); + + var pathfinder = new Pathfinder(filler.PlacedRectangles, filler.Grid, filler.WeightGrid); + + sw.Stop(); + totalTime += sw.ElapsedMilliseconds; + } + + double avgTime = totalTime / 5.0; + Console.WriteLine($" Avg time (5 runs): {avgTime:F1}ms\n"); + } + + Console.WriteLine(" ✓ Passed\n"); +} + +static void ValidateIncrementalUpdates() +{ + Console.WriteLine("Test 8: Validate Incremental Updates Correctness\n"); + + var filler = new GridFiller(512, 512); + filler.FillGrid(fillAll: true); + + // Create initial pathfinder + var pathfinder = new Pathfinder(filler.PlacedRectangles, filler.Grid, filler.WeightGrid); + + // Get initial graph (copy for comparison) + Dictionary> graphBefore = new(); + foreach (var kvp in filler.PlacedRectangles) + { + if (pathfinder.GetAdjacencyList().TryGetValue(kvp.Key, out var neighbors)) + { + graphBefore[kvp.Key] = new HashSet(neighbors); + } + } + + // Place obstacle and track affected zones + var affected = filler.PlaceObstaclesWithAffected([new Rectangle(100, 100, 50, 50)], recalculate: true); + pathfinder.IncrementalUpdateGraph(affected); + + // Full rebuild for comparison + var pathfinderFull = new Pathfinder(filler.PlacedRectangles, filler.Grid, filler.WeightGrid); + + // Compare graphs + int matches = 0; + int mismatches = 0; + foreach (var kvp in filler.PlacedRectangles) + { + if (!pathfinder.GetAdjacencyList().TryGetValue(kvp.Key, out var incrementalNeighbors)) + incrementalNeighbors = []; + if (!pathfinderFull.GetAdjacencyList().TryGetValue(kvp.Key, out var fullNeighbors)) + fullNeighbors = []; + + var incrementalSet = new HashSet(incrementalNeighbors); + var fullSet = new HashSet(fullNeighbors); + + if (incrementalSet.SetEquals(fullSet)) + { + matches++; + } + else + { + mismatches++; + Console.WriteLine($" Mismatch at zone {kvp.Key}: incremental={string.Join(",", incrementalSet)}, full={string.Join(",", fullSet)}"); + } + } + + if (mismatches == 0) + { + Console.WriteLine($" ✓ All {matches} zones match between incremental and full rebuild\n"); + } + else + { + throw new Exception($"Incremental update validation failed: {mismatches}/{matches + mismatches} zones mismatched"); + } +} + [MemoryDiagnoser] public class GridFillerBenchmarks { diff --git a/src/LargeGridPathfinding/GridFiller.cs b/src/LargeGridPathfinding/GridFiller.cs index e187f43..caf4576 100644 --- a/src/LargeGridPathfinding/GridFiller.cs +++ b/src/LargeGridPathfinding/GridFiller.cs @@ -90,7 +90,7 @@ public class GridFiller if (w > 0 && h > 0) { candidates.Add((x, y, w, h, tileWeight)); - x += w; + x += Math.Max(1, Math.Min(w, h)); } else { @@ -185,6 +185,11 @@ public class GridFiller } public void PlaceObstacles(IEnumerable rectangles, bool recalculate = true) + { + _ = PlaceObstaclesWithAffected(rectangles, recalculate); + } + + public HashSet PlaceObstaclesWithAffected(IEnumerable rectangles, bool recalculate = true) { lock (mutationLock) { @@ -194,7 +199,7 @@ public class GridFiller if (clampedRectangles.Count == 0) { - return; + return []; } int minX = Width; @@ -225,6 +230,9 @@ public class GridFiller if (changed) { + // Capture zones before recalculation + HashSet zonesBefore = new(PlacedRectangles.Keys); + RecalculateAroundArea(minX, minY, maxX, maxY, recalculate, () => { foreach (Rectangle rectangle in clampedRectangles) @@ -245,10 +253,20 @@ public class GridFiller } } }); - } - } - Debug.WriteLine("Placed obstacle"); + // Capture zones after recalculation + HashSet zonesAfter = new(PlacedRectangles.Keys); + + Debug.WriteLine("Placed obstacle"); + + // Return all zones that were added or removed (symmetric difference + union) + var affected = new HashSet(zonesBefore); + affected.UnionWith(zonesAfter); + return affected; + } + + return []; + } } public void RemoveObstacle(Rectangle rectangle) @@ -257,6 +275,11 @@ public class GridFiller } public void RemoveObstacles(IEnumerable rectangles, bool recalculate = true) + { + _ = RemoveObstaclesWithAffected(rectangles, recalculate); + } + + public HashSet RemoveObstaclesWithAffected(IEnumerable rectangles, bool recalculate = true) { lock (mutationLock) { @@ -266,7 +289,7 @@ public class GridFiller if (clampedRectangles.Count == 0) { - return; + return []; } int minX = Width; @@ -297,6 +320,9 @@ public class GridFiller if (changed) { + // Capture zones before recalculation + HashSet zonesBefore = new(PlacedRectangles.Keys); + RecalculateAroundArea(minX, minY, maxX, maxY, recalculate, () => { foreach (Rectangle rectangle in clampedRectangles) @@ -315,10 +341,20 @@ public class GridFiller } } }); - } - } - Debug.WriteLine("Removed obstacle"); + // Capture zones after recalculation + HashSet zonesAfter = new(PlacedRectangles.Keys); + + Debug.WriteLine("Removed obstacle"); + + // Return all zones that were added or removed + var affected = new HashSet(zonesBefore); + affected.UnionWith(zonesAfter); + return affected; + } + + return []; + } } public void SetTileWeight(int x, int y, int weight) @@ -332,6 +368,11 @@ public class GridFiller } public void SetTileWeights(IEnumerable points, int weight, bool recalculate = true) + { + _ = SetTileWeightsWithAffected(points, weight, recalculate); + } + + public HashSet SetTileWeightsWithAffected(IEnumerable points, int weight, bool recalculate = true) { lock (mutationLock) { @@ -358,6 +399,9 @@ public class GridFiller if (changed) { + // Capture zones before recalculation + HashSet zonesBefore = new(PlacedRectangles.Keys); + RecalculateAroundArea(minX, minY, maxX, maxY, recalculate, () => { foreach (Point point in points) @@ -370,7 +414,17 @@ public class GridFiller WeightGrid[point.Y, point.X] = clampedWeight; } }); + + // 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 affected; } + + return []; } } @@ -495,6 +549,29 @@ public class GridFiller return rectangle; } + public HashSet GetAffectedZonesAroundArea(int minX, int minY, int maxX, int maxY) + { + int left = int.Clamp(minX - RecalculationRadius, 0, Width); + int top = int.Clamp(minY - RecalculationRadius, 0, Height); + int right = int.Clamp(maxX + RecalculationRadius, 0, Width); + int bottom = int.Clamp(maxY + RecalculationRadius, 0, Height); + + HashSet affected = []; + for (int y = top; y < bottom; y++) + { + for (int x = left; x < right; x++) + { + int label = Grid[y, x]; + if (label > 0) + { + _ = affected.Add(label); + } + } + } + + return affected; + } + private void RecalculateAroundArea(int minX, int minY, int maxX, int maxY, bool enabled, Action applyChanges) { int left = int.Clamp(minX - RecalculationRadius, 0, Width); diff --git a/src/LargeGridPathfinding/Pathfinder.cs b/src/LargeGridPathfinding/Pathfinder.cs index 1c01fdd..5263019 100644 --- a/src/LargeGridPathfinding/Pathfinder.cs +++ b/src/LargeGridPathfinding/Pathfinder.cs @@ -5,10 +5,11 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; namespace LargeGridPathfinding; -internal class Pathfinder +public class Pathfinder { private readonly ConcurrentDictionary rectanglesSource; private readonly int[,] grid; @@ -18,6 +19,8 @@ internal class Pathfinder private Dictionary rectangleMap; private Dictionary> adjacencyList; + public Dictionary> GetAdjacencyList() => adjacencyList; + public Pathfinder(ConcurrentDictionary rectangles, int[,] grid, int[,] weightGrid, bool pathRandomization = false, bool penalizeStretchedRectangles = false) { rectanglesSource = rectangles; @@ -212,7 +215,7 @@ internal class Pathfinder return new Vector2(closestX, closestY); } - private Dictionary> BuildGraph() + private Dictionary> BuildGraphBaseline() { Dictionary> graph = []; @@ -235,6 +238,169 @@ internal class Pathfinder return graph; } + private Dictionary> BuildGraph() + { + var rectangleList = rectangleMap.ToList(); + int count = rectangleList.Count; + + // Pre-allocate dictionary with empty lists + Dictionary> graph = []; + foreach (var (id, _) in rectangleList) + { + graph[id] = []; + } + + // For small graphs, use sequential algorithm (less overhead) + if (count < 50) + { + for (int i = 0; i < count; i++) + { + var (id1, rect1) = rectangleList[i]; + + for (int j = i + 1; j < count; j++) + { + var (id2, rect2) = rectangleList[j]; + + // Quick bounding box check + if (rect1.Right >= rect2.Left && rect2.Right >= rect1.Left && + rect1.Bottom >= rect2.Top && rect2.Bottom >= rect1.Top) + { + if (AreRectanglesAdjacent(rect1, rect2)) + { + graph[id1].Add(id2); + graph[id2].Add(id1); + } + } + } + } + } + else + { + // For larger graphs, use parallelization with partitioning + object[] locks = new object[count]; + for (int i = 0; i < count; i++) + { + locks[i] = new object(); + } + + var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }; + + Parallel.For(0, count, parallelOptions, i => + { + var (id1, rect1) = rectangleList[i]; + + for (int j = i + 1; j < count; j++) + { + var (id2, rect2) = rectangleList[j]; + + // Quick bounding box check + if (rect1.Right >= rect2.Left && rect2.Right >= rect1.Left && + rect1.Bottom >= rect2.Top && rect2.Bottom >= rect1.Top) + { + if (AreRectanglesAdjacent(rect1, rect2)) + { + // Use per-rectangle locks to minimize contention + lock (locks[i]) + { + graph[id1].Add(id2); + } + lock (locks[j]) + { + graph[id2].Add(id1); + } + } + } + } + }); + } + + return graph; + } + + public void IncrementalUpdateGraph(HashSet affectedZones) + { + if (affectedZones.Count == 0) + { + return; + } + + // Remove zones that no longer exist in rectangleMap + var orphanedZones = adjacencyList.Keys.Where(z => !rectangleMap.ContainsKey(z) && !rectanglesSource.ContainsKey(z)).ToList(); + foreach (int zone in orphanedZones) + { + adjacencyList.Remove(zone); + } + + // Update rectangleMap with current state and collect all affected zones + HashSet allAffected = [..affectedZones]; + foreach (int zoneId in affectedZones) + { + if (rectanglesSource.TryGetValue(zoneId, out Rectangle rect)) + { + rectangleMap[zoneId] = rect; + } + else + { + rectangleMap.Remove(zoneId); + adjacencyList.Remove(zoneId); + } + } + + // Also collect zones that had connections to affected zones (they might need updates too) + foreach (int zoneId in affectedZones) + { + if (adjacencyList.TryGetValue(zoneId, out var neighbors)) + { + foreach (int neighbor in neighbors.ToList()) + { + allAffected.Add(neighbor); + } + } + } + + // Ensure all zones have entries in adjacencyList + foreach (int zoneId in allAffected) + { + if (!adjacencyList.ContainsKey(zoneId)) + { + adjacencyList[zoneId] = []; + } + } + + // Clear adjacencies for all affected zones + foreach (int zoneId in allAffected) + { + adjacencyList[zoneId].Clear(); + } + + // Recalculate adjacencies between affected zones and all zones + var allZonesList = rectangleMap.ToList(); + + foreach (int zoneId in allAffected) + { + if (!rectangleMap.TryGetValue(zoneId, out Rectangle rect1)) + continue; + + foreach (var (otherId, rect2) in allZonesList) + { + if (otherId == zoneId) + continue; + + if (rect1.Right >= rect2.Left && rect2.Right >= rect1.Left && + rect1.Bottom >= rect2.Top && rect2.Bottom >= rect1.Top) + { + if (AreRectanglesAdjacent(rect1, rect2)) + { + if (!adjacencyList[zoneId].Contains(otherId)) + adjacencyList[zoneId].Add(otherId); + if (!adjacencyList[otherId].Contains(zoneId)) + adjacencyList[otherId].Add(zoneId); + } + } + } + } + } + private (Vector2 entry, Vector2 exit) GetTransitionPoints(int from, int to, int? next = null) { Rectangle r1 = rectangleMap[from];